Files
iptvnator/tools/release/capture-navigation.ts
T
4grayandClaude Fable 5.1 0d03140661 feat(website): add the offline downloads guide with a reusable content disclaimer
New guide at /blog/offline-downloads-guide/: what the desktop download manager
can save, choosing the folder, downloading a movie, episodes and seasons,
following the queue (pause, resume, automatic reconnects), the offline
library, and the Needs attention states, with a nine-question FAQ. The prose
frames the feature as offline viewing of content the reader already streams
and defers legality to the provider's terms and local law.

ContentDisclaimer.astro carries that notice in a general and an offline
variant so later guides reuse it instead of rewording it.

The three screenshots are mock-backed captures. The Xtream mock's marketing
scenario now serves movie and episode stream URLs from generated local bytes
(downloadStreamFixture: 'local-media'), because the capture's network gate
rejects the public HLS stub every other scenario redirects to; the capture
stubs Electron's folder dialog so "Change Folder" authorizes a folder inside
the isolated data dir rather than the real OS Downloads folder; two new setup
actions queue a movie and two episodes and open the manager and the offline
detail.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 19:41:10 +02:00

605 lines
23 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';
import {
AUTO_DETECT_FIXTURE_MESSAGE,
CAPTURE_DOWNLOAD_FOLDER_NAME,
EPG_FIXTURE_URL,
M3U_FIXTURE_PLAYLIST_TITLE,
M3U_FIXTURE_PLAYLIST_URL,
STALKER_FIXTURE_MAC,
STALKER_FIXTURE_PORTAL_URL,
STALKER_FIXTURE_TITLE,
XTREAM_FIXTURE_CREDENTIALS,
XTREAM_FIXTURE_TITLE,
XTREAM_MOCK_ORIGIN,
} from './capture-fixtures';
/** Route segment of each seeded source, as it appears in `/workspace/<provider>/<id>`. */
export type PlaylistProvider = 'playlists' | 'xtreams' | 'stalker';
const playlistIds = new Map<PlaylistProvider, string>();
export function registerPlaylistId(
provider: PlaylistProvider,
id: string
): void {
playlistIds.set(provider, id);
}
export function requirePlaylistId(provider: PlaylistProvider): string {
return requireId(provider);
}
function requireId(provider: PlaylistProvider): string {
const id = playlistIds.get(provider);
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> {
// Manifest steps are order-independent, and some of them end with a modal
// dialog open or a dirty settings form. Clear whatever the previous step
// left behind before this one starts navigating: a dialog backdrop
// swallows every click, and unsaved settings raise a leave prompt.
await dismissDialogs(page);
await discardUnsavedSettings(page);
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;
}
case 'open-add-playlist-xtream': {
await goHome(page);
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(XTREAM_FIXTURE_CREDENTIALS.username);
await dialog
.locator('#password')
.fill(XTREAM_FIXTURE_CREDENTIALS.password);
// The status probe only talks to the local mock, so the frame can
// show the successful "portal is active" verdict the guide explains.
await dialog
.getByRole('button', { name: /test connection/i })
.first()
.click();
const status = dialog.locator('.connection-status');
await status.waitFor({ state: 'visible', timeout: 30_000 });
// The dialog body scrolls; bring the verdict the guide explains
// into frame together with the credential fields above it.
await status.scrollIntoViewIfNeeded();
await page.waitForTimeout(500);
return;
}
case 'open-add-playlist-auto': {
await goHome(page);
await openAddPlaylistDialog(page);
const dialog = page.locator('mat-dialog-container').last();
await clickDialogOption(dialog, /auto-detect/i);
await dialog
.locator('[data-test-id="auto-detect-textarea"]')
.fill(AUTO_DETECT_FIXTURE_MESSAGE);
const candidate = dialog
.locator('[data-test-id="auto-detect-candidate"]')
.first();
await candidate.waitFor({ state: 'visible', timeout: 15_000 });
await candidate.scrollIntoViewIfNeeded();
await page.waitForTimeout(500);
return;
}
case 'open-xtream-live': {
await goHome(page);
await clickHrefSuffix(
page,
`/workspace/xtreams/${requireId('xtreams')}/vod`
);
await clickHrefSuffix(
page,
`/workspace/xtreams/${requireId('xtreams')}/live`
);
const categories = page.locator(
'app-workspace-context-panel .category-item'
);
const category = param
? categories.filter({ hasText: param }).first()
: categories.first();
await category.waitFor({ state: 'visible', timeout: 30_000 });
await category.click();
// Deliberately no channel click: playback would pull the mock's
// redirect to a public demo stream, and third-party video frames
// must never enter a published shot.
await page
.locator('app-channel-list-item')
.first()
.waitFor({ state: 'visible', timeout: 30_000 });
await page.waitForTimeout(700);
return;
}
case 'open-add-playlist-stalker': {
await goHome(page);
await openAddPlaylistDialog(page);
const dialog = page.locator('mat-dialog-container').last();
await clickDialogOption(dialog, /stalker portal/i);
await dialog.locator('#title').fill(STALKER_FIXTURE_TITLE);
await dialog.locator('#portalUrl').fill(STALKER_FIXTURE_PORTAL_URL);
await dialog.locator('#macAddress').fill(STALKER_FIXTURE_MAC);
// Blur runs the MAC normalization the guide describes.
await dialog.locator('#serialNumber').focus();
// The form is long; frame the identity fields and the derive
// toggle rather than the signature fields at the bottom.
await dialog.locator('.derive-device-ids').scrollIntoViewIfNeeded();
await page.waitForTimeout(500);
return;
}
case 'open-stalker-live': {
await goHome(page);
await clickHrefSuffix(
page,
`/workspace/stalker/${requireId('stalker')}/vod`
);
await clickHrefSuffix(
page,
`/workspace/stalker/${requireId('stalker')}/itv`
);
const categories = page.locator(
'app-workspace-context-panel .category-item'
);
const category = param
? categories.filter({ hasText: param }).first()
: categories.first();
await category.waitFor({ state: 'visible', timeout: 30_000 });
await category.click();
// No channel click: playback would resolve a create_link to a
// public demo stream, and third-party video never enters a shot.
await page
.locator('app-channel-list-item')
.first()
.waitFor({ state: 'visible', timeout: 30_000 });
await page.waitForTimeout(700);
return;
}
case 'open-add-playlist-m3u-url': {
await goHome(page);
await openAddPlaylistDialog(page);
const dialog = page.locator('mat-dialog-container').last();
await clickDialogOption(dialog, /m3u url/i);
// Typed only: the dialog fetches nothing until Add is clicked,
// and the address points at the local mock anyway.
await dialog
.locator('input[formcontrolname="playlistUrl"]')
.fill(M3U_FIXTURE_PLAYLIST_URL);
await dialog
.locator('input[formcontrolname="playlistName"]')
.fill(M3U_FIXTURE_PLAYLIST_TITLE);
await page.waitForTimeout(500);
return;
}
case 'open-settings-epg': {
await runAction(page, 'open-settings', null);
const sectionLink = page
.locator('[data-test-id="settings-section-epg"]')
.first();
await sectionLink.waitFor({ state: 'visible', timeout: 15_000 });
await sectionLink.click({ timeout: 10_000 });
await page.waitForURL(/\/workspace\/settings\/epg/, {
timeout: 15_000,
});
const section = page.locator('#epg');
await section.waitFor({ state: 'visible', timeout: 15_000 });
// Show a filled source row instead of the empty state. The value
// is staged in the form only; nothing is saved or fetched. The
// dirty form is discarded by `discardUnsavedSettings` before the
// next action or the app teardown — the settings close guard
// would otherwise hold `app.close()` open forever.
await section
.getByRole('button', { name: /add epg source/i })
.click({ timeout: 10_000 });
const field = section.locator('input[type="url"]').last();
await field.waitFor({ state: 'visible', timeout: 10_000 });
await field.fill(EPG_FIXTURE_URL, { timeout: 10_000 });
await page.waitForTimeout(500);
return;
}
case 'open-downloads-manager': {
await prepareGuideDownloads(page);
await openDownloadsPage(page);
return;
}
case 'open-downloads-offline-movie': {
await prepareGuideDownloads(page);
await openDownloadsPage(page);
const card = page
.locator('[data-test-id^="download-library-movie-"]')
.first();
await card.waitFor({ state: 'visible', timeout: 30_000 });
await card.locator('.download-library__artwork-button').click();
await page
.locator('[data-testid="offline-play"]')
.waitFor({ state: 'visible', timeout: 30_000 });
await page.waitForTimeout(700);
return;
}
default:
throw new Error(`Unknown setup action: ${action}`);
}
}
/* ------------------------------------------------------------------ */
/* Dialog helpers (shared with the seeding driver) */
/* ------------------------------------------------------------------ */
export 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 });
}
export 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`);
}
/**
* Settings edits staged by a shot (the EPG source row) must never persist:
* saving would start a fetch, and a dirty form arms the app's close guard,
* which blocks `app.close()` until someone answers the save/discard prompt.
*/
export async function discardUnsavedSettings(page: Page): Promise<void> {
const discard = page.locator('[data-test-id="discard-settings"]').first();
if ((await discard.count()) === 0 || !(await discard.isVisible())) {
return;
}
await discard.click({ timeout: 10_000 });
await discard
.waitFor({ state: 'hidden', timeout: 10_000 })
.catch(() => undefined);
}
async function dismissDialogs(page: Page): Promise<void> {
const dialogs = page.locator('mat-dialog-container');
if ((await dialogs.count()) === 0) {
return;
}
await page.keyboard.press('Escape');
await dialogs
.first()
.waitFor({ state: 'detached', timeout: 10_000 })
.catch(async () => {
await page.getByRole('button', { name: /^cancel$/i }).last().click();
await dialogs.first().waitFor({ state: 'detached', timeout: 10_000 });
});
}
/** 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);
}
/* ------------------------------------------------------------------ */
/* Download manager (guide shots) */
/* ------------------------------------------------------------------ */
/** Header shortcut into `/workspace/downloads`, the same button the e2e suite uses. */
async function openDownloadsPage(page: Page): Promise<void> {
await page.getByRole('button', { name: 'Open downloads' }).click();
await page.waitForURL(/\/workspace\/downloads(?:\?.*)?$/, {
timeout: 20_000,
});
await page
.locator('[data-test-id="downloads-content"]')
.waitFor({ state: 'visible', timeout: 20_000 });
await settleUi(page);
}
/**
* Puts the download manager into the state the offline guide describes: the
* authorized folder inside the isolated data dir, one finished movie in the
* library and two episodes trickling through the queue. Every step
* is idempotent, because the manifest runs each shot once per theme: a movie
* already saved shows the done state instead of the download button, and a
* queued or saved episode no longer offers a download label.
*/
async function prepareGuideDownloads(page: Page): Promise<void> {
await openDownloadsPage(page);
await ensureCaptureDownloadFolder(page);
await openXtreamSection(page, 'vod', 'Action & Mystery');
await page
.locator('app-content-hero')
.waitFor({ state: 'visible', timeout: 30_000 });
const downloadMovie = page.locator('[data-testid="vod-download-start"]');
if (await downloadMovie.isVisible().catch(() => false)) {
await downloadMovie.click();
await page
.locator(
'[data-testid="vod-download-progress"], [data-testid="vod-download-done"]'
)
.first()
.waitFor({ state: 'visible', timeout: 30_000 });
}
await runAction(page, 'open-xtream-series', 'Urban Drama');
// Two single episodes rather than "Download season": a six-episode queue
// fills the whole frame and pushes the finished movie below the fold. An
// episode button whose label no longer starts with "Download" is already
// queued or saved (it would play the local file), so it is left alone.
const episodeButtons = page.locator(
'[data-test-id^="episode-download-"][aria-label^="Download "]:not([disabled])'
);
const toQueue = Math.min(await episodeButtons.count(), 2);
for (let index = 0; index < toQueue; index += 1) {
await episodeButtons.first().click();
await page.waitForTimeout(800);
}
await settleUi(page);
}
/**
* The download manager authorizes the OS Downloads folder by default, which
* would put fixture bytes into the maintainer's real Downloads directory and
* show a personal home path in the frame. The capture stubs the folder dialog
* (`installDownloadFolderDialogStub`), so "Change Folder" lands on the
* isolated folder without any native UI.
*/
async function ensureCaptureDownloadFolder(page: Page): Promise<void> {
const folder = page.locator('[data-test-id="downloads-folder"]');
await folder.waitFor({ state: 'visible', timeout: 20_000 });
if ((await folder.innerText()).includes(CAPTURE_DOWNLOAD_FOLDER_NAME)) {
return;
}
await page.getByRole('button', { name: 'Change Folder' }).click();
await page
.locator('[data-test-id="downloads-folder"]')
.filter({ hasText: CAPTURE_DOWNLOAD_FOLDER_NAME })
.waitFor({ state: 'visible', timeout: 20_000 });
}
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);
}