mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 01:16:15 -08:00
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>
251 lines
8.4 KiB
TypeScript
251 lines
8.4 KiB
TypeScript
/**
|
|
* 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);
|
|
}
|
|
|
|
|