diff --git a/tools/release/capture-navigation-download-actions.ts b/tools/release/capture-navigation-download-actions.ts new file mode 100644 index 000000000..acf33c389 --- /dev/null +++ b/tools/release/capture-navigation-download-actions.ts @@ -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 { + 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 { + 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 { + 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 { + await prepareGuideDownloads(page); + await openDownloadsPage(page); +} + +async function openDownloadsOfflineMovie(page: Page): Promise { + 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> = { + 'open-downloads-manager': openDownloadsManager, + 'open-downloads-offline-movie': openDownloadsOfflineMovie, +}; diff --git a/tools/release/capture-navigation-helpers.ts b/tools/release/capture-navigation-helpers.ts new file mode 100644 index 000000000..feca13db8 --- /dev/null +++ b/tools/release/capture-navigation-helpers.ts @@ -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; + +/** Route segment of each seeded source, as it appears in `/workspace//`. */ +export type PlaylistProvider = + | 'playlists' + | 'xtreams' + | 'xtreams-secondary' + | 'stalker'; + +const playlistIds = new Map(); + +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 { + 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, + label: RegExp +): Promise { + // 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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); +} diff --git a/tools/release/capture-navigation-portal-actions.ts b/tools/release/capture-navigation-portal-actions.ts new file mode 100644 index 000000000..2f35704d6 --- /dev/null +++ b/tools/release/capture-navigation-portal-actions.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + await openLiveCategory(page, 'xtreams', 'live', param); +} + +async function openStalkerLive( + page: Page, + param: string | null +): Promise { + await openLiveCategory(page, 'stalker', 'itv', param); +} + +async function openXtreamLiveChannel( + page: Page, + param: string | null +): Promise { + // 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 { + 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 { + 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> = { + '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, +}; diff --git a/tools/release/capture-navigation-setup-actions.ts b/tools/release/capture-navigation-setup-actions.ts new file mode 100644 index 000000000..b8b8bc69a --- /dev/null +++ b/tools/release/capture-navigation-setup-actions.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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> = { + '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, +}; diff --git a/tools/release/capture-navigation.ts b/tools/release/capture-navigation.ts index 9766c87a5..6238fb738 100644 --- a/tools/release/capture-navigation.ts +++ b/tools/release/capture-navigation.ts @@ -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//`. */ -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(); - -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> = { + ...SETUP_ACTIONS, + ...PORTAL_ACTIONS, + ...DOWNLOAD_ACTIONS, +}; /* ------------------------------------------------------------------ */ /* Theme */ @@ -92,674 +80,17 @@ export async function runAction( action: string, param: string | null ): Promise { + 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 { - 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, - label: RegExp -): Promise { - // 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - // 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 { - 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 { - 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); -} - - diff --git a/tools/release/project.json b/tools/release/project.json index 28a2f2fc8..7d2f7e24f 100644 --- a/tools/release/project.json +++ b/tools/release/project.json @@ -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": [