/** * 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 { mkdirSync } from 'node:fs'; import path from 'node:path'; import { _electron as electron, type ElectronApplication, type Page, } from '@playwright/test'; import { STALKER_FIXTURE_MAC, STALKER_FIXTURE_PORTAL_URL, STALKER_FIXTURE_TITLE, STALKER_MOCK_ORIGIN, XTREAM_FIXTURE_CREDENTIALS, XTREAM_FIXTURE_TITLE, XTREAM_MOCK_ORIGIN, XTREAM_SECONDARY_FIXTURE_CREDENTIALS, XTREAM_SECONDARY_FIXTURE_TITLE, } from './capture-fixtures'; import { clickDialogOption, openAddPlaylistDialog, registerPlaylistId, requirePlaylistId, } from './capture-navigation'; import type { PlaylistProvider } from './capture-navigation'; export { M3U_FIXTURE_TITLE, XTREAM_FIXTURE_TITLE, XTREAM_MOCK_ORIGIN, writeM3uFixture, } from './capture-fixtures'; /** * Synthetic categories that only the marketing fixture generator produces, * per catalog. They must be checked against their OWN endpoint: a series * category can never appear in `get_vod_categories`, and asserting it there * made the reuse path below reject every already-running mock. */ const MOCK_FIXTURE_CATEGORIES: ReadonlyArray<{ action: 'get_vod_categories' | 'get_series_categories'; name: string; }> = [ { action: 'get_vod_categories', name: 'Action & Mystery' }, { action: 'get_series_categories', name: 'Urban Drama' }, ]; /** * Live categories of the Stalker mock's marketing-demo scenario, from * `MARKETING_LIVE_CATEGORIES` in `@iptvnator/shared/marketing-fixtures`. * Spelled out here because `pnpm release:screenshots` runs tsx without the * base tsconfig, so workspace path aliases do not resolve in this file. */ const STALKER_MOCK_FIXTURE_CATEGORIES = ['Newsroom', 'Culture & Docs']; /* ------------------------------------------------------------------ */ /* Fixtures */ /* ------------------------------------------------------------------ */ export async function ensureXtreamMockServer( workspaceRoot: string ): Promise { const healthUrl = `${XTREAM_MOCK_ORIGIN}/health`; // Reusing whatever answers on the port is not enough: every guard treats // localhost as trusted, so an unrelated local server or proxy could feed // real catalog data and artwork straight into published screenshots. // Require the marketing fixtures this capture is built around. if (await isHealthy(healthUrl)) { await assertMockServerIdentity(); return undefined; } const child = spawn( path.join(workspaceRoot, 'node_modules/.bin/tsx'), // The mock server imports `@iptvnator/shared/marketing-fixtures`, and // tsx only resolves that path alias when pointed at the base config — // matching how the project's own serve target invokes it. [ '--tsconfig', 'tsconfig.base.json', '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}`); } export async function ensureStalkerMockServer( workspaceRoot: string ): Promise { const healthUrl = `${STALKER_MOCK_ORIGIN}/health`; if (await isHealthy(healthUrl)) { await assertStalkerMockServerIdentity(); return undefined; } const child = spawn( path.join(workspaceRoot, 'node_modules/.bin/tsx'), ['--tsconfig', 'tsconfig.base.json', 'apps/stalker-mock-server/src/main.ts'], { cwd: workspaceRoot, env: { ...process.env, NODE_ENV: 'development', PORT: '3210' }, stdio: ['ignore', 'pipe', 'pipe'], } ); child.stderr?.on('data', (chunk) => process.stderr.write(`[stalker-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(`stalker-mock-server did not become healthy at ${healthUrl}`); } /** * Same trust boundary as the Xtream check: whatever answers on the Stalker * port must serve the marketing-demo scenario's fictional live categories. */ async function assertStalkerMockServerIdentity(): Promise { const response = await fetch( `${STALKER_MOCK_ORIGIN}/portal.php?type=itv&action=get_genres&JsHttpRequest=1-xml`, { headers: { Cookie: `mac=${STALKER_FIXTURE_MAC}; stb_lang=en; timezone=UTC` } } ).catch(() => null); if (!response?.ok) { throw new Error( `Something is listening on ${STALKER_MOCK_ORIGIN} but does not answer the Stalker portal API — stop it and let this script start the mock server itself.` ); } const payload = (await response.json().catch(() => null)) as | { js?: { title?: string }[] } | null; const titles = new Set((payload?.js ?? []).map((entry) => entry.title)); for (const expected of STALKER_MOCK_FIXTURE_CATEGORIES) { if (!titles.has(expected)) { throw new Error( `The server on ${STALKER_MOCK_ORIGIN} is not the IPTVnator marketing mock (missing live category "${expected}"). Refusing to capture screenshots from unknown data.` ); } } } /** * 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 { for (const expected of MOCK_FIXTURE_CATEGORIES) { const response = await fetch( `${XTREAM_MOCK_ORIGIN}/player_api.php?username=marketing&password=marketing&action=${expected.action}` ).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) ); if (!names.has(expected.name)) { throw new Error( `The server on ${XTREAM_MOCK_ORIGIN} is not the IPTVnator marketing mock (missing ${expected.action} entry "${expected.name}"). Refusing to capture screenshots from unknown data.` ); } } } async function isHealthy(url: string): Promise { try { return (await fetch(url)).ok; } catch { return false; } } const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); /* ------------------------------------------------------------------ */ /* Launch and readiness */ /* ------------------------------------------------------------------ */ /** * Replaces Electron's native folder picker with one that answers `folder` * (created here) — the same trick `downloads.e2e.ts` uses. The downloads * page's "Change Folder" button then authorizes a folder inside the isolated * data dir, so guide shots never write into the real OS Downloads folder. */ export async function installDownloadFolderDialogStub( app: ElectronApplication, folder: string ): Promise { mkdirSync(folder, { recursive: true }); await app.evaluate(({ dialog }, target) => { dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [target], }) as Awaited>; }, folder); } export async function launchApp( electronMainPath: string, env: Record, hostResolverRules: string ): Promise { // The resolver switch is the only part of the network gate with no // install-timing window: it takes effect before Electron runs a single // line of app code, so startup traffic cannot slip through ahead of the // session hook. return electron.launch({ args: [ electronMainPath, `--host-resolver-rules=${hostResolverRules}`, ], env, }); } export async function findMainWindow(app: ElectronApplication): Promise { await sleep(1500); for (const candidate of app.windows()) { if (!(await candidate.title()).includes('DevTools')) { return candidate; } } return app.firstWindow(); } export async function sizeWindow( app: ElectronApplication, viewport: { width: number; height: number } ): Promise { await app.evaluate(({ BrowserWindow }, size) => { const win = BrowserWindow.getAllWindows().find( (candidate) => !candidate.webContents.getTitle().includes('DevTools') ); win?.setSize(size.width, size.height); win?.center(); }, viewport); } export async function waitForAppReady(page: Page): Promise { await page.waitForLoadState('domcontentloaded'); await page.waitForSelector('app-root', { timeout: 45_000 }); await page.waitForFunction( () => (document.querySelector('app-root')?.innerHTML.trim().length ?? 0) > 0, { timeout: 45_000 } ); } /* ------------------------------------------------------------------ */ /* Seeding */ /* ------------------------------------------------------------------ */ export interface SeedOptions { /** Also add the Stalker marketing portal (guide shots only: it changes the dashboard). */ stalker?: boolean; /** * Also add the second marketing Xtream source (guide shots only). Its * catalog is identical to the first, which is what makes the movie detail * show a Sources chip. */ secondaryXtream?: boolean; } export async function seedDemoData( page: Page, m3uPath: string, options: SeedOptions = {} ): Promise { await addXtreamPortal(page, { title: XTREAM_FIXTURE_TITLE, credentials: XTREAM_FIXTURE_CREDENTIALS, provider: 'xtreams', }); await addM3uPlaylist(page, m3uPath); if (options.stalker) { await addStalkerPortal(page); } if (options.secondaryXtream) { await addXtreamPortal(page, { title: XTREAM_SECONDARY_FIXTURE_TITLE, credentials: XTREAM_SECONDARY_FIXTURE_CREDENTIALS, provider: 'xtreams-secondary', }); // Multi-source discovery reads the SQLite content table, which the // desktop data source fills per category on first view. Open the // category the guide shot walks into so its movies are cached before // the primary portal's detail page looks for alternatives. await page .locator('app-workspace-context-panel .category-item') .filter({ hasText: 'Action & Mystery' }) .first() .click(); await page .locator('.category-content-layout mat-card') .first() .waitFor({ state: 'visible', timeout: 30_000 }); } await seedDashboardActivity(page); } /** * The dashboard hero and rails only render with favorites/recent activity. * Seed a handful of mock titles through the Electron DB bridge; backdrops * point at the mock server, keeping G3/G4 satisfied. */ async function seedDashboardActivity(page: Page): Promise { const playlistId = requirePlaylistId('xtreams'); const backdrop = (title: string) => `${XTREAM_MOCK_ORIGIN}/assets/marketing/backdrop/${title .toLowerCase() .replace(/[^a-z0-9]+/g, '-')}.svg?size=${encodeURIComponent('1280x720')}`; // Xtream VOD stream ids are assigned by array position // (`MARKETING_VOD_STREAM_ID_BASE + index` in marketing.generator.ts), and // the generator lists POSTER_SHOWCASE_MOVIES first. Keep these three titles // in step with the first three entries of that fixture list, otherwise the // seeded backdrops belong to different movies than the ids resolve to. const items = [ { xtreamId: 62000, type: 'movie', backdropUrl: backdrop('Black Harbor'), recent: true }, { xtreamId: 62001, type: 'movie', backdropUrl: backdrop('The Paper Astronaut'), recent: true }, { xtreamId: 62002, type: 'movie', backdropUrl: backdrop('Summer Static'), recent: false }, { xtreamId: 72000, type: 'series', backdropUrl: backdrop('Skyline Sentinels'), recent: true }, { xtreamId: 72001, type: 'series', backdropUrl: backdrop('The Aegis Club'), recent: false }, ] as const; await page.evaluate( async ({ items, playlistId }) => { const bridge = ( window as typeof window & { electron?: { dbAddFavorite?: (contentId: number, playlistId: string, backdropUrl?: string) => Promise; dbAddRecentItem?: (contentId: number, playlistId: string, backdropUrl?: string) => Promise; dbGetContentByXtreamId?: ( xtreamId: number, playlistId: string, contentType?: 'live' | 'movie' | 'series' ) => Promise<{ id: number } | null>; }; } ).electron; if (!bridge?.dbGetContentByXtreamId) { throw new Error('Electron database bridge is unavailable.'); } for (const item of items) { const content = await bridge.dbGetContentByXtreamId( item.xtreamId, playlistId, item.type ); if (!content?.id) { throw new Error( `Could not find imported Xtream content ${item.xtreamId}.` ); } await bridge.dbAddFavorite?.(content.id, playlistId, item.backdropUrl); if (item.recent) { await bridge.dbAddRecentItem?.(content.id, playlistId, item.backdropUrl); } } }, { items, playlistId } ); } interface XtreamPortalSeed { title: string; credentials: { readonly username: string; readonly password: string }; provider: PlaylistProvider; } async function addXtreamPortal( page: Page, seed: XtreamPortalSeed ): Promise { await openAddPlaylistDialog(page); const dialog = page.locator('mat-dialog-container').last(); await clickDialogOption(dialog, /xtream credentials/i); await dialog.locator('#title').fill(seed.title); await dialog.locator('#serverUrl').fill(XTREAM_MOCK_ORIGIN); await dialog.locator('#username').fill(seed.credentials.username); await dialog.locator('#password').fill(seed.credentials.password); 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(seed.provider, idFromUrl(page.url(), 'xtreams')); await page .locator('.category-content-layout, app-content-card') .first() .waitFor({ state: 'visible', timeout: 45_000 }); } async function addStalkerPortal(page: Page): Promise { await openAddPlaylistDialog(page); const dialog = page.locator('mat-dialog-container').last(); await clickDialogOption(dialog, /stalker portal/i); await dialog.locator('#title').fill(STALKER_FIXTURE_TITLE); await dialog.locator('#portalUrl').fill(STALKER_FIXTURE_PORTAL_URL); await dialog.locator('#macAddress').fill(STALKER_FIXTURE_MAC); // Endpoint discovery probes the mock before the row is written. await dialog .getByRole('button', { name: /^(add|add playlist)$/i }) .last() .click(); await dialog.waitFor({ state: 'detached', timeout: 60_000 }); await page.waitForURL(/\/workspace\/stalker\/[^/]+\/vod/, { timeout: 60_000, }); registerPlaylistId('stalker', idFromUrl(page.url(), 'stalker')); await page .locator('.category-content-layout, app-content-card') .first() .waitFor({ state: 'visible', timeout: 45_000 }); } async function addM3uPlaylist(page: Page, m3uPath: string): Promise { await openAddPlaylistDialog(page); const dialog = page.locator('mat-dialog-container').last(); // Unanchored: the radio's accessible name concatenates title + subtitle. await clickDialogOption(dialog, /m3u file/i); const fileInput = dialog.locator('input[type="file"][name="playlist"]'); await fileInput.setInputFiles(m3uPath); await dialog .getByRole('button', { name: /add playlist/i }) .last() .click({ timeout: 15_000 }); await dialog.waitFor({ state: 'detached', timeout: 30_000 }); await page.waitForURL(/\/workspace\/playlists\/[^/]+\/all/, { timeout: 45_000, }); registerPlaylistId('playlists', idFromUrl(page.url(), 'playlists')); await page .locator('[data-test-id="channel-item"]') .first() .waitFor({ state: 'visible', timeout: 60_000 }); } function idFromUrl(url: string, provider: PlaylistProvider): 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\/([^/]+)\// : provider === 'xtreams' ? /\/workspace\/xtreams\/([^/]+)\// : /\/workspace\/stalker\/([^/]+)\//; const match = url.match(pattern); if (!match) { throw new Error(`Could not extract ${provider} id from ${url}`); } return match[1]; }