Files
iptvnator/tools/release/verify-draft-release.test.mjs
T

433 lines
14 KiB
JavaScript

import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
parseVerifyArguments,
requiredAssetRules,
RUN_POLL_ATTEMPTS,
RUN_POLL_INTERVAL_MS,
runVerification,
verifyReleaseAssets,
} from './verify-draft-release.mjs';
/** The asset list of a real, complete v0.23.0 matrix build. */
function completeAssets(version) {
return [
`iptvnator-${version}-linux-amd64.deb`,
`iptvnator-${version}-linux-amd64.snap`,
`iptvnator-${version}-linux-arm64.AppImage`,
`iptvnator-${version}-linux-arm64.deb`,
`iptvnator-${version}-linux-armhf.snap`,
`iptvnator-${version}-linux-armv7l.AppImage`,
`iptvnator-${version}-linux-armv7l.deb`,
`iptvnator-${version}-linux-x64.pacman`,
`iptvnator-${version}-linux-x86_64.AppImage`,
`iptvnator-${version}-linux-x86_64.flatpak`,
`iptvnator-${version}-linux-x86_64.rpm`,
`iptvnator-${version}-mac-arm64.dmg`,
`iptvnator-${version}-mac-arm64.dmg.blockmap`,
`iptvnator-${version}-mac-arm64.zip`,
`iptvnator-${version}-mac-arm64.zip.blockmap`,
`iptvnator-${version}-mac-x64.dmg`,
`iptvnator-${version}-mac-x64.dmg.blockmap`,
`iptvnator-${version}-mac-x64.zip`,
`iptvnator-${version}-mac-x64.zip.blockmap`,
`iptvnator-${version}-windows-x64-setup.exe`,
`iptvnator-${version}-windows-x64-setup.exe.blockmap`,
'latest-linux-arm.yml',
'latest-linux-arm64.yml',
'latest-linux.yml',
'latest-mac.yml',
'latest.yml',
'linux-frame-copy-runtime-sources.tar.xz',
];
}
function release(overrides = {}) {
return {
name: 'v0.24.0',
isDraft: true,
// Shaped like the real thing: authored section, then GitHub's
// generated notes appended by the tag workflow.
body: '### Features\n\n- **playback** — Up Next rail.\n\n## What\'s Changed\n* chore by @bot in #1\n',
assets: completeAssets('0.24.0').map((name) => ({ name })),
...overrides,
};
}
/** `progressLog` collects transient status for assertions; runVerification
* only reads the known io methods and ignores the extra property. */
function io(overrides = {}) {
const progressLog = [];
return {
listRuns: () => [
{ databaseId: 42, status: 'completed', conclusion: 'success' },
],
watchRun: () => {
throw new Error('watchRun must not be called');
},
viewRelease: () => release(),
readChangelog: () =>
'# Changelog\n\n<!-- next-release -->\n\n# 0.24.0 (2026-08-01)\n\n### Features\n\n- **playback** — Up Next rail.\n',
progress: (message) => progressLog.push(message),
progressLog,
sleep: () => Promise.resolve(),
...overrides,
};
}
describe('verifyReleaseAssets', () => {
it('accepts the complete real-world asset set with no extras', () => {
const { missing, extras } = verifyReleaseAssets(
completeAssets('0.24.0'),
'0.24.0'
);
assert.deepEqual(missing, []);
assert.deepEqual(extras, []);
});
it('every rule is matched by exactly the assets it names', () => {
// One required asset per rule: the counts must line up, otherwise a
// rule silently matches two files and a missing one goes unnoticed.
assert.equal(
requiredAssetRules('0.24.0').length,
completeAssets('0.24.0').length
);
});
it('reports missing assets by label', () => {
const withoutSnap = completeAssets('0.24.0').filter(
(name) => !name.endsWith('.snap')
);
const { missing } = verifyReleaseAssets(withoutSnap, '0.24.0');
assert.deepEqual(missing, [
'Snap (iptvnator-0.24.0-linux-amd64.snap)',
'Snap (iptvnator-0.24.0-linux-armhf.snap)',
]);
});
it('rejects assets from a different version', () => {
const { missing } = verifyReleaseAssets(
completeAssets('0.23.0'),
'0.24.0'
);
assert.ok(missing.length > 0);
});
it('accepts the alternate pacman artifact shape', () => {
const assets = completeAssets('0.24.0').map((name) =>
name.endsWith('.pacman')
? 'iptvnator-0.24.0-linux-x86_64.pkg.tar.zst'
: name
);
assert.deepEqual(verifyReleaseAssets(assets, '0.24.0').missing, []);
});
it('does not let the pacman pattern match across version dots', () => {
const { extras } = verifyReleaseAssets(
['iptvnator-0x24y0-linux-x64.pacman'],
'0.24.0'
);
assert.deepEqual(extras, ['iptvnator-0x24y0-linux-x64.pacman']);
});
it('surfaces unrecognized assets as extras, not errors', () => {
const { missing, extras } = verifyReleaseAssets(
[...completeAssets('0.24.0'), 'iptvnator-0.24.0-win-arm64.exe'],
'0.24.0'
);
assert.deepEqual(missing, []);
assert.deepEqual(extras, ['iptvnator-0.24.0-win-arm64.exe']);
});
});
describe('parseVerifyArguments', () => {
it('defaults to waiting and the canonical repo', () => {
assert.deepEqual(parseVerifyArguments([]), {
version: null,
wait: true,
repo: '4gray/iptvnator',
});
});
it('normalizes a v-prefixed version and honours flags', () => {
assert.deepEqual(
parseVerifyArguments([
'--no-wait',
'--repo',
'fork/iptvnator',
'v0.24.0',
]),
{ version: '0.24.0', wait: false, repo: 'fork/iptvnator' }
);
});
it('ignores a bare `--` separator', () => {
assert.equal(parseVerifyArguments(['--', '0.24.0']).version, '0.24.0');
});
it('rejects bad usage', () => {
for (const args of [
['0.24'],
['0.24.0', 'extra'],
['--unknown'],
['--repo'],
['--repo', '--no-wait'],
['--repo', 'no-slash'],
['--repo', 'too/many/parts'],
]) {
assert.equal(parseVerifyArguments(args), null, args.join(' '));
}
});
});
describe('runVerification', () => {
const options = { version: '0.24.0', wait: true, repo: '4gray/iptvnator' };
it('passes a complete draft and points at the manual next steps', async () => {
const result = await runVerification(options, io());
assert.equal(result.exitCode, 0);
assert.match(result.lines[0], /Draft release v0\.24\.0 found\./);
assert.match(
result.lines.at(-2),
/All 27 required assets present \(27 attached\)\./
);
assert.match(result.lines.at(-1), /publish the release manually/);
});
it('polls before concluding the tag run does not exist', async () => {
let calls = 0;
const slept = [];
const harness = io({
listRuns: () => {
calls += 1;
return calls < 3
? []
: [
{
databaseId: 42,
status: 'completed',
conclusion: 'success',
},
];
},
sleep: (ms) => {
slept.push(ms);
return Promise.resolve();
},
});
const result = await runVerification(options, harness);
assert.equal(calls, 3);
assert.deepEqual(slept, [RUN_POLL_INTERVAL_MS, RUN_POLL_INTERVAL_MS]);
assert.match(harness.progressLog[0], /No build-and-make\.yaml run for v0\.24\.0 yet/);
assert.equal(result.exitCode, 0);
});
it('gives up after the bounded poll window', async () => {
let calls = 0;
const result = await runVerification(
options,
io({
listRuns: () => {
calls += 1;
return [];
},
})
);
assert.equal(calls, RUN_POLL_ATTEMPTS);
assert.equal(result.exitCode, 1);
assert.match(result.lines[0], /No build-and-make\.yaml run found/);
assert.match(
result.lines[0],
new RegExp(`after ${RUN_POLL_ATTEMPTS} attempts`)
);
});
it('fails on a completed run with a non-success conclusion', async () => {
const result = await runVerification(
options,
io({
listRuns: () => [
{
databaseId: 42,
status: 'completed',
conclusion: 'failure',
},
],
})
);
assert.equal(result.exitCode, 1);
assert.match(result.lines[0], /conclusion "failure"/);
});
it('watches an in-progress run before checking the release', async () => {
const watched = [];
const result = await runVerification(
options,
io({
listRuns: () => [
{ databaseId: 7, status: 'in_progress', conclusion: null },
],
watchRun: (request) => watched.push(request),
})
);
assert.deepEqual(watched, [{ repo: '4gray/iptvnator', runId: 7 }]);
assert.equal(result.exitCode, 0);
});
it('skips the run lookup entirely with --no-wait', async () => {
const result = await runVerification(
{ ...options, wait: false },
io({
listRuns: () => {
throw new Error('listRuns must not be called');
},
})
);
assert.equal(result.exitCode, 0);
});
it('fails with the missing-asset list', async () => {
const result = await runVerification(
options,
io({
viewRelease: () =>
release({
assets: completeAssets('0.24.0')
.filter((name) => !name.endsWith('.flatpak'))
.map((name) => ({ name })),
}),
})
);
assert.equal(result.exitCode, 1);
assert.match(result.lines.at(-2), /Missing 1 required asset/);
assert.match(result.lines.at(-1), /Flatpak/);
});
it('fails when the release does not exist', async () => {
const result = await runVerification(
options,
io({ viewRelease: () => null })
);
assert.equal(result.exitCode, 1);
assert.match(result.lines[0], /No release found for v0\.24\.0/);
});
it('never reports success once the release is published', async () => {
const result = await runVerification(
options,
io({ viewRelease: () => release({ isDraft: false }) })
);
// The asset report is still useful for an after-the-fact audit, but a
// pre-publication gate must not pass after publication.
assert.equal(result.exitCode, 1);
assert.match(result.lines[0], /already published/);
assert.match(result.lines.at(-1), /All 27 required assets present/);
assert.ok(
!result.lines.some((line) => /publish the release manually/.test(line))
);
});
it('confirms the authored changelog section inside the combined body', async () => {
const result = await runVerification(options, io());
assert.equal(result.exitCode, 0);
assert.match(
result.lines[1],
/Authored changelog section present in the release body\./
);
});
it('warns when the body carries only GitHub-generated notes', async () => {
// The tag workflow appends generated notes to the authored text, so a
// non-empty body proves nothing — this is the case an emptiness check
// could never catch.
const result = await runVerification(
options,
io({
viewRelease: () =>
release({ body: "## What's Changed\n* chore by @bot in #1" }),
})
);
assert.equal(result.exitCode, 0);
assert.match(
result.lines[1],
/does not contain the authored CHANGELOG section/
);
});
it('expects no authored body for an internal-only release', async () => {
const result = await runVerification(
options,
io({
readChangelog: () =>
'# 0.24.0 (2026-08-01)\n\n<details>\n<summary>Internal changes</summary>\n\n- **deps** — bump.\n\n</details>\n',
viewRelease: () =>
release({ body: "## What's Changed\n* chore by @bot in #1" }),
})
);
assert.equal(result.exitCode, 0);
assert.match(result.lines[1], /internal-only release/);
});
it('warns when the changelog has no section for the version', async () => {
const result = await runVerification(
options,
io({ readChangelog: () => '# Changelog\n\n<!-- next-release -->\n' })
);
assert.match(result.lines[1], /CHANGELOG\.md has no section for 0\.24\.0/);
});
it('does not claim to have verified an unreadable changelog', async () => {
const result = await runVerification(
options,
io({ readChangelog: () => null })
);
assert.match(result.lines[1], /NOTE: CHANGELOG\.md is unreadable/);
});
it('notes unrecognized assets without failing', async () => {
const result = await runVerification(
options,
io({
viewRelease: () =>
release({
assets: [
...completeAssets('0.24.0'),
'iptvnator-0.24.0-win-arm64.exe',
].map((name) => ({ name })),
}),
})
);
assert.equal(result.exitCode, 0);
assert.match(
result.lines.find((line) => line.startsWith('NOTE:')),
/unrecognized asset iptvnator-0\.24\.0-win-arm64\.exe/
);
});
});