Files
iptvnator/tools/release/capture-release-screenshots.ts
T
4grayandClaude Opus 5 b4ec68c1fa feat(release): manifest-driven screenshot capture with fail-closed mock-data guards (#1261)
Third slice of the release-notes pipeline (#1256 format+generator, #1257 CI
gate): release screenshots become reproducible and provably mock-only.

The v0.20 capture script was single-use (hard-coded slugs, paths, hero) and
fail-open: a lost IPTVNATOR_E2E_DATA_DIR silently fell back to the user's
real ~/.iptvnator database, `...process.env` leaked ambient TMDB keys and
proxies, nothing gated network access, and no frame content was ever
validated. Each hole leaks real playlists, credentials, or copyrighted
artwork into published screenshots without a single signal.

New pipeline:

- tools/release/screenshots.manifest.json — declarative shots (slug, title,
  named setup steps, themes). Adding a feature shot = one manifest entry.
- capture-release-screenshots.ts — orchestrator; output goes to
  apps/website/public/blog/<release>/screenshots/<slug>-<theme>.png, release
  slug derived from package.json (or --release), --only/--theme filters.
- capture-app-driver.ts / capture-navigation.ts — launch, seeding, theme,
  and the named-action vocabulary; actions are order-independent (every
  portal action starts from the dashboard).
- screenshot-guards.mjs — the fail-closed policy, pure and unit-tested:
  G1 the real database is snapshotted (sha256+mtime) before launch and must
     be byte-identical after; the isolated DB must actually exist
  G2 the app receives an allowlisted environment, never ...process.env
  G3 deny-by-default network gate; known app-level calls (GitHub update
     check) are answered by local stubs; any other blocked request fails
     the run — a silently-blocked TMDB call would leave a frame that looks
     broken rather than unsafe
  G4 every frame is scanned before capture: external img/background URLs,
     credential-shaped text, MAC addresses, non-localhost m3u8 references
  G5 TMDB enrichment asserted disabled via the renderer's IndexedDB
  Any violation deletes every frame captured in the run and exits non-zero.

The guards paid for themselves on the first live run: G3 caught the mock
server redirecting stream endpoints to a public demo HLS
(test-streams.mux.dev) — meaning earlier hand-run captures could embed
third-party video frames. The M3U shot now deliberately captures the groups
layout without starting playback.

`.changes` validation now cross-checks `screenshot:` slugs against the
manifest, so a note cannot reference an image the capture run never
produces.

Verified end-to-end: 10/10 shots (5 slugs × dark/light) captured against
dist build + xtream-mock-server, frames visually inspected (fictional
titles/artwork only), guard-violation paths exercised live. 67 unit tests
in release-tools, lint green, script files within the repo size limit.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 00:19:02 +02:00

359 lines
12 KiB
TypeScript

/**
* Release screenshot capture, manifest-driven and fail-closed.
*
* pnpm release:screenshots # release slug from package.json
* pnpm release:screenshots --release v0-24 # explicit
* pnpm release:screenshots --only dashboard --theme dark
*
* Reads tools/release/screenshots.manifest.json and writes
* apps/website/public/blog/<release>/screenshots/<slug>-<theme>.png against
* dist builds + the xtream mock server. Guards (screenshot-guards.mjs):
*
* G1 the real ~/.iptvnator/databases directory — including the SQLite WAL
* sidecars, compared after Electron exits and checkpoints — is proven
* untouched
* G2 the app gets an allowlisted environment, never ...process.env
* G3 a main-process recorder (installed before the renderer boots) plus a
* page-level deny-by-default route; any external attempt fails the run
* G4 every frame is checked for external resources / credential text
* G5 TMDB stays disabled (fresh profile default, asserted via IndexedDB)
*
* Frames are staged outside the repository and published only after every
* shot and every guard has passed, so a failure can neither leave unsafe
* frames behind nor destroy previously committed release assets.
*/
import { accessSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { homedir, tmpdir } from 'node:os';
import path from 'node:path';
import process from 'node:process';
import type { Page } from '@playwright/test';
import {
buildCaptureEnv,
compareDatabaseStates,
evaluateFrameReport,
externalRequestViolations,
HOST_RESOLVER_RULES,
isAllowedRequestUrl,
networkPolicy,
parseSetupStep,
publishDirectory,
snapshotDatabaseState,
stubbedResponseFor,
validateManifest,
validateReleaseSlug,
} from './screenshot-guards.mjs';
import * as driver from './capture-app-driver';
import { applyTheme, runAction, settleUi } from './capture-navigation';
import { assertTmdbDisabled } from './capture-tmdb-check';
import {
drainRecordedRequests,
installRequestRecorder,
} from './capture-network-gate';
type Theme = 'dark' | 'light';
const workspaceRoot = process.cwd();
const manifestPath = path.join(
workspaceRoot,
'tools/release/screenshots.manifest.json'
);
const electronMainPath = path.join(
workspaceRoot,
'dist/apps/electron-backend/main.js'
);
const realDbDir = path.join(homedir(), '.iptvnator/databases');
const args = process.argv.slice(2);
const flag = (name: string): string | null => {
const index = args.indexOf(`--${name}`);
return index !== -1 ? (args[index + 1] ?? null) : null;
};
const blockedRequests: string[] = [];
async function main(): Promise<void> {
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
const manifestErrors = validateManifest(manifest);
if (manifestErrors.length > 0) {
throw new Error(`Invalid manifest:\n ${manifestErrors.join('\n ')}`);
}
const release =
flag('release') ?? `v${readAppVersion().split('.').slice(0, 2).join('-')}`;
const releaseError = validateReleaseSlug(release);
if (releaseError) {
throw new Error(`--release rejected: ${releaseError}`);
}
const only = flag('only');
const themeFilter = flag('theme');
const shots = manifest.shots.filter(
(shot: { slug: string }) => !only || shot.slug === only
);
if (shots.length === 0) {
throw new Error(`--only ${only} matches no manifest slug`);
}
// Without this an erased cast would accept `--theme --only`, and every
// non-`dark` value silently renders as light into a misnamed file.
if (themeFilter && !manifest.themes.includes(themeFilter)) {
throw new Error(
`--theme "${themeFilter}" is not one of ${manifest.themes.join(', ')}`
);
}
const themes: Theme[] = themeFilter
? [themeFilter as Theme]
: manifest.themes;
const blogRoot = path.join(workspaceRoot, 'apps/website/public/blog');
const outputRoot = path.join(blogRoot, release, 'screenshots');
// Belt and braces: the slug is validated above, but assert the resolved
// path really lands inside the blog tree before anything deletes there.
assertSync(
path.resolve(outputRoot).startsWith(`${path.resolve(blogRoot)}${path.sep}`),
`refusing to publish outside the blog tree: ${outputRoot}`
);
assertBuiltRuntime();
// G1: snapshot the real database directory BEFORE anything launches.
const dbBefore = snapshotDatabaseState(realDbDir);
// Frames are staged outside the repo and published only once every shot
// and every guard has passed, so a late failure can never destroy the
// release assets an earlier run already committed.
const stagingDir = mkdtempSync(path.join(tmpdir(), 'iptvnator-shots-'));
const mockServer = await driver.ensureXtreamMockServer(workspaceRoot);
const dataDir = mkdtempSync(path.join(tmpdir(), 'iptvnator-release-shots-'));
let app: Awaited<ReturnType<typeof driver.launchApp>> | undefined;
let recordedRequests: string[] = [];
let captured = 0;
let primaryError: unknown;
try {
// G2: constructed environment — nothing ambient crosses over.
app = await driver.launchApp(
electronMainPath,
buildCaptureEnv(process.env, {
ELECTRON_IS_DEV: '0',
IPTVNATOR_E2E_DATA_DIR: dataDir,
NODE_ENV: 'test',
}),
HOST_RESOLVER_RULES
);
// G3, first layer: a main-process gate, installed before the renderer
// can boot. It both records and blocks, because page-level routing
// exists only once a page handle is obtained and never covers what the
// main process itself fetches.
await installRequestRecorder(app, networkPolicy());
const page = await driver.findMainWindow(app);
// G3, second layer: page-level deny-by-default, which also blocks.
await page.route('**/*', async (route) => {
const url = route.request().url();
if (isAllowedRequestUrl(url)) {
await route.continue();
return;
}
// Known app-level calls are answered locally so the run stays
// hermetic without failing on legitimate app behavior.
const stub = stubbedResponseFor(url);
if (stub) {
await route.fulfill({
body: stub.body,
contentType: stub.contentType,
});
return;
}
blockedRequests.push(url);
await route.abort();
});
await driver.sizeWindow(app, manifest.viewport);
await driver.waitForAppReady(page);
await assertTmdbDisabled(page); // G5
await driver.seedDemoData(page, driver.writeM3uFixture(dataDir));
for (const theme of themes) {
await applyTheme(page, theme);
for (const shot of shots) {
for (const step of shot.setup) {
const { action, param } = parseSetupStep(String(step));
await runAction(page, action, param);
}
await captureShot(page, stagingDir, shot.slug, theme);
captured += 1;
}
}
recordedRequests = await drainRecordedRequests(app);
// A recorder that observes nothing is indistinguishable from a
// passing run, which is how a guard silently stops guarding. Assert
// it saw the renderer's own document load rather than merely "some"
// traffic: seeding requests alone would prove nothing about whether
// the hook existed before the renderer started fetching.
assertSync(
recordedRequests.some((url) => /\/index\.html?($|[?#])/.test(url)),
`G3 failed: the request recorder never saw the renderer document load (${recordedRequests.length} URL(s) recorded) — it was installed too late to cover startup`
);
// G3: an attempted external request means a fixture is wrong, not
// that we got away with it.
const external = externalRequestViolations([
...blockedRequests,
...recordedRequests,
]);
if (external.length > 0) {
throw new Error(
`G3 failed: ${external.length} external request(s) were attempted:\n ${external
.slice(0, 15)
.join('\n ')}`
);
}
assertSync(
snapshotDatabaseState(path.join(dataDir, 'databases')).exists,
'G1 failed: the isolated database was never created — the app did not honor IPTVNATOR_E2E_DATA_DIR'
);
} catch (error) {
// Captured rather than rethrown: a failing run is exactly when a lost
// isolation override is most likely, so G1 must still be evaluated.
primaryError = error;
} finally {
// Close before the G1 comparison: SQLite runs in WAL mode, so writes
// may sit in -wal until the worker shuts down and checkpoints.
await app?.close().catch(() => undefined);
mockServer?.kill('SIGTERM');
rmSync(dataDir, { recursive: true, force: true });
}
const dbViolation = compareDatabaseStates(
dbBefore,
snapshotDatabaseState(realDbDir)
);
if (dbViolation) {
rmSync(stagingDir, { recursive: true, force: true });
throw new Error(
`G1 failed: ${dbViolation}${
primaryError instanceof Error
? `\n(raised while handling: ${primaryError.message})`
: ''
}`
);
}
if (primaryError) {
rmSync(stagingDir, { recursive: true, force: true });
throw primaryError;
}
// A filtered run refreshes a subset, so it must overlay rather than
// replace: publishing only the captured frames would delete every other
// screenshot of the release.
publishDirectory(stagingDir, outputRoot, String(process.pid), {
mode: only || themeFilter ? 'merge' : 'replace',
});
rmSync(stagingDir, { recursive: true, force: true });
console.log(
`Captured ${captured} screenshot(s) into ${path.relative(workspaceRoot, outputRoot)}`
);
console.log(
`Guards passed: ${new Set(recordedRequests).size} distinct request(s) observed, all local.`
);
}
async function captureShot(
page: Page,
outputRoot: string,
slug: string,
theme: Theme
): Promise<void> {
await settleUi(page);
// G4: inspect the frame before trusting it.
const report = await page.evaluate(() => {
const urls = new Set<string>();
document.querySelectorAll('img[src]').forEach((img) => {
urls.add((img as HTMLImageElement).src);
});
document.querySelectorAll<HTMLElement>('*').forEach((element) => {
const background = getComputedStyle(element).backgroundImage;
const match = background?.match(/url\("?([^")]+)"?\)/);
if (match) {
urls.add(match[1]);
}
});
return {
resourceUrls: [...urls],
bodyText: document.body.innerText,
};
});
const violations = evaluateFrameReport(report);
if (violations.length > 0) {
throw new Error(
`G4 failed on ${slug} (${theme}):\n ${violations.join('\n ')}`
);
}
await page.screenshot({
path: path.join(outputRoot, `${slug}-${theme}.png`),
type: 'png',
});
console.log(` ✓ ${slug} (${theme})`);
}
function assertBuiltRuntime(): void {
for (const required of [
electronMainPath,
path.join(workspaceRoot, 'dist/apps/web/index.html'),
]) {
try {
accessSync(required);
} catch {
throw new Error(
`Built runtime missing: ${required}\nBuild it first: pnpm nx run electron-backend:build-e2e`
);
}
}
}
function readAppVersion(): string {
return JSON.parse(
readFileSync(path.join(workspaceRoot, 'package.json'), 'utf8')
).version;
}
function assertSync(condition: unknown, message: string): asserts condition {
if (!condition) {
throw new Error(message);
}
}
main().catch((error) => {
console.error(error);
process.exit(1);
});