Files
iptvnator/tools/release/capture-navigation.ts
T
4grayandClaude Fable 5.1 98da686cea feat(website): add the phone remote control guide
New guide at /blog/remote-control-guide/: enabling the remote in Settings,
opening it on a phone from the QR code, what each control does and which
list it navigates, a checklist for a page that does not load, and why the
remote must stay on the local network. Eight FAQ entries. The remote-control
feature page now links to it instead of the M3U guide.

Both screenshots are mock-backed. The phone view is the first "browser" shot:
a manifest entry names a loopback URL and a mobile viewport, and the capture
frames it in a separate Chromium page behind the same network and content
guards, with the manifest validator accepting loopback origins only. The
setup saves the remote-control setting so the app's own server answers, then
selects a live channel; the Xtream mock's marketing scenario now serves live
stream URLs from local bytes so that selection never leaves the machine.

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

766 lines
29 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.
/**
* Named setup actions for capture-release-screenshots.ts — the vocabulary
* that screenshots.manifest.json steps refer to — plus theme switching and
* the playlist-id registry the actions navigate with.
*/
import type { Page } from '@playwright/test';
import {
AUTO_DETECT_FIXTURE_MESSAGE,
CAPTURE_DOWNLOAD_FOLDER_NAME,
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';
/** 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 {
return requireId(provider);
}
function requireId(provider: PlaylistProvider): string {
const id = playlistIds.get(provider);
if (!id) {
throw new Error(`No captured ${provider} playlist id — seeding failed?`);
}
return id;
}
/* ------------------------------------------------------------------ */
/* Theme */
/* ------------------------------------------------------------------ */
export async function applyTheme(
page: Page,
theme: 'dark' | 'light'
): Promise<void> {
await runAction(page, 'open-settings', null);
const testId = theme === 'dark' ? 'DARK_THEME' : 'LIGHT_THEME';
const themeButton = page.locator(`[data-test-id="${testId}"]`).first();
await themeButton.scrollIntoViewIfNeeded();
await themeButton.click();
const saveButton = page.locator('[data-test-id="save-settings"]').first();
if (await saveButton.isEnabled()) {
await saveButton.click();
await settleUi(page);
}
await page.waitForFunction(
(expectedTheme) =>
document.body.classList.contains('dark-theme') ===
(expectedTheme === 'dark'),
theme,
{ timeout: 10_000 }
);
}
/* ------------------------------------------------------------------ */
/* Named setup actions */
/* ------------------------------------------------------------------ */
export async function runAction(
page: Page,
action: string,
param: string | null
): Promise<void> {
// Manifest steps are order-independent, and some of them end with a modal
// dialog open or a dirty settings form. Clear whatever the previous step
// left behind before this one starts navigating: a dialog backdrop
// swallows every click, and unsaved settings raise a leave prompt.
await dismissDialogs(page);
await discardUnsavedSettings(page);
switch (action) {
case 'open-settings': {
await page.locator('a[href$="/workspace/settings"]').first().click();
await page.waitForURL(/\/workspace\/settings/, { timeout: 15_000 });
await page
.locator('[data-test-id="settings-container"]')
.waitFor({ state: 'visible', timeout: 15_000 });
return;
}
case 'open-dashboard': {
await page
.locator('a.brand[href$="/workspace/dashboard"]')
.first()
.click();
await page.waitForURL(/\/workspace\/dashboard/, { timeout: 20_000 });
await page
.locator('[data-test-id="dashboard-hero"]')
.waitFor({ state: 'visible', timeout: 30_000 });
await settleUi(page);
return;
}
case 'open-xtream-vod': {
await openXtreamSection(page, 'vod', param ?? 'Action & Mystery');
await page.waitForURL(
/\/workspace\/xtreams\/[^/]+\/vod\/[^/]+\/[^/]+/,
{ timeout: 30_000 }
);
await page
.locator('app-content-hero')
.waitFor({ state: 'visible', timeout: 30_000 });
await page.waitForTimeout(700);
return;
}
case 'open-xtream-series': {
await openXtreamSection(page, 'series', param ?? 'Urban Drama');
await page.waitForURL(
/\/workspace\/xtreams\/[^/]+\/series\/[^/]+\/[^/]+/,
{ timeout: 30_000 }
);
await page
.locator('app-season-container')
.waitFor({ state: 'visible', timeout: 30_000 });
// Season tabs auto-select a season; click the first pill only
// when no episodes rendered on their own.
const episode = page
.locator('.episode-card, .episode-list-item')
.first();
if (!(await episode.isVisible().catch(() => false))) {
await page
.locator('.season-tabs__pill, [data-testid="season-dropdown"]')
.first()
.click();
}
await episode.waitFor({ state: 'visible', timeout: 20_000 });
await page.waitForTimeout(700);
return;
}
case 'open-m3u-groups': {
const playlistId = requireId('playlists');
await goHome(page);
await page
.locator(`a[href*="/workspace/playlists/${playlistId}"]`)
.first()
.click();
await page.waitForURL(
(url) => url.href.includes(`/workspace/playlists/${playlistId}/`),
{ timeout: 20_000 }
);
await clickHrefSuffix(
page,
`/workspace/playlists/${playlistId}/groups`
);
await page
.locator('.group-nav-item')
.first()
.waitFor({ state: 'visible', timeout: 20_000 });
await page.locator('.group-nav-item').first().click();
// Deliberately no channel click: starting playback would pull a
// real HLS stream (the mock redirects to a public demo stream),
// and third-party video frames must never enter a release shot.
await page
.locator('[data-test-id="channel-item"]')
.first()
.waitFor({ state: 'visible', timeout: 20_000 });
await page.waitForTimeout(500);
return;
}
case 'open-add-playlist-xtream': {
await goHome(page);
await openAddPlaylistDialog(page);
const dialog = page.locator('mat-dialog-container').last();
await clickDialogOption(dialog, /xtream credentials/i);
await dialog.locator('#title').fill(XTREAM_FIXTURE_TITLE);
await dialog.locator('#serverUrl').fill(XTREAM_MOCK_ORIGIN);
await dialog
.locator('#username')
.fill(XTREAM_FIXTURE_CREDENTIALS.username);
await dialog
.locator('#password')
.fill(XTREAM_FIXTURE_CREDENTIALS.password);
// The status probe only talks to the local mock, so the frame can
// show the successful "portal is active" verdict the guide explains.
await dialog
.getByRole('button', { name: /test connection/i })
.first()
.click();
const status = dialog.locator('.connection-status');
await status.waitFor({ state: 'visible', timeout: 30_000 });
// The dialog body scrolls; bring the verdict the guide explains
// into frame together with the credential fields above it.
await status.scrollIntoViewIfNeeded();
await page.waitForTimeout(500);
return;
}
case 'open-add-playlist-auto': {
await goHome(page);
await openAddPlaylistDialog(page);
const dialog = page.locator('mat-dialog-container').last();
await clickDialogOption(dialog, /auto-detect/i);
await dialog
.locator('[data-test-id="auto-detect-textarea"]')
.fill(AUTO_DETECT_FIXTURE_MESSAGE);
const candidate = dialog
.locator('[data-test-id="auto-detect-candidate"]')
.first();
await candidate.waitFor({ state: 'visible', timeout: 15_000 });
await candidate.scrollIntoViewIfNeeded();
await page.waitForTimeout(500);
return;
}
case 'open-xtream-live': {
await goHome(page);
await clickHrefSuffix(
page,
`/workspace/xtreams/${requireId('xtreams')}/vod`
);
await clickHrefSuffix(
page,
`/workspace/xtreams/${requireId('xtreams')}/live`
);
const categories = page.locator(
'app-workspace-context-panel .category-item'
);
const category = param
? categories.filter({ hasText: param }).first()
: categories.first();
await category.waitFor({ state: 'visible', timeout: 30_000 });
await category.click();
// Deliberately no channel click: playback would pull the mock's
// redirect to a public demo stream, and third-party video frames
// must never enter a published shot.
await page
.locator('app-channel-list-item')
.first()
.waitFor({ state: 'visible', timeout: 30_000 });
await page.waitForTimeout(700);
return;
}
case 'open-add-playlist-stalker': {
await goHome(page);
await openAddPlaylistDialog(page);
const dialog = page.locator('mat-dialog-container').last();
await clickDialogOption(dialog, /stalker portal/i);
await dialog.locator('#title').fill(STALKER_FIXTURE_TITLE);
await dialog.locator('#portalUrl').fill(STALKER_FIXTURE_PORTAL_URL);
await dialog.locator('#macAddress').fill(STALKER_FIXTURE_MAC);
// Blur runs the MAC normalization the guide describes.
await dialog.locator('#serialNumber').focus();
// The form is long; frame the identity fields and the derive
// toggle rather than the signature fields at the bottom.
await dialog.locator('.derive-device-ids').scrollIntoViewIfNeeded();
await page.waitForTimeout(500);
return;
}
case 'open-stalker-live': {
await goHome(page);
await clickHrefSuffix(
page,
`/workspace/stalker/${requireId('stalker')}/vod`
);
await clickHrefSuffix(
page,
`/workspace/stalker/${requireId('stalker')}/itv`
);
const categories = page.locator(
'app-workspace-context-panel .category-item'
);
const category = param
? categories.filter({ hasText: param }).first()
: categories.first();
await category.waitFor({ state: 'visible', timeout: 30_000 });
await category.click();
// No channel click: playback would resolve a create_link to a
// public demo stream, and third-party video never enters a shot.
await page
.locator('app-channel-list-item')
.first()
.waitFor({ state: 'visible', timeout: 30_000 });
await page.waitForTimeout(700);
return;
}
case 'open-add-playlist-m3u-url': {
await goHome(page);
await openAddPlaylistDialog(page);
const dialog = page.locator('mat-dialog-container').last();
await clickDialogOption(dialog, /m3u url/i);
// Typed only: the dialog fetches nothing until Add is clicked,
// and the address points at the local mock anyway.
await dialog
.locator('input[formcontrolname="playlistUrl"]')
.fill(M3U_FIXTURE_PLAYLIST_URL);
await dialog
.locator('input[formcontrolname="playlistName"]')
.fill(M3U_FIXTURE_PLAYLIST_TITLE);
await page.waitForTimeout(500);
return;
}
case 'open-settings-epg': {
await runAction(page, 'open-settings', null);
const sectionLink = page
.locator('[data-test-id="settings-section-epg"]')
.first();
await sectionLink.waitFor({ state: 'visible', timeout: 15_000 });
await sectionLink.click({ timeout: 10_000 });
await page.waitForURL(/\/workspace\/settings\/epg/, {
timeout: 15_000,
});
const section = page.locator('#epg');
await section.waitFor({ state: 'visible', timeout: 15_000 });
// Show a filled source row instead of the empty state. The value
// is staged in the form only; nothing is saved or fetched. The
// dirty form is discarded by `discardUnsavedSettings` before the
// next action or the app teardown — the settings close guard
// would otherwise hold `app.close()` open forever.
await section
.getByRole('button', { name: /add epg source/i })
.click({ timeout: 10_000 });
const field = section.locator('input[type="url"]').last();
await field.waitFor({ state: 'visible', timeout: 10_000 });
await field.fill(EPG_FIXTURE_URL, { timeout: 10_000 });
await page.waitForTimeout(500);
return;
}
case 'open-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}`);
}
}
/* ------------------------------------------------------------------ */
/* 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);
}