mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -08:00
Local review round 3 (Greptile; Codex was clean): - Name order compared the stored title, while the rows show a URL-named or credential-bearing source by its host: a source stored as get.php?username=… sorted by its hidden account details. sortPlaylists takes the list's name function, and Sources and the switcher pass sourceSortName, the name they display. - The Xtream delete benchmark clicked Remove while preparing, which starts the four-second Undo window; capture setup before the trigger could outlast it, so the delete committed on its own before the measurement and the close button was gone. Preparation now stops at the open menu, and the trigger clicks Remove and then the toast's close button, with the trigger time taken right before the commit click as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
212 lines
6.9 KiB
TypeScript
212 lines
6.9 KiB
TypeScript
import type { Locator, Page } from '@playwright/test';
|
|
|
|
export interface XtreamSyntheticPortalFields {
|
|
readonly password: 'performance';
|
|
readonly serverUrl: string;
|
|
readonly sourceTitle: string;
|
|
readonly username: 'performance';
|
|
}
|
|
|
|
export type XtreamSourceAction = 'delete' | 'refresh';
|
|
|
|
const XTREAM_METHOD_NAME = /xtream(?:\s+credentials)?/i;
|
|
const XTREAM_ROUTE = /\/workspace\/xtreams\/[^/]+/;
|
|
|
|
export async function prepareXtreamAddTrigger(
|
|
page: Page,
|
|
fields: XtreamSyntheticPortalFields
|
|
): Promise<Locator> {
|
|
await waitForEmptyDashboardReady(page);
|
|
await page.getByRole('link', { name: 'Sources', exact: true }).click();
|
|
await page.waitForURL(/\/workspace\/sources$/);
|
|
await page.locator('app-workspace-sources').waitFor({ state: 'visible' });
|
|
await page
|
|
.getByRole('button', { name: 'Add playlist', exact: true })
|
|
.first()
|
|
.click();
|
|
const dialog = page.locator('mat-dialog-container').last();
|
|
await dialog.waitFor({ state: 'visible' });
|
|
await selectXtreamMethod(dialog);
|
|
await dialog.locator('#title').fill(fields.sourceTitle);
|
|
await dialog.locator('#serverUrl').fill(fields.serverUrl);
|
|
await dialog.locator('#username').fill(fields.username);
|
|
await dialog.locator('#password').fill(fields.password);
|
|
|
|
const exactAdd = dialog
|
|
.getByRole('button', { name: 'Add', exact: true })
|
|
.last();
|
|
const add =
|
|
(await exactAdd.count()) > 0
|
|
? exactAdd
|
|
: dialog
|
|
.getByRole('button', {
|
|
name: /^(add|add playlist)$/i,
|
|
})
|
|
.last();
|
|
await assertTriggerReady(add, 'xtream-final-add');
|
|
return add;
|
|
}
|
|
|
|
async function waitForEmptyDashboardReady(page: Page): Promise<void> {
|
|
await page.waitForURL(/\/workspace\/dashboard$/);
|
|
await page.waitForFunction(
|
|
() =>
|
|
document.querySelector(
|
|
'lib-workspace-dashboard-rails.rails-page-host--empty'
|
|
) !== null,
|
|
{ waitFor: 'empty-dashboard-ready' }
|
|
);
|
|
}
|
|
|
|
export async function prepareXtreamSourceActionTrigger(
|
|
page: Page,
|
|
sourceTitle: string,
|
|
action: XtreamSourceAction
|
|
): Promise<Locator> {
|
|
await page.getByRole('link', { name: 'Sources', exact: true }).click();
|
|
await page.waitForURL(/\/workspace\/sources$/);
|
|
const row = xtreamSourceRow(page, sourceTitle);
|
|
await row.waitFor({ state: 'visible' });
|
|
for (const selector of [
|
|
'.source-row__busy',
|
|
'mat-spinner',
|
|
'.source-row__cancel',
|
|
]) {
|
|
await row.locator(selector).waitFor({ state: 'detached' });
|
|
}
|
|
if (action === 'refresh') {
|
|
const refresh = row.locator('.source-row__refresh');
|
|
await assertTriggerReady(refresh, 'xtream-source-refresh');
|
|
await refresh.click();
|
|
const dialog = page.locator('mat-dialog-container').last();
|
|
await dialog.waitFor({ state: 'visible' });
|
|
const confirmation = dialog
|
|
.getByRole('button', { name: 'Refresh playlist', exact: true })
|
|
.last();
|
|
await assertTriggerReady(confirmation, 'xtream-source-refresh-confirm');
|
|
return confirmation;
|
|
}
|
|
|
|
// Remove starts the Undo window, which commits on its own after four
|
|
// seconds: preparation stops at the open menu, and the trigger removes
|
|
// and commits at once (removeXtreamSource).
|
|
const more = row.locator('.source-row__more');
|
|
await assertTriggerReady(more, 'xtream-source-delete-menu');
|
|
await more.click();
|
|
const remove = page
|
|
.locator('.cdk-overlay-pane [mat-menu-item]')
|
|
.filter({ hasText: 'Remove playlist' })
|
|
.first();
|
|
await assertTriggerReady(remove, 'xtream-source-delete');
|
|
return remove;
|
|
}
|
|
|
|
/**
|
|
* Clicks Remove and returns the Undo toast's close button, which commits the
|
|
* delete without waiting out the Undo window: the measured delete starts
|
|
* with that click.
|
|
*/
|
|
export async function removeXtreamSource(
|
|
page: Page,
|
|
remove: Locator
|
|
): Promise<Locator> {
|
|
await remove.click();
|
|
const commit = page.locator(
|
|
'mat-snack-bar-container.app-toast .source-removal-toast__close'
|
|
);
|
|
await assertTriggerReady(commit, 'xtream-source-delete-confirm');
|
|
return commit;
|
|
}
|
|
|
|
export async function waitForXtreamCatalogTerminal(page: Page): Promise<void> {
|
|
await page.waitForURL(XTREAM_ROUTE);
|
|
await page
|
|
.locator('.workspace-loading-overlay')
|
|
.waitFor({ state: 'detached' });
|
|
await page
|
|
.locator('app-workspace-context-panel .category-item')
|
|
.first()
|
|
.waitFor({ state: 'visible' });
|
|
await page
|
|
.locator('.content-card, [data-test-id="channel-item"], mat-card')
|
|
.first()
|
|
.waitFor({ state: 'visible' });
|
|
}
|
|
|
|
export async function waitForXtreamCancellationTerminal(
|
|
page: Page
|
|
): Promise<void> {
|
|
await page
|
|
.locator('.workspace-loading-overlay')
|
|
.waitFor({ state: 'detached' });
|
|
await page.waitForFunction(
|
|
({ selector, text }) =>
|
|
document.querySelector(selector)?.textContent?.trim() === text,
|
|
{
|
|
selector: '.xtream-content-gate .error-view__title',
|
|
text: 'Import cancelled',
|
|
waitFor: 'cancellation-terminal',
|
|
},
|
|
{ timeout: 120_000 }
|
|
);
|
|
}
|
|
|
|
/**
|
|
* After the database reports the delete complete: the row, back as
|
|
* "Removing…" while it ran or never shown again for a fast delete, leaves.
|
|
*/
|
|
export async function waitForXtreamSourceRowGone(
|
|
page: Page,
|
|
sourceTitle: string
|
|
): Promise<void> {
|
|
await xtreamSourceRow(page, sourceTitle).waitFor({ state: 'detached' });
|
|
}
|
|
|
|
async function selectXtreamMethod(dialog: Locator): Promise<void> {
|
|
const radio = dialog
|
|
.getByRole('radio', { name: XTREAM_METHOD_NAME })
|
|
.first();
|
|
if ((await radio.count()) > 0) {
|
|
await radio.click();
|
|
return;
|
|
}
|
|
const button = dialog
|
|
.getByRole('button', { name: XTREAM_METHOD_NAME })
|
|
.first();
|
|
if ((await button.count()) > 0) {
|
|
await button.click();
|
|
return;
|
|
}
|
|
const fallback = dialog.locator('mat-button-toggle[value="xtream"]');
|
|
if ((await fallback.count()) === 0) {
|
|
throw new Error('xtream-method-selector-missing');
|
|
}
|
|
await fallback.click();
|
|
}
|
|
|
|
function xtreamSourceRow(page: Page, sourceTitle: string): Locator {
|
|
const exactTitle = new RegExp(`^\\s*${escapeRegex(sourceTitle)}\\s*$`, 'u');
|
|
return page
|
|
.locator('app-workspace-sources app-source-row')
|
|
.filter({
|
|
has: page.locator('.source-row__name', {
|
|
hasText: exactTitle,
|
|
}),
|
|
})
|
|
.first();
|
|
}
|
|
|
|
async function assertTriggerReady(
|
|
trigger: Locator,
|
|
label: string
|
|
): Promise<void> {
|
|
await trigger.waitFor({ state: 'visible' });
|
|
if (await trigger.isDisabled()) {
|
|
throw new Error(`${label}-disabled`);
|
|
}
|
|
}
|
|
|
|
function escapeRegex(value: string): string {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
}
|