mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 01:16:15 -08:00
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>
123 lines
4.7 KiB
TypeScript
123 lines
4.7 KiB
TypeScript
/**
|
|
* The Electron-side half of G3: a request gate in the main process covering
|
|
* both Chromium's network stack and Node's `fetch`.
|
|
*
|
|
* Known and unavoidable timing window: this can only be installed once
|
|
* `electron.launch()` resolves, and Playwright releases Electron's ready
|
|
* event before that — so a Node-stack request fired in the first
|
|
* milliseconds of `app.whenReady()` would escape it. It cannot be closed
|
|
* from the test side: Playwright deletes `NODE_OPTIONS` unconditionally
|
|
* (`playwright-core/lib/server/electron/electron.js`), which rules out a
|
|
* `--require` preload, and it exposes no pre-ready hook. Closing it entirely
|
|
* would mean holding renderer startup from inside `main.ts` — production code
|
|
* bent around a screenshot tool.
|
|
*
|
|
* What covers that window instead:
|
|
* - Chromium-stack traffic (renderer, main-process `net`) is blocked from
|
|
* process start by `--host-resolver-rules`, which has no window at all;
|
|
* - the only startup Node `fetch` in the app, the update check, returns
|
|
* early on `!app.isPackaged`, and the capture always runs unpackaged.
|
|
*/
|
|
|
|
import type { ElectronApplication } from '@playwright/test';
|
|
|
|
type NetworkPolicy = {
|
|
schemes: string[];
|
|
hosts: string[];
|
|
protocols: string[];
|
|
stubPrefixes: string[];
|
|
};
|
|
|
|
/**
|
|
* Records and blocks at the Electron session level, from before the renderer
|
|
* boots. Blocking here as well as in the page route matters because
|
|
* `page.route` only covers renderer traffic: anything the main process fetches
|
|
* (updater, telemetry, a future feature) would otherwise reach the network and
|
|
* only be reported afterwards — too late to be a fail-closed boundary.
|
|
*
|
|
* Stubbed URLs are allowed through so the page-level route can fulfill them
|
|
* locally; every other non-local URL is cancelled outright.
|
|
*/
|
|
export async function installRequestRecorder(
|
|
app: ElectronApplication,
|
|
policy: NetworkPolicy
|
|
): Promise<void> {
|
|
// The body is serialized into the Electron main process, where esbuild's
|
|
// `__name` helper does not exist — so it must contain no *named* inner
|
|
// function (`const isLocal = …` or `function isLocal()`), only anonymous
|
|
// callbacks. A named one throws `__name is not defined` at install time.
|
|
await app.evaluate(({ session }, appliedPolicy) => {
|
|
const store: string[] = [];
|
|
const scope = globalThis as unknown as {
|
|
__captureRequests: string[];
|
|
__captureIsLocal: (url: string) => boolean;
|
|
fetch: typeof fetch;
|
|
};
|
|
|
|
scope.__captureRequests = store;
|
|
|
|
// Assigned to a member expression on purpose: esbuild's keepNames
|
|
// wraps functions bound to an identifier with a `__name` helper that
|
|
// does not exist in this serialized context.
|
|
scope.__captureIsLocal = (url: string) => {
|
|
if (appliedPolicy.stubPrefixes.some((p) => url.startsWith(p))) {
|
|
return true;
|
|
}
|
|
|
|
try {
|
|
const parsed = new URL(url);
|
|
|
|
return (
|
|
appliedPolicy.schemes.includes(parsed.protocol) ||
|
|
(appliedPolicy.protocols.includes(parsed.protocol) &&
|
|
appliedPolicy.hosts.includes(parsed.hostname))
|
|
);
|
|
} catch {
|
|
return appliedPolicy.schemes.some((s) => url.startsWith(s));
|
|
}
|
|
};
|
|
|
|
session.defaultSession.webRequest.onBeforeRequest(
|
|
(details, callback) => {
|
|
store.push(details.url);
|
|
callback({ cancel: !scope.__captureIsLocal(details.url) });
|
|
}
|
|
);
|
|
|
|
// Chromium's network stack — and therefore both the resolver switch
|
|
// and the webRequest hook — never sees main-process `fetch`, which
|
|
// runs on Node's stack. The app update service uses exactly that, so
|
|
// wrap it: record every URL and reject non-local ones outright.
|
|
const originalFetch = scope.fetch;
|
|
|
|
scope.fetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const url =
|
|
typeof input === 'string'
|
|
? input
|
|
: input instanceof URL
|
|
? input.href
|
|
: input.url;
|
|
|
|
store.push(url);
|
|
|
|
if (!scope.__captureIsLocal(url)) {
|
|
return Promise.reject(
|
|
new Error(`capture network gate blocked ${url}`)
|
|
);
|
|
}
|
|
|
|
return originalFetch(input, init);
|
|
};
|
|
}, policy);
|
|
}
|
|
|
|
export async function drainRecordedRequests(
|
|
app: ElectronApplication
|
|
): Promise<string[]> {
|
|
return app.evaluate(
|
|
() =>
|
|
(globalThis as unknown as { __captureRequests?: string[] })
|
|
.__captureRequests ?? []
|
|
);
|
|
}
|