Files
iptvnator/tools/release/capture-navigation-setup-actions.ts
T
4grayandClaude Fable 5.1 272e26c47c 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>
2026-09-07 09:14:29 +02:00

265 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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,
};