import assert from 'node:assert/strict';
import { execFileSync, spawnSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { execPath } from 'node:process';
import { afterEach, beforeEach, describe, it } from 'node:test';
import { fileURLToPath } from 'node:url';
import { notesFileForVersion, parseArgs } from './check-release-notes.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const SCRIPT_PATH = join(HERE, 'check-release-notes.mjs');
let repo;
function git(...args) {
return execFileSync('git', args, {
cwd: repo,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
}
function writeRepoFile(relPath, content) {
const full = join(repo, relPath);
mkdirSync(dirname(full), { recursive: true });
writeFileSync(full, content);
}
function writePackageJson(version) {
writeRepoFile('package.json', JSON.stringify({ name: 'fixture', version }, null, 2));
}
function commitAll(message) {
git('add', '-A');
git('commit', '-m', message);
}
function runScript(args) {
return spawnSync(execPath, [SCRIPT_PATH, ...args], { cwd: repo, encoding: 'utf8' });
}
beforeEach(() => {
repo = mkdtempSync(join(tmpdir(), 'pn-release-notes-'));
git('init', '--quiet', '--initial-branch=main');
git('config', 'user.email', 'test@example.com');
git('config', 'user.name', 'Test');
git('config', 'commit.gpgsign', 'false');
});
afterEach(() => {
rmSync(repo, { recursive: true, force: true });
});
describe('notesFileForVersion', () => {
it('maps a version to its committed notes path', () => {
assert.equal(notesFileForVersion('0.12.0'), 'docs/releases/v0.12.0.md');
assert.equal(notesFileForVersion('1.0.0'), 'docs/releases/v1.0.0.md');
});
});
describe('parseArgs', () => {
it('defaults to pr mode, HEAD, no prev/version, text output', () => {
assert.deepEqual(parseArgs([]), {
mode: 'pr',
head: 'HEAD',
prev: null,
version: null,
json: false,
});
});
it('parses every supported flag', () => {
const out = parseArgs([
'--mode',
'publish',
'--head',
'abc',
'--prev',
'def',
'--version',
'0.12.0',
'--json',
]);
assert.equal(out.mode, 'publish');
assert.equal(out.head, 'abc');
assert.equal(out.prev, 'def');
assert.equal(out.version, '0.12.0');
assert.equal(out.json, true);
});
it('rejects an unknown mode', () => {
assert.throws(() => parseArgs(['--mode', 'nope']), /--mode must be/);
});
it('rejects an unknown argument', () => {
assert.throws(() => parseArgs(['--frobnicate']), /unknown argument/);
});
});
describe('check-release-notes — publish mode', () => {
it('exits non-zero when the notes file for the head version is absent', () => {
writePackageJson('0.12.0');
commitAll('release bump, no notes');
const result = runScript(['--mode', 'publish', '--head', 'HEAD']);
assert.notEqual(result.status, 0);
assert.match(result.stderr, /docs\/releases\/v0\.12\.0\.md/);
assert.match(result.stderr, /no\s+auto-generated fallback/);
});
it('exits 0 when the notes file for the head version is present', () => {
writePackageJson('0.12.0');
writeRepoFile('docs/releases/v0.12.0.md', '# v0.12.0\n');
commitAll('release bump with notes');
const result = runScript(['--mode', 'publish', '--head', 'HEAD']);
assert.equal(result.status, 0, `expected exit 0; stderr=${result.stderr}`);
});
it('honours --version over the package.json version', () => {
writePackageJson('0.12.0');
writeRepoFile('docs/releases/v0.99.0.md', '# v0.99.0\n');
commitAll('notes for an explicit version');
const result = runScript(['--mode', 'publish', '--version', '0.99.0']);
assert.equal(result.status, 0, `expected exit 0; stderr=${result.stderr}`);
const missing = runScript(['--mode', 'publish', '--version', '0.12.0']);
assert.notEqual(missing.status, 0);
assert.match(missing.stderr, /docs\/releases\/v0\.12\.0\.md/);
});
});
describe('check-release-notes — pr mode', () => {
it('no-ops (exit 0) when the root version is unchanged between prev and head', () => {
writePackageJson('0.11.0');
commitAll('prev');
const prev = git('rev-parse', 'HEAD');
writeRepoFile('docs/some-unrelated-change.md', 'edit\n');
commitAll('head, no version bump');
const result = runScript(['--mode', 'pr', '--prev', prev, '--head', 'HEAD']);
assert.equal(result.status, 0, `expected exit 0; stderr=${result.stderr}`);
});
it('exits non-zero when the version changed but the matching notes file is absent', () => {
writePackageJson('0.11.0');
commitAll('prev');
const prev = git('rev-parse', 'HEAD');
writePackageJson('0.12.0');
commitAll('head: release bump, no notes file');
const result = runScript(['--mode', 'pr', '--prev', prev, '--head', 'HEAD']);
assert.notEqual(result.status, 0);
assert.match(result.stderr, /docs\/releases\/v0\.12\.0\.md/);
});
it('exits 0 when the version changed and the matching notes file is present', () => {
writePackageJson('0.11.0');
commitAll('prev');
const prev = git('rev-parse', 'HEAD');
writePackageJson('0.12.0');
writeRepoFile('docs/releases/v0.12.0.md', '# v0.12.0\n');
commitAll('head: release bump with notes');
const result = runScript(['--mode', 'pr', '--prev', prev, '--head', 'HEAD']);
assert.equal(result.status, 0, `expected exit 0; stderr=${result.stderr}`);
});
it('--json envelope reports ok=false and the missing notes file', () => {
writePackageJson('0.11.0');
commitAll('prev');
const prev = git('rev-parse', 'HEAD');
writePackageJson('0.12.0');
commitAll('head: release bump, no notes file');
const result = runScript(['--mode', 'pr', '--prev', prev, '--head', 'HEAD', '--json']);
assert.notEqual(result.status, 0);
const parsed = JSON.parse(result.stdout);
assert.equal(parsed.ok, false);
assert.equal(parsed.bumped, true);
assert.equal(parsed.version, '0.12.0');
assert.deepEqual(
parsed.violations.map((v) => v.notesFile),
['docs/releases/v0.12.0.md'],
);
});
});