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>
This commit is contained in:
4grayandClaude Opus 5 authored and GitHub committed 2026-07-26 00:19:02 +02:00
1 parent 4e5132cbb5
commit b4ec68c1fa
14 files changed
+2344 -15

No files matched your search

+24 -1
View File
@@ -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
```
+15 -5
View File
@@ -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):
+15 -5
View File
@@ -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):
+1
View File
@@ -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"
},
+20
View File
@@ -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) {
+361
View File
@@ -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<ChildProcess | undefined> {
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<void> {
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<boolean> {
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<string, string>,
hostResolverRules: string
): Promise<ElectronApplication> {
// 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<Page> {
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<void> {
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<void> {
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<void> {
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<void> {
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<unknown>;
dbAddRecentItem?: (contentId: number, playlistId: string, backdropUrl?: string) => Promise<unknown>;
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<void> {
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<void> {
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<void> {
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<Page['locator']>,
label: RegExp
): Promise<void> {
// 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];
}
+250
View File
@@ -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<void> {
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<void> {
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<void> {
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<void> {
// 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<void> {
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<void> {
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);
}
+122
View File
@@ -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<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 ?? []
);
}
@@ -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/<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);
});
+56
View File
@@ -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<void> {
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);
}
}
+11 -4
View File
@@ -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"
]
}
+504
View File
@@ -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 ?? '<missing 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<string>} 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<string, string | undefined>} baseEnv
* @param {Record<string, string>} overrides explicit run configuration
* @returns {Record<string, string>}
*/
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<string, {size: number, mtimeMs: number, ino: number}> }}
*/
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;
}
+575
View File
@@ -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',
]),
[]
);
});
});
+32
View File
@@ -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"]
}
]
}