/** * scripts/wall-entry.mjs — the mechanical releases-wall entry. * * The script's only real interface is its CLI (it has no importable * exports by design — one door, no parallel API to drift from it), so * these tests spawn it exactly as scripts/release.sh does: as a child * process, against a temp copy of a wall file and a fixture CHANGELOG, * never against the repo's real releases/*.json. */ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { execFileSync } from 'node:child_process' import { mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' const SCRIPT = join(process.cwd(), 'scripts/wall-entry.mjs') /** Run the script and capture the outcome without throwing on a non-zero exit. */ function run(args: string[], cwd: string): { status: number; stdout: string; stderr: string } { try { const stdout = execFileSync('node', [SCRIPT, ...args], { cwd, encoding: 'utf8' }) return { status: 0, stdout, stderr: '' } } catch (err: any) { return { status: err.status ?? 1, stdout: err.stdout ?? '', stderr: err.stderr ?? '' } } } const CHANGELOG_HEADER = '# Changelog\n\nAll notable changes, in this fixture.\n' /** Build a CHANGELOG.md with one entry per [version, bullets[]] pair, newest first. */ function buildChangelog(entries: Array<{ version: string; date: string; bullets: string[] }>): string { const body = entries .map( (e) => `### [${e.version}](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/vX...v${e.version}) (${e.date})\n\n` + e.bullets.map((b) => `- ${b} (abc1234)`).join('\n') + '\n', ) .join('\n') return CHANGELOG_HEADER + '\n' + body } function wallFile(product: string, entries: unknown[]): string { return JSON.stringify( { product, entries, history: 'Earlier releases are recorded in CHANGELOG.md in this repository.' }, null, 2, ) + '\n' } const BASE_ENTRY = { version: '10.4.11', date: '2026-09-02', headline: 'A faster open', items: ['A faster open.'], url: 'https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.11', thumb: null, } let dir: string beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'wall-entry-test-')) }) afterEach(() => { rmSync(dir, { recursive: true, force: true }) }) describe('wall-entry.mjs — generate + prepend', () => { it('derives headline from the first bullet and items from every bullet, hashes stripped', () => { writeFileSync( join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '10.4.12', date: '2026-09-03', bullets: ['fix(wall): mechanize the entry', 'test(wall): pin the shape'] }]), ) writeFileSync(join(dir, 'wall.json'), wallFile('open-brainy', [BASE_ENTRY])) const result = run( ['--product', 'open-brainy', '--version', '10.4.12', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--file', 'wall.json'], dir, ) expect(result.status).toBe(0) const wall = JSON.parse(readFileSync(join(dir, 'wall.json'), 'utf8')) expect(wall.entries).toHaveLength(2) expect(wall.entries[0]).toEqual({ version: '10.4.12', date: '2026-09-03', headline: 'fix(wall): mechanize the entry', items: ['fix(wall): mechanize the entry', 'test(wall): pin the shape'], url: 'https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.12', thumb: null, }) // the older entry stays put, still second expect(wall.entries[1].version).toBe('10.4.11') }) it('prepends newest-first — the new entry lands at index 0 ahead of every existing one', () => { writeFileSync( join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '10.5.0', date: '2026-09-03', bullets: ['feat: ten five'] }]), ) writeFileSync(join(dir, 'wall.json'), wallFile('open-brainy', [BASE_ENTRY, { ...BASE_ENTRY, version: '10.4.10' }])) run(['--product', 'open-brainy', '--version', '10.5.0', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--file', 'wall.json'], dir) const wall = JSON.parse(readFileSync(join(dir, 'wall.json'), 'utf8')) expect(wall.entries.map((e: any) => e.version)).toEqual(['10.5.0', '10.4.11', '10.4.10']) }) it('derives no URL (null) for a product with no known public release-page pattern', () => { writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '11.0.6', date: '2026-09-03', bullets: ['fix: a native-only fix'] }])) writeFileSync(join(dir, 'wall.json'), wallFile('brainy', [{ ...BASE_ENTRY, version: '11.0.5', url: null }])) run(['--product', 'brainy', '--version', '11.0.6', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--file', 'wall.json'], dir) const wall = JSON.parse(readFileSync(join(dir, 'wall.json'), 'utf8')) expect(wall.entries[0].url).toBeNull() expect(wall.entries[0].thumb).toBeNull() }) it('refuses by name when the version is already present, and leaves the file untouched', () => { writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '10.4.11', date: '2026-09-02', bullets: ['fix: whatever'] }])) const before = wallFile('open-brainy', [BASE_ENTRY]) writeFileSync(join(dir, 'wall.json'), before) const result = run( ['--product', 'open-brainy', '--version', '10.4.11', '--date', '2026-09-02', '--from-changelog', 'CHANGELOG.md', '--file', 'wall.json'], dir, ) expect(result.status).toBe(1) expect(result.stderr).toMatch(/refusing.*10\.4\.11.*already present/i) expect(readFileSync(join(dir, 'wall.json'), 'utf8')).toBe(before) // untouched }) it('refuses when the CHANGELOG has no entry yet for the target version', () => { writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '10.4.11', date: '2026-09-02', bullets: ['fix: whatever'] }])) writeFileSync(join(dir, 'wall.json'), wallFile('open-brainy', [])) const result = run( ['--product', 'open-brainy', '--version', '99.0.0', '--date', '2026-09-02', '--from-changelog', 'CHANGELOG.md', '--file', 'wall.json'], dir, ) expect(result.status).toBe(1) expect(result.stderr).toMatch(/no CHANGELOG entry yet/i) }) it('refuses a cross-product write when --product does not match the target file', () => { writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '1.0.0', date: '2026-09-03', bullets: ['fix: wrong repo'] }])) writeFileSync(join(dir, 'wall.json'), wallFile('open-brainy', [BASE_ENTRY])) const result = run( ['--product', 'brainy', '--version', '1.0.0', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--file', 'wall.json'], dir, ) expect(result.status).toBe(1) expect(result.stderr).toMatch(/product "open-brainy".*--product "brainy"/i) }) }) describe('wall-entry.mjs — --check', () => { it('passes a well-formed, newest-first file with no duplicates', () => { writeFileSync(join(dir, 'wall.json'), wallFile('open-brainy', [BASE_ENTRY, { ...BASE_ENTRY, version: '10.4.10' }])) const result = run(['--check', '--file', 'wall.json'], dir) expect(result.status).toBe(0) expect(result.stdout).toMatch(/OK/) }) it('catches a missing entry key', () => { const broken = { version: '1.0.0', date: '2026-09-03', headline: 'h', items: ['i'], url: null } // no "thumb" writeFileSync(join(dir, 'wall.json'), wallFile('open-brainy', [broken])) const result = run(['--check', '--file', 'wall.json'], dir) expect(result.status).toBe(1) expect(result.stderr).toMatch(/missing key\(s\) thumb/) }) it('catches an unexpected top-level key', () => { const raw = JSON.parse(wallFile('open-brainy', [BASE_ENTRY])) raw.extra = 'not allowed' writeFileSync(join(dir, 'wall.json'), JSON.stringify(raw)) const result = run(['--check', '--file', 'wall.json'], dir) expect(result.status).toBe(1) expect(result.stderr).toMatch(/unexpected key\(s\) extra/) }) it('catches entries that are not newest-first', () => { writeFileSync(join(dir, 'wall.json'), wallFile('open-brainy', [{ ...BASE_ENTRY, version: '10.4.10' }, BASE_ENTRY])) const result = run(['--check', '--file', 'wall.json'], dir) expect(result.status).toBe(1) expect(result.stderr).toMatch(/not newest-first/) }) it('catches a duplicate version even with identical entries', () => { writeFileSync(join(dir, 'wall.json'), wallFile('open-brainy', [BASE_ENTRY, { ...BASE_ENTRY }])) const result = run(['--check', '--file', 'wall.json'], dir) expect(result.status).toBe(1) expect(result.stderr).toMatch(/duplicate version 10\.4\.11/) }) it('catches an empty items array', () => { writeFileSync(join(dir, 'wall.json'), wallFile('open-brainy', [{ ...BASE_ENTRY, items: [] }])) const result = run(['--check', '--file', 'wall.json'], dir) expect(result.status).toBe(1) expect(result.stderr).toMatch(/"items" must be a non-empty array/) }) it('catches a malformed date', () => { writeFileSync(join(dir, 'wall.json'), wallFile('open-brainy', [{ ...BASE_ENTRY, date: '09/03/2026' }])) const result = run(['--check', '--file', 'wall.json'], dir) expect(result.status).toBe(1) expect(result.stderr).toMatch(/"date" must be a YYYY-MM-DD string/) }) })