mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
New guide at /blog/remote-control-guide/: enabling the remote in Settings, opening it on a phone from the QR code, what each control does and which list it navigates, a checklist for a page that does not load, and why the remote must stay on the local network. Eight FAQ entries. The remote-control feature page now links to it instead of the M3U guide. Both screenshots are mock-backed. The phone view is the first "browser" shot: a manifest entry names a loopback URL and a mobile viewport, and the capture frames it in a separate Chromium page behind the same network and content guards, with the manifest validator accepting loopback origins only. The setup saves the remote-control setting so the app's own server answers, then selects a live channel; the Xtream mock's marketing scenario now serves live stream URLs from local bytes so that selection never leaves the machine. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
528 lines
18 KiB
TypeScript
528 lines
18 KiB
TypeScript
/**
|
||
* Release screenshot capture, manifest-driven and fail-closed.
|
||
*
|
||
* pnpm release:screenshots # release slug from package.json
|
||
* pnpm release:screenshots --release v0-24 # explicit
|
||
* pnpm release:screenshots --only dashboard --theme dark
|
||
* pnpm release:screenshots --group guides # evergreen guide shots
|
||
*
|
||
* Reads tools/release/screenshots.manifest.json and writes
|
||
* apps/website/public/blog/<release>/screenshots/<slug>-<theme>.png against
|
||
* dist builds + the xtream mock server. Shots carrying a `group` (for
|
||
* example `guides`) are skipped by a release run and land in
|
||
* apps/website/public/blog/<group>/screenshots/ when that group is selected.
|
||
* 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 { chromium, type Page } from '@playwright/test';
|
||
|
||
import {
|
||
DEFAULT_SHOT_GROUP,
|
||
buildCaptureEnv,
|
||
compareDatabaseStates,
|
||
evaluateFrameReport,
|
||
externalRequestViolations,
|
||
HOST_RESOLVER_RULES,
|
||
isAllowedRequestUrl,
|
||
networkPolicy,
|
||
outputDirectoryFor,
|
||
parseSetupStep,
|
||
publishDirectory,
|
||
shotGroup,
|
||
snapshotDatabaseState,
|
||
stubbedResponseFor,
|
||
validateManifest,
|
||
validateReleaseSlug,
|
||
} from './screenshot-guards.mjs';
|
||
import * as driver from './capture-app-driver';
|
||
import { CAPTURE_DOWNLOAD_FOLDER_NAME } from './capture-fixtures';
|
||
import {
|
||
applyTheme,
|
||
discardUnsavedSettings,
|
||
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 group = flag('group') ?? DEFAULT_SHOT_GROUP;
|
||
const groupError = validateReleaseSlug(group);
|
||
|
||
if (groupError) {
|
||
throw new Error(`--group rejected: ${groupError}`);
|
||
}
|
||
|
||
const only = flag('only');
|
||
const themeFilter = flag('theme');
|
||
const shots = manifest.shots.filter(
|
||
(shot: { slug: string; group?: string }) =>
|
||
shotGroup(shot) === group && (!only || shot.slug === only)
|
||
);
|
||
|
||
if (shots.length === 0) {
|
||
throw new Error(
|
||
only
|
||
? `--only ${only} matches no manifest slug in group ${group}`
|
||
: `--group ${group} matches no manifest shots`
|
||
);
|
||
}
|
||
|
||
// 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 = outputDirectoryFor({ blogRoot, group, release });
|
||
|
||
// 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);
|
||
// The Stalker portal is seeded only for shots that walk into it: it adds
|
||
// a third source card to the dashboard, which release shots must not show.
|
||
const needsStalker = shots.some((shot: { setup: string[] }) =>
|
||
shot.setup.some(
|
||
(step) => parseSetupStep(String(step)).action === 'open-stalker-live'
|
||
)
|
||
);
|
||
const stalkerMockServer = needsStalker
|
||
? await driver.ensureStalkerMockServer(workspaceRoot)
|
||
: undefined;
|
||
// Same rule for the second Xtream source: another card on the dashboard,
|
||
// wanted only by the alternative-sources guide shots.
|
||
const needsSecondaryXtream = shots.some((shot: { setup: string[] }) =>
|
||
shot.setup.some((step) =>
|
||
parseSetupStep(String(step)).action.startsWith(
|
||
'open-xtream-vod-sources'
|
||
)
|
||
)
|
||
);
|
||
const dataDir = mkdtempSync(path.join(tmpdir(), 'iptvnator-release-shots-'));
|
||
let app: Awaited<ReturnType<typeof driver.launchApp>> | undefined;
|
||
let page: Page | 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());
|
||
|
||
page = await driver.findMainWindow(app);
|
||
await driver.installDownloadFolderDialogStub(
|
||
app,
|
||
path.join(dataDir, CAPTURE_DOWNLOAD_FOLDER_NAME)
|
||
);
|
||
|
||
// 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), {
|
||
stalker: needsStalker,
|
||
secondaryXtream: needsSecondaryXtream,
|
||
});
|
||
|
||
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);
|
||
}
|
||
|
||
if (shot.browser) {
|
||
await captureBrowserShot(
|
||
shot.browser,
|
||
stagingDir,
|
||
shot.slug,
|
||
theme,
|
||
blockedRequests
|
||
);
|
||
} else {
|
||
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 {
|
||
// A shot may leave the settings form dirty, which arms the app's
|
||
// close guard and would block the close below indefinitely.
|
||
if (page) {
|
||
await discardUnsavedSettings(page).catch(() => undefined);
|
||
}
|
||
// 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');
|
||
stalkerMockServer?.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.`
|
||
);
|
||
}
|
||
|
||
interface BrowserShot {
|
||
url: string;
|
||
viewport: { width: number; height: number };
|
||
}
|
||
|
||
/**
|
||
* A `browser` shot frames a page the app serves to other devices — the
|
||
* remote control's phone view — in a separate mobile-sized Chromium instead of
|
||
* the Electron window. It sits behind the same gates: the page may only load
|
||
* from loopback (deny-by-default route, violations feed G3), and its DOM is
|
||
* inspected by G4 before the frame is written.
|
||
*/
|
||
async function captureBrowserShot(
|
||
shot: BrowserShot,
|
||
outputRoot: string,
|
||
slug: string,
|
||
theme: Theme,
|
||
blocked: string[]
|
||
): Promise<void> {
|
||
const browser = await chromium.launch();
|
||
|
||
try {
|
||
const context = await browser.newContext({
|
||
viewport: shot.viewport,
|
||
deviceScaleFactor: 2,
|
||
isMobile: true,
|
||
hasTouch: true,
|
||
colorScheme: theme,
|
||
});
|
||
const page = await context.newPage();
|
||
|
||
await page.route('**/*', async (route) => {
|
||
const url = route.request().url();
|
||
|
||
if (isAllowedRequestUrl(url)) {
|
||
await route.continue();
|
||
return;
|
||
}
|
||
|
||
blocked.push(url);
|
||
await route.abort();
|
||
});
|
||
|
||
await page.goto(shot.url, { waitUntil: 'networkidle', timeout: 30_000 });
|
||
// The remote polls its status every two seconds; give the first
|
||
// answer time to replace the "No live stream selected" placeholder
|
||
// when a channel is playing, without failing shots that frame the
|
||
// idle state on purpose.
|
||
await page
|
||
.locator('.now-card__channel')
|
||
.filter({ hasNotText: 'No live stream selected' })
|
||
.first()
|
||
.waitFor({ state: 'visible', timeout: 8_000 })
|
||
.catch(() => undefined);
|
||
await page.waitForTimeout(500);
|
||
|
||
const report = await page.evaluate(collectFrameReport);
|
||
const violations = evaluateFrameReport(report);
|
||
|
||
if (violations.length > 0) {
|
||
throw new Error(
|
||
`G4 failed on ${slug} (${theme}):\n ${violations.join('\n ')}`
|
||
);
|
||
}
|
||
|
||
// Not `fullPage`: the remote scrolls inside its own container, so a
|
||
// full-page frame only pads the document with empty background while
|
||
// the container stays clipped. The manifest viewport is tall enough
|
||
// for the whole remote instead.
|
||
await page.screenshot({
|
||
path: path.join(outputRoot, `${slug}-${theme}.png`),
|
||
type: 'png',
|
||
});
|
||
console.log(` ✓ ${slug} (${theme}, browser ${shot.viewport.width}×${shot.viewport.height})`);
|
||
} finally {
|
||
await browser.close();
|
||
}
|
||
}
|
||
|
||
/** Runs inside the page: every image/background URL plus the visible text, for G4. */
|
||
function collectFrameReport(): { resourceUrls: string[]; bodyText: string } {
|
||
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,
|
||
};
|
||
}
|
||
|
||
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);
|
||
});
|