refactor(release): split capture-navigation under the max-lines cap

`tools/release/capture-navigation.ts` had grown to 567 counted lines, past
the 400-line rule, which failed `release-tools:lint` and — because the file
was not in the baseline — the max-lines baseline test on master and on
every PR branched from it. The 19 named setup actions are now grouped by
subject over one leaf module of shared page helpers:

- `capture-navigation-helpers.ts`: playlist-id registry, dialog handling,
  navigation moves, `settleUi`
- `capture-navigation-setup-actions.ts`: add-playlist dialogs, settings
  sections, remote control
- `capture-navigation-portal-actions.ts`: portal catalogs, live lists,
  alternative sources (the two identical live-category flows share one
  helper)
- `capture-navigation-download-actions.ts`: the download manager shots
- `capture-navigation.ts`: the `runAction` dispatcher, theme switching and
  the re-exported API the seeding driver and the capture script import

Actions call their siblings directly instead of recursing through
`runAction`, so no module depends on the dispatcher. The action vocabulary
is unchanged (same 19 names, same waits and timeouts); every file is under
300 lines and the new modules are listed in the `release-tools` lint target.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-07 09:14:29 +02:00
1 parent a94f811db5
commit 272e26c47c
6 files changed
+825 -707

No files matched your search

@@ -0,0 +1,122 @@
/**
* Setup actions for the offline-downloads guide shots: the download manager
* with the isolated capture folder authorized, one finished movie and two
* queued episodes, and the focused offline-movie detail.
*/
import type { Page } from '@playwright/test';
import { CAPTURE_DOWNLOAD_FOLDER_NAME } from './capture-fixtures';
import {
type CaptureAction,
openXtreamSection,
settleUi,
} from './capture-navigation-helpers';
import { openXtreamSeries } from './capture-navigation-portal-actions';
/** 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);
}
/**
* 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 });
}
/**
* 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 openXtreamSeries(page, '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);
}
async function openDownloadsManager(page: Page): Promise<void> {
await prepareGuideDownloads(page);
await openDownloadsPage(page);
}
async function openDownloadsOfflineMovie(page: Page): Promise<void> {
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);
}
export const DOWNLOAD_ACTIONS: Readonly<Record<string, CaptureAction>> = {
'open-downloads-manager': openDownloadsManager,
'open-downloads-offline-movie': openDownloadsOfflineMovie,
};
+191
View File
@@ -0,0 +1,191 @@
/**
* Page helpers shared by the capture setup actions and the seeding driver:
* the playlist-id registry the actions navigate with, dialog handling, and
* the small navigation moves every action is built from. Leaf module — it
* imports nothing from the action modules, so they can all depend on it.
*/
import type { Page } from '@playwright/test';
/** One manifest step: `page` plus the optional `param` the step carries. */
export type CaptureAction = (
page: Page,
param: string | null
) => Promise<void>;
/** Route segment of each seeded source, as it appears in `/workspace/<provider>/<id>`. */
export type PlaylistProvider =
| 'playlists'
| 'xtreams'
| 'xtreams-secondary'
| '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 {
const id = playlistIds.get(provider);
if (!id) {
throw new Error(`No captured ${provider} playlist id — seeding failed?`);
}
return id;
}
/* ------------------------------------------------------------------ */
/* 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);
}
export 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 });
});
}
/* ------------------------------------------------------------------ */
/* Navigation moves */
/* ------------------------------------------------------------------ */
/** Returns to the dashboard via the always-visible brand link. */
export 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);
}
export 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,
});
}
/**
* Opens the primary Xtream portal's `vod` or `series` section, selects the
* named category and clicks its first card. Manifest steps must be
* order-independent, so every portal action starts from the dashboard,
* whose sources rail links into the portal.
*/
export async function openXtreamSection(
page: Page,
section: 'vod' | 'series',
category: string
): Promise<void> {
await goHome(page);
await clickHrefSuffix(
page,
`/workspace/xtreams/${requirePlaylistId('xtreams')}/vod`
);
if (section !== 'vod') {
await clickHrefSuffix(
page,
`/workspace/xtreams/${requirePlaylistId('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();
}
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,210 @@
/**
* Setup actions that browse the seeded sources for a shot: portal catalogs
* and live lists, the M3U groups view, and alternative sources. Actions
* here call each other directly rather than through `runAction`, which
* keeps this module free of a dependency on the dispatcher.
*/
import type { Page } from '@playwright/test';
import {
type CaptureAction,
clickHrefSuffix,
goHome,
openXtreamSection,
requirePlaylistId,
} from './capture-navigation-helpers';
/* ------------------------------------------------------------------ */
/* Portal catalogs */
/* ------------------------------------------------------------------ */
async function openXtreamVod(page: Page, param: string | null): Promise<void> {
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);
}
export async function openXtreamSeries(
page: Page,
param: string | null
): Promise<void> {
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);
}
async function openM3uGroups(page: Page): Promise<void> {
const playlistId = requirePlaylistId('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);
}
/* ------------------------------------------------------------------ */
/* Live lists */
/* ------------------------------------------------------------------ */
/**
* Opens a portal's live section and selects a category. 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.
*/
async function openLiveCategory(
page: Page,
provider: 'xtreams' | 'stalker',
liveSection: 'live' | 'itv',
param: string | null
): Promise<void> {
const playlistId = requirePlaylistId(provider);
await goHome(page);
await clickHrefSuffix(page, `/workspace/${provider}/${playlistId}/vod`);
await clickHrefSuffix(
page,
`/workspace/${provider}/${playlistId}/${liveSection}`
);
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();
await page
.locator('app-channel-list-item')
.first()
.waitFor({ state: 'visible', timeout: 30_000 });
await page.waitForTimeout(700);
}
async function openXtreamLive(page: Page, param: string | null): Promise<void> {
await openLiveCategory(page, 'xtreams', 'live', param);
}
async function openStalkerLive(
page: Page,
param: string | null
): Promise<void> {
await openLiveCategory(page, 'stalker', 'itv', param);
}
async function openXtreamLiveChannel(
page: Page,
param: string | null
): Promise<void> {
// Unlike `open-xtream-live`, this selects a channel: the marketing
// scenario serves live streams from local bytes (`local-media`),
// so playback never reaches a public stream. The player shows a
// format error, which the phone-view shot never frames; what it
// needs is the remote status the selection publishes.
await openXtreamLive(page, param);
const channel = page.locator('app-channel-list-item').first();
await channel.click();
await page.waitForTimeout(1500);
}
/* ------------------------------------------------------------------ */
/* Alternative sources (guide shots) */
/* ------------------------------------------------------------------ */
/**
* Opens a movie of the primary portal whose copy also exists in the secondary
* one (seeded with `secondaryXtream`), and waits for lazy discovery to render
* the Sources chip.
*/
async function openXtreamVodSources(
page: Page,
param: string | null
): Promise<void> {
requirePlaylistId('xtreams-secondary');
await openXtreamVod(page, param ?? 'Action & Mystery');
await page
.locator('app-vod-sources-chip button')
.waitFor({ state: 'visible', timeout: 30_000 });
await page.waitForTimeout(500);
}
async function openXtreamVodSourcesMenu(
page: Page,
param: string | null
): Promise<void> {
await openXtreamVodSources(page, param);
await page.locator('app-vod-sources-chip button').click();
const menu = page.locator('app-vod-sources-menu');
await menu.waitFor({ state: 'visible', timeout: 15_000 });
// "check all" probes every unchecked copy (one get_vod_info plus a
// HEAD against the mock) so the rows carry verdicts in the frame.
const checkAll = menu.locator('.sources-menu__check-all');
if (await checkAll.isVisible().catch(() => false)) {
await checkAll.click();
await checkAll
.waitFor({ state: 'hidden', timeout: 30_000 })
.catch(() => undefined);
}
await page.waitForTimeout(700);
}
export const PORTAL_ACTIONS: Readonly<Record<string, CaptureAction>> = {
'open-xtream-vod': openXtreamVod,
'open-xtream-series': openXtreamSeries,
'open-m3u-groups': openM3uGroups,
'open-xtream-live': openXtreamLive,
'open-xtream-live-channel': openXtreamLiveChannel,
'open-stalker-live': openStalkerLive,
'open-xtream-vod-sources': openXtreamVodSources,
'open-xtream-vod-sources-menu': openXtreamVodSourcesMenu,
};
@@ -0,0 +1,264 @@
/**
* Setup actions that stage a dialog or a settings page for a shot: the
* add-playlist forms, the settings sections, the remote-control switch.
* Actions here call each other directly rather than through `runAction`,
* which keeps this module free of a dependency on the dispatcher.
*/
import type { Page } from '@playwright/test';
import {
AUTO_DETECT_FIXTURE_MESSAGE,
CAPTURE_REMOTE_CONTROL_PORT,
CAPTURE_REMOTE_CONTROL_URL,
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';
import {
type CaptureAction,
clickDialogOption,
goHome,
openAddPlaylistDialog,
settleUi,
} from './capture-navigation-helpers';
export async function openSettings(page: Page): Promise<void> {
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 });
}
async function openDashboard(page: Page): Promise<void> {
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);
}
/* ------------------------------------------------------------------ */
/* Add-playlist dialog */
/* ------------------------------------------------------------------ */
async function openAddPlaylistXtream(page: Page): Promise<void> {
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);
}
async function openAddPlaylistAuto(page: Page): Promise<void> {
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);
}
async function openAddPlaylistStalker(page: Page): Promise<void> {
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);
}
async function openAddPlaylistM3uUrl(page: Page): Promise<void> {
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);
}
/* ------------------------------------------------------------------ */
/* Settings sections */
/* ------------------------------------------------------------------ */
async function openSettingsEpg(page: Page): Promise<void> {
await openSettings(page);
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);
}
/* ------------------------------------------------------------------ */
/* Remote control (guide shots) */
/* ------------------------------------------------------------------ */
/**
* Opens Settings › Remote control with the feature switched on and the
* capture port in the field. The form is left dirty unless a later
* `enable-remote-control` step saves it; `discardUnsavedSettings` clears it
* before the next action.
*/
async function openRemoteControlSettings(page: Page): Promise<void> {
await openSettings(page);
const sectionLink = page
.locator('[data-test-id="settings-section-remote-control"]')
.first();
await sectionLink.waitFor({ state: 'visible', timeout: 15_000 });
await sectionLink.click({ timeout: 10_000 });
await page.waitForURL(/\/workspace\/settings\/remote-control/, {
timeout: 15_000,
});
const section = page.locator('#remote-control');
await section.waitFor({ state: 'visible', timeout: 15_000 });
const toggle = section.locator(
'[data-test-id="remote-control-enabled"] input[type="checkbox"]'
);
if (!(await toggle.isChecked())) {
await section.locator('[data-test-id="remote-control-enabled"]').click();
}
const port = section.locator('[data-test-id="remote-control-port"]');
await port.waitFor({ state: 'visible', timeout: 10_000 });
if ((await port.inputValue()) !== String(CAPTURE_REMOTE_CONTROL_PORT)) {
await port.fill(String(CAPTURE_REMOTE_CONTROL_PORT));
}
await section
.locator('.remote-control-url')
.first()
.waitFor({ state: 'visible', timeout: 15_000 });
}
async function openSettingsRemoteControl(page: Page): Promise<void> {
await openRemoteControlSettings(page);
const section = page.locator('#remote-control');
const qrButton = section.locator('.url-row button').first();
await qrButton.waitFor({ state: 'visible', timeout: 15_000 });
await qrButton.click();
await section
.locator('qrcode canvas, qrcode img')
.first()
.waitFor({ state: 'visible', timeout: 15_000 });
await page.waitForTimeout(500);
}
async function enableRemoteControl(page: Page): Promise<void> {
await openRemoteControlSettings(page);
const save = page.locator('[data-test-id="save-settings"]').first();
if (await save.isEnabled().catch(() => false)) {
await save.click();
await settleUi(page);
}
await waitForRemoteControlServer();
}
/** Polls the status endpoint the phone view reads until the app's server answers. */
async function waitForRemoteControlServer(): Promise<void> {
const statusUrl = `${CAPTURE_REMOTE_CONTROL_URL}api/remote-control/status`;
const deadline = Date.now() + 15_000;
while (Date.now() < deadline) {
try {
const response = await fetch(statusUrl);
if (response.ok) {
return;
}
} catch {
// not up yet
}
await new Promise((resolve) => setTimeout(resolve, 300));
}
throw new Error(
`Remote control server did not answer at ${statusUrl} — is port ${CAPTURE_REMOTE_CONTROL_PORT} held by another IPTVnator instance?`
);
}
export const SETUP_ACTIONS: Readonly<Record<string, CaptureAction>> = {
'open-settings': openSettings,
'open-dashboard': openDashboard,
'open-add-playlist-xtream': openAddPlaylistXtream,
'open-add-playlist-auto': openAddPlaylistAuto,
'open-add-playlist-stalker': openAddPlaylistStalker,
'open-add-playlist-m3u-url': openAddPlaylistM3uUrl,
'open-settings-epg': openSettingsEpg,
'open-settings-remote-control': openSettingsRemoteControl,
'enable-remote-control': enableRemoteControl,
};
+37 -706
View File
@@ -2,55 +2,43 @@
* 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.
*
* The actions themselves live in three modules by subject
* (`capture-navigation-setup-actions.ts`: dialogs, settings, remote
* control; `capture-navigation-portal-actions.ts`: portal browsing and
* alternative sources; `capture-navigation-download-actions.ts`: the
* download manager) over the shared page helpers in
* `capture-navigation-helpers.ts`. This file only dispatches a step name
* and re-exports the API the seeding driver and the capture script use.
*/
import type { Page } from '@playwright/test';
import {
AUTO_DETECT_FIXTURE_MESSAGE,
CAPTURE_DOWNLOAD_FOLDER_NAME,
CAPTURE_REMOTE_CONTROL_PORT,
CAPTURE_REMOTE_CONTROL_URL,
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';
type CaptureAction,
discardUnsavedSettings,
dismissDialogs,
settleUi,
} from './capture-navigation-helpers';
import { DOWNLOAD_ACTIONS } from './capture-navigation-download-actions';
import { PORTAL_ACTIONS } from './capture-navigation-portal-actions';
import { SETUP_ACTIONS } from './capture-navigation-setup-actions';
/** Route segment of each seeded source, as it appears in `/workspace/<provider>/<id>`. */
export type PlaylistProvider =
| 'playlists'
| 'xtreams'
| 'xtreams-secondary'
| 'stalker';
export {
clickDialogOption,
discardUnsavedSettings,
openAddPlaylistDialog,
registerPlaylistId,
requirePlaylistId,
settleUi,
} from './capture-navigation-helpers';
export type { PlaylistProvider } from './capture-navigation-helpers';
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;
}
const ACTIONS: Readonly<Record<string, CaptureAction>> = {
...SETUP_ACTIONS,
...PORTAL_ACTIONS,
...DOWNLOAD_ACTIONS,
};
/* ------------------------------------------------------------------ */
/* Theme */
@@ -92,674 +80,17 @@ export async function runAction(
action: string,
param: string | null
): Promise<void> {
const run = ACTIONS[action];
if (!run) {
throw new Error(`Unknown setup action: ${action}`);
}
// 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-xtream-live-channel': {
// Unlike `open-xtream-live`, this selects a channel: the marketing
// scenario serves live streams from local bytes (`local-media`),
// so playback never reaches a public stream. The player shows a
// format error, which the phone-view shot never frames; what it
// needs is the remote status the selection publishes.
await runAction(page, 'open-xtream-live', param);
const channel = page.locator('app-channel-list-item').first();
await channel.click();
await page.waitForTimeout(1500);
return;
}
case 'open-settings-remote-control': {
await openRemoteControlSettings(page);
const section = page.locator('#remote-control');
const qrButton = section.locator('.url-row button').first();
await qrButton.waitFor({ state: 'visible', timeout: 15_000 });
await qrButton.click();
await section
.locator('qrcode canvas, qrcode img')
.first()
.waitFor({ state: 'visible', timeout: 15_000 });
await page.waitForTimeout(500);
return;
}
case 'enable-remote-control': {
await openRemoteControlSettings(page);
const save = page.locator('[data-test-id="save-settings"]').first();
if (await save.isEnabled().catch(() => false)) {
await save.click();
await settleUi(page);
}
await waitForRemoteControlServer();
return;
}
case 'open-xtream-vod-sources': {
await openXtreamVodWithSources(page, param ?? 'Action & Mystery');
return;
}
case 'open-xtream-vod-sources-menu': {
await openXtreamVodWithSources(page, param ?? 'Action & Mystery');
await page.locator('app-vod-sources-chip button').click();
const menu = page.locator('app-vod-sources-menu');
await menu.waitFor({ state: 'visible', timeout: 15_000 });
// "check all" probes every unchecked copy (one get_vod_info plus a
// HEAD against the mock) so the rows carry verdicts in the frame.
const checkAll = menu.locator('.sources-menu__check-all');
if (await checkAll.isVisible().catch(() => false)) {
await checkAll.click();
await checkAll
.waitFor({ state: 'hidden', timeout: 30_000 })
.catch(() => undefined);
}
await page.waitForTimeout(700);
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}`);
}
await run(page, param);
}
/* ------------------------------------------------------------------ */
/* 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);
}
/* ------------------------------------------------------------------ */
/* Remote control (guide shots) */
/* ------------------------------------------------------------------ */
/**
* Opens Settings › Remote control with the feature switched on and the
* capture port in the field. The form is left dirty unless a later
* `enable-remote-control` step saves it; `discardUnsavedSettings` clears it
* before the next action.
*/
async function openRemoteControlSettings(page: Page): Promise<void> {
await runAction(page, 'open-settings', null);
const sectionLink = page
.locator('[data-test-id="settings-section-remote-control"]')
.first();
await sectionLink.waitFor({ state: 'visible', timeout: 15_000 });
await sectionLink.click({ timeout: 10_000 });
await page.waitForURL(/\/workspace\/settings\/remote-control/, {
timeout: 15_000,
});
const section = page.locator('#remote-control');
await section.waitFor({ state: 'visible', timeout: 15_000 });
const toggle = section.locator(
'[data-test-id="remote-control-enabled"] input[type="checkbox"]'
);
if (!(await toggle.isChecked())) {
await section.locator('[data-test-id="remote-control-enabled"]').click();
}
const port = section.locator('[data-test-id="remote-control-port"]');
await port.waitFor({ state: 'visible', timeout: 10_000 });
if ((await port.inputValue()) !== String(CAPTURE_REMOTE_CONTROL_PORT)) {
await port.fill(String(CAPTURE_REMOTE_CONTROL_PORT));
}
await section
.locator('.remote-control-url')
.first()
.waitFor({ state: 'visible', timeout: 15_000 });
}
/** Polls the status endpoint the phone view reads until the app's server answers. */
async function waitForRemoteControlServer(): Promise<void> {
const statusUrl = `${CAPTURE_REMOTE_CONTROL_URL}api/remote-control/status`;
const deadline = Date.now() + 15_000;
while (Date.now() < deadline) {
try {
const response = await fetch(statusUrl);
if (response.ok) {
return;
}
} catch {
// not up yet
}
await new Promise((resolve) => setTimeout(resolve, 300));
}
throw new Error(
`Remote control server did not answer at ${statusUrl} — is port ${CAPTURE_REMOTE_CONTROL_PORT} held by another IPTVnator instance?`
);
}
/* ------------------------------------------------------------------ */
/* Alternative sources (guide shots) */
/* ------------------------------------------------------------------ */
/**
* Opens a movie of the primary portal whose copy also exists in the secondary
* one (seeded with `secondaryXtream`), and waits for lazy discovery to render
* the Sources chip.
*/
async function openXtreamVodWithSources(
page: Page,
category: string
): Promise<void> {
requireId('xtreams-secondary');
await runAction(page, 'open-xtream-vod', category);
await page
.locator('app-vod-sources-chip button')
.waitFor({ state: 'visible', timeout: 30_000 });
await page.waitForTimeout(500);
}
/* ------------------------------------------------------------------ */
/* 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);
}
+1 -1
View File
@@ -41,7 +41,7 @@
"{workspaceRoot}/tools/eslint-rules/**/*",
"{workspaceRoot}/tools/eslint/**/*"
],
"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\""
"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-navigation-helpers.ts\" \"tools/release/capture-navigation-setup-actions.ts\" \"tools/release/capture-navigation-portal-actions.ts\" \"tools/release/capture-navigation-download-actions.ts\" \"tools/release/capture-network-gate.ts\" \"tools/release/capture-tmdb-check.ts\""
}
},
"tags": [