diff --git a/packages/bumpy/src/utils/shell.ts b/packages/bumpy/src/utils/shell.ts index 9a01b49..82759d9 100644 --- a/packages/bumpy/src/utils/shell.ts +++ b/packages/bumpy/src/utils/shell.ts @@ -9,9 +9,33 @@ export function sq(value: string): string { return "'" + value.replace(/'/g, "'\\''") + "'"; } +// ---- Test interception ---- + +type CommandInterceptor = ( + args: string[], + opts?: { cwd?: string; input?: string }, +) => { intercepted: true; result: string } | { intercepted: true; error: string } | { intercepted: false }; + +let _interceptor: CommandInterceptor | null = null; + +/** @internal Install a command interceptor for testing. Returns a cleanup function. */ +export function _setInterceptor(fn: CommandInterceptor | null): void { + _interceptor = fn; +} + +function checkIntercept(args: string[], opts?: { cwd?: string; input?: string }) { + if (!_interceptor) return null; + return _interceptor(args, opts); +} + // ---- String-based commands (for static/trusted command strings only) ---- export function run(cmd: string, opts?: { cwd?: string; input?: string }): string { + const result = checkIntercept(cmd.split(/\s+/), opts); + if (result?.intercepted) { + if ('error' in result) throw new Error(result.error); + return result.result; + } return execSync(cmd, { cwd: opts?.cwd, input: opts?.input, @@ -21,6 +45,11 @@ export function run(cmd: string, opts?: { cwd?: string; input?: string }): strin } export function runAsync(cmd: string, opts?: { cwd?: string; input?: string }): Promise { + const result = checkIntercept(cmd.split(/\s+/), opts); + if (result?.intercepted) { + if ('error' in result) return Promise.reject(new Error(result.error)); + return Promise.resolve(result.result); + } return new Promise((resolve, reject) => { const child = exec(cmd, { cwd: opts?.cwd, encoding: 'utf-8' }, (err, stdout, stderr) => { if (err) { @@ -48,6 +77,11 @@ export function tryRun(cmd: string, opts?: { cwd?: string }): string | null { /** Run a command with an argument array — bypasses the shell entirely */ export function runArgs(args: string[], opts?: { cwd?: string; input?: string }): string { + const result = checkIntercept(args, opts); + if (result?.intercepted) { + if ('error' in result) throw new Error(result.error); + return result.result; + } const [cmd, ...rest] = args; return execFileSync(cmd!, rest, { cwd: opts?.cwd, @@ -59,6 +93,11 @@ export function runArgs(args: string[], opts?: { cwd?: string; input?: string }) /** Async version of runArgs */ export function runArgsAsync(args: string[], opts?: { cwd?: string; input?: string }): Promise { + const result = checkIntercept(args, opts); + if (result?.intercepted) { + if ('error' in result) return Promise.reject(new Error(result.error)); + return Promise.resolve(result.result); + } const [cmd, ...rest] = args; return new Promise((resolve, reject) => { const child = execFile(cmd!, rest, { cwd: opts?.cwd, encoding: 'utf-8' }, (err, stdout, stderr) => { diff --git a/packages/bumpy/test/core/apply-release-plan.test.ts b/packages/bumpy/test/core/apply-release-plan.test.ts new file mode 100644 index 0000000..55f89ef --- /dev/null +++ b/packages/bumpy/test/core/apply-release-plan.test.ts @@ -0,0 +1,264 @@ +import { test, expect, describe, beforeEach, afterEach } from 'bun:test'; +import { resolve } from 'node:path'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { writeJson, readJson, readText, writeText, ensureDir, exists } from '../../src/utils/fs.ts'; +import { makeRelease, makeChangeset, makeReleasePlan, makeConfig } from '../helpers.ts'; +import { applyReleasePlan } from '../../src/core/apply-release-plan.ts'; +import type { WorkspacePackage } from '../../src/types.ts'; + +describe('applyReleasePlan', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await mkdtemp(resolve(tmpdir(), 'bumpy-apply-')); + }); + + afterEach(async () => { + await rm(tmpDir, { recursive: true }); + }); + + async function setupPackage(name: string, version: string, extraPkgJson: Record = {}) { + const pkgDir = resolve(tmpDir, `packages/${name}`); + await ensureDir(pkgDir); + await writeJson(resolve(pkgDir, 'package.json'), { name, version, ...extraPkgJson }); + return pkgDir; + } + + test('bumps package.json version', async () => { + const pkgDir = await setupPackage('pkg-a', '1.0.0'); + + const packages = new Map(); + packages.set('pkg-a', { + name: 'pkg-a', + version: '1.0.0', + dir: pkgDir, + relativeDir: 'packages/pkg-a', + packageJson: { name: 'pkg-a', version: '1.0.0' }, + private: false, + dependencies: {}, + devDependencies: {}, + peerDependencies: {}, + optionalDependencies: {}, + }); + + const changeset = makeChangeset('cs1', [{ name: 'pkg-a', type: 'minor' }], 'New feature'); + const release = makeRelease('pkg-a', '1.1.0', { + type: 'minor', + oldVersion: '1.0.0', + changesets: ['cs1'], + }); + + // Create the .bumpy dir with changeset file + await ensureDir(resolve(tmpDir, '.bumpy')); + await writeText(resolve(tmpDir, '.bumpy/cs1.md'), '---\n"pkg-a": minor\n---\n\nNew feature\n'); + + await applyReleasePlan(makeReleasePlan([release], [changeset]), packages, tmpDir, makeConfig()); + + const pkgJson = await readJson>(resolve(pkgDir, 'package.json')); + expect(pkgJson.version).toBe('1.1.0'); + }); + + test('creates CHANGELOG.md when it does not exist', async () => { + const pkgDir = await setupPackage('pkg-a', '1.0.0'); + + const packages = new Map(); + packages.set('pkg-a', { + name: 'pkg-a', + version: '1.0.0', + dir: pkgDir, + relativeDir: 'packages/pkg-a', + packageJson: { name: 'pkg-a', version: '1.0.0' }, + private: false, + dependencies: {}, + devDependencies: {}, + peerDependencies: {}, + optionalDependencies: {}, + }); + + const changeset = makeChangeset('cs1', [{ name: 'pkg-a', type: 'patch' }], 'Bug fix'); + const release = makeRelease('pkg-a', '1.0.1', { + oldVersion: '1.0.0', + changesets: ['cs1'], + }); + + await ensureDir(resolve(tmpDir, '.bumpy')); + await writeText(resolve(tmpDir, '.bumpy/cs1.md'), '---\n"pkg-a": patch\n---\n\nBug fix\n'); + + await applyReleasePlan(makeReleasePlan([release], [changeset]), packages, tmpDir, makeConfig()); + + const changelogPath = resolve(pkgDir, 'CHANGELOG.md'); + expect(await exists(changelogPath)).toBe(true); + const content = await readText(changelogPath); + expect(content).toContain('## 1.0.1'); + expect(content).toContain('Bug fix'); + }); + + test('prepends to existing CHANGELOG.md', async () => { + const pkgDir = await setupPackage('pkg-a', '1.0.0'); + await writeText(resolve(pkgDir, 'CHANGELOG.md'), '# Changelog\n\n## 1.0.0\n\n- Initial release\n'); + + const packages = new Map(); + packages.set('pkg-a', { + name: 'pkg-a', + version: '1.0.0', + dir: pkgDir, + relativeDir: 'packages/pkg-a', + packageJson: { name: 'pkg-a', version: '1.0.0' }, + private: false, + dependencies: {}, + devDependencies: {}, + peerDependencies: {}, + optionalDependencies: {}, + }); + + const changeset = makeChangeset('cs1', [{ name: 'pkg-a', type: 'minor' }], 'Feature'); + const release = makeRelease('pkg-a', '1.1.0', { + type: 'minor', + oldVersion: '1.0.0', + changesets: ['cs1'], + }); + + await ensureDir(resolve(tmpDir, '.bumpy')); + await writeText(resolve(tmpDir, '.bumpy/cs1.md'), '---\n"pkg-a": minor\n---\n\nFeature\n'); + + await applyReleasePlan(makeReleasePlan([release], [changeset]), packages, tmpDir, makeConfig()); + + const content = await readText(resolve(pkgDir, 'CHANGELOG.md')); + // New entry should be before old entry + const newIdx = content.indexOf('## 1.1.0'); + const oldIdx = content.indexOf('## 1.0.0'); + expect(newIdx).toBeLessThan(oldIdx); + expect(content).toContain('- Initial release'); + }); + + test('updates internal dependency ranges', async () => { + const coreDir = await setupPackage('core', '1.0.0'); + const appDir = await setupPackage('app', '1.0.0', { + dependencies: { core: '^1.0.0' }, + }); + + const packages = new Map(); + packages.set('core', { + name: 'core', + version: '1.0.0', + dir: coreDir, + relativeDir: 'packages/core', + packageJson: { name: 'core', version: '1.0.0' }, + private: false, + dependencies: {}, + devDependencies: {}, + peerDependencies: {}, + optionalDependencies: {}, + }); + packages.set('app', { + name: 'app', + version: '1.0.0', + dir: appDir, + relativeDir: 'packages/app', + packageJson: { name: 'app', version: '1.0.0', dependencies: { core: '^1.0.0' } }, + private: false, + dependencies: { core: '^1.0.0' }, + devDependencies: {}, + peerDependencies: {}, + optionalDependencies: {}, + }); + + const changeset = makeChangeset('cs1', [{ name: 'core', type: 'major' }], 'Breaking'); + const coreRelease = makeRelease('core', '2.0.0', { + type: 'major', + oldVersion: '1.0.0', + changesets: ['cs1'], + }); + const appRelease = makeRelease('app', '1.0.1', { + oldVersion: '1.0.0', + isDependencyBump: true, + }); + + await ensureDir(resolve(tmpDir, '.bumpy')); + await writeText(resolve(tmpDir, '.bumpy/cs1.md'), '---\n"core": major\n---\n\nBreaking\n'); + + await applyReleasePlan(makeReleasePlan([coreRelease, appRelease], [changeset]), packages, tmpDir, makeConfig()); + + const appPkg = await readJson>(resolve(appDir, 'package.json')); + const deps = appPkg.dependencies as Record; + expect(deps.core).toBe('^2.0.0'); + }); + + test('preserves workspace: protocol in dependency ranges', async () => { + const coreDir = await setupPackage('core', '1.0.0'); + const appDir = await setupPackage('app', '1.0.0', { + dependencies: { core: 'workspace:^1.0.0' }, + }); + + const packages = new Map(); + packages.set('core', { + name: 'core', + version: '1.0.0', + dir: coreDir, + relativeDir: 'packages/core', + packageJson: { name: 'core', version: '1.0.0' }, + private: false, + dependencies: {}, + devDependencies: {}, + peerDependencies: {}, + optionalDependencies: {}, + }); + packages.set('app', { + name: 'app', + version: '1.0.0', + dir: appDir, + relativeDir: 'packages/app', + packageJson: { name: 'app', version: '1.0.0', dependencies: { core: 'workspace:^1.0.0' } }, + private: false, + dependencies: { core: 'workspace:^1.0.0' }, + devDependencies: {}, + peerDependencies: {}, + optionalDependencies: {}, + }); + + const coreRelease = makeRelease('core', '2.0.0', { type: 'major', oldVersion: '1.0.0' }); + const appRelease = makeRelease('app', '1.0.1', { oldVersion: '1.0.0', isDependencyBump: true }); + + await ensureDir(resolve(tmpDir, '.bumpy')); + + await applyReleasePlan(makeReleasePlan([coreRelease, appRelease]), packages, tmpDir, makeConfig()); + + const appPkg = await readJson>(resolve(appDir, 'package.json')); + const deps = appPkg.dependencies as Record; + expect(deps.core).toBe('workspace:^2.0.0'); + }); + + test('deletes consumed changeset files', async () => { + const pkgDir = await setupPackage('pkg-a', '1.0.0'); + + const packages = new Map(); + packages.set('pkg-a', { + name: 'pkg-a', + version: '1.0.0', + dir: pkgDir, + relativeDir: 'packages/pkg-a', + packageJson: { name: 'pkg-a', version: '1.0.0' }, + private: false, + dependencies: {}, + devDependencies: {}, + peerDependencies: {}, + optionalDependencies: {}, + }); + + const changeset = makeChangeset('cs-to-delete', [{ name: 'pkg-a', type: 'patch' }], 'Fix'); + const release = makeRelease('pkg-a', '1.0.1', { + oldVersion: '1.0.0', + changesets: ['cs-to-delete'], + }); + + await ensureDir(resolve(tmpDir, '.bumpy')); + const csPath = resolve(tmpDir, '.bumpy/cs-to-delete.md'); + await writeText(csPath, '---\n"pkg-a": patch\n---\n\nFix\n'); + expect(await exists(csPath)).toBe(true); + + await applyReleasePlan(makeReleasePlan([release], [changeset]), packages, tmpDir, makeConfig()); + + expect(await exists(csPath)).toBe(false); + }); +}); diff --git a/packages/bumpy/test/core/changelog-github.test.ts b/packages/bumpy/test/core/changelog-github.test.ts new file mode 100644 index 0000000..5a2dc84 --- /dev/null +++ b/packages/bumpy/test/core/changelog-github.test.ts @@ -0,0 +1,185 @@ +import { test, expect, describe, beforeEach, afterEach } from 'bun:test'; +import { makeRelease, makeChangeset } from '../helpers.ts'; +import { installShellMock, uninstallShellMock, addMockRule } from '../helpers-shell-mock.ts'; +import { createGithubFormatter } from '../../src/core/changelog-github.ts'; + +describe('createGithubFormatter', () => { + beforeEach(() => { + installShellMock(); + addMockRule({ match: 'gh repo view', response: 'dmno-dev/bumpy' }); + }); + + afterEach(() => { + uninstallShellMock(); + }); + + test('formats basic release entry', async () => { + addMockRule({ match: /^git log/, response: '' }); + + const formatter = createGithubFormatter({ repo: 'dmno-dev/bumpy' }); + const release = makeRelease('pkg-a', '1.1.0', { + type: 'minor', + changesets: ['cs1'], + }); + const changesets = [makeChangeset('cs1', [{ name: 'pkg-a', type: 'minor' }], 'Added feature X')]; + + const result = await formatter({ release, changesets, date: '2026-04-14' }); + + expect(result).toContain('## 1.1.0'); + expect(result).toContain('_2026-04-14_'); + expect(result).toContain('Added feature X'); + }); + + test('includes PR link when changeset has PR metadata', async () => { + addMockRule({ match: /^git log/, response: '' }); + addMockRule({ + match: /gh pr view 42/, + response: JSON.stringify({ + url: 'https://github.com/dmno-dev/bumpy/pull/42', + author: { login: 'contributor' }, + mergeCommit: { oid: 'abc1234567890' }, + }), + }); + + const formatter = createGithubFormatter({ repo: 'dmno-dev/bumpy' }); + const release = makeRelease('pkg-a', '1.0.1', { changesets: ['cs1'] }); + const changesets = [makeChangeset('cs1', [{ name: 'pkg-a', type: 'patch' }], 'pr: #42\nFixed the bug')]; + + const result = await formatter({ release, changesets, date: '2026-04-14' }); + + expect(result).toContain('[#42]'); + expect(result).toContain('https://github.com/dmno-dev/bumpy/pull/42'); + }); + + test('includes commit link when changeset has commit metadata', async () => { + addMockRule({ match: /^git log/, response: '' }); + + const formatter = createGithubFormatter({ repo: 'dmno-dev/bumpy' }); + const release = makeRelease('pkg-a', '1.0.1', { changesets: ['cs1'] }); + const changesets = [makeChangeset('cs1', [{ name: 'pkg-a', type: 'patch' }], 'commit: abc1234567890\nFixed it')]; + + const result = await formatter({ release, changesets, date: '2026-04-14' }); + + expect(result).toContain('[`abc1234`]'); + expect(result).toContain('/commit/abc1234567890'); + }); + + test('includes author thanks for external contributors', async () => { + addMockRule({ match: /^git log/, response: '' }); + + const formatter = createGithubFormatter({ + repo: 'dmno-dev/bumpy', + internalAuthors: ['theoephraim'], + }); + const release = makeRelease('pkg-a', '1.0.1', { changesets: ['cs1'] }); + const changesets = [makeChangeset('cs1', [{ name: 'pkg-a', type: 'patch' }], 'author: @external-dev\nFixed it')]; + + const result = await formatter({ release, changesets, date: '2026-04-14' }); + + expect(result).toContain('Thanks [@external-dev]'); + }); + + test('skips thanks for internal authors', async () => { + addMockRule({ match: /^git log/, response: '' }); + + const formatter = createGithubFormatter({ + repo: 'dmno-dev/bumpy', + internalAuthors: ['theoephraim'], + }); + const release = makeRelease('pkg-a', '1.0.1', { changesets: ['cs1'] }); + const changesets = [makeChangeset('cs1', [{ name: 'pkg-a', type: 'patch' }], 'author: @theoephraim\nFixed it')]; + + const result = await formatter({ release, changesets, date: '2026-04-14' }); + + expect(result).not.toContain('Thanks'); + }); + + test('linkifies bare issue references', async () => { + addMockRule({ match: /^git log/, response: '' }); + + const formatter = createGithubFormatter({ repo: 'dmno-dev/bumpy' }); + const release = makeRelease('pkg-a', '1.0.1', { changesets: ['cs1'] }); + const changesets = [makeChangeset('cs1', [{ name: 'pkg-a', type: 'patch' }], 'Fixed #123 and #456')]; + + const result = await formatter({ release, changesets, date: '2026-04-14' }); + + expect(result).toContain('[#123](https://github.com/dmno-dev/bumpy/issues/123)'); + expect(result).toContain('[#456](https://github.com/dmno-dev/bumpy/issues/456)'); + }); + + test('does not double-linkify already linked references', async () => { + addMockRule({ match: /^git log/, response: '' }); + + const formatter = createGithubFormatter({ repo: 'dmno-dev/bumpy' }); + const release = makeRelease('pkg-a', '1.0.1', { changesets: ['cs1'] }); + const changesets = [ + makeChangeset('cs1', [{ name: 'pkg-a', type: 'patch' }], 'Fixed [#123](https://example.com/123)'), + ]; + + const result = await formatter({ release, changesets, date: '2026-04-14' }); + + expect(result).toContain('[#123](https://example.com/123)'); + expect(result).not.toContain('issues/123'); + }); + + test('handles dependency bump with no changesets', async () => { + const formatter = createGithubFormatter({ repo: 'dmno-dev/bumpy' }); + const release = makeRelease('pkg-a', '1.0.1', { + isDependencyBump: true, + changesets: [], + }); + + const result = await formatter({ release, changesets: [], date: '2026-04-14' }); + + expect(result).toContain('- Updated dependencies'); + }); + + test('handles cascade bump with no changesets', async () => { + const formatter = createGithubFormatter({ repo: 'dmno-dev/bumpy' }); + const release = makeRelease('pkg-a', '1.0.1', { + isCascadeBump: true, + changesets: [], + }); + + const result = await formatter({ release, changesets: [], date: '2026-04-14' }); + + expect(result).toContain('- Version bump via cascade rule'); + }); + + test('resolves changeset info from git log', async () => { + addMockRule({ + match: /git log.*\.bumpy\/cs1\.md/, + response: 'deadbeef1234567890abcdef', + }); + addMockRule({ + match: /gh pr list.*deadbeef/, + response: JSON.stringify({ + number: 99, + url: 'https://github.com/dmno-dev/bumpy/pull/99', + author: { login: 'contributor' }, + }), + }); + + const formatter = createGithubFormatter({ repo: 'dmno-dev/bumpy' }); + const release = makeRelease('pkg-a', '1.0.1', { changesets: ['cs1'] }); + const changesets = [makeChangeset('cs1', [{ name: 'pkg-a', type: 'patch' }], 'Some fix')]; + + const result = await formatter({ release, changesets, date: '2026-04-14' }); + + expect(result).toContain('[#99]'); + expect(result).toContain('[`deadbee`]'); + expect(result).toContain('Thanks [@contributor]'); + }); + + test('gracefully handles gh errors', async () => { + addMockRule({ match: /^git log/, response: 'abc123' }); + addMockRule({ match: /^gh pr list/, error: 'auth required' }); + + const formatter = createGithubFormatter({ repo: 'dmno-dev/bumpy' }); + const release = makeRelease('pkg-a', '1.0.1', { changesets: ['cs1'] }); + const changesets = [makeChangeset('cs1', [{ name: 'pkg-a', type: 'patch' }], 'Fix')]; + + const result = await formatter({ release, changesets, date: '2026-04-14' }); + expect(result).toContain('Fix'); + }); +}); diff --git a/packages/bumpy/test/core/changelog.test.ts b/packages/bumpy/test/core/changelog.test.ts new file mode 100644 index 0000000..d665d2f --- /dev/null +++ b/packages/bumpy/test/core/changelog.test.ts @@ -0,0 +1,156 @@ +import { test, expect, describe } from 'bun:test'; +import { makeRelease, makeChangeset } from '../helpers.ts'; +import { defaultFormatter, generateChangelogEntry, prependToChangelog } from '../../src/core/changelog.ts'; + +describe('defaultFormatter', () => { + test('formats basic release with changesets', async () => { + const release = makeRelease('pkg-a', '1.1.0', { + type: 'minor', + oldVersion: '1.0.0', + changesets: ['cs1', 'cs2'], + }); + const changesets = [ + makeChangeset('cs1', [{ name: 'pkg-a', type: 'minor' }], 'Added new feature'), + makeChangeset('cs2', [{ name: 'pkg-a', type: 'patch' }], 'Fixed a bug'), + ]; + + const result = await defaultFormatter({ release, changesets, date: '2026-04-14' }); + + expect(result).toContain('## 1.1.0'); + expect(result).toContain('_2026-04-14_'); + expect(result).toContain('- Added new feature'); + expect(result).toContain('- Fixed a bug'); + }); + + test('formats dependency bump with no changesets', async () => { + const release = makeRelease('pkg-a', '1.0.1', { + isDependencyBump: true, + changesets: [], + }); + + const result = await defaultFormatter({ release, changesets: [], date: '2026-04-14' }); + + expect(result).toContain('- Updated dependencies'); + }); + + test('formats cascade bump with no changesets', async () => { + const release = makeRelease('pkg-a', '1.0.1', { + isCascadeBump: true, + changesets: [], + }); + + const result = await defaultFormatter({ release, changesets: [], date: '2026-04-14' }); + + expect(result).toContain('- Version bump via cascade rule'); + }); + + test('dependency bump takes precedence over cascade in message', async () => { + const release = makeRelease('pkg-a', '1.0.1', { + isDependencyBump: true, + isCascadeBump: true, + changesets: [], + }); + + const result = await defaultFormatter({ release, changesets: [], date: '2026-04-14' }); + + expect(result).toContain('- Updated dependencies'); + expect(result).not.toContain('cascade'); + }); + + test('handles multi-line changeset summaries', async () => { + const release = makeRelease('pkg-a', '1.1.0', { + type: 'minor', + changesets: ['cs1'], + }); + const changesets = [makeChangeset('cs1', [{ name: 'pkg-a', type: 'minor' }], 'First line\n\nSecond paragraph')]; + + const result = await defaultFormatter({ release, changesets, date: '2026-04-14' }); + + expect(result).toContain('- First line'); + expect(result).toContain(' Second paragraph'); + }); + + test('only includes changesets referenced by the release', async () => { + const release = makeRelease('pkg-a', '1.0.1', { changesets: ['cs1'] }); + const changesets = [ + makeChangeset('cs1', [{ name: 'pkg-a', type: 'patch' }], 'Relevant fix'), + makeChangeset('cs2', [{ name: 'pkg-b', type: 'patch' }], 'Unrelated fix'), + ]; + + const result = await defaultFormatter({ release, changesets, date: '2026-04-14' }); + + expect(result).toContain('Relevant fix'); + expect(result).not.toContain('Unrelated fix'); + }); +}); + +describe('generateChangelogEntry', () => { + test('uses default formatter when none specified', async () => { + const release = makeRelease('pkg-a', '1.0.1', { changesets: ['cs1'] }); + const changesets = [makeChangeset('cs1', [{ name: 'pkg-a', type: 'patch' }], 'Fix')]; + + const result = await generateChangelogEntry(release, changesets); + + expect(result).toContain('## 1.0.1'); + expect(result).toContain('- Fix'); + }); + + test('uses custom formatter', async () => { + const customFormatter = (ctx: any) => `Custom: ${ctx.release.newVersion}`; + const release = makeRelease('pkg-a', '2.0.0'); + + const result = await generateChangelogEntry(release, [], customFormatter); + + expect(result).toBe('Custom: 2.0.0'); + }); + + test('uses provided date', async () => { + const release = makeRelease('pkg-a', '1.0.0'); + + const result = await generateChangelogEntry(release, [], undefined, '2020-01-01'); + + expect(result).toContain('_2020-01-01_'); + }); +}); + +describe('prependToChangelog', () => { + test('prepends to existing changelog with title and entries', () => { + const existing = '# Changelog\n\n## 1.0.0\n\n- Initial release\n'; + const newEntry = '## 1.1.0\n\n- New feature\n'; + + const result = prependToChangelog(existing, newEntry); + + expect(result).toContain('# Changelog'); + // New entry should appear before old entry + const newIdx = result.indexOf('## 1.1.0'); + const oldIdx = result.indexOf('## 1.0.0'); + expect(newIdx).toBeLessThan(oldIdx); + }); + + test('creates fresh changelog when no existing content', () => { + const result = prependToChangelog('', '## 1.0.0\n\n- Initial\n'); + + expect(result).toContain('# Changelog'); + expect(result).toContain('## 1.0.0'); + }); + + test('appends after title when no existing entries', () => { + const existing = '# Changelog'; + const newEntry = '## 1.0.0\n\n- First\n'; + + const result = prependToChangelog(existing, newEntry); + + expect(result).toContain('# Changelog'); + expect(result).toContain('## 1.0.0'); + }); + + test('preserves all existing content', () => { + const existing = '# Changelog\n\n## 1.0.0\n\n- Old entry\n'; + const newEntry = '## 2.0.0\n\n- New entry\n'; + + const result = prependToChangelog(existing, newEntry); + + expect(result).toContain('- Old entry'); + expect(result).toContain('- New entry'); + }); +}); diff --git a/packages/bumpy/test/core/config.test.ts b/packages/bumpy/test/core/config.test.ts index 82642f9..493971e 100644 --- a/packages/bumpy/test/core/config.test.ts +++ b/packages/bumpy/test/core/config.test.ts @@ -1,7 +1,6 @@ import { test, expect, describe } from 'bun:test'; import { matchGlob, isPackageManaged } from '../../src/core/config.ts'; -import { DEFAULT_CONFIG } from '../../src/types.ts'; -import type { BumpyConfig } from '../../src/types.ts'; +import { makeConfig } from '../helpers.ts'; describe('matchGlob', () => { test('exact match', () => { @@ -27,10 +26,6 @@ describe('matchGlob', () => { }); }); -function makeConfig(overrides: Partial = {}): BumpyConfig { - return { ...DEFAULT_CONFIG, ...overrides }; -} - describe('isPackageManaged', () => { test('public packages are managed by default', () => { expect(isPackageManaged('pkg-a', false, makeConfig())).toBe(true); @@ -41,15 +36,7 @@ describe('isPackageManaged', () => { }); test('private packages managed when privatePackages.version is true', () => { - expect( - isPackageManaged( - 'pkg-a', - true, - makeConfig({ - privatePackages: { version: true, tag: false }, - }), - ), - ).toBe(true); + expect(isPackageManaged('pkg-a', true, makeConfig({ privatePackages: { version: true, tag: false } }))).toBe(true); }); test('ignore excludes packages by exact name', () => { @@ -57,108 +44,33 @@ describe('isPackageManaged', () => { }); test('ignore supports globs', () => { - expect( - isPackageManaged( - '@myorg/internal-tool', - false, - makeConfig({ - ignore: ['@myorg/internal-*'], - }), - ), - ).toBe(false); - expect( - isPackageManaged( - '@myorg/core', - false, - makeConfig({ - ignore: ['@myorg/internal-*'], - }), - ), - ).toBe(true); + expect(isPackageManaged('@myorg/internal-tool', false, makeConfig({ ignore: ['@myorg/internal-*'] }))).toBe(false); + expect(isPackageManaged('@myorg/core', false, makeConfig({ ignore: ['@myorg/internal-*'] }))).toBe(true); }); test('include overrides private', () => { - expect( - isPackageManaged( - 'my-vscode-ext', - true, - makeConfig({ - include: ['my-vscode-ext'], - }), - ), - ).toBe(true); + expect(isPackageManaged('my-vscode-ext', true, makeConfig({ include: ['my-vscode-ext'] }))).toBe(true); }); test('include supports globs', () => { - expect( - isPackageManaged( - '@myorg/app-a', - true, - makeConfig({ - include: ['@myorg/app-*'], - }), - ), - ).toBe(true); - expect( - isPackageManaged( - '@myorg/lib-b', - true, - makeConfig({ - include: ['@myorg/app-*'], - }), - ), - ).toBe(false); + expect(isPackageManaged('@myorg/app-a', true, makeConfig({ include: ['@myorg/app-*'] }))).toBe(true); + expect(isPackageManaged('@myorg/lib-b', true, makeConfig({ include: ['@myorg/app-*'] }))).toBe(false); }); test('include overrides ignore', () => { - expect( - isPackageManaged( - 'pkg-special', - false, - makeConfig({ - ignore: ['pkg-*'], - include: ['pkg-special'], - }), - ), - ).toBe(true); - // other pkg-* still ignored - expect( - isPackageManaged( - 'pkg-other', - false, - makeConfig({ - ignore: ['pkg-*'], - include: ['pkg-special'], - }), - ), - ).toBe(false); + expect(isPackageManaged('pkg-special', false, makeConfig({ ignore: ['pkg-*'], include: ['pkg-special'] }))).toBe( + true, + ); + expect(isPackageManaged('pkg-other', false, makeConfig({ ignore: ['pkg-*'], include: ['pkg-special'] }))).toBe( + false, + ); }); test('per-package managed: true overrides everything', () => { - // Private + ignored, but managed: true - expect( - isPackageManaged( - 'pkg-a', - true, - makeConfig({ - ignore: ['pkg-a'], - }), - { managed: true }, - ), - ).toBe(true); + expect(isPackageManaged('pkg-a', true, makeConfig({ ignore: ['pkg-a'] }), { managed: true })).toBe(true); }); test('per-package managed: false overrides everything', () => { - // Public + included, but managed: false - expect( - isPackageManaged( - 'pkg-a', - false, - makeConfig({ - include: ['pkg-a'], - }), - { managed: false }, - ), - ).toBe(false); + expect(isPackageManaged('pkg-a', false, makeConfig({ include: ['pkg-a'] }), { managed: false })).toBe(false); }); }); diff --git a/packages/bumpy/test/core/dep-graph.test.ts b/packages/bumpy/test/core/dep-graph.test.ts index 59bcb39..0c4d048 100644 --- a/packages/bumpy/test/core/dep-graph.test.ts +++ b/packages/bumpy/test/core/dep-graph.test.ts @@ -1,44 +1,14 @@ import { test, expect, describe } from 'bun:test'; import { DependencyGraph } from '../../src/core/dep-graph.ts'; -import type { WorkspacePackage } from '../../src/types.ts'; - -function makePkg( - name: string, - version: string, - deps: Partial< - Pick - > = {}, -): WorkspacePackage { - return { - name, - version, - dir: `/fake/${name}`, - relativeDir: `packages/${name}`, - packageJson: {}, - private: false, - dependencies: deps.dependencies || {}, - devDependencies: deps.devDependencies || {}, - peerDependencies: deps.peerDependencies || {}, - optionalDependencies: deps.optionalDependencies || {}, - }; -} +import { makePkg } from '../helpers.ts'; describe('DependencyGraph', () => { test('finds direct dependents', () => { - const packages = new Map(); - packages.set('core', makePkg('core', '1.0.0')); - packages.set( - 'plugin-a', - makePkg('plugin-a', '1.0.0', { - dependencies: { core: '^1.0.0' }, - }), - ); - packages.set( - 'plugin-b', - makePkg('plugin-b', '1.0.0', { - peerDependencies: { core: '^1.0.0' }, - }), - ); + const packages = new Map([ + ['core', makePkg('core', '1.0.0')], + ['plugin-a', makePkg('plugin-a', '1.0.0', { dependencies: { core: '^1.0.0' } })], + ['plugin-b', makePkg('plugin-b', '1.0.0', { peerDependencies: { core: '^1.0.0' } })], + ]); const graph = new DependencyGraph(packages); const dependents = graph.getDependents('core'); @@ -47,13 +17,14 @@ describe('DependencyGraph', () => { }); test('ignores external dependencies', () => { - const packages = new Map(); - packages.set( - 'my-pkg', - makePkg('my-pkg', '1.0.0', { - dependencies: { lodash: '^4.0.0', 'external-thing': '^1.0.0' }, - }), - ); + const packages = new Map([ + [ + 'my-pkg', + makePkg('my-pkg', '1.0.0', { + dependencies: { lodash: '^4.0.0', 'external-thing': '^1.0.0' }, + }), + ], + ]); const graph = new DependencyGraph(packages); expect(graph.getDependents('lodash')).toEqual([]); @@ -61,20 +32,11 @@ describe('DependencyGraph', () => { }); test('tracks dependency type correctly', () => { - const packages = new Map(); - packages.set('core', makePkg('core', '1.0.0')); - packages.set( - 'app', - makePkg('app', '1.0.0', { - dependencies: { core: '^1.0.0' }, - }), - ); - packages.set( - 'tests', - makePkg('tests', '1.0.0', { - devDependencies: { core: '^1.0.0' }, - }), - ); + const packages = new Map([ + ['core', makePkg('core', '1.0.0')], + ['app', makePkg('app', '1.0.0', { dependencies: { core: '^1.0.0' } })], + ['tests', makePkg('tests', '1.0.0', { devDependencies: { core: '^1.0.0' } })], + ]); const graph = new DependencyGraph(packages); const dependents = graph.getDependents('core'); @@ -85,20 +47,11 @@ describe('DependencyGraph', () => { }); test('topological sort puts deps before dependents', () => { - const packages = new Map(); - packages.set('core', makePkg('core', '1.0.0')); - packages.set( - 'utils', - makePkg('utils', '1.0.0', { - dependencies: { core: '^1.0.0' }, - }), - ); - packages.set( - 'app', - makePkg('app', '1.0.0', { - dependencies: { utils: '^1.0.0', core: '^1.0.0' }, - }), - ); + const packages = new Map([ + ['core', makePkg('core', '1.0.0')], + ['utils', makePkg('utils', '1.0.0', { dependencies: { core: '^1.0.0' } })], + ['app', makePkg('app', '1.0.0', { dependencies: { utils: '^1.0.0', core: '^1.0.0' } })], + ]); const graph = new DependencyGraph(packages); const sorted = graph.topologicalSort(packages); @@ -107,4 +60,29 @@ describe('DependencyGraph', () => { expect(sorted.indexOf('utils')).toBeLessThan(sorted.indexOf('app')); expect(sorted.indexOf('core')).toBeLessThan(sorted.indexOf('app')); }); + + test('handles packages with no dependencies', () => { + const packages = new Map([ + ['standalone-a', makePkg('standalone-a', '1.0.0')], + ['standalone-b', makePkg('standalone-b', '1.0.0')], + ]); + + const graph = new DependencyGraph(packages); + expect(graph.getDependents('standalone-a')).toEqual([]); + expect(graph.getDependents('standalone-b')).toEqual([]); + const sorted = graph.topologicalSort(packages); + expect(sorted).toHaveLength(2); + }); + + test('handles optional dependencies', () => { + const packages = new Map([ + ['core', makePkg('core', '1.0.0')], + ['plugin', makePkg('plugin', '1.0.0', { optionalDependencies: { core: '^1.0.0' } })], + ]); + + const graph = new DependencyGraph(packages); + const dependents = graph.getDependents('core'); + expect(dependents).toHaveLength(1); + expect(dependents[0]!.depType).toBe('optionalDependencies'); + }); }); diff --git a/packages/bumpy/test/core/git.test.ts b/packages/bumpy/test/core/git.test.ts index 5c3655e..77dbc58 100644 --- a/packages/bumpy/test/core/git.test.ts +++ b/packages/bumpy/test/core/git.test.ts @@ -1,8 +1,6 @@ import { test, expect, describe, beforeEach, afterEach } from 'bun:test'; import { resolve } from 'node:path'; -import { mkdtemp, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { runArgs } from '../../src/utils/shell.ts'; +import { createTempGitRepo, cleanupTempDir, gitInDir } from '../helpers.ts'; import { createTag, tagExists, @@ -14,21 +12,15 @@ import { } from '../../src/core/git.ts'; import { writeText } from '../../src/utils/fs.ts'; -function initRepo(dir: string) { - runArgs(['git', 'init'], { cwd: dir }); - runArgs(['git', 'commit', '--allow-empty', '-m', 'init'], { cwd: dir }); -} - describe('git helpers', () => { let tmpDir: string; beforeEach(async () => { - tmpDir = await mkdtemp(resolve(tmpdir(), 'bumpy-git-test-')); - initRepo(tmpDir); + tmpDir = await createTempGitRepo(); }); afterEach(async () => { - await rm(tmpDir, { recursive: true }); + await cleanupTempDir(tmpDir); }); // ---- createTag / tagExists ---- @@ -70,7 +62,6 @@ describe('git helpers', () => { }); test('glob matches date-based release tags for suffix logic', () => { - // This is the pattern used by createAggregateRelease createTag('release-2026-04-14', { cwd: tmpDir }); expect(listTags('release-2026-04-14*', { cwd: tmpDir })).toEqual(['release-2026-04-14']); @@ -103,7 +94,6 @@ describe('git helpers', () => { describe('getCurrentBranch', () => { test('returns current branch name', () => { - // git init defaults to main or master depending on config const branch = getCurrentBranch({ cwd: tmpDir }); expect(typeof branch).toBe('string'); expect(branch!.length).toBeGreaterThan(0); @@ -119,7 +109,6 @@ describe('git helpers', () => { commitFiles(['a.txt', 'b.txt'], 'add files', { cwd: tmpDir }); - // Verify clean working tree expect(hasUncommittedChanges({ cwd: tmpDir })).toBe(false); }); @@ -129,7 +118,6 @@ describe('git helpers', () => { commitFiles(['staged.txt'], 'partial commit', { cwd: tmpDir }); - // unstaged.txt should still be dirty expect(hasUncommittedChanges({ cwd: tmpDir })).toBe(true); }); }); @@ -138,19 +126,18 @@ describe('git helpers', () => { describe('pushWithTags', () => { test('pushes commits and tags to remote', async () => { - // Set up a bare remote and clone - const remoteDir = await mkdtemp(resolve(tmpdir(), 'bumpy-remote-')); - runArgs(['git', 'init', '--bare'], { cwd: remoteDir }); - runArgs(['git', 'remote', 'add', 'origin', remoteDir], { cwd: tmpDir }); - // Push once with -u to set up tracking before testing pushWithTags - runArgs(['git', 'push', '-u', 'origin', 'HEAD'], { cwd: tmpDir }); + const { mkdtemp, rm } = await import('node:fs/promises'); + const remoteDir = await mkdtemp(resolve((await import('node:os')).tmpdir(), 'bumpy-remote-')); + gitInDir(['init', '--bare'], remoteDir); + gitInDir(['remote', 'add', 'origin', remoteDir], tmpDir); + gitInDir(['push', '-u', 'origin', 'HEAD'], tmpDir); createTag('v1.0.0', { cwd: tmpDir }); pushWithTags({ cwd: tmpDir }); // Clone from remote and check the tag arrived - const cloneDir = await mkdtemp(resolve(tmpdir(), 'bumpy-clone-')); - runArgs(['git', 'clone', remoteDir, '.'], { cwd: cloneDir }); + const cloneDir = await mkdtemp(resolve((await import('node:os')).tmpdir(), 'bumpy-clone-')); + gitInDir(['clone', remoteDir, '.'], cloneDir); expect(tagExists('v1.0.0', { cwd: cloneDir })).toBe(true); await rm(remoteDir, { recursive: true }); diff --git a/packages/bumpy/test/core/github-release.test.ts b/packages/bumpy/test/core/github-release.test.ts index 89fe094..67c18d2 100644 --- a/packages/bumpy/test/core/github-release.test.ts +++ b/packages/bumpy/test/core/github-release.test.ts @@ -1,32 +1,12 @@ -import { test, expect, describe } from 'bun:test'; -import { resolve } from 'node:path'; -import { mkdtemp, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { runArgs } from '../../src/utils/shell.ts'; +import { test, expect, describe, beforeEach, afterEach } from 'bun:test'; +import { makeRelease, createTempGitRepo, cleanupTempDir } from '../helpers.ts'; +import { installShellMock, uninstallShellMock, getCallsMatching, addMockRule } from '../helpers-shell-mock.ts'; import { listTags, tagExists } from '../../src/core/git.ts'; import { resolveAggregateTagAndTitle, createAggregateRelease, createIndividualReleases, } from '../../src/core/github-release.ts'; -import type { PlannedRelease } from '../../src/types.ts'; - -function initRepo(dir: string) { - runArgs(['git', 'init'], { cwd: dir }); - runArgs(['git', 'commit', '--allow-empty', '-m', 'init'], { cwd: dir }); -} - -function makeRelease(name: string, version: string, type: 'major' | 'minor' | 'patch' = 'patch'): PlannedRelease { - return { - name, - type, - oldVersion: '0.0.0', - newVersion: version, - changesets: [], - isDependencyBump: false, - isCascadeBump: false, - }; -} // ---- Pure unit tests for tag/title resolution ---- @@ -61,96 +41,161 @@ describe('resolveAggregateTagAndTitle', () => { }); }); -// ---- Integration tests using real git repos ---- +// ---- Integration tests using real git repos + mocked gh CLI ---- describe('createAggregateRelease', () => { let tmpDir: string; - test('creates a date-based git tag (gh failure is caught)', async () => { - tmpDir = await mkdtemp(resolve(tmpdir(), 'bumpy-ghrel-')); - initRepo(tmpDir); + beforeEach(async () => { + tmpDir = await createTempGitRepo(); + installShellMock(); + addMockRule({ match: /^gh release create/, response: '' }); + }); + + afterEach(async () => { + uninstallShellMock(); + await cleanupTempDir(tmpDir); + }); - const releases = [makeRelease('pkg-a', '1.0.0', 'minor')]; + test('creates a date-based git tag', async () => { + const releases = [makeRelease('pkg-a', '1.0.0', { type: 'minor' })]; - // This will create the git tag, then fail at `gh release create` (no auth), - // but the error is caught — we verify the tag was created correctly await createAggregateRelease(releases, [], tmpDir); const today = new Date().toISOString().split('T')[0]; expect(tagExists(`release-${today}`, { cwd: tmpDir })).toBe(true); + }); + + test('calls gh release create with correct arguments', async () => { + const releases = [makeRelease('pkg-a', '1.0.0', { type: 'minor' })]; - await rm(tmpDir, { recursive: true }); + await createAggregateRelease(releases, [], tmpDir); + + const ghCalls = getCallsMatching('gh release create'); + expect(ghCalls).toHaveLength(1); + expect(ghCalls[0]!.command).toContain('release-'); + expect(ghCalls[0]!.command).toContain('--title'); + expect(ghCalls[0]!.command).toContain('--notes'); }); test('second call on same day creates tag with -2 suffix', async () => { - tmpDir = await mkdtemp(resolve(tmpdir(), 'bumpy-ghrel-')); - initRepo(tmpDir); - const releases = [makeRelease('pkg-a', '1.0.0')]; const today = new Date().toISOString().split('T')[0]; - // First release await createAggregateRelease(releases, [], tmpDir); expect(tagExists(`release-${today}`, { cwd: tmpDir })).toBe(true); - // Second release await createAggregateRelease(releases, [], tmpDir); expect(tagExists(`release-${today}-2`, { cwd: tmpDir })).toBe(true); - // Third release await createAggregateRelease(releases, [], tmpDir); expect(tagExists(`release-${today}-3`, { cwd: tmpDir })).toBe(true); - // All three tags exist const tags = listTags(`release-${today}*`, { cwd: tmpDir }); expect(tags).toHaveLength(3); - - await rm(tmpDir, { recursive: true }); }); test('skips with empty releases array', async () => { - tmpDir = await mkdtemp(resolve(tmpdir(), 'bumpy-ghrel-')); - initRepo(tmpDir); - - // Should return early, no tags created await createAggregateRelease([], [], tmpDir); const tags = listTags('release-*', { cwd: tmpDir }); expect(tags).toHaveLength(0); + const ghCalls = getCallsMatching('gh release create'); + expect(ghCalls).toHaveLength(0); + }); + + test('handles gh failure gracefully', async () => { + // Override the default gh release create rule with an error + installShellMock(); + addMockRule({ match: /^gh release create/, error: 'auth required' }); + + const releases = [makeRelease('pkg-a', '1.0.0', { type: 'minor' })]; + + // Should not throw + await createAggregateRelease(releases, [], tmpDir); + + // Tag should still be created (git tag happens before gh release create) + const today = new Date().toISOString().split('T')[0]; + expect(tagExists(`release-${today}`, { cwd: tmpDir })).toBe(true); + }); + + test('skips entirely when gh is not available', async () => { + installShellMock({ interceptGh: false }); + addMockRule({ match: 'gh --version', error: 'not found' }); - await rm(tmpDir, { recursive: true }); + const releases = [makeRelease('pkg-a', '1.0.0')]; + await createAggregateRelease(releases, [], tmpDir); + + const tags = listTags('release-*', { cwd: tmpDir }); + expect(tags).toHaveLength(0); }); }); describe('createIndividualReleases', () => { let tmpDir: string; - test('dry run does not create tags or call gh', async () => { - tmpDir = await mkdtemp(resolve(tmpdir(), 'bumpy-ghrel-')); - initRepo(tmpDir); + beforeEach(async () => { + tmpDir = await createTempGitRepo(); + installShellMock(); + addMockRule({ match: /^gh release create/, response: '' }); + }); + afterEach(async () => { + uninstallShellMock(); + await cleanupTempDir(tmpDir); + }); + + test('dry run does not create tags or call gh', async () => { const releases = [makeRelease('pkg-a', '1.0.0'), makeRelease('pkg-b', '2.0.0')]; await createIndividualReleases(releases, [], tmpDir, { dryRun: true }); - // No tags should be created in dry-run mode expect(tagExists('pkg-a@1.0.0', { cwd: tmpDir })).toBe(false); expect(tagExists('pkg-b@2.0.0', { cwd: tmpDir })).toBe(false); - await rm(tmpDir, { recursive: true }); + const ghCalls = getCallsMatching('gh release create'); + expect(ghCalls).toHaveLength(0); }); - test('creates per-package releases (gh failure is caught)', async () => { - tmpDir = await mkdtemp(resolve(tmpdir(), 'bumpy-ghrel-')); - initRepo(tmpDir); + test('creates per-package releases via gh', async () => { + const releases = [ + makeRelease('pkg-a', '1.0.0', { type: 'minor' }), + makeRelease('pkg-b', '2.0.0', { type: 'major' }), + ]; - const releases = [makeRelease('pkg-a', '1.0.0', 'minor'), makeRelease('pkg-b', '2.0.0', 'major')]; + await createIndividualReleases(releases, [], tmpDir); + + const ghCalls = getCallsMatching('gh release create'); + expect(ghCalls).toHaveLength(2); + expect(ghCalls[0]!.command).toContain('pkg-a@1.0.0'); + expect(ghCalls[1]!.command).toContain('pkg-b@2.0.0'); + }); + + test('includes changeset summaries in release body', async () => { + const changesets = [ + { id: 'cs1', releases: [{ name: 'pkg-a', type: 'patch' as const }], summary: 'Fixed the login bug' }, + ]; + const releases = [makeRelease('pkg-a', '1.0.1', { changesets: ['cs1'] })]; + + await createIndividualReleases(releases, changesets, tmpDir); + + const ghCalls = getCallsMatching('gh release create'); + expect(ghCalls).toHaveLength(1); + const notesIdx = ghCalls[0]!.args!.indexOf('--notes'); + expect(notesIdx).toBeGreaterThan(-1); + expect(ghCalls[0]!.args![notesIdx + 1]).toContain('Fixed the login bug'); + }); + + test('continues after individual release failure', async () => { + installShellMock(); + addMockRule({ match: 'pkg-a@1.0.0', error: 'tag already exists' }); + addMockRule({ match: /^gh release create/, response: '' }); + + const releases = [makeRelease('pkg-a', '1.0.0'), makeRelease('pkg-b', '2.0.0')]; - // gh will fail but errors are caught per-release — all releases attempted await createIndividualReleases(releases, [], tmpDir); - // Individual releases don't create git tags (that's done by publish-pipeline) - // but this verifies the function doesn't throw on gh failure - await rm(tmpDir, { recursive: true }); + const ghCalls = getCallsMatching('gh release create'); + expect(ghCalls).toHaveLength(2); }); }); diff --git a/packages/bumpy/test/core/names.test.ts b/packages/bumpy/test/core/names.test.ts new file mode 100644 index 0000000..f831392 --- /dev/null +++ b/packages/bumpy/test/core/names.test.ts @@ -0,0 +1,53 @@ +import { test, expect, describe } from 'bun:test'; +import { randomName, slugify } from '../../src/utils/names.ts'; + +describe('randomName', () => { + test('returns a string with two hyphens (adj-adj-noun)', () => { + const name = randomName(); + const parts = name.split('-'); + expect(parts).toHaveLength(3); + }); + + test('generates different names on successive calls', () => { + const names = new Set(Array.from({ length: 20 }, () => randomName())); + // With 60 adjectives and 60 nouns, collisions are extremely unlikely in 20 tries + expect(names.size).toBeGreaterThan(10); + }); + + test('contains only lowercase letters and hyphens', () => { + for (let i = 0; i < 10; i++) { + const name = randomName(); + expect(name).toMatch(/^[a-z]+-[a-z]+-[a-z]+$/); + } + }); +}); + +describe('slugify', () => { + test('lowercases and replaces spaces with hyphens', () => { + expect(slugify('Hello World')).toBe('hello-world'); + }); + + test('replaces special characters with hyphens', () => { + expect(slugify('foo@bar!baz')).toBe('foo-bar-baz'); + }); + + test('collapses multiple non-alphanumeric chars', () => { + expect(slugify('foo---bar')).toBe('foo-bar'); + }); + + test('strips leading and trailing hyphens', () => { + expect(slugify('---hello---')).toBe('hello'); + }); + + test('handles empty string', () => { + expect(slugify('')).toBe(''); + }); + + test('preserves numbers', () => { + expect(slugify('v1.2.3')).toBe('v1-2-3'); + }); + + test('handles already-slugified input', () => { + expect(slugify('already-good')).toBe('already-good'); + }); +}); diff --git a/packages/bumpy/test/core/publish-pipeline.test.ts b/packages/bumpy/test/core/publish-pipeline.test.ts index 087175f..db58cd8 100644 --- a/packages/bumpy/test/core/publish-pipeline.test.ts +++ b/packages/bumpy/test/core/publish-pipeline.test.ts @@ -1,59 +1,50 @@ -import { test, expect, describe } from 'bun:test'; +import { test, expect, describe, beforeEach, afterEach } from 'bun:test'; import { resolve } from 'node:path'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { writeJson, readJson, ensureDir, writeText } from '../../src/utils/fs.ts'; +import { makePkg, gitInDir } from '../helpers.ts'; +import { installShellMock, uninstallShellMock } from '../helpers-shell-mock.ts'; import { DependencyGraph } from '../../src/core/dep-graph.ts'; import { publishPackages } from '../../src/core/publish-pipeline.ts'; import type { WorkspacePackage, ReleasePlan, BumpyConfig } from '../../src/types.ts'; import { DEFAULT_CONFIG, DEFAULT_PUBLISH_CONFIG } from '../../src/types.ts'; -/** Config override that uses in-place resolution (for testing protocol resolution) */ const IN_PLACE_CONFIG: BumpyConfig = { ...DEFAULT_CONFIG, publish: { ...DEFAULT_PUBLISH_CONFIG, protocolResolution: 'in-place' }, }; -function makePkg( - name: string, - version: string, - dir: string, - deps: Partial> = {}, -): WorkspacePackage { - return { - name, - version, - dir, - relativeDir: `packages/${name}`, - packageJson: { name, version }, - private: deps.private || false, - dependencies: deps.dependencies || {}, - devDependencies: {}, - peerDependencies: deps.peerDependencies || {}, - optionalDependencies: {}, - bumpy: deps.bumpy, - }; -} - describe('publishPackages', () => { let tmpDir: string; - test('dry run does not modify files', async () => { + beforeEach(async () => { tmpDir = await mkdtemp(resolve(tmpdir(), 'bumpy-test-')); + installShellMock(); + }); + + afterEach(async () => { + uninstallShellMock(); + await rm(tmpDir, { recursive: true }); + }); + + function setupGitRepo() { + gitInDir(['init'], tmpDir); + gitInDir(['add', '.'], tmpDir); + gitInDir(['commit', '-m', 'init', '--allow-empty'], tmpDir); + } + + test('dry run does not modify files', async () => { const pkgDir = resolve(tmpDir, 'packages/my-pkg'); await ensureDir(pkgDir); await writeJson(resolve(pkgDir, 'package.json'), { name: 'my-pkg', version: '1.0.0' }); - - // Init git so tag checks work - const { run } = await import('../../src/utils/shell.ts'); - run('git init', { cwd: tmpDir }); - run('git add .', { cwd: tmpDir }); - run('git commit -m "init" --allow-empty', { cwd: tmpDir }); + await setupGitRepo(); const packages = new Map(); packages.set( 'my-pkg', - makePkg('my-pkg', '1.0.0', pkgDir, { + makePkg('my-pkg', '1.0.0', { + dir: pkgDir, bumpy: { skipNpmPublish: true }, }), ); @@ -76,34 +67,24 @@ describe('publishPackages', () => { const result = await publishPackages(plan, packages, depGraph, DEFAULT_CONFIG, tmpDir, { dryRun: true }); - // Skipped because skipNpmPublish + dryRun expect(result.failed).toHaveLength(0); - // package.json should be unchanged const pkg = await readJson>(resolve(pkgDir, 'package.json')); expect(pkg.version).toBe('1.0.0'); - - await rm(tmpDir, { recursive: true }); }); test('custom publish command gets version/name templated', async () => { - tmpDir = await mkdtemp(resolve(tmpdir(), 'bumpy-test-')); const pkgDir = resolve(tmpDir, 'packages/my-ext'); await ensureDir(pkgDir); await writeJson(resolve(pkgDir, 'package.json'), { name: 'my-ext', version: '2.0.0' }); - - // Write a dummy publish script that records what it received await writeText(resolve(pkgDir, 'publish.sh'), 'echo "$@" > published.txt'); - - const { run } = await import('../../src/utils/shell.ts'); - run('git init', { cwd: tmpDir }); - run('git add .', { cwd: tmpDir }); - run('git commit -m "init"', { cwd: tmpDir }); + await setupGitRepo(); const packages = new Map(); packages.set( 'my-ext', - makePkg('my-ext', '2.0.0', pkgDir, { + makePkg('my-ext', '2.0.0', { + dir: pkgDir, bumpy: { publishCommand: 'echo published {{name}}@{{version}}', }, @@ -131,25 +112,19 @@ describe('publishPackages', () => { expect(result.published).toHaveLength(1); expect(result.published[0]!.name).toBe('my-ext'); expect(result.published[0]!.version).toBe('2.1.0'); - - await rm(tmpDir, { recursive: true }); }); test('skips private packages without custom publish', async () => { - tmpDir = await mkdtemp(resolve(tmpdir(), 'bumpy-test-')); const pkgDir = resolve(tmpDir, 'packages/private-pkg'); await ensureDir(pkgDir); await writeJson(resolve(pkgDir, 'package.json'), { name: 'private-pkg', version: '1.0.0', private: true }); - - const { run } = await import('../../src/utils/shell.ts'); - run('git init', { cwd: tmpDir }); - run('git add .', { cwd: tmpDir }); - run('git commit -m "init"', { cwd: tmpDir }); + await setupGitRepo(); const packages = new Map(); packages.set( 'private-pkg', - makePkg('private-pkg', '1.0.0', pkgDir, { + makePkg('private-pkg', '1.0.0', { + dir: pkgDir, private: true, }), ); @@ -175,12 +150,9 @@ describe('publishPackages', () => { expect(result.skipped).toHaveLength(1); expect(result.skipped[0]!.reason).toBe('private'); expect(result.published).toHaveLength(0); - - await rm(tmpDir, { recursive: true }); }); test('workspace protocol resolution for publish', async () => { - tmpDir = await mkdtemp(resolve(tmpdir(), 'bumpy-test-')); const coreDir = resolve(tmpDir, 'packages/core'); const appDir = resolve(tmpDir, 'packages/app'); await ensureDir(coreDir); @@ -192,17 +164,14 @@ describe('publishPackages', () => { version: '1.0.1', dependencies: { core: 'workspace:^' }, }); - - const { run } = await import('../../src/utils/shell.ts'); - run('git init', { cwd: tmpDir }); - run('git add .', { cwd: tmpDir }); - run('git commit -m "init"', { cwd: tmpDir }); + await setupGitRepo(); const packages = new Map(); - packages.set('core', makePkg('core', '1.1.0', coreDir)); + packages.set('core', makePkg('core', '1.1.0', { dir: coreDir })); packages.set( 'app', - makePkg('app', '1.0.1', appDir, { + makePkg('app', '1.0.1', { + dir: appDir, dependencies: { core: 'workspace:^' }, }), ); @@ -232,19 +201,14 @@ describe('publishPackages', () => { ], }; - // Dry run with in-place resolution — protocols get resolved but npm publish doesn't run await publishPackages(plan, packages, depGraph, IN_PLACE_CONFIG, tmpDir, { dryRun: true }); - // Check that workspace: was resolved in app's package.json const appPkg = await readJson>(resolve(appDir, 'package.json')); const deps = appPkg.dependencies as Record; - expect(deps.core).toBe('^1.1.0'); // workspace:^ → ^1.1.0 - - await rm(tmpDir, { recursive: true }); + expect(deps.core).toBe('^1.1.0'); }); test('catalog: protocol resolution for publish', async () => { - tmpDir = await mkdtemp(resolve(tmpdir(), 'bumpy-test-')); const appDir = resolve(tmpDir, 'packages/app'); await ensureDir(appDir); @@ -253,14 +217,10 @@ describe('publishPackages', () => { version: '1.0.0', dependencies: { react: 'catalog:', jest: 'catalog:testing' }, }); - - const { run } = await import('../../src/utils/shell.ts'); - run('git init', { cwd: tmpDir }); - run('git add .', { cwd: tmpDir }); - run('git commit -m "init"', { cwd: tmpDir }); + await setupGitRepo(); const packages = new Map(); - packages.set('app', makePkg('app', '1.0.0', appDir)); + packages.set('app', makePkg('app', '1.0.0', { dir: appDir })); const depGraph = new DependencyGraph(packages); const plan: ReleasePlan = { @@ -278,19 +238,15 @@ describe('publishPackages', () => { ], }; - // Set up catalogs const catalogs = new Map>(); - catalogs.set('', { react: '^19.0.0', 'react-dom': '^19.0.0' }); // default catalog - catalogs.set('testing', { jest: '^30.0.0' }); // named catalog + catalogs.set('', { react: '^19.0.0', 'react-dom': '^19.0.0' }); + catalogs.set('testing', { jest: '^30.0.0' }); - // Dry run with in-place resolution — protocols get resolved but npm publish doesn't run await publishPackages(plan, packages, depGraph, IN_PLACE_CONFIG, tmpDir, { dryRun: true }, catalogs); const appPkg = await readJson>(resolve(appDir, 'package.json')); const deps = appPkg.dependencies as Record; - expect(deps.react).toBe('^19.0.0'); // catalog: → resolved from default catalog - expect(deps.jest).toBe('^30.0.0'); // catalog:testing → resolved from named catalog - - await rm(tmpDir, { recursive: true }); + expect(deps.react).toBe('^19.0.0'); + expect(deps.jest).toBe('^30.0.0'); }); }); diff --git a/packages/bumpy/test/core/release-plan.test.ts b/packages/bumpy/test/core/release-plan.test.ts index e09b19e..da64eed 100644 --- a/packages/bumpy/test/core/release-plan.test.ts +++ b/packages/bumpy/test/core/release-plan.test.ts @@ -1,39 +1,12 @@ import { test, expect, describe } from 'bun:test'; import { assembleReleasePlan } from '../../src/core/release-plan.ts'; import { DependencyGraph } from '../../src/core/dep-graph.ts'; -import type { WorkspacePackage, Changeset, BumpyConfig } from '../../src/types.ts'; -import { DEFAULT_CONFIG } from '../../src/types.ts'; - -function makePkg( - name: string, - version: string, - deps: Partial< - Pick - > = {}, -): WorkspacePackage { - return { - name, - version, - dir: `/fake/${name}`, - relativeDir: `packages/${name}`, - packageJson: {}, - private: false, - dependencies: deps.dependencies || {}, - devDependencies: deps.devDependencies || {}, - peerDependencies: deps.peerDependencies || {}, - optionalDependencies: deps.optionalDependencies || {}, - bumpy: deps.bumpy, - }; -} - -function makeConfig(overrides: Partial = {}): BumpyConfig { - return { ...DEFAULT_CONFIG, ...overrides }; -} +import { makePkg, makeConfig } from '../helpers.ts'; +import type { Changeset } from '../../src/types.ts'; describe('assembleReleasePlan', () => { test('basic single package bump', () => { - const packages = new Map(); - packages.set('pkg-a', makePkg('pkg-a', '1.0.0')); + const packages = new Map([['pkg-a', makePkg('pkg-a', '1.0.0')]]); const changesets: Changeset[] = [ { id: 'cs1', releases: [{ name: 'pkg-a', type: 'minor' }], summary: 'Added feature' }, @@ -50,8 +23,7 @@ describe('assembleReleasePlan', () => { }); test('multiple changesets for same package take highest bump', () => { - const packages = new Map(); - packages.set('pkg-a', makePkg('pkg-a', '1.0.0')); + const packages = new Map([['pkg-a', makePkg('pkg-a', '1.0.0')]]); const changesets: Changeset[] = [ { id: 'cs1', releases: [{ name: 'pkg-a', type: 'patch' }], summary: 'Fix' }, @@ -67,19 +39,14 @@ describe('assembleReleasePlan', () => { }); test('dependency propagation - patch bump propagates patch to dependents', () => { - const packages = new Map(); - packages.set('core', makePkg('core', '1.0.0')); - packages.set( - 'app', - makePkg('app', '2.0.0', { - dependencies: { core: '^1.0.0' }, - }), - ); + const packages = new Map([ + ['core', makePkg('core', '1.0.0')], + ['app', makePkg('app', '2.0.0', { dependencies: { core: '^1.0.0' } })], + ]); const changesets: Changeset[] = [{ id: 'cs1', releases: [{ name: 'core', type: 'patch' }], summary: 'Fix' }]; const graph = new DependencyGraph(packages); - // Use "patch" mode so propagation always happens regardless of range const plan = assembleReleasePlan(changesets, packages, graph, makeConfig({ updateInternalDependencies: 'patch' })); expect(plan.releases).toHaveLength(2); @@ -91,34 +58,25 @@ describe('assembleReleasePlan', () => { }); test('peer dependency minor bump does NOT propagate by default', () => { - const packages = new Map(); - packages.set('core', makePkg('core', '1.0.0')); - packages.set( - 'plugin', - makePkg('plugin', '1.0.0', { - peerDependencies: { core: '^1.0.0' }, - }), - ); + const packages = new Map([ + ['core', makePkg('core', '1.0.0')], + ['plugin', makePkg('plugin', '1.0.0', { peerDependencies: { core: '^1.0.0' } })], + ]); const changesets: Changeset[] = [{ id: 'cs1', releases: [{ name: 'core', type: 'minor' }], summary: 'Feature' }]; const graph = new DependencyGraph(packages); const plan = assembleReleasePlan(changesets, packages, graph, makeConfig()); - // Only core should be released - peer dep minor doesn't trigger major (unlike changesets!) expect(plan.releases).toHaveLength(1); expect(plan.releases[0]!.name).toBe('core'); }); test('peer dependency major bump DOES propagate by default', () => { - const packages = new Map(); - packages.set('core', makePkg('core', '1.0.0')); - packages.set( - 'plugin', - makePkg('plugin', '1.0.0', { - peerDependencies: { core: '^1.0.0' }, - }), - ); + const packages = new Map([ + ['core', makePkg('core', '1.0.0')], + ['plugin', makePkg('plugin', '1.0.0', { peerDependencies: { core: '^1.0.0' } })], + ]); const changesets: Changeset[] = [{ id: 'cs1', releases: [{ name: 'core', type: 'major' }], summary: 'Breaking' }]; @@ -132,14 +90,10 @@ describe('assembleReleasePlan', () => { }); test('isolated bump skips dependency propagation', () => { - const packages = new Map(); - packages.set('core', makePkg('core', '1.0.0')); - packages.set( - 'app', - makePkg('app', '2.0.0', { - dependencies: { core: '^1.0.0' }, - }), - ); + const packages = new Map([ + ['core', makePkg('core', '1.0.0')], + ['app', makePkg('app', '2.0.0', { dependencies: { core: '^1.0.0' } })], + ]); const changesets: Changeset[] = [ { id: 'cs1', releases: [{ name: 'core', type: 'patch-isolated' }], summary: 'Internal' }, @@ -148,21 +102,16 @@ describe('assembleReleasePlan', () => { const graph = new DependencyGraph(packages); const plan = assembleReleasePlan(changesets, packages, graph, makeConfig()); - // Only core - no propagation due to isolated expect(plan.releases).toHaveLength(1); expect(plan.releases[0]!.name).toBe('core'); expect(plan.releases[0]!.newVersion).toBe('1.0.1'); }); test('non-isolated changeset overrides isolated for same package', () => { - const packages = new Map(); - packages.set('core', makePkg('core', '1.0.0')); - packages.set( - 'app', - makePkg('app', '2.0.0', { - dependencies: { core: '^1.0.0' }, - }), - ); + const packages = new Map([ + ['core', makePkg('core', '1.0.0')], + ['app', makePkg('app', '2.0.0', { dependencies: { core: '^1.0.0' } })], + ]); const changesets: Changeset[] = [ { id: 'cs1', releases: [{ name: 'core', type: 'patch-isolated' }], summary: 'Internal' }, @@ -172,19 +121,14 @@ describe('assembleReleasePlan', () => { const graph = new DependencyGraph(packages); const plan = assembleReleasePlan(changesets, packages, graph, makeConfig({ updateInternalDependencies: 'patch' })); - // Both should be released because one changeset is non-isolated expect(plan.releases).toHaveLength(2); }); test('out-of-range: skips propagation when version still satisfies range', () => { - const packages = new Map(); - packages.set('core', makePkg('core', '1.0.0')); - packages.set( - 'app', - makePkg('app', '1.0.0', { - dependencies: { core: '^1.0.0' }, // ^1.0.0 includes 1.1.0 - }), - ); + const packages = new Map([ + ['core', makePkg('core', '1.0.0')], + ['app', makePkg('app', '1.0.0', { dependencies: { core: '^1.0.0' } })], + ]); const changesets: Changeset[] = [{ id: 'cs1', releases: [{ name: 'core', type: 'minor' }], summary: 'Feature' }]; @@ -192,20 +136,15 @@ describe('assembleReleasePlan', () => { const config = makeConfig({ updateInternalDependencies: 'out-of-range' }); const plan = assembleReleasePlan(changesets, packages, graph, config); - // core bumps to 1.1.0, ^1.0.0 still satisfies → app NOT bumped expect(plan.releases).toHaveLength(1); expect(plan.releases[0]!.name).toBe('core'); }); test('out-of-range: propagates when version leaves range', () => { - const packages = new Map(); - packages.set('core', makePkg('core', '1.0.0')); - packages.set( - 'app', - makePkg('app', '1.0.0', { - dependencies: { core: '^1.0.0' }, // ^1.0.0 does NOT include 2.0.0 - }), - ); + const packages = new Map([ + ['core', makePkg('core', '1.0.0')], + ['app', makePkg('app', '1.0.0', { dependencies: { core: '^1.0.0' } })], + ]); const changesets: Changeset[] = [{ id: 'cs1', releases: [{ name: 'core', type: 'major' }], summary: 'Breaking' }]; @@ -213,15 +152,15 @@ describe('assembleReleasePlan', () => { const config = makeConfig({ updateInternalDependencies: 'out-of-range' }); const plan = assembleReleasePlan(changesets, packages, graph, config); - // core bumps to 2.0.0, ^1.0.0 no longer satisfies → app bumped expect(plan.releases).toHaveLength(2); }); test('changeset-level cascade overrides', () => { - const packages = new Map(); - packages.set('core', makePkg('core', '1.0.0')); - packages.set('plugin-a', makePkg('plugin-a', '1.0.0')); - packages.set('plugin-b', makePkg('plugin-b', '1.0.0')); + const packages = new Map([ + ['core', makePkg('core', '1.0.0')], + ['plugin-a', makePkg('plugin-a', '1.0.0')], + ['plugin-b', makePkg('plugin-b', '1.0.0')], + ]); const changesets: Changeset[] = [ { @@ -250,68 +189,65 @@ describe('assembleReleasePlan', () => { }); test('cascadeTo config on source package', () => { - const packages = new Map(); - packages.set( - 'core', - makePkg('core', '1.0.0', { - bumpy: { - cascadeTo: { - 'plugin-*': { trigger: 'minor', bumpAs: 'patch' }, + const packages = new Map([ + [ + 'core', + makePkg('core', '1.0.0', { + bumpy: { + cascadeTo: { + 'plugin-*': { trigger: 'minor', bumpAs: 'patch' }, + }, }, - }, - }), - ); - packages.set('plugin-a', makePkg('plugin-a', '1.0.0')); - packages.set('plugin-b', makePkg('plugin-b', '1.0.0')); - packages.set('unrelated', makePkg('unrelated', '1.0.0')); + }), + ], + ['plugin-a', makePkg('plugin-a', '1.0.0')], + ['plugin-b', makePkg('plugin-b', '1.0.0')], + ['unrelated', makePkg('unrelated', '1.0.0')], + ]); const changesets: Changeset[] = [{ id: 'cs1', releases: [{ name: 'core', type: 'minor' }], summary: 'Feature' }]; const graph = new DependencyGraph(packages); const plan = assembleReleasePlan(changesets, packages, graph, makeConfig()); - expect(plan.releases).toHaveLength(3); // core + 2 plugins, NOT unrelated + expect(plan.releases).toHaveLength(3); expect(plan.releases.find((r) => r.name === 'unrelated')).toBeUndefined(); expect(plan.releases.find((r) => r.name === 'plugin-a')!.type).toBe('patch'); }); test('specific dependency rules override global rules', () => { - const packages = new Map(); - packages.set('core', makePkg('core', '1.0.0')); - packages.set( - 'special', - makePkg('special', '1.0.0', { - dependencies: { core: '^1.0.0' }, - bumpy: { - specificDependencyRules: { - core: { trigger: 'none', bumpAs: 'patch' }, // never propagate from core + const packages = new Map([ + ['core', makePkg('core', '1.0.0')], + [ + 'special', + makePkg('special', '1.0.0', { + dependencies: { core: '^1.0.0' }, + bumpy: { + specificDependencyRules: { + core: { trigger: 'none', bumpAs: 'patch' }, + }, }, - }, - }), - ); - packages.set( - 'normal', - makePkg('normal', '1.0.0', { - dependencies: { core: '^1.0.0' }, - }), - ); + }), + ], + ['normal', makePkg('normal', '1.0.0', { dependencies: { core: '^1.0.0' } })], + ]); const changesets: Changeset[] = [{ id: 'cs1', releases: [{ name: 'core', type: 'patch' }], summary: 'Fix' }]; const graph = new DependencyGraph(packages); const plan = assembleReleasePlan(changesets, packages, graph, makeConfig({ updateInternalDependencies: 'patch' })); - // normal gets bumped (default dep rule), special does NOT (specific rule says none) expect(plan.releases).toHaveLength(2); expect(plan.releases.find((r) => r.name === 'normal')).toBeDefined(); expect(plan.releases.find((r) => r.name === 'special')).toBeUndefined(); }); test('fixed groups: all packages get highest bump', () => { - const packages = new Map(); - packages.set('pkg-a', makePkg('pkg-a', '1.0.0')); - packages.set('pkg-b', makePkg('pkg-b', '1.0.0')); - packages.set('pkg-c', makePkg('pkg-c', '1.0.0')); + const packages = new Map([ + ['pkg-a', makePkg('pkg-a', '1.0.0')], + ['pkg-b', makePkg('pkg-b', '1.0.0')], + ['pkg-c', makePkg('pkg-c', '1.0.0')], + ]); const changesets: Changeset[] = [ { id: 'cs1', releases: [{ name: 'pkg-a', type: 'minor' }], summary: 'Feature' }, @@ -322,7 +258,6 @@ describe('assembleReleasePlan', () => { const config = makeConfig({ fixed: [['pkg-a', 'pkg-b', 'pkg-c']] }); const plan = assembleReleasePlan(changesets, packages, graph, config); - // All three should get minor (highest in group) expect(plan.releases).toHaveLength(3); for (const r of plan.releases) { expect(r.type).toBe('minor'); @@ -331,14 +266,10 @@ describe('assembleReleasePlan', () => { }); test('devDependencies do not propagate by default', () => { - const packages = new Map(); - packages.set('test-utils', makePkg('test-utils', '1.0.0')); - packages.set( - 'app', - makePkg('app', '1.0.0', { - devDependencies: { 'test-utils': '^1.0.0' }, - }), - ); + const packages = new Map([ + ['test-utils', makePkg('test-utils', '1.0.0')], + ['app', makePkg('app', '1.0.0', { devDependencies: { 'test-utils': '^1.0.0' } })], + ]); const changesets: Changeset[] = [ { id: 'cs1', releases: [{ name: 'test-utils', type: 'major' }], summary: 'Breaking' }, @@ -347,14 +278,12 @@ describe('assembleReleasePlan', () => { const graph = new DependencyGraph(packages); const plan = assembleReleasePlan(changesets, packages, graph, makeConfig()); - // Only test-utils - devDep trigger is "none" by default expect(plan.releases).toHaveLength(1); expect(plan.releases[0]!.name).toBe('test-utils'); }); test('empty changesets returns empty plan', () => { - const packages = new Map(); - packages.set('pkg-a', makePkg('pkg-a', '1.0.0')); + const packages = new Map([['pkg-a', makePkg('pkg-a', '1.0.0')]]); const graph = new DependencyGraph(packages); const plan = assembleReleasePlan([], packages, graph, makeConfig()); @@ -364,32 +293,45 @@ describe('assembleReleasePlan', () => { }); test('transitive dependency propagation', () => { - const packages = new Map(); - packages.set('core', makePkg('core', '1.0.0')); - packages.set( - 'middle', - makePkg('middle', '1.0.0', { - dependencies: { core: '~1.0.0' }, // ~1.0.0 does NOT include 1.1.0 - }), - ); - packages.set( - 'app', - makePkg('app', '1.0.0', { - dependencies: { middle: '~1.0.0' }, - }), - ); + const packages = new Map([ + ['core', makePkg('core', '1.0.0')], + ['middle', makePkg('middle', '1.0.0', { dependencies: { core: '~1.0.0' } })], + ['app', makePkg('app', '1.0.0', { dependencies: { middle: '~1.0.0' } })], + ]); const changesets: Changeset[] = [{ id: 'cs1', releases: [{ name: 'core', type: 'minor' }], summary: 'Feature' }]; const graph = new DependencyGraph(packages); - // out-of-range: ~1.0.0 doesn't include 1.1.0, so middle gets bumped - // then middle bumps to 1.0.1, ~1.0.0 still includes → app NOT bumped const config = makeConfig({ updateInternalDependencies: 'out-of-range' }); const plan = assembleReleasePlan(changesets, packages, graph, config); expect(plan.releases.find((r) => r.name === 'core')).toBeDefined(); expect(plan.releases.find((r) => r.name === 'middle')).toBeDefined(); - // app's dep on middle ~1.0.0 still satisfies 1.0.1, so no bump expect(plan.releases.find((r) => r.name === 'app')).toBeUndefined(); }); + + test('multi-package changeset bumps all listed packages', () => { + const packages = new Map([ + ['pkg-a', makePkg('pkg-a', '1.0.0')], + ['pkg-b', makePkg('pkg-b', '2.0.0')], + ]); + + const changesets: Changeset[] = [ + { + id: 'cs1', + releases: [ + { name: 'pkg-a', type: 'minor' }, + { name: 'pkg-b', type: 'patch' }, + ], + summary: 'Cross-package change', + }, + ]; + + const graph = new DependencyGraph(packages); + const plan = assembleReleasePlan(changesets, packages, graph, makeConfig()); + + expect(plan.releases).toHaveLength(2); + expect(plan.releases.find((r) => r.name === 'pkg-a')!.newVersion).toBe('1.1.0'); + expect(plan.releases.find((r) => r.name === 'pkg-b')!.newVersion).toBe('2.0.1'); + }); }); diff --git a/packages/bumpy/test/core/semver.test.ts b/packages/bumpy/test/core/semver.test.ts new file mode 100644 index 0000000..1cf3bc8 --- /dev/null +++ b/packages/bumpy/test/core/semver.test.ts @@ -0,0 +1,144 @@ +import { test, expect, describe } from 'bun:test'; +import { bumpVersion, satisfies, stripProtocol, compareVersions, isValidVersion } from '../../src/core/semver.ts'; + +describe('bumpVersion', () => { + test('bumps patch', () => { + expect(bumpVersion('1.0.0', 'patch')).toBe('1.0.1'); + }); + + test('bumps minor', () => { + expect(bumpVersion('1.0.0', 'minor')).toBe('1.1.0'); + }); + + test('bumps major', () => { + expect(bumpVersion('1.0.0', 'major')).toBe('2.0.0'); + }); + + test('bumps minor resets patch', () => { + expect(bumpVersion('1.2.3', 'minor')).toBe('1.3.0'); + }); + + test('bumps major resets minor and patch', () => { + expect(bumpVersion('1.2.3', 'major')).toBe('2.0.0'); + }); + + test('handles prerelease versions', () => { + expect(bumpVersion('1.0.0-alpha.1', 'patch')).toBe('1.0.0'); + }); + + test('throws on invalid version', () => { + expect(() => bumpVersion('not-a-version', 'patch')).toThrow(); + }); +}); + +describe('satisfies', () => { + test('caret range satisfied by minor bump', () => { + expect(satisfies('1.1.0', '^1.0.0')).toBe(true); + }); + + test('caret range NOT satisfied by major bump', () => { + expect(satisfies('2.0.0', '^1.0.0')).toBe(false); + }); + + test('tilde range satisfied by patch bump', () => { + expect(satisfies('1.0.1', '~1.0.0')).toBe(true); + }); + + test('tilde range NOT satisfied by minor bump', () => { + expect(satisfies('1.1.0', '~1.0.0')).toBe(false); + }); + + test('exact range only matches exact version', () => { + expect(satisfies('1.0.0', '1.0.0')).toBe(true); + expect(satisfies('1.0.1', '1.0.0')).toBe(false); + }); + + test('wildcard always satisfies', () => { + expect(satisfies('99.99.99', '*')).toBe(true); + }); + + test('empty range always satisfies', () => { + expect(satisfies('1.0.0', '')).toBe(true); + }); + + // workspace: protocol handling + test('workspace:^ always satisfies', () => { + expect(satisfies('2.0.0', 'workspace:^')).toBe(true); + }); + + test('workspace:~ always satisfies', () => { + expect(satisfies('2.0.0', 'workspace:~')).toBe(true); + }); + + test('workspace:* always satisfies', () => { + expect(satisfies('2.0.0', 'workspace:*')).toBe(true); + }); + + test('workspace: with real range checks the range', () => { + expect(satisfies('1.1.0', 'workspace:^1.0.0')).toBe(true); + expect(satisfies('2.0.0', 'workspace:^1.0.0')).toBe(false); + }); + + // catalog: protocol handling + test('catalog: always satisfies (cannot resolve)', () => { + expect(satisfies('99.0.0', 'catalog:')).toBe(true); + }); + + test('catalog:named always satisfies', () => { + expect(satisfies('99.0.0', 'catalog:testing')).toBe(true); + }); +}); + +describe('stripProtocol', () => { + test('strips workspace: prefix', () => { + expect(stripProtocol('workspace:^1.0.0')).toBe('^1.0.0'); + }); + + test('strips workspace: with shorthand', () => { + expect(stripProtocol('workspace:*')).toBe('*'); + }); + + test('leaves non-workspace ranges unchanged', () => { + expect(stripProtocol('^1.0.0')).toBe('^1.0.0'); + }); + + test('leaves catalog: unchanged (only strips workspace:)', () => { + expect(stripProtocol('catalog:')).toBe('catalog:'); + }); +}); + +describe('compareVersions', () => { + test('returns -1 when a < b', () => { + expect(compareVersions('1.0.0', '2.0.0')).toBe(-1); + }); + + test('returns 0 when equal', () => { + expect(compareVersions('1.0.0', '1.0.0')).toBe(0); + }); + + test('returns 1 when a > b', () => { + expect(compareVersions('2.0.0', '1.0.0')).toBe(1); + }); + + test('compares minor versions', () => { + expect(compareVersions('1.1.0', '1.2.0')).toBe(-1); + }); + + test('compares patch versions', () => { + expect(compareVersions('1.0.1', '1.0.2')).toBe(-1); + }); +}); + +describe('isValidVersion', () => { + test('valid semver returns true', () => { + expect(isValidVersion('1.0.0')).toBe(true); + expect(isValidVersion('0.0.1')).toBe(true); + expect(isValidVersion('1.2.3-alpha.1')).toBe(true); + }); + + test('invalid semver returns false', () => { + expect(isValidVersion('not-a-version')).toBe(false); + expect(isValidVersion('1.0')).toBe(false); + expect(isValidVersion('')).toBe(false); + }); +}); diff --git a/packages/bumpy/test/helpers-shell-mock.ts b/packages/bumpy/test/helpers-shell-mock.ts new file mode 100644 index 0000000..1b02f4e --- /dev/null +++ b/packages/bumpy/test/helpers-shell-mock.ts @@ -0,0 +1,108 @@ +/** + * Shell mock utilities for testing. + * + * Uses the shell module's built-in interceptor to hook into command execution. + * This intercepts `gh` CLI commands by default while passing through + * real git/system commands to the actual implementation. + * + * Usage: + * import { installShellMock, resetMockState, addMockRule } from '../helpers-shell-mock.ts'; + * + * beforeEach(() => { resetMockState(); }); + * afterEach(() => { uninstallShellMock(); }); + * // Or: install once at top of file, call resetMockState() in beforeEach + */ +import { _setInterceptor } from '../src/utils/shell.ts'; + +export interface CommandCall { + command: string; + args: string[]; + opts?: { cwd?: string; input?: string }; +} + +export interface MockRule { + /** Pattern to match against the joined command args (string match or regex) */ + match: string | RegExp; + /** Response to return (stdout) */ + response?: string; + /** Error to throw (simulates command failure) */ + error?: string; +} + +let calls: CommandCall[] = []; +let rules: MockRule[] = []; + +function matchCommand(cmdString: string, rule: MockRule): boolean { + if (typeof rule.match === 'string') { + return cmdString.includes(rule.match); + } + return rule.match.test(cmdString); +} + +function findRule(cmdString: string): MockRule | undefined { + return rules.find((r) => matchCommand(cmdString, r)); +} + +/** + * Install the shell mock interceptor. + * By default intercepts all `gh` commands. Real git/system commands pass through. + */ +export function installShellMock(opts: { interceptGh?: boolean } = {}) { + calls = []; + rules = []; + + const interceptGh = opts.interceptGh ?? true; + if (interceptGh) { + rules.push({ match: 'gh --version', response: 'gh version 2.50.0' }); + rules.push({ match: /^gh /, response: '{}' }); + } + + _setInterceptor((args, opts) => { + const cmdString = args.join(' '); + calls.push({ command: cmdString, args: [...args], opts }); + + const rule = findRule(cmdString); + if (rule) { + if (rule.error) return { intercepted: true, error: rule.error }; + return { intercepted: true, result: rule.response ?? '' }; + } + + // Not intercepted — pass through to real implementation + return { intercepted: false }; + }); +} + +/** Reset mock state (calls + rules) and re-install with fresh defaults */ +export function resetMockState(opts: { interceptGh?: boolean } = {}) { + installShellMock(opts); +} + +/** Uninstall the interceptor entirely */ +export function uninstallShellMock() { + _setInterceptor(null); + calls = []; + rules = []; +} + +/** Add a custom mock rule (higher priority than defaults) */ +export function addMockRule(rule: MockRule) { + rules.unshift(rule); +} + +/** Get all recorded command calls */ +export function getCalls(): CommandCall[] { + return [...calls]; +} + +/** Get calls matching a pattern */ +export function getCallsMatching(pattern: string | RegExp): CommandCall[] { + return calls.filter((c) => { + if (typeof pattern === 'string') return c.command.includes(pattern); + return pattern.test(c.command); + }); +} + +/** Clear recorded calls */ +export function clearCalls() { + calls = []; +} diff --git a/packages/bumpy/test/helpers.ts b/packages/bumpy/test/helpers.ts new file mode 100644 index 0000000..5e74793 --- /dev/null +++ b/packages/bumpy/test/helpers.ts @@ -0,0 +1,94 @@ +/** + * Shared test helpers for bumpy tests. + * Provides factory functions and utilities to reduce duplication across test files. + */ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { resolve } from 'node:path'; +import { execFileSync } from 'node:child_process'; +import type { WorkspacePackage, PlannedRelease, BumpType, BumpyConfig, Changeset, ReleasePlan } from '../src/types.ts'; +import { DEFAULT_CONFIG } from '../src/types.ts'; + +// ---- Factory functions ---- + +/** Create a WorkspacePackage for testing (no real filesystem needed) */ +export function makePkg( + name: string, + version: string, + deps: Partial< + Pick< + WorkspacePackage, + 'dependencies' | 'devDependencies' | 'peerDependencies' | 'optionalDependencies' | 'bumpy' | 'private' + > + > & { dir?: string } = {}, +): WorkspacePackage { + return { + name, + version, + dir: deps.dir ?? `/fake/${name}`, + relativeDir: `packages/${name}`, + packageJson: { name, version }, + private: deps.private ?? false, + dependencies: deps.dependencies ?? {}, + devDependencies: deps.devDependencies ?? {}, + peerDependencies: deps.peerDependencies ?? {}, + optionalDependencies: deps.optionalDependencies ?? {}, + bumpy: deps.bumpy, + }; +} + +/** Create a BumpyConfig with overrides */ +export function makeConfig(overrides: Partial = {}): BumpyConfig { + return { ...DEFAULT_CONFIG, ...overrides }; +} + +/** Create a PlannedRelease for testing */ +export function makeRelease( + name: string, + newVersion: string, + opts: Partial> = {}, +): PlannedRelease { + return { + name, + type: opts.type ?? 'patch', + oldVersion: opts.oldVersion ?? '0.0.0', + newVersion, + changesets: opts.changesets ?? [], + isDependencyBump: opts.isDependencyBump ?? false, + isCascadeBump: opts.isCascadeBump ?? false, + }; +} + +/** Create a Changeset for testing */ +export function makeChangeset( + id: string, + releases: { name: string; type: BumpType }[], + summary = 'Test change', +): Changeset { + return { id, releases, summary }; +} + +/** Create a ReleasePlan for testing */ +export function makeReleasePlan(releases: PlannedRelease[], changesets: Changeset[] = []): ReleasePlan { + return { releases, changesets }; +} + +// ---- Temp git repo helpers ---- + +/** Create a temp directory and initialize a git repo in it */ +export async function createTempGitRepo(): Promise { + const dir = await mkdtemp(resolve(tmpdir(), 'bumpy-test-')); + execFileSync('git', ['init'], { cwd: dir, stdio: 'pipe' }); + execFileSync('git', ['commit', '--allow-empty', '-m', 'init'], { cwd: dir, stdio: 'pipe' }); + return dir; +} + +/** Remove a temp directory */ +export async function cleanupTempDir(dir: string): Promise { + await rm(dir, { recursive: true }); +} + +/** Run a git command in a directory (for test setup only) */ +export function gitInDir(args: string[], cwd: string): string { + return execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: 'pipe' }).trim(); +}