import { describe, expect, test } from 'bun:test'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { cliKeyOf, keyContent, keyPaths } from '../tools/cli-key'; import { staleReason } from '../tools/acceptance'; import { channelUrl, seedPlan } from '../tools/desktop-release'; import { COPIES } from '../tools/desktop-version'; import { candidateTag, isCandidateTag, isTestTag, pruneList, type Release } from '../tools/forgejo-release'; import { isPrerelease, tagVersion, taskState, type Task, versionOnlyProblems } from '../tools/release-candidate'; import { cutChangelog, newer, timingTable } from '../tools/release'; const ROOT = join(import.meta.dir, '..'); const read = (f: string) => readFileSync(join(ROOT, f), 'utf8'); const SHA = 'a'.repeat(40); const bump = (to: string) => COPIES.map(c => ({ file: c.file, status: 'M', before: read(c.file), after: c.write(read(c.file), to) })); describe('version-only commits (ci.yml gate)', () => { const from = COPIES[0]!.read(read('package.json'))!; test('a bump of every copy plus the changelog is version-only', () => { const r = versionOnlyProblems([...bump('9.9.9'), { file: 'CHANGELOG.md', status: 'M', before: 'a', after: 'b' }, { file: 'docs/acceptance/macOS-bun.json', status: 'M', before: '{}', after: '[]' }]); expect(r).toEqual({ from, to: '9.9.9', problems: [] }); }); test('any other file, or a copy with one more change, is not', () => { expect(versionOnlyProblems([...bump('9.9.9'), { file: 'cli/index.ts', status: 'M', before: 'a', after: 'b' }]).problems).toEqual(['cli/index.ts: not a version copy (M)']); const sneaky = bump('9.9.9').map(c => (c.file === 'package.json' ? { ...c, after: c.after.replace('"type": "module"', '"type": "commonjs"') } : c)); expect(versionOnlyProblems(sneaky).problems).toEqual(['package.json: changes more than the version']); const dep = bump('9.9.9').map(c => (c.file === 'apps/desktop/src-tauri/Cargo.lock' ? { ...c, after: `${c.after}\n# extra` } : c)); expect(versionOnlyProblems(dep).problems).toEqual(['apps/desktop/src-tauri/Cargo.lock: changes more than the version']); expect(versionOnlyProblems([{ file: 'CHANGELOG.md', status: 'M', before: 'a', after: 'b' }]).problems[0]).toMatch(/version does not change/); expect(versionOnlyProblems([...bump('9.9.9'), { file: 'docs/acceptance/x.json', status: 'A', before: null, after: '{}' }]).problems).toEqual([]); expect(versionOnlyProblems([...bump('9.9.9'), { file: 'gemini-extension.json', status: 'D', before: '{}', after: null }]).problems.some(p => /gemini-extension\.json: D/.test(p))).toBe(true); }); }); describe('candidate state from the ci.yml jobs', () => { const t = (name: string, status: string, run = 5, sha = SHA, wf = 'ci.yml'): Task => ({ id: 1, name, run_number: run, workflow_id: wf, head_sha: sha, status }); const url = (n: number) => `run/${n}`; test('none, pending, red; the newest run counts', () => { expect(taskState([t('linux', 'success', 5, 'b'.repeat(40))], SHA, url)).toEqual({ state: 'none' }); expect(taskState([t('ci', 'success'), t('windows', 'running')], SHA, url)).toEqual({ state: 'pending', run: 'run/5', running: ['windows (running)'] }); expect(taskState([t('linux', 'success'), t('windows', 'failure')], SHA, url)).toEqual({ state: 'red', run: 'run/5', failed: ['windows'], cancelledOnly: false }); // a cancelled run (old-tag runs freed, superseded) is not a failure: a version-only child runs everything expect(taskState([t('ci', 'success'), t('linux', 'cancelled')], SHA, url)).toEqual({ state: 'red', run: 'run/5', failed: ['linux'], cancelledOnly: true }); expect(taskState([t('linux', 'failure', 4), t('linux', 'running', 6)], SHA, url).state).toBe('pending'); expect(taskState([t('promote', 'failure', 9, SHA, 'release.yml')], SHA, url)).toEqual({ state: 'none' }); // background tests never gate: a red ci / acceptance job leaves the candidate pending on its installers expect(taskState([t('ci', 'failure'), t('acceptance (bun)', 'failure'), t('linux', 'success'), t('windows', 'running')], SHA, url)).toEqual({ state: 'pending', run: 'run/5', running: ['windows (running)'] }); }); test('tags and prereleases', () => { expect(tagVersion('v0.8.5')).toBe('0.8.5'); expect(tagVersion('v0.0.0-rehearsal.3')).toBe('0.0.0-rehearsal.3'); expect(tagVersion('0.8.5')).toBeNull(); expect(isPrerelease('0.0.0-rehearsal.3')).toBe(true); expect(isPrerelease('0.8.5')).toBe(false); }); }); describe('forgejo: candidates and rehearsals', () => { const rel = (id: number, tag: string, draft = false): Release => ({ id, tag_name: tag, name: tag, draft, prerelease: false, body: '', assets: [] }); test('prune keeps candidate drafts; rehearsal tags are deletable test tags', () => { const rc = candidateTag(SHA); expect(isCandidateTag(rc)).toBe(true); expect(pruneList([rel(1, 'v0.8.4'), rel(2, 'v0.8.5'), rel(3, rc, true)], 'v0.8.5').remove.map(r => r.tag_name)).toEqual(['v0.8.4']); expect(isTestTag('v0.0.0-rehearsal.12')).toBe(true); expect(isTestTag('v0.8.5')).toBe(false); expect(isTestTag('v0.0.1-rehearsal.1')).toBe(false); }); test('seed plan: files the server fetches from the release by hash (renamed installer too)', () => { const h = (c: string) => c.repeat(64); const sums = `${h('a')} storebound_1.0.0_amd64.AppImage\n${h('b')} storebound_1.0.0_x64-setup.exe\n`; const assets = [{ name: 'storebound_1.0.0_amd64.AppImage', url: 'https://x/a' }, { name: 'storebound_1.0.0_x64-setup.exe', url: 'https://x/b' }]; expect(seedPlan([{ file: 'storebound_1.0.0_amd64.AppImage', sha256: h('a') }, { file: 'storebound_1.0.0_x64-setup-unsigned.exe', sha256: h('b') }, { file: 'storebound_1.0.0_universal.dmg', sha256: h('c') }], sums, assets)) .toEqual([{ file: 'storebound_1.0.0_amd64.AppImage', url: 'https://x/a', sha256: h('a') }, { file: 'storebound_1.0.0_x64-setup-unsigned.exe', url: 'https://x/b', sha256: h('b') }]); expect(channelUrl('stable')).toBe('https://storebound.dev/desktop/latest.json'); expect(channelUrl('rehearsal')).toBe('https://storebound.dev/desktop/rehearsal/latest.json'); }); }); describe('CLI key and acceptance runs of a version-only parent', () => { test('the key ignores the version copies and the prose, not the code', () => { const files: Record = { 'package.json': read('package.json'), 'cli/a.ts': 'x', 'CHANGELOG.md': 'one' }; const key = (f: Record) => cliKeyOf(Object.keys(f), n => Buffer.from(f[n]!)); const base = key(files); expect(key({ ...files, 'package.json': COPIES[0]!.write(files['package.json']!, '9.9.9'), 'CHANGELOG.md': 'two' })).toBe(base); expect(key({ ...files, 'cli/a.ts': 'y' })).not.toBe(base); expect(keyContent('cli/a.ts', Buffer.from('0.8.4')).toString()).toBe('0.8.4'); expect(keyPaths(['dist/storebound.mjs', 'cli'])).toContain('tests/fixtures'); expect(keyPaths(['dist/storebound.mjs', 'cli'])).not.toContain('dist/storebound.mjs'); }); test('a run of another version counts only with the same CLI key', () => { expect(staleReason({ version: '0.8.4', quick: false }, '0.8.4', 'k')).toBeNull(); expect(staleReason({ version: '0.8.4', quick: false, cliKey: 'k' }, '0.8.5', 'k')).toBeNull(); expect(staleReason({ version: '0.8.4', quick: false, cliKey: 'j' }, '0.8.5', 'k')).toMatch(/other CLI code/); expect(staleReason({ version: '0.8.4', quick: false }, '0.8.5', 'k')).toMatch(/version 0.8.4/); expect(staleReason({ version: '0.8.5', quick: true, cliKey: 'k' }, '0.8.5', 'k')).toBe('a quick run'); }); }); describe('release.ts', () => { test('changelog cut', () => { const cl = '# Changelog\n\n## Unreleased\n\nSummary.\n\n- a\n\n## 0.8.4 · 2026-10-14\n\n- b\n'; expect(cutChangelog(cl, '0.8.5', '2026-10-15')).toBe('# Changelog\n\n## Unreleased\n\n## 0.8.5 · 2026-10-15\n\nSummary.\n\n- a\n\n## 0.8.4 · 2026-10-14\n\n- b\n'); expect(() => cutChangelog('# Changelog\n\n## Unreleased\n\n## 0.8.4\n', '0.8.5', 'd')).toThrow(/empty/); expect(() => cutChangelog('# Changelog\n\n## 0.8.4\n', '0.8.5', 'd')).toThrow(/Unreleased/); expect(() => cutChangelog(cl, '0.8.4', 'd')).toThrow(/already/); }); test('versions and the timing table', () => { expect(newer('0.8.5', '0.8.4')).toBe(true); expect(newer('0.10.0', '0.9.9')).toBe(true); expect(newer('0.8.4', '0.8.4')).toBe(false); expect(newer('0.8.5', '0.8.5-rc.1')).toBe(true); expect(newer('0.8.5-rc.1', '0.8.5')).toBe(false); const tbl = timingTable([{ name: 'a', start: 1000, end: 61_000 }, { name: 'b', start: 2000, end: 302_000 }], 1000); expect(tbl).toContain('took 1:00'); expect(tbl).toMatch(/total .* 5:01/); }); });