Files
iptvnator/tools/release/build-release-notes.test.mjs
T
4grayandClaude Fable 5 4b30e0613a fix(release): address review findings on the release tooling
CodeQL: the pacman rule built a regex from the interpolated version and
escaped only dots. It now compares plain strings, so the exported
requiredAssetRules() carries no escaping trap; only the literal compression
suffix stays a regex.

verify-draft-release:
- Never exits 0 once the release is published. The asset report still
  prints (auditing after the fact is useful), but a pre-publication gate
  must not report success for a boundary already crossed.
- Polls for the tag run before concluding it does not exist: `gh run list`
  reports what is indexed right now, and a run pushed seconds ago routinely
  is not. Adds injectable progress/sleep io, so runVerification is async.
- The live sleep timer is deliberately not unref'd — a pending promise does
  not hold the event loop open, and an empty loop exits 0, which would turn
  a mid-poll exit into a silent false success.
- `--repo` is shape-checked so a typo fails with the usage line.

Announcements: an internal-only release now returns null instead of
throwing a misleading "even a single entry exceeds the limit" error. The
CLI explains it on stderr, leaves stdout empty and exits 0 — the shape
extract-changelog-section.mjs --public already uses for the same case.

Highlight cards: card filenames derive from the note filename, never the
screenshot slug. Two highlights may legitimately share one manifest shot,
which previously made the second card overwrite the first.

Tests: the CLI harness moved to spawnSync — execFileSync discards stderr on
success, hiding exactly the messages these paths emit. Adds regressions for
each finding above (165 passing).

Docs: CLAUDE.md and AGENTS.md release sections (mirrored, verified
identical) now document the highlight field, the announcement/card
commands, and the draft verifier; .changes/README.md records the
internal-only announcement behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 08:57:00 +02:00

177 lines
5.5 KiB
JavaScript

/**
* CLI-level tests for build-release-notes.mjs.
*
* The script parses its arguments and runs on import, so these drive the real
* entry point in a subprocess rather than importing parseArgs directly.
*/
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import { after, describe, it } from 'node:test';
const workspaceRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'../..'
);
const CLI = path.join(workspaceRoot, 'tools/release/build-release-notes.mjs');
const tempDirs = [];
/** A notes directory holding one valid note, so runs have something to render. */
function makeNotesDir() {
const directory = mkdtempSync(path.join(tmpdir(), 'release-cli-'));
tempDirs.push(directory);
writeFileSync(
path.join(directory, 'playback-example.md'),
'---\ntype: feature\narea: playback\n---\n\nAn example note.\n',
'utf8'
);
return directory;
}
/**
* spawnSync rather than execFileSync: a successful run also writes to stderr
* (the resolved-version notice, the internal-only announcement explanation),
* and execFileSync only hands back stdout.
*
* @returns {{ status: number, stdout: string, stderr: string }}
*/
function runCli(args) {
const result = spawnSync(process.execPath, [CLI, ...args], {
cwd: workspaceRoot,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});
return {
status: result.status ?? 1,
stdout: result.stdout ?? '',
stderr: result.stderr ?? '',
};
}
after(() => {
for (const directory of tempDirs) {
rmSync(directory, { recursive: true, force: true });
}
});
describe('build-release-notes CLI arguments', () => {
it('validates a notes directory', () => {
const result = runCli(['--validate', '--dir', makeNotesDir()]);
assert.equal(result.status, 0);
assert.match(result.stdout, /1 release note\(s\) valid\./);
});
it('ignores a bare `--` separator', () => {
// pnpm forwards `--` to the script instead of consuming it, so the
// npm-style `pnpm run release:notes:validate -- --dir x` reaches us
// with the separator still attached.
const result = runCli(['--validate', '--', '--dir', makeNotesDir()]);
assert.equal(result.status, 0);
assert.match(result.stdout, /1 release note\(s\) valid\./);
});
it('ignores a leading `--` separator', () => {
const result = runCli(['--', '--validate', '--dir', makeNotesDir()]);
assert.equal(result.status, 0);
assert.match(result.stdout, /1 release note\(s\) valid\./);
});
it('renders the github body with a version passed after `--`', () => {
const result = runCli([
'--format',
'github',
'--',
'--version',
'0.24.0',
'--dir',
makeNotesDir(),
]);
assert.equal(result.status, 0);
assert.match(result.stdout, /## Features/);
assert.match(result.stdout, /\*\*playback\*\* — An example note\./);
});
it('renders the telegram announcement to stdout', () => {
const result = runCli([
'--format',
'telegram',
'--version',
'0.24.0',
'--dir',
makeNotesDir(),
]);
assert.equal(result.status, 0);
assert.match(result.stdout, /🎉 IPTVnator v0\.24\.0 is out!/);
assert.match(result.stdout, /✨ An example note\./);
assert.match(result.stdout, /releases\/tag\/v0\.24\.0/);
});
it('renders the reddit announcement to stdout', () => {
const result = runCli([
'--format',
'reddit',
'--version',
'0.24.0',
'--dir',
makeNotesDir(),
]);
assert.equal(result.status, 0);
assert.match(result.stdout, /^Suggested title: IPTVnator v0\.24\.0/);
assert.match(result.stdout, /- \*\*playback\*\* — An example note\./);
});
it('reports an internal-only release instead of failing on announcements', () => {
const directory = mkdtempSync(path.join(tmpdir(), 'release-cli-'));
tempDirs.push(directory);
writeFileSync(
path.join(directory, 'deps-bump.md'),
'---\ntype: internal\narea: deps\n---\n\nBumped a parser.\n',
'utf8'
);
for (const format of ['telegram', 'reddit']) {
const result = runCli([
'--format',
format,
'--version',
'0.24.0',
'--dir',
directory,
]);
assert.equal(result.status, 0, format);
assert.equal(result.stdout, '', format);
assert.match(result.stderr, /Internal-only release/, format);
}
});
it('still rejects a genuinely unknown argument', () => {
const result = runCli(['--validate', '--nope']);
assert.equal(result.status, 1);
assert.match(result.stderr, /unknown argument: --nope/);
});
it('still rejects a flag whose value is missing', () => {
const result = runCli(['--format']);
assert.equal(result.status, 1);
assert.match(result.stderr, /--format requires a value/);
});
});