mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 20:56:19 -08:00
Confirmations: ConfirmDialogData.confirmLabel is required, so no dialog can
fall back to "Yes"/"No"; the dismiss defaults to "Cancel" and
`tone: 'destructive'` styles the confirm with .app-destructive-button. Every
caller names its action ("Remove playlist", "Clear", "Refresh playlist",
"Cancel download" with a "Close" dismiss). The confirm button has the
confirm-dialog-confirm test id and drops its no-op color="primary".
The no-op `warn` color input becomes .app-destructive-button on the EPG
mapping, playlist item, error view, EPG/reset settings, delete-all and source
cleanup buttons, and on the unsaved-changes dialog's Discard.
Provider icons come from SOURCE_TYPE_ICONS in shared/interfaces (Xtream
cloud, Stalker cast, M3U playlist_play / link / description / subject) in the
add dialog, auto-import, empty state, playlist switcher, playlist rows,
dashboard source rail, command palette, Sources filters and both reset
summaries. Stalker no longer borrows the Dashboard icon, and Xtream no longer
shares a glyph with M3U URL playlists.
The playlist error view removed a playlist through the stale
PlaylistActions.removePlaylist: it dropped the playlist from state before the
delete ran, swallowed failures, skipped the source activity guard and showed
no toast. It now uses PlaylistDeleteActionService like every other removal,
commits only a completed delete, toasts and goes home. The unused action and
its effect are removed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
182 lines
5.8 KiB
TypeScript
182 lines
5.8 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 [
|
|
'.busy-state__message',
|
|
'.action-spinner',
|
|
'.cancel-btn',
|
|
]) {
|
|
await row.locator(selector).waitFor({ state: 'detached' });
|
|
}
|
|
const actionSelector =
|
|
action === 'refresh' ? '.refresh-btn' : '.delete-btn';
|
|
const actionButton = row.locator(actionSelector);
|
|
await assertTriggerReady(actionButton, `xtream-source-${action}`);
|
|
await actionButton.click();
|
|
|
|
const dialog = page.locator('mat-dialog-container').last();
|
|
await dialog.waitFor({ state: 'visible' });
|
|
const confirmation = dialog
|
|
.getByRole('button', {
|
|
name: action === 'refresh' ? 'Refresh playlist' : 'Remove playlist',
|
|
exact: true,
|
|
})
|
|
.last();
|
|
await assertTriggerReady(confirmation, `xtream-source-${action}-confirm`);
|
|
return confirmation;
|
|
}
|
|
|
|
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 }
|
|
);
|
|
}
|
|
|
|
export async function waitForXtreamDeleteTerminal(
|
|
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-playlist-item')
|
|
.filter({
|
|
has: page.locator('.playlist-title', {
|
|
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, '\\$&');
|
|
}
|