/** * Workspace planner tests — openspec/changes/publilo-cli/proposal.md Phase 2 payoff. * * The planner moved out of the monolithic runPublishPhase precisely so * these tests would be possible: no npm subprocesses, no bun publish * shelling, no fs writes. We mock the I/O helpers and assert that the * planner produces the right WorkspaceItem[] for each PublishMode. * * Scoped to this file's import-chain only — `./helpers` resolves to * `apps/celilo/src/cli/commands/publish/helpers.ts`, and that path is * not imported anywhere outside `commands/publish/`, so the * `mock.module` call here doesn't leak into unrelated test files. * (Contrast with the `prompts.ts` leak that bit us in * `interactive-config.test.ts` — `prompts.ts` is imported across the * codebase, so a partial mock taints everything.) */ import { describe, expect, mock, test } from 'bun:test'; const PACKAGES = [ 'packages/cli-display', 'packages/capabilities', 'packages/event-bus', 'apps/celilo', 'packages/e2e', ] as const; // Synthetic package state. Tests can swap entries here before importing // the SUT to control what `readPkg` reports. type FakePkg = { name: string; version: string }; const fakePkgs: Record = { 'packages/cli-display': { name: '@celilo/cli-display', version: '0.2.0' }, 'packages/capabilities': { name: '@celilo/capabilities', version: '0.3.0' }, 'packages/event-bus': { name: '@celilo/event-bus', version: '0.4.0' }, 'apps/celilo': { name: '@celilo/cli', version: '0.5.0' }, 'packages/e2e': { name: '@celilo/e2e', version: '0.6.0' }, }; // Tunable per-test responses for the npm/git helpers. let nextAlphaResponses: Record = {}; let skipResponses: Record = {}; let publishedSet: Set = new Set(); mock.module('./helpers', () => ({ PACKAGES, REPO_ROOT: '/fake/repo', ENV_FILE: '/fake/repo/.env', readPkg: (pkg: string) => fakePkgs[pkg] ?? { name: undefined, version: undefined }, isPublished: (name: string, version: string) => publishedSet.has(`${name}@${version}`), currentGitHead: () => 'fakehead00000000', lastCommitTouching: () => null, isAncestor: () => false, buildWorkspaceVersionMap: () => { const m = new Map(); for (const pkg of PACKAGES) m.set(fakePkgs[pkg].name, fakePkgs[pkg].version); return m; }, listModuleDirs: () => [], readExternalProjectPaths: () => [], readNpmPublishTarget: () => null, findPackageJsons: () => [], bareVersion: (s: string) => s.replace(/^[\s^~=><]+/, '').trim(), withOperator: (oldSpec: string, newVersion: string) => { const m = oldSpec.match(/^(\^|~|>=|<=|=|>|<)?/); return `${m?.[1] ?? ''}${newVersion}`; }, readGlobalInstalledVersion: () => null, fetchLatestVersions: () => new Map(), })); mock.module('./alpha', () => ({ ALPHA_TAG: 'alpha', parsePackageSpec: (spec: string) => { const i = spec.lastIndexOf('@'); return { name: spec.slice(0, i), version: spec.slice(i + 1) }; }, stripAlphaSuffix: (v: string) => v.replace(/-alpha\.\d+$/, ''), prereleaseDistTag: (v: string) => { const dash = v.indexOf('-'); return dash === -1 ? undefined : v.slice(dash + 1).split('.')[0] || undefined; }, isAlphaVersion: (v: string) => /-alpha\.\d+$/.test(v), nextAlphaNumber: (name: string, semverCore: string) => nextAlphaResponses[`${name}@${semverCore}`] ?? 0, alphaSkipDecision: (_pkg: string, name: string, semverCore: string, n: number) => skipResponses[`${name}@${semverCore}-alpha.${n}`] ?? { skip: false }, // Pure helpers — re-export real impls so any inner usage works. pickNextAlphaN: () => 0, decideAlphaSkip: () => ({ skip: false }), })); const { planWorkspace, buildPublishArgs } = await import('./workspace'); function buildBaseMap(): Map { const m = new Map(); for (const pkg of PACKAGES) m.set(fakePkgs[pkg].name, fakePkgs[pkg].version); return m; } describe('planWorkspace', () => { describe('normal mode', () => { test('plans every package at its current base version', () => { publishedSet = new Set(); const result = planWorkspace({ mode: { kind: 'normal' }, packages: PACKAGES, baseWorkspaceVersions: buildBaseMap(), gitHead: 'fakehead00000000', }); expect(result.items).toHaveLength(5); expect(result.items.map((i) => `${i.name}@${i.versionToPublish}`)).toEqual([ '@celilo/cli-display@0.2.0', '@celilo/capabilities@0.3.0', '@celilo/event-bus@0.4.0', '@celilo/cli@0.5.0', '@celilo/e2e@0.6.0', ]); expect(result.items.every((i) => i.tag === undefined)).toBe(true); expect(result.items.every((i) => i.skipReason === undefined)).toBe(true); }); test('marks already-published packages as skipped', () => { publishedSet = new Set(['@celilo/e2e@0.6.0']); const result = planWorkspace({ mode: { kind: 'normal' }, packages: PACKAGES, baseWorkspaceVersions: buildBaseMap(), gitHead: 'fakehead00000000', }); const e2e = result.items.find((i) => i.name === '@celilo/e2e'); expect(e2e?.skipReason).toBe('already published'); // Other packages keep going. const cli = result.items.find((i) => i.name === '@celilo/cli'); expect(cli?.skipReason).toBeUndefined(); }); test('e2e gets only the registry-bundle hook (caches/netapps fetched at build-infra, not bundled)', () => { publishedSet = new Set(); const result = planWorkspace({ mode: { kind: 'normal' }, packages: PACKAGES, baseWorkspaceVersions: buildBaseMap(), gitHead: 'fakehead00000000', }); const e2e = result.items.find((i) => i.pkg === 'packages/e2e'); expect(e2e?.hooks).toEqual(['registryServerBundle']); const cli = result.items.find((i) => i.pkg === 'apps/celilo'); expect(cli?.hooks).toEqual([]); }); }); describe('alpha mode', () => { test('plans X.Y.Z-alpha.0 when no prior alphas on npm', () => { publishedSet = new Set(); nextAlphaResponses = {}; skipResponses = {}; const result = planWorkspace({ mode: { kind: 'alpha', trackAlpha: false, alphaModules: false }, packages: PACKAGES, baseWorkspaceVersions: buildBaseMap(), gitHead: 'fakehead00000000', }); expect(result.items.map((i) => i.versionToPublish)).toEqual([ '0.2.0-alpha.0', '0.3.0-alpha.0', '0.4.0-alpha.0', '0.5.0-alpha.0', '0.6.0-alpha.0', ]); expect(result.items.every((i) => i.tag === 'alpha')).toBe(true); }); test('uses next-N + skipDecision per package', () => { publishedSet = new Set(); nextAlphaResponses = { '@celilo/cli-display@0.2.0': 3, '@celilo/capabilities@0.3.0': 0, '@celilo/event-bus@0.4.0': 1, '@celilo/cli@0.5.0': 2, '@celilo/e2e@0.6.0': 5, }; // event-bus has nothing new since alpha.0 — skip. skipResponses = { '@celilo/event-bus@0.4.0-alpha.1': { skip: true, reason: 'no source changes since 0.4.0-alpha.0 (gitHead abc123)', }, }; const result = planWorkspace({ mode: { kind: 'alpha', trackAlpha: false, alphaModules: false }, packages: PACKAGES, baseWorkspaceVersions: buildBaseMap(), gitHead: 'fakehead00000000', }); // Skipped → pins to the EXISTING alpha (alpha.{n-1} = alpha.0), NOT the // computed-next alpha.1 which is never published. (ISS-0017: pinning to // the next made dependents unpublishable — "@celilo/...@alpha.1 not on // npm".) Both the item and the consumer-pin map must use alpha.0. const eb = result.items.find((i) => i.name === '@celilo/event-bus'); expect(eb?.versionToPublish).toBe('0.4.0-alpha.0'); expect(eb?.skipReason).toContain('no source changes since 0.4.0-alpha.0'); expect(result.workspaceVersions.get('@celilo/event-bus')).toBe('0.4.0-alpha.0'); const cli = result.items.find((i) => i.name === '@celilo/cli'); expect(cli?.versionToPublish).toBe('0.5.0-alpha.2'); expect(cli?.skipReason).toBeUndefined(); }); test('alpha mode sets exactPins and stamps gitHead in rewriteOptions', () => { publishedSet = new Set(); nextAlphaResponses = {}; skipResponses = {}; const result = planWorkspace({ mode: { kind: 'alpha', trackAlpha: false, alphaModules: false }, packages: PACKAGES, baseWorkspaceVersions: buildBaseMap(), gitHead: 'abcdef1234567890', }); for (const item of result.items) { expect(item.rewriteOptions.exactPins).toBe(true); expect(item.rewriteOptions.gitHead).toBe('abcdef1234567890'); expect(item.rewriteOptions.targetVersion).toBe(item.versionToPublish); } }); test('workspace dep map gets tightened to alpha versions for sibling rewrites', () => { publishedSet = new Set(); nextAlphaResponses = {}; skipResponses = {}; const result = planWorkspace({ mode: { kind: 'alpha', trackAlpha: false, alphaModules: false }, packages: PACKAGES, baseWorkspaceVersions: buildBaseMap(), gitHead: 'fakehead00000000', }); // After planning, the workspaceVersions map should hold alpha // versions (so the executor's workspace:^ rewrites pin to alphas). expect(result.workspaceVersions.get('@celilo/cli-display')).toBe('0.2.0-alpha.0'); expect(result.workspaceVersions.get('@celilo/event-bus')).toBe('0.4.0-alpha.0'); expect(result.workspaceVersions.get('@celilo/e2e')).toBe('0.6.0-alpha.0'); }); }); describe('promote mode', () => { test('plans only the named package, stripping the alpha suffix', () => { publishedSet = new Set(); const result = planWorkspace({ mode: { kind: 'promote', target: { name: '@celilo/e2e', version: '0.6.0-alpha.3' }, }, packages: PACKAGES, baseWorkspaceVersions: buildBaseMap(), gitHead: 'fakehead00000000', }); expect(result.items).toHaveLength(1); expect(result.items[0].name).toBe('@celilo/e2e'); expect(result.items[0].versionToPublish).toBe('0.6.0'); expect(result.items[0].tag).toBeUndefined(); }); test('throws when the target name is not a known workspace', () => { publishedSet = new Set(); expect(() => planWorkspace({ mode: { kind: 'promote', target: { name: '@some/other-pkg', version: '1.0.0-alpha.0' }, }, packages: PACKAGES, baseWorkspaceVersions: buildBaseMap(), gitHead: 'fakehead00000000', }), ).toThrow('not a known workspace package'); }); test('throws when the target version has no alpha suffix', () => { publishedSet = new Set(); expect(() => planWorkspace({ mode: { kind: 'promote', target: { name: '@celilo/e2e', version: '0.6.0' }, }, packages: PACKAGES, baseWorkspaceVersions: buildBaseMap(), gitHead: 'fakehead00000000', }), ).toThrow('not an alpha'); }); }); describe('buildPublishArgs registry target', () => { test('no target → default registry (unchanged behavior)', () => { expect(buildPublishArgs({ name: '@celilo/cli', tag: undefined }, null)).toEqual([ 'publish', '--access', 'public', ]); }); test('target set → --registry appended for @celilo/* package', () => { expect( buildPublishArgs({ name: '@celilo/cli', tag: undefined }, 'https://npm.example.test/'), ).toEqual(['publish', '--access', 'public', '--registry', 'https://npm.example.test/']); }); test('tag and registry both present', () => { expect( buildPublishArgs({ name: '@celilo/cli', tag: 'alpha' }, 'https://npm.example.test/'), ).toEqual([ 'publish', '--access', 'public', '--tag', 'alpha', '--registry', 'https://npm.example.test/', ]); }); test('non-@celilo package never gets the private registry', () => { expect( buildPublishArgs({ name: 'some-other-pkg', tag: undefined }, 'https://npm.example.test/'), ).toEqual(['publish', '--access', 'public']); }); }); describe('dependency order', () => { test('preserves PACKAGES order in planned items', () => { publishedSet = new Set(); const result = planWorkspace({ mode: { kind: 'normal' }, packages: PACKAGES, baseWorkspaceVersions: buildBaseMap(), gitHead: 'fakehead00000000', }); // Leaves (cli-display) first, consumers (e2e) last — same as the // PACKAGES tuple. The executor relies on this ordering for the // workspace:^ rewrite to find sibling versions before they're // needed. expect(result.items.map((i) => i.pkg)).toEqual([...PACKAGES]); }); }); });