/** * Alpha-helper tests — openspec/changes/publilo-cli/proposal.md Phase 3 payoff. * * The pure functions in alpha.ts (parsePackageSpec, stripAlphaSuffix, * isAlphaVersion, pickNextAlphaN, decideAlphaSkip) need no I/O to * exercise. The impure wrappers (nextAlphaNumber, alphaSkipDecision) * just glue npm view + git log onto the pure inputs, so testing the * pure layer covers the real logic. */ import { describe, expect, test } from 'bun:test'; import { decideAlphaSkip, isAlphaVersion, parsePackageSpec, pickNextAlphaN, prereleaseDistTag, stripAlphaSuffix, } from './alpha'; describe('parsePackageSpec', () => { test('parses scoped name@version', () => { expect(parsePackageSpec('@celilo/e2e@0.7.14-alpha.3')).toEqual({ name: '@celilo/e2e', version: '0.7.14-alpha.3', }); }); test('parses unscoped name@version', () => { expect(parsePackageSpec('lodash@4.17.21')).toEqual({ name: 'lodash', version: '4.17.21', }); }); test('throws when no @ separator beyond an initial scope @', () => { expect(() => parsePackageSpec('@celilo/e2e')).toThrow('Invalid package spec'); expect(() => parsePackageSpec('lodash')).toThrow('Invalid package spec'); }); test('uses the LAST @ as the separator (scoped names contain another)', () => { // @scope/name@1.0.0-pre@special — last @ before version wins. (Not a // realistic form, but documents the contract.) expect(parsePackageSpec('@a/b@1.0.0-pre@x')).toEqual({ name: '@a/b@1.0.0-pre', version: 'x', }); }); }); describe('stripAlphaSuffix', () => { test('removes -alpha.N suffix', () => { expect(stripAlphaSuffix('0.7.14-alpha.3')).toBe('0.7.14'); expect(stripAlphaSuffix('1.0.0-alpha.0')).toBe('1.0.0'); expect(stripAlphaSuffix('10.20.30-alpha.999')).toBe('10.20.30'); }); test('leaves real versions alone', () => { expect(stripAlphaSuffix('0.7.14')).toBe('0.7.14'); expect(stripAlphaSuffix('1.0.0')).toBe('1.0.0'); }); test('leaves other pre-releases alone (only -alpha.N matches)', () => { expect(stripAlphaSuffix('1.0.0-beta.0')).toBe('1.0.0-beta.0'); expect(stripAlphaSuffix('1.0.0-rc.1')).toBe('1.0.0-rc.1'); expect(stripAlphaSuffix('1.0.0-alpha')).toBe('1.0.0-alpha'); // no .N }); }); describe('isAlphaVersion', () => { test('identifies -alpha.N', () => { expect(isAlphaVersion('0.7.14-alpha.0')).toBe(true); expect(isAlphaVersion('0.7.14-alpha.3')).toBe(true); expect(isAlphaVersion('1.0.0-alpha.999')).toBe(true); }); test('rejects other forms', () => { expect(isAlphaVersion('0.7.14')).toBe(false); expect(isAlphaVersion('0.7.14-beta.0')).toBe(false); expect(isAlphaVersion('0.7.14-alpha')).toBe(false); // no .N }); }); describe('prereleaseDistTag (ISS-0083)', () => { test('stable versions get no override → @latest', () => { expect(prereleaseDistTag('0.5.0')).toBeUndefined(); expect(prereleaseDistTag('1.0.0')).toBeUndefined(); expect(prereleaseDistTag('10.20.30')).toBeUndefined(); }); test('prerelease versions derive their dist-tag from the identifier', () => { expect(prereleaseDistTag('0.5.0-alpha.0')).toBe('alpha'); expect(prereleaseDistTag('0.5.0-alpha.3')).toBe('alpha'); expect(prereleaseDistTag('1.0.0-beta.2')).toBe('beta'); expect(prereleaseDistTag('1.0.0-rc.1')).toBe('rc'); }); test('prerelease without a dotted counter still yields its identifier', () => { expect(prereleaseDistTag('1.0.0-alpha')).toBe('alpha'); expect(prereleaseDistTag('1.0.0-next')).toBe('next'); }); test('a prerelease NEVER maps to latest', () => { expect(prereleaseDistTag('0.5.0-alpha.0')).not.toBe('latest'); expect(prereleaseDistTag('0.5.0-alpha.0')).not.toBeUndefined(); }); }); describe('pickNextAlphaN', () => { test('returns 0 when no alphas exist for the target core', () => { expect(pickNextAlphaN([], '0.7.14')).toBe(0); expect(pickNextAlphaN(['0.7.13', '0.7.14', '0.7.15'], '0.7.14')).toBe(0); }); test('returns max+1 when alphas exist', () => { expect(pickNextAlphaN(['0.7.14-alpha.0'], '0.7.14')).toBe(1); expect(pickNextAlphaN(['0.7.14-alpha.0', '0.7.14-alpha.1'], '0.7.14')).toBe(2); expect(pickNextAlphaN(['0.7.14-alpha.0', '0.7.14-alpha.1', '0.7.14-alpha.5'], '0.7.14')).toBe( 6, ); }); test('ignores alphas of other cores', () => { expect(pickNextAlphaN(['0.7.13-alpha.99', '0.7.14-alpha.0', '0.7.15-alpha.10'], '0.7.14')).toBe( 1, ); }); test("ignores versions that aren't -alpha.N", () => { expect( pickNextAlphaN(['0.7.14', '0.7.14-beta.0', '0.7.14-rc.5', '0.7.14-alpha.2'], '0.7.14'), ).toBe(3); }); test('handles gaps (uses max, not min-gap)', () => { // Operator manually published 0.7.14-alpha.3 and 0.7.14-alpha.7 — // next is 8, NOT 4. Avoid reusing numbers, ever. expect(pickNextAlphaN(['0.7.14-alpha.3', '0.7.14-alpha.7'], '0.7.14')).toBe(8); }); test('regex-escapes dots in the semver core', () => { // If we didn't escape, "0.7.14" would match "0X7X14" too. Verify // the escape works by feeding noise that would match a non-escaped // pattern. expect(pickNextAlphaN(['0X7X14-alpha.0'], '0.7.14')).toBe(0); }); }); describe('decideAlphaSkip', () => { test('publishes the first alpha (N=0)', () => { expect( decideAlphaSkip({ nextN: 0, priorGitHead: '', priorHeadReachable: false, sourceChangedSincePrior: false, priorVersion: '', }), ).toEqual({ skip: false }); }); test('publishes when prior alpha has no gitHead', () => { const decision = decideAlphaSkip({ nextN: 3, priorGitHead: '', priorHeadReachable: false, sourceChangedSincePrior: false, priorVersion: '0.7.14-alpha.2', }); expect(decision.skip).toBe(false); expect(decision.reason).toContain('no gitHead on 0.7.14-alpha.2'); }); test('publishes when prior gitHead is unreachable', () => { const decision = decideAlphaSkip({ nextN: 3, priorGitHead: 'deadbeef1234', priorHeadReachable: false, sourceChangedSincePrior: false, priorVersion: '0.7.14-alpha.2', }); expect(decision.skip).toBe(false); expect(decision.reason).toContain('deadbeef unreachable'); }); test('publishes when source changed since prior alpha', () => { const decision = decideAlphaSkip({ nextN: 3, priorGitHead: 'abcdef1234567890', priorHeadReachable: true, sourceChangedSincePrior: true, priorVersion: '0.7.14-alpha.2', }); expect(decision).toEqual({ skip: false }); }); test('skips when prior alpha exists and source is unchanged', () => { const decision = decideAlphaSkip({ nextN: 3, priorGitHead: 'abcdef1234567890', priorHeadReachable: true, sourceChangedSincePrior: false, priorVersion: '0.7.14-alpha.2', }); expect(decision.skip).toBe(true); if (decision.skip) { expect(decision.reason).toContain('no source changes since 0.7.14-alpha.2'); expect(decision.reason).toContain('gitHead abcdef12'); } }); });