From b4ec68c1fa33ee5ccbabf6aa74484857725f6593 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 26 Jul 2026 00:19:02 +0200 Subject: [PATCH] feat(release): manifest-driven screenshot capture with fail-closed mock-data guards (#1261) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//screenshots/-.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 --- .changes/README.md | 25 +- .claude/skills/release-cut/SKILL.md | 20 +- .codex/skills/release-cut/SKILL.md | 20 +- package.json | 1 + tools/release/build-release-notes.mjs | 20 + tools/release/capture-app-driver.ts | 361 ++++++++++++ tools/release/capture-navigation.ts | 250 ++++++++ tools/release/capture-network-gate.ts | 122 ++++ tools/release/capture-release-screenshots.ts | 358 ++++++++++++ tools/release/capture-tmdb-check.ts | 56 ++ tools/release/project.json | 15 +- tools/release/screenshot-guards.mjs | 504 ++++++++++++++++ tools/release/screenshot-guards.test.mjs | 575 +++++++++++++++++++ tools/release/screenshots.manifest.json | 32 ++ 14 files changed, 2344 insertions(+), 15 deletions(-) create mode 100644 tools/release/capture-app-driver.ts create mode 100644 tools/release/capture-navigation.ts create mode 100644 tools/release/capture-network-gate.ts create mode 100644 tools/release/capture-release-screenshots.ts create mode 100644 tools/release/capture-tmdb-check.ts create mode 100644 tools/release/screenshot-guards.mjs create mode 100644 tools/release/screenshot-guards.test.mjs create mode 100644 tools/release/screenshots.manifest.json diff --git a/.changes/README.md b/.changes/README.md index 2e529ab0f..a2a8454b6 100644 --- a/.changes/README.md +++ b/.changes/README.md @@ -30,7 +30,7 @@ of the current season, watch progress, and click-to-play inline. | `type` | yes | `breaking`, `feature`, `fix`, `perf`, or `internal` | | `area` | yes | lowercase slug, same as the conventional-commit scope | | `issues` | no | issue numbers this closes — `[1187]` or `1187` | -| `screenshot` | no | slug from the release screenshot manifest | +| `screenshot` | no | slug from `tools/release/screenshots.manifest.json` | There is **no version field**. The release version is chosen deliberately at release time, not derived from these files. @@ -84,3 +84,26 @@ The website publishes **one post per minor version** (`v0-18` … `v0-22`), and release screenshots live under the matching `blog/v0-24/` directory. A patch release therefore edits the existing post rather than generating a new one, so `--format blog` refuses to overwrite unless you pass `--force`. + +## Screenshots + +`pnpm run release:screenshots` captures every manifest shot in dark and light +against the built app plus the Xtream mock server — never a real account. +The run is fail-closed: it proves the real `~/.iptvnator/databases` directory +(including the SQLite WAL sidecars, checked after Electron exits) was not +touched, launches the app with an allowlisted environment, records and blocks +all non-localhost traffic, scans every frame for external resources and +credential-shaped text, and asserts TMDB enrichment stays disabled. Frames are +staged outside the repository and published only once every shot and every +guard has passed. + +Adding a shot for a new feature = one entry in +`tools/release/screenshots.manifest.json` (plus, if navigation is new, one +named action in `tools/release/capture-navigation.ts`). + +```bash +pnpm nx run electron-backend:build-e2e # once, before capturing +pnpm run release:screenshots # all shots, both themes +pnpm run release:screenshots -- --only dashboard --theme dark +pnpm run release:screenshots -- --release v0-24 +``` diff --git a/.claude/skills/release-cut/SKILL.md b/.claude/skills/release-cut/SKILL.md index 2007a7795..62e07edd5 100644 --- a/.claude/skills/release-cut/SKILL.md +++ b/.claude/skills/release-cut/SKILL.md @@ -42,11 +42,21 @@ is not optional. for a patch release, edit the existing post (the scaffold refuses to overwrite without `--force`). -5. **Screenshots** — only from the release capture script against the mock - servers (`tools/release/`), never from a real playlist or account: real - streams, logos, and TMDB artwork are copyrighted, and credentials must - never reach a published image. Output goes to - `apps/website/public/blog/v0-XX/screenshots/`. +5. **Screenshots** — only from the fail-closed capture script against the + mock servers, never from a real playlist or account: real streams, logos, + and TMDB artwork are copyrighted, and credentials must never reach a + published image. + + ```bash + pnpm nx run electron-backend:build-e2e # once + pnpm run release:screenshots # all manifest shots, dark+light + ``` + + Output goes to `apps/website/public/blog/v0-XX/screenshots/`. New feature + to showcase = new entry in `tools/release/screenshots.manifest.json` + (slug must match the note's `screenshot:` field). The run aborts and + deletes its frames on any guard violation (real-DB touch, external + request, credential-shaped text in frame, TMDB active). 6. **Consume the notes** (the only destructive step): diff --git a/.codex/skills/release-cut/SKILL.md b/.codex/skills/release-cut/SKILL.md index 2007a7795..62e07edd5 100644 --- a/.codex/skills/release-cut/SKILL.md +++ b/.codex/skills/release-cut/SKILL.md @@ -42,11 +42,21 @@ is not optional. for a patch release, edit the existing post (the scaffold refuses to overwrite without `--force`). -5. **Screenshots** — only from the release capture script against the mock - servers (`tools/release/`), never from a real playlist or account: real - streams, logos, and TMDB artwork are copyrighted, and credentials must - never reach a published image. Output goes to - `apps/website/public/blog/v0-XX/screenshots/`. +5. **Screenshots** — only from the fail-closed capture script against the + mock servers, never from a real playlist or account: real streams, logos, + and TMDB artwork are copyrighted, and credentials must never reach a + published image. + + ```bash + pnpm nx run electron-backend:build-e2e # once + pnpm run release:screenshots # all manifest shots, dark+light + ``` + + Output goes to `apps/website/public/blog/v0-XX/screenshots/`. New feature + to showcase = new entry in `tools/release/screenshots.manifest.json` + (slug must match the note's `screenshot:` field). The run aborts and + deletes its frames on any guard violation (real-DB touch, external + request, credential-shaped text in frame, TMDB active). 6. **Consume the notes** (the only destructive step): diff --git a/package.json b/package.json index 872879829..7f4879c18 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "release:notes:github": "node tools/release/build-release-notes.mjs --format github", "release:notes:changelog": "node tools/release/build-release-notes.mjs --format changelog", "release:notes:blog": "node tools/release/build-release-notes.mjs --format blog", + "release:screenshots": "tsx tools/release/capture-release-screenshots.ts", "lint": "nx run-many --target=lint --all", "build": "nx build electron-backend" }, diff --git a/tools/release/build-release-notes.mjs b/tools/release/build-release-notes.mjs index 7f7a27de1..83745334b 100644 --- a/tools/release/build-release-notes.mjs +++ b/tools/release/build-release-notes.mjs @@ -19,6 +19,7 @@ import process from 'node:process'; import { fileURLToPath } from 'node:url'; import { loadNotes } from './release-notes.mjs'; +import { manifestSlugs } from './screenshot-guards.mjs'; import { releaseSlug, renderBlogScaffold, @@ -232,6 +233,25 @@ function main() { const notesDir = path.resolve(workspaceRoot, options.dir); const { notes, errors } = loadNotes(notesDir); + // `screenshot:` slugs must exist in the capture manifest, otherwise the + // blog scaffold would reference images the capture run never produces. + const slugs = manifestSlugs( + JSON.parse( + readFileSync( + path.join(workspaceRoot, 'tools/release/screenshots.manifest.json'), + 'utf8' + ) + ) + ); + + for (const note of notes) { + if (note.screenshot && !slugs.has(note.screenshot)) { + errors.push( + `${path.basename(note.sourcePath)}: \`screenshot: ${note.screenshot}\` is not a slug in tools/release/screenshots.manifest.json` + ); + } + } + if (errors.length > 0) { console.error('Invalid release notes:\n'); for (const error of errors) { diff --git a/tools/release/capture-app-driver.ts b/tools/release/capture-app-driver.ts new file mode 100644 index 000000000..bd643d07f --- /dev/null +++ b/tools/release/capture-app-driver.ts @@ -0,0 +1,361 @@ +/** + * App driving for capture-release-screenshots.ts: launch, readiness, demo + * seeding, theme switching, and the named setup actions the manifest refers + * to. Mechanics proven in the v0.20 capture run, generalized behind action + * names. + */ + +import { spawn, type ChildProcess } from 'node:child_process'; +import { writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { + _electron as electron, + type ElectronApplication, + type Page, +} from '@playwright/test'; + +import { registerPlaylistId, requirePlaylistId } from './capture-navigation'; + +export const XTREAM_MOCK_ORIGIN = 'http://localhost:3211'; +export const XTREAM_FIXTURE_TITLE = 'Fictional Xtream Demo'; +export const M3U_FIXTURE_TITLE = 'release-demo'; + +/** Synthetic categories that only the marketing fixture generator produces. */ +const MOCK_FIXTURE_CATEGORIES = ['Action & Mystery', 'Urban Drama']; + + +/* ------------------------------------------------------------------ */ +/* Fixtures */ +/* ------------------------------------------------------------------ */ + +/** Entirely synthetic channels; streams and logos point at the mock. */ +export function writeM3uFixture(dataDir: string): string { + const channels = [ + ['Newsroom', 'Aurora Local', 'aurora-local'], + ['Newsroom', 'Civic Pulse', 'civic-pulse'], + ['Sports', 'Fieldside One', 'fieldside-one'], + ['Sports', 'Motion Arena', 'motion-arena'], + ['Kids', 'Horizon Kids', 'horizon-kids'], + ['Kids', 'Story Lantern', 'story-lantern'], + ['Culture', 'Atlas Culture', 'atlas-culture'], + ['Culture', 'Night Music', 'night-music'], + ]; + const stream = `${XTREAM_MOCK_ORIGIN}/live/marketing/marketing/52000.m3u8`; + const lines = ['#EXTM3U']; + + channels.forEach(([group, title, slug], index) => { + lines.push( + `#EXTINF:-1 tvg-id="demo-${index + 1}" tvg-name="${title}" tvg-logo="${XTREAM_MOCK_ORIGIN}/assets/marketing/logo/${slug}.svg?size=256x256" group-title="${group}",${title}`, + stream + ); + }); + + const filePath = path.join(dataDir, `${M3U_FIXTURE_TITLE}.m3u`); + writeFileSync(filePath, `${lines.join('\n')}\n`, 'utf8'); + + return filePath; +} + +export async function ensureXtreamMockServer( + workspaceRoot: string +): Promise { + const healthUrl = `${XTREAM_MOCK_ORIGIN}/health`; + + // Reusing whatever answers on the port is not enough: every guard treats + // localhost as trusted, so an unrelated local server or proxy could feed + // real catalog data and artwork straight into published screenshots. + // Require the marketing fixtures this capture is built around. + if (await isHealthy(healthUrl)) { + await assertMockServerIdentity(); + return undefined; + } + + const child = spawn( + path.join(workspaceRoot, 'node_modules/.bin/tsx'), + ['apps/xtream-mock-server/src/main.ts'], + { + cwd: workspaceRoot, + env: { ...process.env, NODE_ENV: 'development', PORT: '3211' }, + stdio: ['ignore', 'pipe', 'pipe'], + } + ); + + child.stderr?.on('data', (chunk) => + process.stderr.write(`[xtream-mock] ${chunk}`) + ); + + const deadline = Date.now() + 20_000; + + while (Date.now() < deadline) { + if (await isHealthy(healthUrl)) { + return child; + } + await sleep(500); + } + + child.kill('SIGTERM'); + throw new Error(`xtream-mock-server did not become healthy at ${healthUrl}`); +} + +/** + * Confirms the service on the mock port is our fixture server: it must serve + * the marketing catalog with the exact synthetic titles the shots rely on. + */ +async function assertMockServerIdentity(): Promise { + const response = await fetch( + `${XTREAM_MOCK_ORIGIN}/player_api.php?username=marketing&password=marketing&action=get_vod_categories` + ).catch(() => null); + + if (!response?.ok) { + throw new Error( + `Something is listening on ${XTREAM_MOCK_ORIGIN} but does not answer the Xtream marketing API — stop it and let this script start the mock server itself.` + ); + } + + const categories = (await response.json().catch(() => null)) as + | { category_name?: string }[] + | null; + const names = new Set( + (categories ?? []).map((entry) => entry.category_name) + ); + + for (const expected of MOCK_FIXTURE_CATEGORIES) { + if (!names.has(expected)) { + throw new Error( + `The server on ${XTREAM_MOCK_ORIGIN} is not the IPTVnator marketing mock (missing category "${expected}"). Refusing to capture screenshots from unknown data.` + ); + } + } +} + +async function isHealthy(url: string): Promise { + try { + return (await fetch(url)).ok; + } catch { + return false; + } +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/* ------------------------------------------------------------------ */ +/* Launch and readiness */ +/* ------------------------------------------------------------------ */ + +export async function launchApp( + electronMainPath: string, + env: Record, + hostResolverRules: string +): Promise { + // The resolver switch is the only part of the network gate with no + // install-timing window: it takes effect before Electron runs a single + // line of app code, so startup traffic cannot slip through ahead of the + // session hook. + return electron.launch({ + args: [ + electronMainPath, + `--host-resolver-rules=${hostResolverRules}`, + ], + env, + }); +} + +export async function findMainWindow(app: ElectronApplication): Promise { + await sleep(1500); + + for (const candidate of app.windows()) { + if (!(await candidate.title()).includes('DevTools')) { + return candidate; + } + } + + return app.firstWindow(); +} + +export async function sizeWindow( + app: ElectronApplication, + viewport: { width: number; height: number } +): Promise { + await app.evaluate(({ BrowserWindow }, size) => { + const win = BrowserWindow.getAllWindows().find( + (candidate) => !candidate.webContents.getTitle().includes('DevTools') + ); + win?.setSize(size.width, size.height); + win?.center(); + }, viewport); +} + +export async function waitForAppReady(page: Page): Promise { + await page.waitForLoadState('domcontentloaded'); + await page.waitForSelector('app-root', { timeout: 45_000 }); + await page.waitForFunction( + () => + (document.querySelector('app-root')?.innerHTML.trim().length ?? 0) > + 0, + { timeout: 45_000 } + ); +} + +/* ------------------------------------------------------------------ */ +/* Seeding */ +/* ------------------------------------------------------------------ */ + +export async function seedDemoData(page: Page, m3uPath: string): Promise { + await addXtreamPortal(page); + await addM3uPlaylist(page, m3uPath); + await seedDashboardActivity(page); +} + +/** + * The dashboard hero and rails only render with favorites/recent activity. + * Seed a handful of mock titles through the Electron DB bridge; backdrops + * point at the mock server, keeping G3/G4 satisfied. + */ +async function seedDashboardActivity(page: Page): Promise { + const playlistId = requirePlaylistId('xtreams'); + const backdrop = (title: string) => + `${XTREAM_MOCK_ORIGIN}/assets/marketing/backdrop/${title + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-')}.svg?size=${encodeURIComponent('1280x720')}`; + const items = [ + { xtreamId: 62000, type: 'movie', backdropUrl: backdrop('Crimson Skylark'), recent: true }, + { xtreamId: 62001, type: 'movie', backdropUrl: backdrop('The Voltage Guard'), recent: true }, + { xtreamId: 62002, type: 'movie', backdropUrl: backdrop('Midnight Mantle'), recent: false }, + { xtreamId: 72000, type: 'series', backdropUrl: backdrop('Skyline Sentinels'), recent: true }, + { xtreamId: 72001, type: 'series', backdropUrl: backdrop('The Aegis Club'), recent: false }, + ] as const; + + await page.evaluate( + async ({ items, playlistId }) => { + const bridge = ( + window as typeof window & { + electron?: { + dbAddFavorite?: (contentId: number, playlistId: string, backdropUrl?: string) => Promise; + dbAddRecentItem?: (contentId: number, playlistId: string, backdropUrl?: string) => Promise; + dbGetContentByXtreamId?: ( + xtreamId: number, + playlistId: string, + contentType?: 'live' | 'movie' | 'series' + ) => Promise<{ id: number } | null>; + }; + } + ).electron; + + if (!bridge?.dbGetContentByXtreamId) { + throw new Error('Electron database bridge is unavailable.'); + } + + for (const item of items) { + const content = await bridge.dbGetContentByXtreamId( + item.xtreamId, + playlistId, + item.type + ); + + if (!content?.id) { + throw new Error( + `Could not find imported Xtream content ${item.xtreamId}.` + ); + } + + await bridge.dbAddFavorite?.(content.id, playlistId, item.backdropUrl); + + if (item.recent) { + await bridge.dbAddRecentItem?.(content.id, playlistId, item.backdropUrl); + } + } + }, + { items, playlistId } + ); +} + +async function addXtreamPortal(page: Page): Promise { + await openAddPlaylistDialog(page); + const dialog = page.locator('mat-dialog-container').last(); + + await clickDialogOption(dialog, /xtream credentials/i); + await dialog.locator('#title').fill(XTREAM_FIXTURE_TITLE); + await dialog.locator('#serverUrl').fill(XTREAM_MOCK_ORIGIN); + await dialog.locator('#username').fill('marketing'); + await dialog.locator('#password').fill('marketing'); + await dialog + .getByRole('button', { name: /^(add|add playlist)$/i }) + .last() + .click(); + await dialog.waitFor({ state: 'detached', timeout: 30_000 }); + await page.waitForURL(/\/workspace\/xtreams\/[^/]+\/vod/, { + timeout: 45_000, + }); + registerPlaylistId('xtreams', idFromUrl(page.url(), 'xtreams')); + await page + .locator('.category-content-layout, app-content-card') + .first() + .waitFor({ state: 'visible', timeout: 45_000 }); +} + +async function addM3uPlaylist(page: Page, m3uPath: string): Promise { + await openAddPlaylistDialog(page); + const dialog = page.locator('mat-dialog-container').last(); + + // Unanchored: the radio's accessible name concatenates title + subtitle. + await clickDialogOption(dialog, /m3u file/i); + + const fileInput = dialog.locator('input[type="file"][name="playlist"]'); + await fileInput.setInputFiles(m3uPath); + await dialog + .getByRole('button', { name: /add playlist/i }) + .last() + .click({ timeout: 15_000 }); + await dialog.waitFor({ state: 'detached', timeout: 30_000 }); + await page.waitForURL(/\/workspace\/playlists\/[^/]+\/all/, { + timeout: 45_000, + }); + registerPlaylistId('playlists', idFromUrl(page.url(), 'playlists')); + await page + .locator('[data-test-id="channel-item"]') + .first() + .waitFor({ state: 'visible', timeout: 60_000 }); +} + +async function openAddPlaylistDialog(page: Page): Promise { + await page.getByRole('button', { name: /add playlist/i }).first().click(); + await page + .locator('mat-dialog-container') + .last() + .waitFor({ state: 'visible', timeout: 15_000 }); +} + +async function clickDialogOption( + dialog: ReturnType, + label: RegExp +): Promise { + // The add-playlist dialog has changed shape across releases: source + // methods were tabs, then plain buttons, now a radio group. + for (const role of ['radio', 'tab', 'button'] as const) { + const option = dialog.getByRole(role, { name: label }).first(); + + if ((await option.count()) > 0) { + await option.click(); + return; + } + } + + throw new Error(`Dialog option matching ${label} not found`); +} + + +function idFromUrl(url: string, provider: 'playlists' | 'xtreams'): string { + // `provider` is a closed union, but build the pattern from a literal + // anyway so no future caller can inject regex syntax through it. + const pattern = + provider === 'playlists' + ? /\/workspace\/playlists\/([^/]+)\// + : /\/workspace\/xtreams\/([^/]+)\//; + const match = url.match(pattern); + + if (!match) { + throw new Error(`Could not extract ${provider} id from ${url}`); + } + + return match[1]; +} diff --git a/tools/release/capture-navigation.ts b/tools/release/capture-navigation.ts new file mode 100644 index 000000000..9a8f033ed --- /dev/null +++ b/tools/release/capture-navigation.ts @@ -0,0 +1,250 @@ +/** + * Named setup actions for capture-release-screenshots.ts — the vocabulary + * that screenshots.manifest.json steps refer to — plus theme switching and + * the playlist-id registry the actions navigate with. + */ + +import type { Page } from '@playwright/test'; + +let m3uPlaylistId: string | undefined; +let xtreamPlaylistId: string | undefined; + +export function registerPlaylistId( + provider: 'playlists' | 'xtreams', + id: string +): void { + if (provider === 'playlists') { + m3uPlaylistId = id; + } else { + xtreamPlaylistId = id; + } +} + +export function requirePlaylistId( + provider: 'playlists' | 'xtreams' +): string { + return requireId(provider); +} + +function requireId(provider: 'playlists' | 'xtreams'): string { + const id = provider === 'playlists' ? m3uPlaylistId : xtreamPlaylistId; + + if (!id) { + throw new Error(`No captured ${provider} playlist id — seeding failed?`); + } + + return id; +} + +/* ------------------------------------------------------------------ */ +/* Theme */ +/* ------------------------------------------------------------------ */ + +export async function applyTheme( + page: Page, + theme: 'dark' | 'light' +): Promise { + await runAction(page, 'open-settings', null); + const testId = theme === 'dark' ? 'DARK_THEME' : 'LIGHT_THEME'; + const themeButton = page.locator(`[data-test-id="${testId}"]`).first(); + + await themeButton.scrollIntoViewIfNeeded(); + await themeButton.click(); + + const saveButton = page.locator('[data-test-id="save-settings"]').first(); + + if (await saveButton.isEnabled()) { + await saveButton.click(); + await settleUi(page); + } + + await page.waitForFunction( + (expectedTheme) => + document.body.classList.contains('dark-theme') === + (expectedTheme === 'dark'), + theme, + { timeout: 10_000 } + ); +} + +/* ------------------------------------------------------------------ */ +/* Named setup actions */ +/* ------------------------------------------------------------------ */ + +export async function runAction( + page: Page, + action: string, + param: string | null +): Promise { + switch (action) { + case 'open-settings': { + await page.locator('a[href$="/workspace/settings"]').first().click(); + await page.waitForURL(/\/workspace\/settings/, { timeout: 15_000 }); + await page + .locator('[data-test-id="settings-container"]') + .waitFor({ state: 'visible', timeout: 15_000 }); + return; + } + case 'open-dashboard': { + await page + .locator('a.brand[href$="/workspace/dashboard"]') + .first() + .click(); + await page.waitForURL(/\/workspace\/dashboard/, { timeout: 20_000 }); + await page + .locator('[data-test-id="dashboard-hero"]') + .waitFor({ state: 'visible', timeout: 30_000 }); + await settleUi(page); + return; + } + case 'open-xtream-vod': { + await openXtreamSection(page, 'vod', param ?? 'Action & Mystery'); + await page.waitForURL( + /\/workspace\/xtreams\/[^/]+\/vod\/[^/]+\/[^/]+/, + { timeout: 30_000 } + ); + await page + .locator('app-content-hero') + .waitFor({ state: 'visible', timeout: 30_000 }); + await page.waitForTimeout(700); + return; + } + case 'open-xtream-series': { + await openXtreamSection(page, 'series', param ?? 'Urban Drama'); + await page.waitForURL( + /\/workspace\/xtreams\/[^/]+\/series\/[^/]+\/[^/]+/, + { timeout: 30_000 } + ); + await page + .locator('app-season-container') + .waitFor({ state: 'visible', timeout: 30_000 }); + + // Season tabs auto-select a season; click the first pill only + // when no episodes rendered on their own. + const episode = page + .locator('.episode-card, .episode-list-item') + .first(); + + if (!(await episode.isVisible().catch(() => false))) { + await page + .locator('.season-tabs__pill, [data-testid="season-dropdown"]') + .first() + .click(); + } + + await episode.waitFor({ state: 'visible', timeout: 20_000 }); + await page.waitForTimeout(700); + return; + } + case 'open-m3u-groups': { + const playlistId = requireId('playlists'); + + await goHome(page); + await page + .locator(`a[href*="/workspace/playlists/${playlistId}"]`) + .first() + .click(); + await page.waitForURL( + (url) => url.href.includes(`/workspace/playlists/${playlistId}/`), + { timeout: 20_000 } + ); + await clickHrefSuffix( + page, + `/workspace/playlists/${playlistId}/groups` + ); + await page + .locator('.group-nav-item') + .first() + .waitFor({ state: 'visible', timeout: 20_000 }); + await page.locator('.group-nav-item').first().click(); + // Deliberately no channel click: starting playback would pull a + // real HLS stream (the mock redirects to a public demo stream), + // and third-party video frames must never enter a release shot. + await page + .locator('[data-test-id="channel-item"]') + .first() + .waitFor({ state: 'visible', timeout: 20_000 }); + await page.waitForTimeout(500); + return; + } + default: + throw new Error(`Unknown setup action: ${action}`); + } +} + +/** Returns to the dashboard via the always-visible brand link. */ +async function goHome(page: Page): Promise { + if (/\/workspace\/dashboard/.test(page.url())) { + return; + } + + await page.locator('a.brand[href$="/workspace/dashboard"]').first().click(); + await page.waitForURL(/\/workspace\/dashboard/, { timeout: 20_000 }); + await settleUi(page); +} + +async function openXtreamSection( + page: Page, + section: 'vod' | 'series', + category: string +): Promise { + // Manifest steps must be order-independent, so every portal action + // starts from the dashboard, whose sources rail links into the portal. + await goHome(page); + await clickHrefSuffix( + page, + `/workspace/xtreams/${requireId('xtreams')}/vod` + ); + + if (section !== 'vod') { + await clickHrefSuffix( + page, + `/workspace/xtreams/${requireId('xtreams')}/${section}` + ); + } + + const item = page + .locator('app-workspace-context-panel .category-item') + .filter({ hasText: category }) + .first(); + + await item.waitFor({ state: 'visible', timeout: 30_000 }); + await item.click(); + await page.waitForTimeout(600); + + const card = page.locator('.category-content-layout mat-card').first(); + await card.waitFor({ state: 'visible', timeout: 30_000 }); + await card.click(); +} + +async function clickHrefSuffix(page: Page, suffix: string): Promise { + await page.locator(`a[href$="${suffix}"]`).first().click(); + // Predicate rather than a RegExp built from the suffix: the value carries + // playlist ids and path separators, and hand-escaping only some + // metacharacters is how incomplete-sanitization bugs are born. + await page.waitForURL((url) => url.href.includes(suffix), { + timeout: 20_000, + }); +} + +export async function settleUi(page: Page): Promise { + await page + .locator('.mat-mdc-snack-bar-container') + .first() + .waitFor({ state: 'detached', timeout: 10_000 }) + .catch(() => undefined); + // Park the cursor so no nav item keeps its hover tooltip in frame. + await page.mouse.move(640, 700); + await page.evaluate(() => { + document + .querySelectorAll( + '.mat-mdc-snack-bar-container, simple-snack-bar, .mat-mdc-tooltip, .cdk-describedby-message-container' + ) + .forEach((element) => { + (element.closest('.cdk-overlay-pane') ?? element).remove(); + }); + }); + await page.waitForTimeout(250); +} + + diff --git a/tools/release/capture-network-gate.ts b/tools/release/capture-network-gate.ts new file mode 100644 index 000000000..6540e56e3 --- /dev/null +++ b/tools/release/capture-network-gate.ts @@ -0,0 +1,122 @@ +/** + * 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 { + // 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 { + return app.evaluate( + () => + (globalThis as unknown as { __captureRequests?: string[] }) + .__captureRequests ?? [] + ); +} diff --git a/tools/release/capture-release-screenshots.ts b/tools/release/capture-release-screenshots.ts new file mode 100644 index 000000000..83f7d6a6a --- /dev/null +++ b/tools/release/capture-release-screenshots.ts @@ -0,0 +1,358 @@ +/** + * 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//screenshots/-.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 { + 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> | 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 { + await settleUi(page); + + // G4: inspect the frame before trusting it. + const report = await page.evaluate(() => { + const urls = new Set(); + + document.querySelectorAll('img[src]').forEach((img) => { + urls.add((img as HTMLImageElement).src); + }); + document.querySelectorAll('*').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); +}); diff --git a/tools/release/capture-tmdb-check.ts b/tools/release/capture-tmdb-check.ts new file mode 100644 index 000000000..8f0df00b6 --- /dev/null +++ b/tools/release/capture-tmdb-check.ts @@ -0,0 +1,56 @@ +/** + * G5: proof that TMDB enrichment stays off for the capture profile, so no + * licensed poster or still can reach a published screenshot. + */ + +import type { Page } from '@playwright/test'; + +/** + * G5: settings live in the renderer's IndexedDB (ngx-pwa StorageMap). A + * fresh profile has no entry, which means the TMDB defaults (disabled, + * empty key) apply. Read-only assertion — if a future change flips the + * default or seeds a key, the run stops before a licensed poster can render. + */ +export async function assertTmdbDisabled(page: Page): Promise { + const tmdb = await page.evaluate( + () => + new Promise<{ enabled?: boolean; apiKey?: string } | null>( + (resolve) => { + const request = indexedDB.open('ngStorage'); + + request.onerror = () => resolve(null); + request.onsuccess = () => { + const db = request.result; + + if (!db.objectStoreNames.contains('localStorage')) { + resolve(null); + return; + } + + const get = db + .transaction('localStorage') + .objectStore('localStorage') + .get('settings'); + + get.onerror = () => resolve(null); + get.onsuccess = () => + resolve( + (get.result as { tmdb?: { enabled?: boolean; apiKey?: string } }) + ?.tmdb ?? null + ); + }; + } + ) + ); + + assertSync( + !tmdb?.enabled && !tmdb?.apiKey, + `G5 failed: TMDB enrichment is active in the capture profile (${JSON.stringify(tmdb)})` + ); +} + +function assertSync(condition: unknown, message: string): asserts condition { + if (!condition) { + throw new Error(message); + } +} diff --git a/tools/release/project.json b/tools/release/project.json index 5bc632856..ed024a440 100644 --- a/tools/release/project.json +++ b/tools/release/project.json @@ -12,11 +12,14 @@ "{workspaceRoot}/tools/release/release-notes-render.mjs", "{workspaceRoot}/tools/release/extract-changelog-section.mjs", "{workspaceRoot}/tools/release/check-release-note-gate.mjs", + "{workspaceRoot}/tools/release/screenshot-guards.mjs", + "{workspaceRoot}/tools/release/screenshots.manifest.json", "{workspaceRoot}/tools/release/release-notes.test.mjs", - "{workspaceRoot}/tools/release/release-note-gate.test.mjs" + "{workspaceRoot}/tools/release/release-note-gate.test.mjs", + "{workspaceRoot}/tools/release/screenshot-guards.test.mjs" ], "options": { - "command": "node --test tools/release/release-notes.test.mjs tools/release/release-note-gate.test.mjs", + "command": "node --test tools/release/release-notes.test.mjs tools/release/release-note-gate.test.mjs tools/release/screenshot-guards.test.mjs", "cwd": "{workspaceRoot}" } }, @@ -27,8 +30,12 @@ "{workspaceRoot}/tools/eslint-rules/**/*", "{workspaceRoot}/tools/eslint/**/*" ], - "command": "eslint \"tools/release/*.mjs\"" + "command": "eslint \"tools/release/*.mjs\" \"tools/release/capture-release-screenshots.ts\" \"tools/release/capture-app-driver.ts\" \"tools/release/capture-navigation.ts\" \"tools/release/capture-network-gate.ts\" \"tools/release/capture-tmdb-check.ts\"" } }, - "tags": ["scope:tools", "domain:release", "type:tool"] + "tags": [ + "scope:tools", + "domain:release", + "type:tool" + ] } diff --git a/tools/release/screenshot-guards.mjs b/tools/release/screenshot-guards.mjs new file mode 100644 index 000000000..ae29b4950 --- /dev/null +++ b/tools/release/screenshot-guards.mjs @@ -0,0 +1,504 @@ +/** + * Fail-closed guards for the release screenshot capture + * (capture-release-screenshots.ts). Published screenshots must never contain + * a real playlist, credential, stream, or third-party artwork — these + * helpers make that a property of the run, not a hope. + * + * Pure (or fs-only) so the policy is unit-tested; the Playwright driver just + * wires them in. + */ + +import { + copyFileSync, + existsSync, + mkdirSync, + readdirSync, + renameSync, + rmSync, + statSync, +} from 'node:fs'; +import path from 'node:path'; + +/* ------------------------------------------------------------------ */ +/* Manifest */ +/* ------------------------------------------------------------------ */ + +const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]*$/; + +/** The capture and the blog scaffold both assume exactly these themes. */ +export const SUPPORTED_THEMES = ['dark', 'light']; + +/** + * Chromium switch applied at process start, so it has no install-timing + * window at all: every hostname fails to resolve except the local mock. It + * covers Chromium-stack traffic (renderer plus the main process `net` + * module); requests made through Node's own stack are not affected, which is + * why the session hook and the recorded-URL verdict still exist. + */ +export const HOST_RESOLVER_RULES = + 'MAP * ~NOTFOUND, EXCLUDE localhost, EXCLUDE 127.0.0.1'; + +/** Setup actions the driver implements. Manifest steps must match. */ +export const KNOWN_ACTIONS = [ + 'open-dashboard', + 'open-settings', + 'open-xtream-vod', + 'open-xtream-series', + 'open-m3u-groups', +]; + +/** @param {string} step e.g. `open-xtream-vod=Hero Premieres` */ +export function parseSetupStep(step) { + const separator = step.indexOf('='); + const action = separator === -1 ? step : step.slice(0, separator); + const param = separator === -1 ? null : step.slice(separator + 1); + + return { action, param }; +} + +/** + * @param {object} manifest parsed screenshots.manifest.json + * @returns {string[]} problems; empty when valid + */ +export function validateManifest(manifest) { + const errors = []; + + if (manifest?.version !== 1) { + errors.push('manifest `version` must be 1'); + } + + // The blog scaffold always references both `-dark.png` and `-light.png`, + // and a full run publishes in replace mode — a manifest missing a theme + // would silently delete the previous set and leave the post with broken + // images. + const themes = manifest?.themes; + + if ( + !Array.isArray(themes) || + themes.length !== SUPPORTED_THEMES.length || + SUPPORTED_THEMES.some((theme) => !themes.includes(theme)) + ) { + errors.push( + `manifest \`themes\` must be exactly ${SUPPORTED_THEMES.join(' and ')}, got ${JSON.stringify(themes)}` + ); + } + + const shots = Array.isArray(manifest?.shots) ? manifest.shots : null; + + if (!shots || shots.length === 0) { + return [...errors, 'manifest `shots` must be a non-empty array']; + } + + const seen = new Set(); + + for (const shot of shots) { + const label = shot?.slug ?? ''; + + if (!shot?.slug || !SLUG_PATTERN.test(shot.slug)) { + errors.push(`shot "${label}": slug must be a lowercase slug`); + } + + if (seen.has(shot?.slug)) { + errors.push(`shot "${label}": duplicate slug`); + } + + seen.add(shot?.slug); + + if (!Array.isArray(shot?.setup) || shot.setup.length === 0) { + errors.push(`shot "${label}": setup must be a non-empty array`); + continue; + } + + for (const step of shot.setup) { + const { action } = parseSetupStep(String(step)); + + if (!KNOWN_ACTIONS.includes(action)) { + errors.push( + `shot "${label}": unknown setup action "${action}" (expected ${KNOWN_ACTIONS.join(', ')})` + ); + } + } + } + + return errors; +} + +/** + * A release slug names a directory under apps/website/public/blog, which a + * full run replaces — including a recursive delete of what was there. Reject + * anything that could escape that tree or resolve somewhere unexpected. + * + * @param {string} release + * @returns {string | null} error message, or null when the slug is safe + */ +export function validateReleaseSlug(release) { + if (!release) { + return 'release slug is empty'; + } + + if (!/^[a-z0-9][a-z0-9.-]*$/.test(release) || release.includes('..')) { + return `release slug "${release}" must be a lowercase slug without path separators (for example v0-24)`; + } + + return null; +} + +/** @returns {Set} all slugs, for `.changes/` screenshot validation */ +export function manifestSlugs(manifest) { + return new Set((manifest?.shots ?? []).map((shot) => shot.slug)); +} + +/* ------------------------------------------------------------------ */ +/* G2 — environment allowlist */ +/* ------------------------------------------------------------------ */ + +const ENV_ALLOWLIST = [ + 'PATH', + 'HOME', + 'SHELL', + 'TMPDIR', + 'TZ', + 'LANG', + 'DISPLAY', + 'WAYLAND_DISPLAY', + // X11 sessions authenticate through an xauth cookie file; without it the + // Electron child cannot open the display and the run dies before a window + // exists. It grants display access, not application credentials. + 'XAUTHORITY', +]; +const ENV_ALLOWED_PREFIXES = ['LC_', 'XDG_']; + +/** + * The capture app gets a constructed environment, never `...process.env`: + * ambient variables are how TMDB keys, proxies, and experiment flags leak + * into a run that must be hermetic. + * + * @param {Record} baseEnv + * @param {Record} overrides explicit run configuration + * @returns {Record} + */ +export function buildCaptureEnv(baseEnv, overrides) { + const env = {}; + + for (const [key, value] of Object.entries(baseEnv)) { + if (value === undefined) { + continue; + } + + if ( + ENV_ALLOWLIST.includes(key) || + ENV_ALLOWED_PREFIXES.some((prefix) => key.startsWith(prefix)) + ) { + env[key] = value; + } + } + + return { ...env, ...overrides }; +} + +/* ------------------------------------------------------------------ */ +/* G3 — network gate */ +/* ------------------------------------------------------------------ */ + +/** Single source of truth for what may load during a capture run. */ +export const ALLOWED_SCHEMES = [ + 'file:', + 'data:', + 'blob:', + 'about:', + 'chrome:', + 'devtools:', +]; +export const ALLOWED_HOSTS = ['localhost', '127.0.0.1']; +export const ALLOWED_NETWORK_PROTOCOLS = ['http:', 'ws:']; + +/** + * Deny by default. Only local mock traffic and the app's own bundle may load; + * anything else (TMDB, logo CDNs, real streams) is blocked and — because the + * caller fails on a non-empty list — fatal, since a silently blocked request + * produces a frame that merely looks broken instead of unsafe. + * + * @param {string} url + * @returns {boolean} + */ +export function isAllowedRequestUrl(url) { + try { + const { hostname, protocol } = new URL(url); + + if (ALLOWED_SCHEMES.includes(protocol)) { + return true; + } + + return ( + ALLOWED_NETWORK_PROTOCOLS.includes(protocol) && + ALLOWED_HOSTS.includes(hostname) + ); + } catch { + // `blob:file:///…` and similar opaque forms do not parse as URLs. + return ALLOWED_SCHEMES.some((scheme) => url.startsWith(scheme)); + } +} + +/** + * App-level requests that are legitimate in production but must not leave + * the machine during a capture run. The driver fulfills them locally with + * an empty payload instead of letting them hit the network — a deterministic + * stub, not an allowlist hole. Keep this list minimal and exact. + * + * @param {string} url + * @returns {{ body: string, contentType: string } | null} stub response + */ +export const STUB_URL_PREFIXES = [ + // Settings "release notes" / update check. + 'https://api.github.com/repos/4gray/iptvnator/releases', +]; + +export function stubbedResponseFor(url) { + if (STUB_URL_PREFIXES.some((prefix) => url.startsWith(prefix))) { + return { body: '[]', contentType: 'application/json' }; + } + + return null; +} + +/** + * Data handed to the main-process request hook, which cannot import this + * module. Keeping the values here means the two predicates cannot disagree + * about what is local even though the check itself is written twice. + * + * @returns {{ schemes: string[], hosts: string[], protocols: string[], stubPrefixes: string[] }} + */ +export function networkPolicy() { + return { + schemes: ALLOWED_SCHEMES, + hosts: ALLOWED_HOSTS, + protocols: ALLOWED_NETWORK_PROTOCOLS, + stubPrefixes: STUB_URL_PREFIXES, + }; +} + +/** + * Publishes a staged frame set over the release directory as one swap. + * + * Copy-then-rename rather than moving file by file: staging lives in the + * system temp directory, which may be a different filesystem (rename would + * fail with EXDEV), and a per-file loop that fails midway would leave the + * release holding a mix of new and old screenshots. Files are copied into a + * sibling of the target — same filesystem, so the directory swap is atomic — + * and the previous set is only deleted once the new one is in place. + * + * `mode` decides what happens to shots the staging set does not contain: + * a full run replaces the directory so slugs dropped from the manifest do not + * linger, while a filtered run (`--only`, `--theme`) must overlay its frames + * onto the existing set — otherwise refreshing one shot would delete every + * other screenshot of the release. + * + * @param {string} stagingDir + * @param {string} outputRoot + * @param {string} token unique suffix for the scratch directories + * @param {{ mode?: 'replace' | 'merge', rename?: (from: string, to: string) => void }} [options] + * `rename` is a seam for exercising the rollback path, which the filesystem + * will not fail on demand + * @returns {number} number of published files + */ +export function publishDirectory( + stagingDir, + outputRoot, + token, + { mode = 'replace', rename = renameSync } = {} +) { + const incoming = `${outputRoot}.incoming-${token}`; + const retired = `${outputRoot}.retired-${token}`; + + mkdirSync(path.dirname(outputRoot), { recursive: true }); + rmSync(incoming, { recursive: true, force: true }); + mkdirSync(incoming, { recursive: true }); + + if (mode === 'merge' && existsSync(outputRoot)) { + for (const name of readdirSync(outputRoot)) { + const source = path.join(outputRoot, name); + + if (statSync(source).isFile()) { + copyFileSync(source, path.join(incoming, name)); + } + } + } + + const names = readdirSync(stagingDir); + + for (const name of names) { + copyFileSync(path.join(stagingDir, name), path.join(incoming, name)); + } + + const hadPrevious = existsSync(outputRoot); + + if (hadPrevious) { + rename(outputRoot, retired); + } + + try { + rename(incoming, outputRoot); + } catch (error) { + if (hadPrevious) { + rename(retired, outputRoot); + } + + rmSync(incoming, { recursive: true, force: true }); + throw error; + } + + rmSync(retired, { recursive: true, force: true }); + + return names.length; +} + +/** + * Verdict over every request URL observed during a run — both the ones the + * page-level route blocked and the ones the main-process recorder saw during + * startup, before page interception exists. + * + * @param {string[]} urls + * @returns {string[]} unique offending URLs; empty when the run stayed local + */ +export function externalRequestViolations(urls) { + return [...new Set(urls)].filter( + (url) => !isAllowedRequestUrl(url) && !stubbedResponseFor(url) + ); +} + +/* ------------------------------------------------------------------ */ +/* G4 — frame content assertions */ +/* ------------------------------------------------------------------ */ + +const CREDENTIAL_TEXT_PATTERNS = [ + /https?:\/\/[^\s"']*(?:username|password)=/i, + /\b[0-9a-f]{2}(?::[0-9a-f]{2}){5}\b/i, // MAC address (Stalker identity) + /https?:\/\/(?!localhost|127\.0\.0\.1)[^\s"']+\.m3u8?\b/i, +]; + +/** + * Evaluated against a DOM report collected right before each screenshot: + * every image/background URL and the visible text. External resources or + * credential-shaped text fail the shot. + * + * @param {{ resourceUrls: string[], bodyText: string }} report + * @returns {string[]} violations; empty when the frame is safe + */ +export function evaluateFrameReport(report) { + const violations = []; + + for (const url of report.resourceUrls) { + if (!isAllowedRequestUrl(url)) { + violations.push(`external resource in frame: ${url}`); + } + } + + for (const pattern of CREDENTIAL_TEXT_PATTERNS) { + const match = report.bodyText.match(pattern); + + if (match) { + violations.push( + `credential-shaped text visible in frame: "${match[0].slice(0, 80)}"` + ); + } + } + + return violations; +} + +/* ------------------------------------------------------------------ */ +/* G1 — real database untouched */ +/* ------------------------------------------------------------------ */ + +/** + * Snapshots every file in the real database directory — `iptvnator.db` plus + * its SQLite WAL sidecars (`-wal`, `-shm`), where writes live until a + * checkpoint, so a mutation cannot hide in them. + * + * Identity is size + mtime + inode, deliberately not a content hash: the + * production database is multi-gigabyte, and the previous `readFileSync` + + * sha256 approach threw on it and was swallowed into "file does not exist" — + * which silently disabled this guard entirely. Only ENOENT means absent; any + * other error propagates. + * + * @param {string} directory + * @returns {{ exists: boolean, entries: Record }} + */ +export function snapshotDatabaseState(directory) { + let names; + + try { + names = readdirSync(directory); + } catch (error) { + if (error.code === 'ENOENT') { + return { exists: false, entries: {} }; + } + + throw error; + } + + const entries = {}; + + for (const name of names.sort()) { + try { + const stats = statSync(path.join(directory, name)); + + if (stats.isFile()) { + entries[name] = { + size: stats.size, + mtimeMs: stats.mtimeMs, + ino: stats.ino, + }; + } + } catch (error) { + if (error.code !== 'ENOENT') { + throw error; + } + } + } + + return { exists: true, entries }; +} + +/** + * @returns {string | null} violation message, or null when unchanged + */ +export function compareDatabaseStates(before, after) { + if (before.exists !== after.exists) { + return before.exists + ? 'the real database directory disappeared during the capture run' + : 'the real database directory was CREATED during the capture run'; + } + + if (!before.exists) { + return null; + } + + const names = new Set([ + ...Object.keys(before.entries), + ...Object.keys(after.entries), + ]); + + for (const name of [...names].sort()) { + const from = before.entries[name]; + const to = after.entries[name]; + + if (!from) { + return `the real database gained ${name} during the capture run`; + } + + if (!to) { + return `the real database lost ${name} during the capture run`; + } + + if ( + from.size !== to.size || + from.mtimeMs !== to.mtimeMs || + from.ino !== to.ino + ) { + return `the real database file ${name} was modified during the capture run (close IPTVnator and any other process using it, then retry)`; + } + } + + return null; +} diff --git a/tools/release/screenshot-guards.test.mjs b/tools/release/screenshot-guards.test.mjs new file mode 100644 index 000000000..6b80f852c --- /dev/null +++ b/tools/release/screenshot-guards.test.mjs @@ -0,0 +1,575 @@ +import assert from 'node:assert/strict'; +import { + mkdtempSync, + readdirSync, + readFileSync, + renameSync, + rmSync, + utimesSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { after, describe, it } from 'node:test'; + +import { + buildCaptureEnv, + HOST_RESOLVER_RULES, + networkPolicy, + publishDirectory, + compareDatabaseStates, + evaluateFrameReport, + externalRequestViolations, + isAllowedRequestUrl, + manifestSlugs, + parseSetupStep, + snapshotDatabaseState, + stubbedResponseFor, + validateManifest, + validateReleaseSlug, +} from './screenshot-guards.mjs'; + +const tempDirs = []; + +after(() => { + for (const dir of tempDirs) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +function validManifest() { + return { + version: 1, + viewport: { width: 1280, height: 720 }, + themes: ['dark', 'light'], + shots: [ + { slug: 'dashboard', title: 'Dashboard', setup: ['open-dashboard'] }, + { + slug: 'vod', + title: 'VOD', + setup: ['open-xtream-vod=Hero Premieres'], + }, + ], + }; +} + +describe('manifest validation', () => { + it('accepts the committed manifest shape', () => { + assert.deepEqual(validateManifest(validManifest()), []); + }); + + it('rejects unknown setup actions, bad slugs and duplicates', () => { + const manifest = validManifest(); + manifest.shots.push( + { slug: 'Bad Slug', title: 'x', setup: ['open-dashboard'] }, + { slug: 'dashboard', title: 'dupe', setup: ['fly-to-the-moon'] } + ); + + const errors = validateManifest(manifest); + + assert.ok(errors.some((error) => /slug must be a lowercase/.test(error))); + assert.ok(errors.some((error) => /duplicate slug/.test(error))); + assert.ok( + errors.some((error) => /unknown setup action "fly-to-the-moon"/.test(error)) + ); + }); + + it('rejects a manifest that does not carry exactly dark and light', () => { + for (const themes of [['dark'], ['dark', 'drak'], [], undefined, 'dark']) { + const manifest = { ...validManifest(), themes }; + + assert.ok( + validateManifest(manifest).some((error) => + /`themes` must be exactly/.test(error) + ), + `should reject themes: ${JSON.stringify(themes)}` + ); + } + }); + + it('accepts dark and light in either order', () => { + const manifest = { ...validManifest(), themes: ['light', 'dark'] }; + + assert.deepEqual(validateManifest(manifest), []); + }); + + it('rejects an empty or missing shots array', () => { + assert.ok( + validateManifest({ version: 1, themes: ['dark', 'light'], shots: [] }) + .length > 0 + ); + assert.ok( + validateManifest({ version: 1, themes: ['dark', 'light'] }).length > 0 + ); + }); + + it('parses setup steps with and without a parameter', () => { + assert.deepEqual(parseSetupStep('open-dashboard'), { + action: 'open-dashboard', + param: null, + }); + assert.deepEqual(parseSetupStep('open-xtream-vod=Hero Premieres'), { + action: 'open-xtream-vod', + param: 'Hero Premieres', + }); + }); + + it('exposes slugs for .changes screenshot validation', () => { + assert.deepEqual( + [...manifestSlugs(validManifest())], + ['dashboard', 'vod'] + ); + }); +}); + +describe('host resolver gate', () => { + it('blocks every host except the local mock', () => { + assert.match(HOST_RESOLVER_RULES, /^MAP \* ~NOTFOUND/); + assert.match(HOST_RESOLVER_RULES, /EXCLUDE localhost/); + assert.match(HOST_RESOLVER_RULES, /EXCLUDE 127\.0\.0\.1/); + }); +}); + +describe('release slug validation', () => { + it('accepts ordinary release slugs', () => { + for (const slug of ['v0-24', 'v0-24.1', 'v-smoke-test', 'v1-0']) { + assert.equal(validateReleaseSlug(slug), null, slug); + } + }); + + it('rejects traversal and separators that would escape the blog tree', () => { + for (const slug of [ + '../../assets', + '..', + 'v0-24/../../etc', + '/etc/passwd', + 'v0-24/nested', + 'V0-24', + '', + ]) { + assert.ok(validateReleaseSlug(slug), `should reject: ${slug}`); + } + }); +}); + +describe('network policy handed to the main process', () => { + it('carries the same data the in-process predicate uses', () => { + const policy = networkPolicy(); + + assert.ok(policy.schemes.includes('file:')); + assert.ok(policy.hosts.includes('127.0.0.1')); + assert.ok(policy.protocols.includes('http:')); + assert.deepEqual(policy.stubPrefixes, [ + 'https://api.github.com/repos/4gray/iptvnator/releases', + ]); + }); + + it('agrees with isAllowedRequestUrl on local and external URLs', () => { + const policy = networkPolicy(); + const isLocal = (url) => { + if (policy.stubPrefixes.some((p) => url.startsWith(p))) return true; + try { + const parsed = new URL(url); + return ( + policy.schemes.includes(parsed.protocol) || + (policy.protocols.includes(parsed.protocol) && + policy.hosts.includes(parsed.hostname)) + ); + } catch { + return policy.schemes.some((s) => url.startsWith(s)); + } + }; + + for (const url of [ + 'http://localhost:3211/a', + 'file:///x/index.html', + 'data:image/png;base64,AAA', + 'blob:file:///abc', + ]) { + assert.equal(isLocal(url), isAllowedRequestUrl(url), url); + } + + for (const url of [ + 'https://image.tmdb.org/p.jpg', + 'https://test-streams.mux.dev/x.m3u8', + 'https://localhost.evil.example/x', + ]) { + assert.equal(isLocal(url), false, url); + assert.equal(isAllowedRequestUrl(url), false, url); + } + }); +}); + +describe('G2 — environment allowlist', () => { + it('keeps only allowlisted variables plus explicit overrides', () => { + const env = buildCaptureEnv( + { + PATH: '/usr/bin', + HOME: '/Users/x', + LC_ALL: 'en_US.UTF-8', + XDG_RUNTIME_DIR: '/run/user/1000', + TMDB_API_KEY: 'leaky-secret', + HTTPS_PROXY: 'http://proxy:8080', + IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT: '1', + AWS_SECRET_ACCESS_KEY: 'nope', + }, + { NODE_ENV: 'test', IPTVNATOR_E2E_DATA_DIR: '/tmp/x' } + ); + + assert.deepEqual(env, { + PATH: '/usr/bin', + HOME: '/Users/x', + LC_ALL: 'en_US.UTF-8', + XDG_RUNTIME_DIR: '/run/user/1000', + NODE_ENV: 'test', + IPTVNATOR_E2E_DATA_DIR: '/tmp/x', + }); + }); + + it('keeps the X11 display credentials Electron needs on Linux', () => { + // Without XAUTHORITY an xauth-based X11 session refuses the + // connection and the capture dies before a window exists. + const env = buildCaptureEnv( + { + DISPLAY: ':99', + XAUTHORITY: '/run/user/1000/gdm/Xauthority', + WAYLAND_DISPLAY: 'wayland-0', + TMDB_API_KEY: 'leaky-secret', + }, + {} + ); + + assert.deepEqual(env, { + DISPLAY: ':99', + XAUTHORITY: '/run/user/1000/gdm/Xauthority', + WAYLAND_DISPLAY: 'wayland-0', + }); + }); + + it('drops undefined values', () => { + assert.deepEqual(buildCaptureEnv({ PATH: undefined }, {}), {}); + }); +}); + +describe('G3 — network gate', () => { + it('allows localhost, loopback and internal schemes', () => { + for (const url of [ + 'http://localhost:3211/player_api.php?username=marketing', + 'http://127.0.0.1:3211/assets/logo.svg', + 'ws://localhost:4200/ng-cli-ws', + 'file:///dist/apps/web/index.html', + 'data:image/png;base64,AAA', + 'blob:file:///abc', + 'about:blank', + 'devtools://devtools/bundled/root.js', + ]) { + assert.equal(isAllowedRequestUrl(url), true, url); + } + }); + + it('blocks everything external', () => { + for (const url of [ + 'https://api.themoviedb.org/3/trending/all/week', + 'https://image.tmdb.org/t/p/w500/x.jpg', + 'http://real-provider.example:8080/live/user/pass/1.m3u8', + 'https://localhost.evil.example/x', // suffix-spoofed hostname + 'http://192.168.1.50/stream.ts', + 'not a url', + ]) { + assert.equal(isAllowedRequestUrl(url), false, url); + } + }); +}); + +describe('G3 — local stubs', () => { + it('stubs the GitHub releases update check with an empty payload', () => { + const stub = stubbedResponseFor( + 'https://api.github.com/repos/4gray/iptvnator/releases?per_page=100' + ); + + assert.deepEqual(stub, { body: '[]', contentType: 'application/json' }); + }); + + it('stubs nothing else', () => { + for (const url of [ + 'https://api.github.com/repos/4gray/iptvnator/issues', + 'https://api.github.com/repos/other/repo/releases', + 'https://api.themoviedb.org/3/trending/all/week', + 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8', + ]) { + assert.equal(stubbedResponseFor(url), null, url); + } + }); +}); + +describe('G4 — frame content assertions', () => { + it('passes a frame with only mock resources and clean text', () => { + const violations = evaluateFrameReport({ + resourceUrls: [ + 'http://localhost:3211/assets/marketing/poster/crimson-skylark.svg', + 'data:image/svg+xml;base64,AAA', + ], + bodyText: 'Crimson Skylark\nHero Premieres\nAurora Local', + }); + + assert.deepEqual(violations, []); + }); + + it('flags external artwork in the frame', () => { + const violations = evaluateFrameReport({ + resourceUrls: ['https://image.tmdb.org/t/p/w500/poster.jpg'], + bodyText: '', + }); + + assert.equal(violations.length, 1); + assert.match(violations[0], /external resource/); + }); + + it('flags credential-shaped URLs, MAC addresses and external m3u8 text', () => { + const violations = evaluateFrameReport({ + resourceUrls: [], + bodyText: [ + 'http://provider.example/get.php?username=real&password=secret', + '00:1A:79:12:34:56', + 'stream at http://cdn.example/live/42.m3u8 is down', + ].join('\n'), + }); + + assert.equal(violations.length, 3); + assert.ok(violations.every((entry) => /credential-shaped/.test(entry))); + }); + + it('does not flag the mock server stream URL', () => { + const violations = evaluateFrameReport({ + resourceUrls: [], + bodyText: 'http://localhost:3211/live/marketing/marketing/52000.m3u8', + }); + + assert.deepEqual(violations, []); + }); +}); + +describe('G1 — real database untouched', () => { + function tempDbDir(files) { + const dir = mkdtempSync(path.join(tmpdir(), 'guard-db-')); + tempDirs.push(dir); + + for (const [name, content] of Object.entries(files)) { + writeFileSync(path.join(dir, name), content); + } + + return dir; + } + + it('reports no violation for an untouched directory', () => { + const dir = tempDbDir({ + 'iptvnator.db': 'main', + 'iptvnator.db-wal': 'wal', + 'iptvnator.db-shm': 'shm', + }); + + assert.equal( + compareDatabaseStates( + snapshotDatabaseState(dir), + snapshotDatabaseState(dir) + ), + null + ); + }); + + it('reports no violation when the directory does not exist', () => { + const missing = snapshotDatabaseState('/definitely/not/here-9d3f'); + + assert.equal(missing.exists, false); + assert.equal(compareDatabaseStates(missing, missing), null); + }); + + it('detects a write that only lands in the WAL sidecar', () => { + // The exact hole this replaced: hashing iptvnator.db alone missed + // writes that SQLite parks in -wal until a checkpoint. + const dir = tempDbDir({ 'iptvnator.db': 'main', 'iptvnator.db-wal': 'w' }); + const before = snapshotDatabaseState(dir); + writeFileSync(path.join(dir, 'iptvnator.db-wal'), 'w+more'); + + assert.match( + compareDatabaseStates(before, snapshotDatabaseState(dir)), + /iptvnator\.db-wal was modified/ + ); + }); + + it('detects a bare touch of the main database', () => { + const dir = tempDbDir({ 'iptvnator.db': 'same-bytes' }); + const before = snapshotDatabaseState(dir); + utimesSync( + path.join(dir, 'iptvnator.db'), + new Date(), + new Date(Date.now() + 5_000) + ); + + assert.match( + compareDatabaseStates(before, snapshotDatabaseState(dir)), + /iptvnator\.db was modified/ + ); + }); + + it('detects added and removed sidecars', () => { + const dir = tempDbDir({ 'iptvnator.db': 'main' }); + const before = snapshotDatabaseState(dir); + writeFileSync(path.join(dir, 'iptvnator.db-wal'), 'new'); + const after = snapshotDatabaseState(dir); + + assert.match(compareDatabaseStates(before, after), /gained iptvnator\.db-wal/); + assert.match(compareDatabaseStates(after, before), /lost iptvnator\.db-wal/); + }); + + it('detects a directory that appears during the run', () => { + const dir = tempDbDir({ 'iptvnator.db': 'x' }); + + assert.match( + compareDatabaseStates({ exists: false, entries: {} }, snapshotDatabaseState(dir)), + /CREATED/ + ); + }); + + it('does not hash the database, so multi-gigabyte files are fine', () => { + // Regression guard: the previous implementation read the whole file + // into memory and swallowed the resulting failure as "absent", + // silently disabling G1 against a 4 GB production database. + const dir = tempDbDir({ 'iptvnator.db': 'x' }); + const snapshot = snapshotDatabaseState(dir); + + assert.equal(snapshot.exists, true); + assert.ok(!('sha256' in snapshot.entries['iptvnator.db'])); + assert.ok(snapshot.entries['iptvnator.db'].ino > 0); + }); +}); + +describe('publishDirectory', () => { + function staged(files) { + const dir = mkdtempSync(path.join(tmpdir(), 'guard-stage-')); + tempDirs.push(dir); + + for (const [name, content] of Object.entries(files)) { + writeFileSync(path.join(dir, name), content); + } + + return dir; + } + + function outputPath() { + const parent = mkdtempSync(path.join(tmpdir(), 'guard-out-')); + tempDirs.push(parent); + + return path.join(parent, 'screenshots'); + } + + it('publishes into a directory that does not exist yet', () => { + const out = outputPath(); + const count = publishDirectory( + staged({ 'a-dark.png': 'A', 'a-light.png': 'B' }), + out, + 'test' + ); + + assert.equal(count, 2); + assert.deepEqual(readdirSync(out).sort(), ['a-dark.png', 'a-light.png']); + }); + + it('replaces a previous set wholesale, leaving no stale files', () => { + const out = outputPath(); + publishDirectory(staged({ 'old.png': 'OLD', 'a.png': 'v1' }), out, 't1'); + publishDirectory(staged({ 'a.png': 'v2' }), out, 't2'); + + assert.deepEqual(readdirSync(out), ['a.png']); + assert.equal(readFileSync(path.join(out, 'a.png'), 'utf8'), 'v2'); + }); + + it('merge mode overlays a partial set without deleting the rest', () => { + // The `--only` / `--theme` path: refreshing one shot must not wipe + // the other screenshots of the release. + const out = outputPath(); + publishDirectory( + staged({ 'a-dark.png': 'A1', 'b-dark.png': 'B1' }), + out, + 't1' + ); + + const count = publishDirectory(staged({ 'a-dark.png': 'A2' }), out, 't2', { + mode: 'merge', + }); + + assert.equal(count, 1); + assert.deepEqual(readdirSync(out).sort(), ['a-dark.png', 'b-dark.png']); + assert.equal(readFileSync(path.join(out, 'a-dark.png'), 'utf8'), 'A2'); + assert.equal(readFileSync(path.join(out, 'b-dark.png'), 'utf8'), 'B1'); + }); + + it('merge mode works when nothing was published before', () => { + const out = outputPath(); + publishDirectory(staged({ 'a.png': 'A' }), out, 't', { mode: 'merge' }); + + assert.deepEqual(readdirSync(out), ['a.png']); + }); + + it('leaves no scratch directories behind', () => { + const out = outputPath(); + publishDirectory(staged({ 'a.png': 'A' }), out, 'tok'); + + assert.deepEqual(readdirSync(path.dirname(out)), ['screenshots']); + }); + + it('rolls the previous set back when the swap fails', () => { + const out = outputPath(); + publishDirectory(staged({ 'a.png': 'original' }), out, 't1'); + + let calls = 0; + const rename = (from, to) => { + calls += 1; + // First call retires the previous set; fail the swap that follows. + if (calls === 2) { + throw new Error('simulated EXDEV'); + } + renameSync(from, to); + }; + + assert.throws( + () => + publishDirectory(staged({ 'a.png': 'new' }), out, 'boom', { + rename, + }), + /simulated EXDEV/ + ); + + assert.equal(calls, 3, 'the rollback rename must have run'); + assert.equal(readFileSync(path.join(out, 'a.png'), 'utf8'), 'original'); + assert.deepEqual(readdirSync(path.dirname(out)), ['screenshots']); + }); +}); + +describe('external request verdict', () => { + it('ignores local and stubbed URLs, reports the rest once', () => { + const violations = externalRequestViolations([ + 'http://localhost:3211/player_api.php', + 'file:///dist/apps/web/index.html', + 'https://api.github.com/repos/4gray/iptvnator/releases', + 'https://image.tmdb.org/t/p/w500/a.jpg', + 'https://image.tmdb.org/t/p/w500/a.jpg', + 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8', + ]); + + assert.deepEqual(violations, [ + 'https://image.tmdb.org/t/p/w500/a.jpg', + 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8', + ]); + }); + + it('returns nothing for a fully local run', () => { + assert.deepEqual( + externalRequestViolations([ + 'http://127.0.0.1:3211/assets/logo.svg', + 'data:image/png;base64,AAA', + ]), + [] + ); + }); +}); diff --git a/tools/release/screenshots.manifest.json b/tools/release/screenshots.manifest.json new file mode 100644 index 000000000..31cef378c --- /dev/null +++ b/tools/release/screenshots.manifest.json @@ -0,0 +1,32 @@ +{ + "version": 1, + "viewport": { "width": 1280, "height": 720 }, + "themes": ["dark", "light"], + "shots": [ + { + "slug": "dashboard", + "title": "Dashboard", + "setup": ["open-dashboard"] + }, + { + "slug": "settings", + "title": "Settings", + "setup": ["open-settings"] + }, + { + "slug": "xtream-vod-details", + "title": "Movie details", + "setup": ["open-xtream-vod=Action & Mystery"] + }, + { + "slug": "xtream-series-season-open", + "title": "Series season view", + "setup": ["open-xtream-series=Urban Drama"] + }, + { + "slug": "m3u-live-groups-two-column", + "title": "Live TV groups", + "setup": ["open-m3u-groups"] + } + ] +}