/** * 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 fixture CHANGELOG and a throwaway local bare repo * standing in for git@source.soulcraft.com:soulcraftlabs/releases.git * (--remote) plus a throwaway cache directory (--cache-dir) standing in * for ~/.cache/soulcraft-releases — never the real remote, never the * real developer cache. */ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { execFileSync } from 'node:child_process' import { mkdtempSync, rmSync, writeFileSync, readFileSync, chmodSync } 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 ?? '' } } } function git(args: string[], cwd: string): string { return execFileSync('git', ['-C', cwd, ...args], { encoding: 'utf8' }).trim() } 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 }, 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, } /** A throwaway bare repo standing in for the real soulcraftlabs/releases remote. */ function initBareRemote(): string { const remoteDir = mkdtempSync(join(tmpdir(), 'wall-remote-')) execFileSync('git', ['init', '--bare', '-b', 'main', remoteDir]) return remoteDir } /** Seed the bare remote with an initial .json, via a throwaway clone. */ function seedRemote(remoteDir: string, product: string, entries: unknown[]): void { const seedDir = mkdtempSync(join(tmpdir(), 'wall-seed-')) execFileSync('git', ['clone', remoteDir, seedDir], { stdio: 'ignore' }) git(['config', 'user.email', 'seed@example.com'], seedDir) git(['config', 'user.name', 'Seed'], seedDir) writeFileSync(join(seedDir, `${product}.json`), wallFile(product, entries)) git(['add', `${product}.json`], seedDir) git(['commit', '-m', 'seed'], seedDir) git(['push', 'origin', 'main'], seedDir) rmSync(seedDir, { recursive: true, force: true }) } /** Read .json back out of the bare remote's main tip, via a throwaway clone. */ function readRemote(remoteDir: string, product: string): any { const readDir = mkdtempSync(join(tmpdir(), 'wall-read-')) execFileSync('git', ['clone', remoteDir, readDir], { stdio: 'ignore' }) const data = JSON.parse(readFileSync(join(readDir, `${product}.json`), 'utf8')) rmSync(readDir, { recursive: true, force: true }) return data } /** Reject every push — stands in for any push failure (including a genuine * non-fast-forward raced by a concurrent release rail), which this script * treats identically: refuse loudly, name the cure, touch nothing further. */ function makeRemoteRejectPushes(remoteDir: string): void { const hookPath = join(remoteDir, 'hooks', 'pre-receive') writeFileSync(hookPath, '#!/bin/sh\necho "remote: simulated push rejection" >&2\nexit 1\n') chmodSync(hookPath, 0o755) } let dir: string let remoteDir: string let cacheDir: string beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'wall-entry-test-')) // wall-entry.mjs is run with this dir as its cwd, standing in for the real // developer checkout it reads its commit identity from (process.cwd()) — // give it a repo-local identity the same way seedRemote gives one to the // seed clone, so the suite is deterministic on a host with no global git // config (a bare CI box) as much as one with a developer's own. execFileSync('git', ['init', '-q', dir]) git(['config', 'user.name', 'Wall Entry Test'], dir) git(['config', 'user.email', 'wall-entry-test@example.com'], dir) remoteDir = initBareRemote() cacheDir = join(mkdtempSync(join(tmpdir(), 'wall-cache-')), 'soulcraft-releases') }) afterEach(() => { rmSync(dir, { recursive: true, force: true }) rmSync(remoteDir, { recursive: true, force: true }) rmSync(cacheDir, { recursive: true, force: true }) }) describe('wall-entry.mjs — generate + publish', () => { it('derives headline from the first bullet and items from every bullet, hashes stripped, and pushes it to the remote', () => { seedRemote(remoteDir, 'open-brainy', [BASE_ENTRY]) 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'] }]), ) const result = run( ['--product', 'open-brainy', '--version', '10.4.12', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--remote', remoteDir, '--cache-dir', cacheDir], dir, ) expect(result.status).toBe(0) expect(result.stdout).toMatch(/wrote v10\.4\.12.*pushed/i) const wall = readRemote(remoteDir, 'open-brainy') 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', () => { seedRemote(remoteDir, 'open-brainy', [BASE_ENTRY, { ...BASE_ENTRY, version: '10.4.10' }]) writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '10.5.0', date: '2026-09-03', bullets: ['feat: ten five'] }])) run(['--product', 'open-brainy', '--version', '10.5.0', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--remote', remoteDir, '--cache-dir', cacheDir], dir) const wall = readRemote(remoteDir, 'open-brainy') expect(wall.entries.map((e: any) => e.version)).toEqual(['10.5.0', '10.4.11', '10.4.10']) }) it('replaces an entry with the same version instead of duplicating it — idempotent re-runs', () => { seedRemote(remoteDir, 'open-brainy', [ { ...BASE_ENTRY, headline: 'stale headline, pre-fix' }, { ...BASE_ENTRY, version: '10.4.10' }, ]) writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '10.4.11', date: '2026-09-02', bullets: ['fix: the corrected headline'] }])) const result = run( ['--product', 'open-brainy', '--version', '10.4.11', '--date', '2026-09-02', '--from-changelog', 'CHANGELOG.md', '--remote', remoteDir, '--cache-dir', cacheDir], dir, ) expect(result.status).toBe(0) expect(result.stdout).toMatch(/replaced v10\.4\.11/i) const wall = readRemote(remoteDir, 'open-brainy') expect(wall.entries).toHaveLength(2) // not 3 — replaced, not duplicated expect(wall.entries[0].version).toBe('10.4.11') expect(wall.entries[0].headline).toBe('fix: the corrected headline') expect(wall.entries[1].version).toBe('10.4.10') }) it('a re-run with byte-identical content commits nothing and still succeeds', () => { // headline always equals items[0] for a derived entry, so this fixture // (unlike BASE_ENTRY, whose headline/items intentionally diverge for the // shape-only tests below) has to keep the two in lockstep to ever roundtrip. const stableEntry = { ...BASE_ENTRY, headline: 'A faster open.', items: ['A faster open.'] } seedRemote(remoteDir, 'open-brainy', [stableEntry]) writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '10.4.11', date: '2026-09-02', bullets: ['A faster open.'] }])) const before = readRemote(remoteDir, 'open-brainy') const result = run( ['--product', 'open-brainy', '--version', '10.4.11', '--date', '2026-09-02', '--from-changelog', 'CHANGELOG.md', '--remote', remoteDir, '--cache-dir', cacheDir], dir, ) expect(result.status).toBe(0) expect(result.stdout).toMatch(/nothing to commit/i) expect(readRemote(remoteDir, 'open-brainy')).toEqual(before) }) it('derives the public package-page permalink for the product engine (private repo, never null)', () => { seedRemote(remoteDir, 'brainy', [{ ...BASE_ENTRY, version: '11.0.5', url: 'https://source.soulcraft.com/soulcraft/-/packages/npm/@soulcraft%2Fbrainy/11.0.5' }]) writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '11.0.6', date: '2026-09-03', bullets: ['fix: a native-only fix'] }])) const result = run( ['--product', 'brainy', '--version', '11.0.6', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--remote', remoteDir, '--cache-dir', cacheDir], dir, ) expect(result.status).toBe(0) const wall = readRemote(remoteDir, 'brainy') expect(wall.entries[0].url).toBe('https://source.soulcraft.com/soulcraft/-/packages/npm/@soulcraft%2Fbrainy/11.0.6') expect(wall.entries[0].thumb).toBeNull() }) it('refuses a product with no permalink pattern, naming the cure', () => { seedRemote(remoteDir, 'open-brainy', [BASE_ENTRY]) writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '1.0.0', date: '2026-09-03', bullets: ['feat: first'] }])) const result = run(['--product', 'mystery', '--version', '1.0.0', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--remote', remoteDir, '--cache-dir', cacheDir], dir) expect(result.status).not.toBe(0) expect(result.stderr).toMatch(/no permalink pattern for product "mystery"/) expect(result.stderr).toMatch(/never carry url: null/) }) it('refuses when the CHANGELOG has no entry yet for the target version, and touches no remote', () => { seedRemote(remoteDir, 'open-brainy', []) writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '10.4.11', date: '2026-09-02', bullets: ['fix: whatever'] }])) const beforeSha = git(['rev-parse', 'main'], remoteDir) const result = run( ['--product', 'open-brainy', '--version', '99.0.0', '--date', '2026-09-02', '--from-changelog', 'CHANGELOG.md', '--remote', remoteDir, '--cache-dir', cacheDir], dir, ) expect(result.status).toBe(1) expect(result.stderr).toMatch(/no CHANGELOG entry yet/i) expect(git(['rev-parse', 'main'], remoteDir)).toBe(beforeSha) }) it('refuses by naming the cure when the remote cannot be cloned', () => { writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '10.4.12', date: '2026-09-03', bullets: ['fix: whatever'] }])) const noSuchRemote = join(tmpdir(), 'wall-remote-does-not-exist-' + Date.now()) const result = run( ['--product', 'open-brainy', '--version', '10.4.12', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--remote', noSuchRemote, '--cache-dir', cacheDir], dir, ) expect(result.status).toBe(1) expect(result.stderr).toMatch(/cannot clone/i) expect(result.stderr).toMatch(/cure:/i) }) it('refuses by naming the cure, and touches no remote, when the fetched wall fails shape validation', () => { const seedDir = mkdtempSync(join(tmpdir(), 'wall-seed-broken-')) execFileSync('git', ['clone', remoteDir, seedDir], { stdio: 'ignore' }) git(['config', 'user.email', 'seed@example.com'], seedDir) git(['config', 'user.name', 'Seed'], seedDir) writeFileSync( join(seedDir, 'open-brainy.json'), JSON.stringify({ product: 'open-brainy', entries: [{ version: '10.4.11', date: '2026-09-02', items: ['x'], url: null }] }, null, 2), ) git(['add', 'open-brainy.json'], seedDir) git(['commit', '-m', 'seed broken'], seedDir) git(['push', 'origin', 'main'], seedDir) rmSync(seedDir, { recursive: true, force: true }) const beforeSha = git(['rev-parse', 'main'], remoteDir) writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '10.4.12', date: '2026-09-03', bullets: ['fix: whatever'] }])) const result = run( ['--product', 'open-brainy', '--version', '10.4.12', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--remote', remoteDir, '--cache-dir', cacheDir], dir, ) expect(result.status).toBe(1) expect(result.stderr).toMatch(/fails shape validation/i) expect(result.stderr).toMatch(/missing key\(s\) headline/i) expect(git(['rev-parse', 'main'], remoteDir)).toBe(beforeSha) }) it('refuses by naming the cure when the remote rejects the push (stands in for a raced non-fast-forward)', () => { seedRemote(remoteDir, 'open-brainy', [BASE_ENTRY]) makeRemoteRejectPushes(remoteDir) writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '10.4.12', date: '2026-09-03', bullets: ['fix: whatever'] }])) const result = run( ['--product', 'open-brainy', '--version', '10.4.12', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--remote', remoteDir, '--cache-dir', cacheDir], dir, ) expect(result.status).toBe(1) expect(result.stderr).toMatch(/push to .* failed/i) expect(result.stderr).toMatch(/cure:/i) }) it('refuses a cross-product write when the file\'s "product" field does not match --product', () => { seedRemote(remoteDir, 'open-brainy', [BASE_ENTRY]) const seedDir = mkdtempSync(join(tmpdir(), 'wall-seed-mismatch-')) execFileSync('git', ['clone', remoteDir, seedDir], { stdio: 'ignore' }) git(['config', 'user.email', 'seed@example.com'], seedDir) git(['config', 'user.name', 'Seed'], seedDir) const corrupted = JSON.parse(readFileSync(join(seedDir, 'open-brainy.json'), 'utf8')) corrupted.product = 'brainy' writeFileSync(join(seedDir, 'open-brainy.json'), JSON.stringify(corrupted, null, 2) + '\n') git(['add', 'open-brainy.json'], seedDir) git(['commit', '-m', 'corrupt product field'], seedDir) git(['push', 'origin', 'main'], seedDir) rmSync(seedDir, { recursive: true, force: true }) writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '1.0.0', date: '2026-09-03', bullets: ['fix: wrong repo'] }])) const result = run( ['--product', 'open-brainy', '--version', '1.0.0', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--remote', remoteDir, '--cache-dir', cacheDir], dir, ) expect(result.status).toBe(1) expect(result.stderr).toMatch(/product "brainy".*--product "open-brainy"/i) }) }) describe('wall-entry.mjs — --dry-run', () => { it('prints the entry and the target path, and touches neither the cache dir nor the remote', () => { seedRemote(remoteDir, 'open-brainy', [BASE_ENTRY]) writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '10.4.12', date: '2026-09-03', bullets: ['fix: a dry run'] }])) const beforeSha = git(['rev-parse', 'main'], remoteDir) const result = run( ['--dry-run', '--product', 'open-brainy', '--version', '10.4.12', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--remote', remoteDir, '--cache-dir', cacheDir], dir, ) expect(result.status).toBe(0) expect(result.stdout).toMatch(/would write to/i) expect(result.stdout).toMatch(/"version": "10\.4\.12"/) expect(git(['rev-parse', 'main'], remoteDir)).toBe(beforeSha) }) }) 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('passes a file where "thumb" is entirely absent (optional per the HQ contract)', () => { const { thumb, ...noThumb } = BASE_ENTRY as any writeFileSync(join(dir, 'wall.json'), wallFile('open-brainy', [noThumb])) const result = run(['--check', '--file', 'wall.json'], dir) expect(result.status).toBe(0) }) it('catches a missing entry key', () => { const broken = { version: '1.0.0', date: '2026-09-03', headline: 'h', items: ['i'] } // no "url" 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\) url/) }) it('catches an unexpected top-level key (e.g. the retired "history" field)', () => { const raw = JSON.parse(wallFile('open-brainy', [BASE_ENTRY])) raw.history = 'retired field' 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\) history/) }) 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/) }) })