mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
1 parent
4e5132cbb5
commit
b4ec68c1fa
14 files changed
+2344
-15
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
]),
|
||||
[]
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user