import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { evaluateGate, GATE_LABEL, } from './check-release-note-gate.mjs'; function file(filename, status = 'modified') { return { filename, status }; } function gate(files, labels = []) { return evaluateGate({ files, labels }); } describe('evaluateGate', () => { it('passes when runtime code ships with an added note', () => { const verdict = gate([ file('libs/ui/playback/src/lib/player.ts'), file('.changes/playback-volume-memory.md', 'added'), ]); assert.equal(verdict.ok, true); assert.match(verdict.message, /playback-volume-memory/); }); it('fails when runtime code ships without a note', () => { const verdict = gate([file('apps/web/src/app/app.component.ts')]); assert.equal(verdict.ok, false); assert.match(verdict.message, /app\.component\.ts/); assert.match(verdict.message, /\.changes\/-\.md/); assert.match(verdict.message, new RegExp(GATE_LABEL)); }); it('accepts the escape label', () => { const verdict = gate( [file('apps/web/src/app/app.component.ts')], [GATE_LABEL] ); assert.equal(verdict.ok, true); }); it('a modified existing note does not satisfy the gate', () => { const verdict = gate([ file('libs/m3u-state/src/lib/reducer.ts'), file('.changes/old-note.md', 'modified'), ]); assert.equal(verdict.ok, false); }); it('a renamed note does not satisfy the gate', () => { // A note added and renamed within one PR still reports as `added`; // `renamed` means it came from the base branch, i.e. another PR's. const verdict = gate([ file('libs/m3u-state/src/lib/reducer.ts'), file('.changes/m3u-better-name.md', 'renamed'), ]); assert.equal(verdict.ok, false); }); it('a nested note does not satisfy the gate', () => { // loadNotes() only reads the immediate directory, so a nested file // would never be validated or rendered into a release surface. const verdict = gate([ file('libs/m3u-state/src/lib/reducer.ts'), file('.changes/subdir/m3u-note.md', 'added'), ]); assert.equal(verdict.ok, false); }); it('accepts a top-level note regardless of surrounding path noise', () => { const verdict = gate([ file('libs/m3u-state/src/lib/reducer.ts'), file('.changes/m3u-long-urls.md', 'added'), ]); assert.equal(verdict.ok, true); }); it('.changes/README.md is not a note', () => { const verdict = gate([ file('libs/m3u-state/src/lib/reducer.ts'), file('.changes/README.md', 'added'), ]); assert.equal(verdict.ok, false); }); it('ignores changes outside apps/ and libs/', () => { const verdict = gate([ file('tools/release/build-release-notes.mjs'), file('.github/workflows/ci.yml'), file('CLAUDE.md'), file('package.json'), ]); assert.equal(verdict.ok, true); assert.match(verdict.message, /not required/); }); it('exempts tests, snapshots, e2e, website, mock servers and docs', () => { const verdict = gate([ file('apps/web/src/app/app.component.spec.ts'), file('apps/web-e2e/src/xtream.e2e.ts'), file('apps/electron-backend-e2e/src/search.e2e.ts'), file('apps/website/src/pages/index.astro'), file('apps/xtream-mock-server/src/main.ts'), file('apps/stalker-mock-server/src/main.ts'), file('libs/shared/testing/src/lib/helpers.ts'), file('libs/ui/playback/src/lib/__snapshots__/player.snap'), file('libs/ui/playback/README.md'), ]); assert.equal(verdict.ok, true); }); it('one runtime file among exempt ones still triggers the gate', () => { const verdict = gate([ file('apps/web/src/app/app.component.spec.ts'), file('apps/web/src/app/app.component.ts'), ]); assert.equal(verdict.ok, false); assert.match(verdict.message, /app\.component\.ts/); assert.doesNotMatch(verdict.message, /spec\.ts/); }); it('truncates long file listings in the failure message', () => { const files = Array.from({ length: 15 }, (_, index) => file(`libs/services/src/lib/file-${index}.ts`) ); const verdict = gate(files); assert.equal(verdict.ok, false); assert.match(verdict.message, /… and 5 more/); }); });