Skip to content

Commit 4063dc4

Browse files
authored
Merge pull request #10 from dmno-dev/enhance/test-overhaul
Overhaul test infrastructure and expand coverage
2 parents 92a5316 + 1632bb4 commit 4063dc4

14 files changed

Lines changed: 1365 additions & 502 deletions

packages/bumpy/src/utils/shell.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,33 @@ export function sq(value: string): string {
99
return "'" + value.replace(/'/g, "'\\''") + "'";
1010
}
1111

12+
// ---- Test interception ----
13+
14+
type CommandInterceptor = (
15+
args: string[],
16+
opts?: { cwd?: string; input?: string },
17+
) => { intercepted: true; result: string } | { intercepted: true; error: string } | { intercepted: false };
18+
19+
let _interceptor: CommandInterceptor | null = null;
20+
21+
/** @internal Install a command interceptor for testing. Returns a cleanup function. */
22+
export function _setInterceptor(fn: CommandInterceptor | null): void {
23+
_interceptor = fn;
24+
}
25+
26+
function checkIntercept(args: string[], opts?: { cwd?: string; input?: string }) {
27+
if (!_interceptor) return null;
28+
return _interceptor(args, opts);
29+
}
30+
1231
// ---- String-based commands (for static/trusted command strings only) ----
1332

1433
export function run(cmd: string, opts?: { cwd?: string; input?: string }): string {
34+
const result = checkIntercept(cmd.split(/\s+/), opts);
35+
if (result?.intercepted) {
36+
if ('error' in result) throw new Error(result.error);
37+
return result.result;
38+
}
1539
return execSync(cmd, {
1640
cwd: opts?.cwd,
1741
input: opts?.input,
@@ -21,6 +45,11 @@ export function run(cmd: string, opts?: { cwd?: string; input?: string }): strin
2145
}
2246

2347
export function runAsync(cmd: string, opts?: { cwd?: string; input?: string }): Promise<string> {
48+
const result = checkIntercept(cmd.split(/\s+/), opts);
49+
if (result?.intercepted) {
50+
if ('error' in result) return Promise.reject(new Error(result.error));
51+
return Promise.resolve(result.result);
52+
}
2453
return new Promise((resolve, reject) => {
2554
const child = exec(cmd, { cwd: opts?.cwd, encoding: 'utf-8' }, (err, stdout, stderr) => {
2655
if (err) {
@@ -48,6 +77,11 @@ export function tryRun(cmd: string, opts?: { cwd?: string }): string | null {
4877

4978
/** Run a command with an argument array — bypasses the shell entirely */
5079
export function runArgs(args: string[], opts?: { cwd?: string; input?: string }): string {
80+
const result = checkIntercept(args, opts);
81+
if (result?.intercepted) {
82+
if ('error' in result) throw new Error(result.error);
83+
return result.result;
84+
}
5185
const [cmd, ...rest] = args;
5286
return execFileSync(cmd!, rest, {
5387
cwd: opts?.cwd,
@@ -59,6 +93,11 @@ export function runArgs(args: string[], opts?: { cwd?: string; input?: string })
5993

6094
/** Async version of runArgs */
6195
export function runArgsAsync(args: string[], opts?: { cwd?: string; input?: string }): Promise<string> {
96+
const result = checkIntercept(args, opts);
97+
if (result?.intercepted) {
98+
if ('error' in result) return Promise.reject(new Error(result.error));
99+
return Promise.resolve(result.result);
100+
}
62101
const [cmd, ...rest] = args;
63102
return new Promise((resolve, reject) => {
64103
const child = execFile(cmd!, rest, { cwd: opts?.cwd, encoding: 'utf-8' }, (err, stdout, stderr) => {
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
import { test, expect, describe, beforeEach, afterEach } from 'bun:test';
2+
import { resolve } from 'node:path';
3+
import { mkdtemp, rm } from 'node:fs/promises';
4+
import { tmpdir } from 'node:os';
5+
import { writeJson, readJson, readText, writeText, ensureDir, exists } from '../../src/utils/fs.ts';
6+
import { makeRelease, makeChangeset, makeReleasePlan, makeConfig } from '../helpers.ts';
7+
import { applyReleasePlan } from '../../src/core/apply-release-plan.ts';
8+
import type { WorkspacePackage } from '../../src/types.ts';
9+
10+
describe('applyReleasePlan', () => {
11+
let tmpDir: string;
12+
13+
beforeEach(async () => {
14+
tmpDir = await mkdtemp(resolve(tmpdir(), 'bumpy-apply-'));
15+
});
16+
17+
afterEach(async () => {
18+
await rm(tmpDir, { recursive: true });
19+
});
20+
21+
async function setupPackage(name: string, version: string, extraPkgJson: Record<string, unknown> = {}) {
22+
const pkgDir = resolve(tmpDir, `packages/${name}`);
23+
await ensureDir(pkgDir);
24+
await writeJson(resolve(pkgDir, 'package.json'), { name, version, ...extraPkgJson });
25+
return pkgDir;
26+
}
27+
28+
test('bumps package.json version', async () => {
29+
const pkgDir = await setupPackage('pkg-a', '1.0.0');
30+
31+
const packages = new Map<string, WorkspacePackage>();
32+
packages.set('pkg-a', {
33+
name: 'pkg-a',
34+
version: '1.0.0',
35+
dir: pkgDir,
36+
relativeDir: 'packages/pkg-a',
37+
packageJson: { name: 'pkg-a', version: '1.0.0' },
38+
private: false,
39+
dependencies: {},
40+
devDependencies: {},
41+
peerDependencies: {},
42+
optionalDependencies: {},
43+
});
44+
45+
const changeset = makeChangeset('cs1', [{ name: 'pkg-a', type: 'minor' }], 'New feature');
46+
const release = makeRelease('pkg-a', '1.1.0', {
47+
type: 'minor',
48+
oldVersion: '1.0.0',
49+
changesets: ['cs1'],
50+
});
51+
52+
// Create the .bumpy dir with changeset file
53+
await ensureDir(resolve(tmpDir, '.bumpy'));
54+
await writeText(resolve(tmpDir, '.bumpy/cs1.md'), '---\n"pkg-a": minor\n---\n\nNew feature\n');
55+
56+
await applyReleasePlan(makeReleasePlan([release], [changeset]), packages, tmpDir, makeConfig());
57+
58+
const pkgJson = await readJson<Record<string, unknown>>(resolve(pkgDir, 'package.json'));
59+
expect(pkgJson.version).toBe('1.1.0');
60+
});
61+
62+
test('creates CHANGELOG.md when it does not exist', async () => {
63+
const pkgDir = await setupPackage('pkg-a', '1.0.0');
64+
65+
const packages = new Map<string, WorkspacePackage>();
66+
packages.set('pkg-a', {
67+
name: 'pkg-a',
68+
version: '1.0.0',
69+
dir: pkgDir,
70+
relativeDir: 'packages/pkg-a',
71+
packageJson: { name: 'pkg-a', version: '1.0.0' },
72+
private: false,
73+
dependencies: {},
74+
devDependencies: {},
75+
peerDependencies: {},
76+
optionalDependencies: {},
77+
});
78+
79+
const changeset = makeChangeset('cs1', [{ name: 'pkg-a', type: 'patch' }], 'Bug fix');
80+
const release = makeRelease('pkg-a', '1.0.1', {
81+
oldVersion: '1.0.0',
82+
changesets: ['cs1'],
83+
});
84+
85+
await ensureDir(resolve(tmpDir, '.bumpy'));
86+
await writeText(resolve(tmpDir, '.bumpy/cs1.md'), '---\n"pkg-a": patch\n---\n\nBug fix\n');
87+
88+
await applyReleasePlan(makeReleasePlan([release], [changeset]), packages, tmpDir, makeConfig());
89+
90+
const changelogPath = resolve(pkgDir, 'CHANGELOG.md');
91+
expect(await exists(changelogPath)).toBe(true);
92+
const content = await readText(changelogPath);
93+
expect(content).toContain('## 1.0.1');
94+
expect(content).toContain('Bug fix');
95+
});
96+
97+
test('prepends to existing CHANGELOG.md', async () => {
98+
const pkgDir = await setupPackage('pkg-a', '1.0.0');
99+
await writeText(resolve(pkgDir, 'CHANGELOG.md'), '# Changelog\n\n## 1.0.0\n\n- Initial release\n');
100+
101+
const packages = new Map<string, WorkspacePackage>();
102+
packages.set('pkg-a', {
103+
name: 'pkg-a',
104+
version: '1.0.0',
105+
dir: pkgDir,
106+
relativeDir: 'packages/pkg-a',
107+
packageJson: { name: 'pkg-a', version: '1.0.0' },
108+
private: false,
109+
dependencies: {},
110+
devDependencies: {},
111+
peerDependencies: {},
112+
optionalDependencies: {},
113+
});
114+
115+
const changeset = makeChangeset('cs1', [{ name: 'pkg-a', type: 'minor' }], 'Feature');
116+
const release = makeRelease('pkg-a', '1.1.0', {
117+
type: 'minor',
118+
oldVersion: '1.0.0',
119+
changesets: ['cs1'],
120+
});
121+
122+
await ensureDir(resolve(tmpDir, '.bumpy'));
123+
await writeText(resolve(tmpDir, '.bumpy/cs1.md'), '---\n"pkg-a": minor\n---\n\nFeature\n');
124+
125+
await applyReleasePlan(makeReleasePlan([release], [changeset]), packages, tmpDir, makeConfig());
126+
127+
const content = await readText(resolve(pkgDir, 'CHANGELOG.md'));
128+
// New entry should be before old entry
129+
const newIdx = content.indexOf('## 1.1.0');
130+
const oldIdx = content.indexOf('## 1.0.0');
131+
expect(newIdx).toBeLessThan(oldIdx);
132+
expect(content).toContain('- Initial release');
133+
});
134+
135+
test('updates internal dependency ranges', async () => {
136+
const coreDir = await setupPackage('core', '1.0.0');
137+
const appDir = await setupPackage('app', '1.0.0', {
138+
dependencies: { core: '^1.0.0' },
139+
});
140+
141+
const packages = new Map<string, WorkspacePackage>();
142+
packages.set('core', {
143+
name: 'core',
144+
version: '1.0.0',
145+
dir: coreDir,
146+
relativeDir: 'packages/core',
147+
packageJson: { name: 'core', version: '1.0.0' },
148+
private: false,
149+
dependencies: {},
150+
devDependencies: {},
151+
peerDependencies: {},
152+
optionalDependencies: {},
153+
});
154+
packages.set('app', {
155+
name: 'app',
156+
version: '1.0.0',
157+
dir: appDir,
158+
relativeDir: 'packages/app',
159+
packageJson: { name: 'app', version: '1.0.0', dependencies: { core: '^1.0.0' } },
160+
private: false,
161+
dependencies: { core: '^1.0.0' },
162+
devDependencies: {},
163+
peerDependencies: {},
164+
optionalDependencies: {},
165+
});
166+
167+
const changeset = makeChangeset('cs1', [{ name: 'core', type: 'major' }], 'Breaking');
168+
const coreRelease = makeRelease('core', '2.0.0', {
169+
type: 'major',
170+
oldVersion: '1.0.0',
171+
changesets: ['cs1'],
172+
});
173+
const appRelease = makeRelease('app', '1.0.1', {
174+
oldVersion: '1.0.0',
175+
isDependencyBump: true,
176+
});
177+
178+
await ensureDir(resolve(tmpDir, '.bumpy'));
179+
await writeText(resolve(tmpDir, '.bumpy/cs1.md'), '---\n"core": major\n---\n\nBreaking\n');
180+
181+
await applyReleasePlan(makeReleasePlan([coreRelease, appRelease], [changeset]), packages, tmpDir, makeConfig());
182+
183+
const appPkg = await readJson<Record<string, unknown>>(resolve(appDir, 'package.json'));
184+
const deps = appPkg.dependencies as Record<string, string>;
185+
expect(deps.core).toBe('^2.0.0');
186+
});
187+
188+
test('preserves workspace: protocol in dependency ranges', async () => {
189+
const coreDir = await setupPackage('core', '1.0.0');
190+
const appDir = await setupPackage('app', '1.0.0', {
191+
dependencies: { core: 'workspace:^1.0.0' },
192+
});
193+
194+
const packages = new Map<string, WorkspacePackage>();
195+
packages.set('core', {
196+
name: 'core',
197+
version: '1.0.0',
198+
dir: coreDir,
199+
relativeDir: 'packages/core',
200+
packageJson: { name: 'core', version: '1.0.0' },
201+
private: false,
202+
dependencies: {},
203+
devDependencies: {},
204+
peerDependencies: {},
205+
optionalDependencies: {},
206+
});
207+
packages.set('app', {
208+
name: 'app',
209+
version: '1.0.0',
210+
dir: appDir,
211+
relativeDir: 'packages/app',
212+
packageJson: { name: 'app', version: '1.0.0', dependencies: { core: 'workspace:^1.0.0' } },
213+
private: false,
214+
dependencies: { core: 'workspace:^1.0.0' },
215+
devDependencies: {},
216+
peerDependencies: {},
217+
optionalDependencies: {},
218+
});
219+
220+
const coreRelease = makeRelease('core', '2.0.0', { type: 'major', oldVersion: '1.0.0' });
221+
const appRelease = makeRelease('app', '1.0.1', { oldVersion: '1.0.0', isDependencyBump: true });
222+
223+
await ensureDir(resolve(tmpDir, '.bumpy'));
224+
225+
await applyReleasePlan(makeReleasePlan([coreRelease, appRelease]), packages, tmpDir, makeConfig());
226+
227+
const appPkg = await readJson<Record<string, unknown>>(resolve(appDir, 'package.json'));
228+
const deps = appPkg.dependencies as Record<string, string>;
229+
expect(deps.core).toBe('workspace:^2.0.0');
230+
});
231+
232+
test('deletes consumed changeset files', async () => {
233+
const pkgDir = await setupPackage('pkg-a', '1.0.0');
234+
235+
const packages = new Map<string, WorkspacePackage>();
236+
packages.set('pkg-a', {
237+
name: 'pkg-a',
238+
version: '1.0.0',
239+
dir: pkgDir,
240+
relativeDir: 'packages/pkg-a',
241+
packageJson: { name: 'pkg-a', version: '1.0.0' },
242+
private: false,
243+
dependencies: {},
244+
devDependencies: {},
245+
peerDependencies: {},
246+
optionalDependencies: {},
247+
});
248+
249+
const changeset = makeChangeset('cs-to-delete', [{ name: 'pkg-a', type: 'patch' }], 'Fix');
250+
const release = makeRelease('pkg-a', '1.0.1', {
251+
oldVersion: '1.0.0',
252+
changesets: ['cs-to-delete'],
253+
});
254+
255+
await ensureDir(resolve(tmpDir, '.bumpy'));
256+
const csPath = resolve(tmpDir, '.bumpy/cs-to-delete.md');
257+
await writeText(csPath, '---\n"pkg-a": patch\n---\n\nFix\n');
258+
expect(await exists(csPath)).toBe(true);
259+
260+
await applyReleasePlan(makeReleasePlan([release], [changeset]), packages, tmpDir, makeConfig());
261+
262+
expect(await exists(csPath)).toBe(false);
263+
});
264+
});

0 commit comments

Comments
 (0)