mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 20:16:15 -08:00
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:
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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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": [
|
||||
|
||||
Reference in new issue
Block a user