Merge remote-tracking branch 'origin/master' into perf/zoneless-flip

# Conflicts:
#	tools/performance/journey-baselines.json
This commit is contained in:
4gray committed 2026-10-14 19:58:21 +02:00
commit 8e807242db
191 files changed
+15429 -6865

No files matched your search

@@ -28,6 +28,9 @@ import { applyTheme, expectTextContrast } from './theme-contrast';
// Single-choice menus (catalog refine, live channels, categories, sources)
// are radio groups: every row reserves the same leading check slot, so the
// labels share one left edge, and `aria-checked` marks the active choice.
//
// The refine menu keeps its rating thresholds in a submenu, so it opens
// whole below its button in a short window.
// ---------------------------------------------------------------------------
const locales = ['en', 'de', 'ru', 'hu'] as const;
@@ -83,14 +86,62 @@ function openMenuPanel(page: Page): Locator {
return page.locator('.cdk-overlay-pane .mat-mdc-menu-panel').last();
}
/** The refine menu's own panel, also while its rating submenu is open. */
function refineMenuPanel(page: Page): Locator {
return page.locator(
'.cdk-overlay-pane .mat-mdc-menu-panel.refine-menu:not(.refine-rating-menu)'
);
}
function ratingSubmenuPanel(page: Page): Locator {
return page.locator('.cdk-overlay-pane .refine-rating-menu');
}
async function openRefineMenu(page: Page): Promise<Locator> {
await page.locator('app-category-content-view .refine-action').click();
const menu = openMenuPanel(page);
const menu = refineMenuPanel(page);
await expect(menu).toBeVisible();
await settleAnimations(menu);
return menu;
}
/** Opens the minimum-rating thresholds from the open refine menu. */
async function openRatingSubmenu(page: Page): Promise<Locator> {
await page.locator('.refine-rating-trigger').click();
const submenu = ratingSubmenuPanel(page);
await expect(submenu).toBeVisible();
await settleAnimations(submenu);
return submenu;
}
/** The panel lies inside the window and shows every row without scrolling. */
async function expectWholeOnScreen(
panel: Locator,
label: string
): Promise<{ top: number; bottom: number }> {
const geometry = await panel.evaluate((element) => {
const rect = element.getBoundingClientRect();
return {
top: rect.top,
bottom: rect.bottom,
windowHeight: window.innerHeight,
scrolls: element.scrollHeight > element.clientHeight + 1,
};
});
expect(
geometry.top,
`${label}: ${JSON.stringify(geometry)}`
).toBeGreaterThanOrEqual(0);
expect(
geometry.bottom,
`${label}: ${JSON.stringify(geometry)}`
).toBeLessThanOrEqual(geometry.windowHeight);
expect(geometry.scrolls, `${label}: ${JSON.stringify(geometry)}`).toBe(
false
);
return geometry;
}
async function settleAnimations(menu: Locator): Promise<void> {
await menu.evaluate((element) =>
Promise.all(
@@ -210,12 +261,8 @@ test.describe('Electron catalog sort menus', () => {
// A rating threshold shows the second chip next to the sort one.
let menu = await openRefineMenu(page);
await menu
.getByRole('group')
.nth(1)
.getByRole('menuitemradio')
.nth(5)
.click();
const thresholds = await openRatingSubmenu(page);
await thresholds.getByRole('menuitemradio').nth(5).click();
await expect(menu).toBeHidden();
for (const locale of locales) {
@@ -252,16 +299,33 @@ test.describe('Electron catalog sort menus', () => {
menu = await openRefineMenu(page);
const groups = menu.getByRole('group');
await expect(groups).toHaveCount(2);
await expect(groups).toHaveCount(1);
await expectRadioGroup(groups.nth(0), 5, `${locale} sort`);
await expectRadioGroup(groups.nth(1), 5, `${locale} rating`);
const lefts = (await radioRows(menu)).map(
(row) => row.labelLeft
);
// The thresholds row starts its label on the sort labels'
// edge and names the active threshold.
const ratingRow = menu.locator('.refine-rating-trigger');
await expect(ratingRow).toContainText('5.0+');
const lefts = [
...(await radioRows(menu)).map((row) => row.labelLeft),
Math.round(
await ratingRow
.locator('.mat-mdc-menu-item-text')
.evaluate(
(text) => text.getBoundingClientRect().left
)
),
];
expect(
[...new Set(lefts)],
`${locale}: both groups`
`${locale}: sort and rating rows`
).toHaveLength(1);
const ratings = await openRatingSubmenu(page);
await expectRadioGroup(
ratings.getByRole('group'),
5,
`${locale} rating`
);
await closeMenu(page, ratings);
await closeMenu(page, menu);
}
} finally {
@@ -269,6 +333,63 @@ test.describe('Electron catalog sort menus', () => {
}
});
test('opens the refine menu whole below its button in a 600px-tall window, with the rating thresholds in a keyboard submenu', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const app = await launchElectronApp(dataDir);
const page = app.mainWindow;
try {
await addXtreamPortal(page);
await waitForXtreamWorkspaceReady(page);
await page.setViewportSize({ width: 1280, height: 600 });
await openWorkspaceSection(page, 'Movies');
await expect(
page.locator('app-category-content-view mat-card').first()
).toBeVisible({ timeout: 20_000 });
const trigger = page.locator(
'app-category-content-view .refine-action'
);
await trigger.focus();
await page.keyboard.press('Enter');
const menu = refineMenuPanel(page);
await expect(menu).toBeVisible();
await settleAnimations(menu);
const triggerBottom = await trigger.evaluate(
(element) => element.getBoundingClientRect().bottom
);
const menuBox = await expectWholeOnScreen(menu, 'refine menu');
// Below the button, not pushed up over it.
expect(menuBox.top).toBeGreaterThanOrEqual(triggerBottom);
// The last row opens the thresholds with the arrow key.
await page.keyboard.press('ArrowUp');
await expect(menu.locator('.refine-rating-trigger')).toBeFocused();
await page.keyboard.press('ArrowRight');
const thresholds = ratingSubmenuPanel(page);
await expect(thresholds).toBeVisible();
await settleAnimations(thresholds);
await expect(thresholds.getByRole('menuitemradio')).toHaveCount(6);
await expect(
thresholds.getByRole('menuitemradio').first()
).toBeFocused();
await expectWholeOnScreen(thresholds, 'rating submenu');
// Escape steps back out: the submenu, then the menu.
await page.keyboard.press('Escape');
await expect(thresholds).toBeHidden();
await expect(menu.locator('.refine-rating-trigger')).toBeFocused();
await page.keyboard.press('Escape');
await expect(menu).toBeHidden();
await expect(trigger).toBeFocused();
} finally {
await closeElectronApp(app);
}
});
test('shows aligned radio rows with a legible check in the live, category and source sort menus, in light and dark themes', async ({
dataDir,
request,
@@ -545,8 +545,15 @@ async function expectCategoryCentered(row: Locator): Promise<void> {
}
async function refreshFromWorkspaceHeader(page: Page): Promise<void> {
// Refresh for the active source lives in its switcher row menu.
await page.getByTestId('playlist-switcher-trigger').click();
await page
.getByRole('button', { name: 'Refresh playlist', exact: true })
.locator(
'app-playlist-switcher-panel [role="option"][aria-current="true"] .source-row__more'
)
.click();
await page
.getByRole('menuitem', { name: 'Refresh playlist', exact: true })
.click();
const dialog = page.locator('mat-dialog-container');
@@ -1073,7 +1073,10 @@ export async function importM3uPlaylistFromUrl(
playlistUrl
);
if (userAgent !== undefined) {
await setInputValue(dialog.getByRole('textbox', { name: 'User agent', exact: true }), userAgent);
await setInputValue(
dialog.getByRole('textbox', { name: 'User agent', exact: true }),
userAgent
);
}
await dialog.getByRole('button', { name: /Add playlist/i }).click();
await dialog.waitFor({ state: 'detached' });
@@ -1187,7 +1190,10 @@ export async function createMutableTextServer(
return;
}
if (options.requiredUserAgent && req.headers['user-agent'] !== options.requiredUserAgent) {
if (
options.requiredUserAgent &&
req.headers['user-agent'] !== options.requiredUserAgent
) {
res.writeHead(403);
res.end('User-Agent required');
return;
@@ -1469,20 +1475,23 @@ export async function clickCategoryByNameExact(
}
export function sourceRowByTitle(page: Page, title: string): Locator {
return page.locator('app-playlist-item').filter({
return page.locator('app-workspace-sources app-source-row').filter({
hasText: flexibleTextPattern(title),
});
}
export async function getVisibleSourceTitles(page: Page): Promise<string[]> {
return page.locator('app-playlist-item').evaluateAll((elements) =>
elements
.map((element) => {
const titleElement = element.querySelector('.playlist-title');
return titleElement?.textContent?.trim() ?? '';
})
.filter((title) => title.length > 0)
);
return page
.locator('app-workspace-sources app-source-row')
.evaluateAll((elements) =>
elements
.map((element) => {
const titleElement =
element.querySelector('.source-row__name');
return titleElement?.textContent?.trim() ?? '';
})
.filter((title) => title.length > 0)
);
}
export async function selectSourceTypeFilter(
@@ -1497,8 +1506,10 @@ export async function selectSourceSort(
sortLabel:
| 'Date added (Newest first)'
| 'Date added (Oldest first)'
| 'Last updated'
| 'Name (A-Z)'
| 'Name (Z-A)'
| 'Most channels'
| 'Custom order'
): Promise<void> {
const sortTrigger = page.locator('app-workspace-sources .sort-trigger');
@@ -1523,10 +1534,10 @@ export async function dragSourceBefore(
sourceTitle: string,
targetTitle: string
): Promise<void> {
const source = sourceRowByTitle(page, sourceTitle).locator('.drag-icon');
const target = sourceRowByTitle(page, targetTitle).locator(
'.playlist-item'
const source = sourceRowByTitle(page, sourceTitle).locator(
'.source-row__handle'
);
const target = sourceRowByTitle(page, targetTitle).locator('.source-row');
await expect(source.first()).toBeVisible();
await expect(target.first()).toBeVisible();
@@ -1560,15 +1571,18 @@ export async function openSourceEditor(
const row = sourceRowByTitle(page, title).first();
await expect(row).toBeVisible();
await row.locator('.edit-btn').click();
await row.hover();
await row.locator('.source-row__edit').click();
const dialog = page.locator('mat-dialog-container').last();
await expect(dialog).toBeVisible();
// Material applies its initial focus after the opening transition.
await dialog.evaluate(async (element) => {
await Promise.all(element.getAnimations({ subtree: true }).map((animation) =>
animation.finished.catch(() => undefined)
));
await Promise.all(
element
.getAnimations({ subtree: true })
.map((animation) => animation.finished.catch(() => undefined))
);
});
return dialog;
}
@@ -1678,12 +1692,24 @@ export async function expectSourceDialogValues(
}
}
/**
* Deletes a source from its ⋯ menu. The row leaves at once; the delete
* itself runs when the Undo toast closes, so this waits for the toast to go
* before the caller checks persisted state.
*/
export async function deleteSource(page: Page, title: string): Promise<void> {
const row = sourceRowByTitle(page, title).first();
await expect(row).toBeVisible();
await row.locator('.delete-btn').click();
await confirmDialog(page, 'Remove playlist');
await row.hover();
await row.locator('.source-row__more').click();
await page
.locator('.cdk-overlay-pane [mat-menu-item]')
.filter({ hasText: 'Remove playlist' })
.click();
const toast = page.locator('mat-snack-bar-container.app-toast');
await expect(toast).toContainText('Removed');
await expect(toast).toBeHidden({ timeout: 15000 });
}
export async function refreshSource(
@@ -1697,7 +1723,8 @@ export async function refreshSource(
const row = sourceRowByTitle(page, title).first();
await expect(row).toBeVisible();
await row.locator('.refresh-btn').click();
await row.hover();
await row.locator('.source-row__refresh').click();
if (confirm) {
await confirmDialog(page, 'Refresh playlist');
@@ -1711,10 +1738,10 @@ export async function waitForSourceRowIdle(
const row = sourceRowByTitle(page, title).first();
await expect(row).toBeVisible();
await expect(row.locator('.busy-state__message')).toHaveCount(0, {
await expect(row.locator('.source-row__busy')).toHaveCount(0, {
timeout: 60000,
});
await expect(row.locator('.action-spinner')).toHaveCount(0, {
await expect(row.locator('mat-spinner')).toHaveCount(0, {
timeout: 60000,
});
}
@@ -70,7 +70,7 @@ test.describe('Electron Native Playlist Import', () => {
});
await openSources(app.mainWindow);
await expect(
app.mainWindow.locator('app-playlist-item')
app.mainWindow.locator('app-workspace-sources app-source-row')
).toHaveCount(2, { timeout: 30000 });
} finally {
await closeElectronApp(app);
@@ -435,6 +435,7 @@ async function openMoviesWithRatingFilter(page: Page): Promise<void> {
timeout: 20_000,
});
await view.locator('.refine-action').click();
await page.locator('.refine-rating-trigger').click();
await page
.getByRole('menuitemradio', { name: /^\s*5\.0 and higher/ })
.click();
@@ -550,19 +551,21 @@ test.describe('Keyboard focus ring', () => {
// Sources: the type filters, then the list's row actions.
await openSources(page);
await expect(
page.locator('app-playlist-item').first()
page.locator('app-workspace-sources app-source-row').first()
).toBeVisible();
// Add, the Type and Status rows, Clean up, Sort, then the first
// row and its actions; Check again lives in the row's menu.
await walk(
'sources list',
page.locator('app-workspace-shell'),
page
.locator('app-workspace-sources-filters-panel button')
.first(),
12
16
);
expect(
report['sources list (dark)'].map((stop) => stop.label)
).toContain('Check again');
).toContain('Playlist actions');
// The live EPG list: its rows are Tab stops (`role="button"`)
// with their own inset ring, then the row's info button.
@@ -590,7 +590,7 @@ test('launch options target the workspace source cards and the shared sentinel',
assert.equal(options.splashId, 'initial-splash');
assert.equal(options.routeFragment, '/workspace');
assert.match(options.cardSelector, /dashboard-recent-sources-rail-card/);
assert.match(options.cardSelector, /app-playlist-item/);
assert.match(options.cardSelector, /app-source-row/);
assert.deepEqual(options.settle, {
capMs: JOURNEY_SETTLE_CAP_MS,
quietMs: JOURNEY_SETTLE_QUIET_MS,
@@ -921,7 +921,7 @@ test('open-source options start at the portal card and end on the source route',
options.startClick?.selector ?? '',
/dashboard-recent-sources-rail-card/
);
assert.match(options.startClick?.selector ?? '', /app-playlist-item/);
assert.match(options.startClick?.selector ?? '', /app-source-row/);
assert.equal(options.routeFragment, '/workspace/xtreams/');
assert.match(options.cardSelector, /app-grid-list mat-card/);
assert.match(options.cardSelector, /channel-item/);
@@ -57,7 +57,7 @@ export const JOURNEY_PLAYBACK_START_SELECTOR =
export const JOURNEY_PLAYBACK_VIDEO_SELECTOR = 'app-web-player-view video';
/** The Xtream portal card on the dashboard or its row on /workspace/sources. */
export const JOURNEY_OPEN_SOURCE_START_SELECTOR =
'[data-test-id="dashboard-recent-sources-rail-card"], app-playlist-item';
'[data-test-id="dashboard-recent-sources-rail-card"], app-source-row';
/**
* Bridge method used for the sentinels. The preload emits the trace event
* before it forwards the call, and `SOURCE_HEALTH_CANCEL` only looks the id
@@ -862,7 +862,7 @@ export function createLaunchJourneyProbeOptions(
): JourneyRendererProbeOptions {
return {
cardSelector:
'[data-test-id="dashboard-recent-sources-rail-card"], app-playlist-item',
'[data-test-id="dashboard-recent-sources-rail-card"], app-source-row',
cdTickCounterKey: JOURNEY_CD_TICK_COUNTER_KEY,
...(idleWindowMs === null
? {}
@@ -262,7 +262,7 @@ async function runIteration(
await renderer.markOperationStart();
const row = sourceRowByTitle(app.mainWindow, SOURCE_TITLE).first();
await row.locator('.refresh-btn').click();
await row.locator('.source-row__refresh').click();
await renderer.waitForCancelClick();
await Promise.all([
renderer.waitForTerminal(),
@@ -301,7 +301,7 @@ async function installRendererProbe(
const epoch = (): number =>
performance.timeOrigin + performance.now();
const sourceRow = (): Element | undefined =>
Array.from(document.querySelectorAll('app-playlist-item')).find(
Array.from(document.querySelectorAll('app-source-row')).find(
(element) => element.textContent?.includes(title)
);
const state = {
@@ -330,7 +330,7 @@ async function installRendererProbe(
const row = sourceRow();
const message =
row
?.querySelector('.busy-state__message')
?.querySelector('.source-row__busy-message')
?.textContent?.trim()
.toLowerCase() ?? '';
for (const phase of ['fetch', 'pars', 'sav']) {
@@ -345,9 +345,10 @@ async function installRendererProbe(
state.cancelClickEpochMs !== null &&
state.uiSettledEpochMs === null &&
row !== undefined &&
row.querySelector('.busy-state__message') === null &&
row.querySelector('.action-spinner') === null &&
row.querySelector('.cancel-btn') === null
row.querySelector('.source-row__busy-message') === null &&
row.querySelector('.source-row__refresh mat-spinner') ===
null &&
row.querySelector('.source-row__cancel') === null
) {
state.uiSettledEpochMs = epoch();
state.uiPaintFrameRequestId = requestAnimationFrame(() => {
@@ -421,7 +422,9 @@ async function installRendererProbe(
event.phase === 'parsing' &&
state.cancelClickEpochMs === null
) {
const button = sourceRow()?.querySelector('.cancel-btn');
const button = sourceRow()?.querySelector(
'.source-row__cancel'
);
state.cancelButtonFound =
button instanceof HTMLButtonElement;
if (button instanceof HTMLButtonElement) {
@@ -152,11 +152,11 @@ export async function installXtreamRendererProbe(
);
const sourceRowAbsent = (): boolean =>
!Array.from(
document.querySelectorAll('app-playlist-item')
document.querySelectorAll('app-source-row')
).some(
(row) =>
row
.querySelector('.playlist-title')
.querySelector('.source-row__name')
?.textContent?.trim() === state.sourceTitle
);
const terminalDomGate = (): boolean => {
@@ -32,14 +32,14 @@ describe('Xtream scenario driver source wiring', () => {
"'#serverUrl'",
"'#username'",
"'#password'",
"'app-playlist-item'",
"'.refresh-btn'",
"'.delete-btn'",
"'app-workspace-sources app-source-row'",
"'.source-row__refresh'",
"'.source-row__more'",
"'Refresh playlist'",
"'Remove playlist'",
"'.busy-state__message'",
"'.action-spinner'",
"'.cancel-btn'",
"'.source-row__busy'",
"'mat-spinner'",
"'.source-row__cancel'",
"'.workspace-loading-overlay'",
]) {
assert.ok(ui.includes(selector), `missing ${selector}`);
@@ -68,6 +68,7 @@ describe('Xtream scenario driver source wiring', () => {
);
assert.ok(driver.includes('installXtreamCancellationObserver'));
assert.ok(driver.includes('installXtreamOperationWindow'));
assert.ok(driver.includes("'delete-playlist'"));
assert.ok(driver.includes('runBackgroundProbe'));
assert.doesNotMatch(combined, /waitForTimeout\s*\(/);
assert.doesNotMatch(combined, /setTimeout\s*\(/);
@@ -157,15 +157,18 @@ describe('Xtream Playwright scenario driver', () => {
});
it('does not require a misleading portal-status badge before refresh or delete', async () => {
for (const [scenarioId, selector, confirmLabel] of [
for (const [scenarioId, selector, confirmClick] of [
[
XTREAM_SCENARIO_ID.REFRESH_LARGE,
'.refresh-btn',
'Refresh playlist',
'.source-row__refresh',
'name=Refresh playlist;exact=true',
],
[
XTREAM_SCENARIO_ID.DELETE_LARGE,
'.source-row__more',
'.source-removal-toast__close',
],
[XTREAM_SCENARIO_ID.DELETE_LARGE, '.delete-btn', 'Remove playlist'],
] as const) {
const confirmClick = `name=${confirmLabel};exact=true`;
const fake = new FakePage();
const prepared = await prepareXtreamScenario(
fake.asPage(),
@@ -188,6 +191,54 @@ describe('Xtream Playwright scenario driver', () => {
}
});
it('arms the playlist delete before its commit click and ends on its completion', async () => {
const fake = new FakePage();
const completed = {
active: false,
operation: 'delete-playlist',
operationId: 'delete-playlist-1',
playlistId: 'playlist-1',
startedEpochMs: 100,
terminalEpochMs: 150,
terminalStatus: 'completed',
};
fake.operationSnapshot = completed;
const prepared = await prepareXtreamScenario(
fake.asPage(),
scenarioOptions(XTREAM_SCENARIO_ID.DELETE_LARGE)
);
// Preparation stops at the open menu: Remove would start the Undo
// window, which commits on its own before a slow capture setup ends.
assert.equal(fake.clicksMatching('filter(Remove playlist)'), 0);
await prepared.trigger();
assert.equal(fake.clicksMatching('filter(Remove playlist)'), 1);
const armIndex = fake.log.indexOf('evaluate:install-operation');
const clickIndex = fake.log.findIndex(
(entry) =>
entry.startsWith('click:') &&
entry.includes('.source-removal-toast__close')
);
assert.ok(armIndex >= 0, 'the delete window is armed');
assert.ok(armIndex < clickIndex, 'before the commit click');
await prepared.waitForTerminal();
assert.ok(
fake.log.indexOf('waitForFunction:operation-terminal') > clickIndex
);
const failed = new FakePage();
failed.operationSnapshot = { ...completed, terminalStatus: 'error' };
const failing = await prepareXtreamScenario(
failed.asPage(),
scenarioOptions(XTREAM_SCENARIO_ID.DELETE_LARGE)
);
await failing.trigger();
await assert.rejects(
() => failing.waitForTerminal(),
/xtream-delete-did-not-complete/
);
});
it('arms cancellation after preparation and immediately before the final Add trigger', async () => {
const fake = new FakePage();
const prepared = await prepareXtreamScenario(
@@ -19,7 +19,8 @@ import {
prepareXtreamSourceActionTrigger,
waitForXtreamCancellationTerminal,
waitForXtreamCatalogTerminal,
waitForXtreamDeleteTerminal,
removeXtreamSource,
waitForXtreamSourceRowGone,
} from './xtream-scenario-ui';
import { assertXtreamMockOrigin } from './xtream-control-client';
import { assertXtreamBackgroundRefreshActive } from './xtream-renderer-probe';
@@ -106,6 +107,7 @@ export async function prepareXtreamScenario(
return createPreparedScenario({
backgroundOperation,
cancellationObserver: null,
deleteOperation: null,
options,
page,
trigger,
@@ -119,6 +121,8 @@ export async function prepareXtreamScenario(
interface PreparedState {
readonly backgroundOperation: XtreamOperationWindow | null;
cancellationObserver: XtreamCancellationObserver | null;
/** The playlist delete, armed before its commit click. */
deleteOperation: XtreamOperationWindow<'delete-playlist'> | null;
readonly options: PrepareXtreamScenarioOptions;
readonly page: Page;
readonly trigger: Locator;
@@ -136,6 +140,7 @@ function createPreparedScenario(state: PreparedState): PreparedXtreamScenario {
await Promise.allSettled([
state.cancellationObserver?.dispose(),
state.backgroundOperation?.dispose(),
state.deleteOperation?.dispose(),
]);
};
const prepared: PreparedXtreamScenario = {
@@ -189,8 +194,21 @@ function createPreparedScenario(state: PreparedState): PreparedXtreamScenario {
state.cancellationObserver =
await installXtreamCancellationObserver(state.page);
}
if (state.options.scenarioId === XTREAM_SCENARIO_ID.DELETE_LARGE) {
// A fast delete can bring its row back and drop it again
// before a UI wait starts; the operation's own events cannot
// be missed once armed.
state.deleteOperation = await installXtreamOperationWindow(
state.page,
'delete-playlist'
);
}
let commit = state.trigger;
if (state.options.scenarioId === XTREAM_SCENARIO_ID.DELETE_LARGE) {
commit = await removeXtreamSource(state.page, state.trigger);
}
const triggerEpochMs = Date.now();
await state.trigger.click();
await commit.click();
return {
scenarioId: state.options.scenarioId,
triggerEpochMs,
@@ -224,12 +242,20 @@ async function waitForScenarioTerminal(
cancellation = await state.cancellationObserver.waitForClick();
await waitForXtreamCancellationTerminal(state.page);
break;
case XTREAM_SCENARIO_ID.DELETE_LARGE:
await waitForXtreamDeleteTerminal(
case XTREAM_SCENARIO_ID.DELETE_LARGE: {
if (!state.deleteOperation) {
throw new Error('xtream-delete-operation-unavailable');
}
const deleted = await state.deleteOperation.waitForTerminal();
if (deleted.terminalStatus !== 'completed') {
throw new Error('xtream-delete-did-not-complete');
}
await waitForXtreamSourceRowGone(
state.page,
state.options.sourceTitle
);
break;
}
case XTREAM_SCENARIO_ID.BACKGROUND_UI:
if (readBackgroundProbe() === null) {
throw new Error('xtream-background-probe-incomplete');
@@ -2,11 +2,16 @@ import type { Page } from '@playwright/test';
const OPERATION_STATE_KEY = '__iptvnatorXtreamScenarioOperationWindow';
const DELETE_OPERATION = 'delete-xtream-content';
/** A content delete before a refresh, or a whole playlist's delete. */
export type XtreamObservedOperation =
typeof DELETE_OPERATION | 'delete-playlist';
const OPERATION_TIMEOUT_MS = 120_000;
export interface XtreamOperationWindowSnapshot {
export interface XtreamOperationWindowSnapshot<
Operation extends XtreamObservedOperation = typeof DELETE_OPERATION,
> {
readonly active: boolean;
readonly operation: typeof DELETE_OPERATION;
readonly operation: Operation;
readonly operationId: string;
readonly playlistId: string;
readonly startedEpochMs: number;
@@ -14,16 +19,21 @@ export interface XtreamOperationWindowSnapshot {
readonly terminalStatus: 'cancelled' | 'completed' | 'error' | null;
}
export interface XtreamOperationWindow {
assertActive(): Promise<XtreamOperationWindowSnapshot>;
export interface XtreamOperationWindow<
Operation extends XtreamObservedOperation = typeof DELETE_OPERATION,
> {
assertActive(): Promise<XtreamOperationWindowSnapshot<Operation>>;
dispose(): Promise<void>;
waitForActive(): Promise<XtreamOperationWindowSnapshot>;
waitForTerminal(): Promise<XtreamOperationWindowSnapshot>;
waitForActive(): Promise<XtreamOperationWindowSnapshot<Operation>>;
waitForTerminal(): Promise<XtreamOperationWindowSnapshot<Operation>>;
}
export async function installXtreamOperationWindow(
page: Page
): Promise<XtreamOperationWindow> {
export async function installXtreamOperationWindow<
Operation extends XtreamObservedOperation = typeof DELETE_OPERATION,
>(
page: Page,
operation: Operation = DELETE_OPERATION as Operation
): Promise<XtreamOperationWindow<Operation>> {
await page.evaluate(
({ command, operation, stateKey }) => {
interface State {
@@ -104,20 +114,20 @@ export async function installXtreamOperationWindow(
},
{
command: 'install-operation',
operation: DELETE_OPERATION,
operation,
stateKey: OPERATION_STATE_KEY,
}
);
return {
assertActive: () => assertOperationActive(page),
assertActive: () => assertOperationActive(page, operation),
dispose: () => disposeOperationState(page),
async waitForActive() {
await waitForOperationState(page, 'operation-active');
return assertOperationActive(page);
return assertOperationActive(page, operation);
},
async waitForTerminal() {
await waitForOperationState(page, 'operation-terminal');
return readOperationSnapshot(page);
return readOperationSnapshot(page, operation);
},
};
}
@@ -144,19 +154,21 @@ async function waitForOperationState(
);
}
async function assertOperationActive(
page: Page
): Promise<XtreamOperationWindowSnapshot> {
const snapshot = await readOperationSnapshot(page);
async function assertOperationActive<Operation extends XtreamObservedOperation>(
page: Page,
operation: Operation
): Promise<XtreamOperationWindowSnapshot<Operation>> {
const snapshot = await readOperationSnapshot(page, operation);
if (!snapshot.active || snapshot.terminalEpochMs !== null) {
throw new Error('xtream-background-action-outside-delete-window');
}
return snapshot;
}
async function readOperationSnapshot(
page: Page
): Promise<XtreamOperationWindowSnapshot> {
async function readOperationSnapshot<Operation extends XtreamObservedOperation>(
page: Page,
operation: Operation
): Promise<XtreamOperationWindowSnapshot<Operation>> {
const value = await page.evaluate(
({ stateKey }) => {
const target = globalThis as unknown as Record<string, unknown>;
@@ -183,7 +195,7 @@ async function readOperationSnapshot(
},
{ command: 'snapshot-operation', stateKey: OPERATION_STATE_KEY }
);
if (!isOperationSnapshot(value)) {
if (!isOperationSnapshot(value, operation)) {
throw new Error('xtream-operation-window-invalid');
}
return value;
@@ -202,16 +214,17 @@ async function disposeOperationState(page: Page): Promise<void> {
);
}
function isOperationSnapshot(
value: unknown
): value is XtreamOperationWindowSnapshot {
function isOperationSnapshot<Operation extends XtreamObservedOperation>(
value: unknown,
operation: Operation
): value is XtreamOperationWindowSnapshot<Operation> {
if (!isRecord(value)) return false;
const active = value['active'];
const startedEpochMs = value['startedEpochMs'];
const terminalEpochMs = value['terminalEpochMs'];
const terminalStatus = value['terminalStatus'];
return (
value['operation'] === DELETE_OPERATION &&
value['operation'] === operation &&
typeof active === 'boolean' &&
finitePositive(startedEpochMs) &&
nonEmptyString(value['operationId']) &&
@@ -68,28 +68,54 @@ export async function prepareXtreamSourceActionTrigger(
const row = xtreamSourceRow(page, sourceTitle);
await row.waitFor({ state: 'visible' });
for (const selector of [
'.busy-state__message',
'.action-spinner',
'.cancel-btn',
'.source-row__busy',
'mat-spinner',
'.source-row__cancel',
]) {
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();
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;
}
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;
// 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> {
@@ -125,7 +151,11 @@ export async function waitForXtreamCancellationTerminal(
);
}
export async function waitForXtreamDeleteTerminal(
/**
* 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> {
@@ -157,9 +187,9 @@ async function selectXtreamMethod(dialog: Locator): Promise<void> {
function xtreamSourceRow(page: Page, sourceTitle: string): Locator {
const exactTitle = new RegExp(`^\\s*${escapeRegex(sourceTitle)}\\s*$`, 'u');
return page
.locator('app-playlist-item')
.locator('app-workspace-sources app-source-row')
.filter({
has: page.locator('.playlist-title', {
has: page.locator('.source-row__name', {
hasText: exactTitle,
}),
})
@@ -178,7 +178,7 @@ class SearchCompletionFakePage {
}
this.searchGateObserved =
/searchParams\.get\((['"])q\1\)/u.test(source) &&
source.includes('app-playlist-item') &&
source.includes('app-source-row') &&
source.includes('no-results-state');
if (this.searchGateObserved) {
this.searchResultsApplied = true;
@@ -154,14 +154,14 @@ export async function runXtreamBackgroundUiActions(
);
const resultRows = Array.from(
document.querySelectorAll(
'app-workspace-sources app-playlist-item'
'app-workspace-sources app-source-row'
)
);
const resultPainted =
resultRows.length > 0
? resultRows.every((row) =>
(
row.querySelector('.playlist-title')
row.querySelector('.source-row__name')
?.textContent ?? ''
)
.trim()
@@ -245,44 +245,41 @@ test.describe('Electron Playlist Switcher', () => {
await waitForM3uCatalog(app.mainWindow);
await openPlaylistFavorites(app.mainWindow);
await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount(
1
);
await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount(
0
);
await expect(
channelItemByTitle(app.mainWindow, localAChannelName)
).toHaveCount(1);
await expect(
channelItemByTitle(app.mainWindow, localBChannelName)
).toHaveCount(0);
await switchPlaylistFromHeader(app.mainWindow, localBDisplayName);
await expectPathname(
app.mainWindow,
/\/workspace\/global-favorites$/
);
await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount(
1
);
await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount(
0
);
await expect(
channelItemByTitle(app.mainWindow, localBChannelName)
).toHaveCount(1);
await expect(
channelItemByTitle(app.mainWindow, localAChannelName)
).toHaveCount(0);
await openPlaylistRecent(app.mainWindow);
await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount(
1
);
await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount(
0
);
await expect(
channelItemByTitle(app.mainWindow, localBChannelName)
).toHaveCount(1);
await expect(
channelItemByTitle(app.mainWindow, localAChannelName)
).toHaveCount(0);
await switchPlaylistFromHeader(app.mainWindow, localADisplayName);
await expectPathname(
app.mainWindow,
/\/workspace\/global-recent$/
);
await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount(
1
);
await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount(
0
);
await expectPathname(app.mainWindow, /\/workspace\/global-recent$/);
await expect(
channelItemByTitle(app.mainWindow, localAChannelName)
).toHaveCount(1);
await expect(
channelItemByTitle(app.mainWindow, localBChannelName)
).toHaveCount(0);
} finally {
await closeElectronApp(app);
}
@@ -319,7 +316,9 @@ test.describe('Electron Playlist Switcher', () => {
username: xtreamStressUsername,
});
const overlay = app.mainWindow.locator('.workspace-loading-overlay');
const overlay = app.mainWindow.locator(
'.workspace-loading-overlay'
);
await expect(overlay).toBeVisible({ timeout: 15000 });
await waitForDbOperationEvent(app.mainWindow, {
operation: 'save-content',
@@ -428,17 +427,172 @@ test.describe('Electron Playlist Switcher', () => {
await switchPlaylistFromHeader(app.mainWindow, offlineXtreamName);
await waitForXtreamCatalog(app.mainWindow);
await expectXtreamUnavailableWarning(app.mainWindow);
await expect(app.mainWindow.locator('.xtream-content-gate')).toHaveCount(
0
);
await expect(
app.mainWindow.locator('.xtream-content-gate')
).toHaveCount(0);
app = await restartElectronApp(app, dataDir);
await waitForXtreamCatalog(app.mainWindow);
await expectXtreamUnavailableWarning(app.mainWindow);
await expect(app.mainWindow.locator('.xtream-content-gate')).toHaveCount(
0
await expect(
app.mainWindow.locator('.xtream-content-gate')
).toHaveCount(0);
} finally {
await closeElectronApp(app);
}
});
test('runs from the keyboard: Cmd/Ctrl+P, filter, arrows, Enter and Escape', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const localAPath = writeTemporaryM3uFile(dataDir, localAFileName, [
{
groupTitle: 'News',
name: 'Switcher Local A',
url: 'https://streams.example.test/switcher-local-a.m3u8',
},
]);
const localBPath = writeTemporaryM3uFile(dataDir, localBFileName, [
{
groupTitle: 'Sports',
name: 'Switcher Local B',
url: 'https://streams.example.test/switcher-local-b.m3u8',
},
]);
const app = await launchElectronApp(dataDir);
const page = app.mainWindow;
try {
await importM3uPlaylistFromNativeDialog(app, localAPath);
await importM3uPlaylistFromNativeDialog(app, localBPath);
await addXtreamPortal(page, { name: xtreamAName });
await waitForXtreamCatalog(page);
const trigger = page.getByTestId('playlist-switcher-trigger');
const panel = page.locator('app-playlist-switcher-panel');
const search = panel.locator('input[role="combobox"]');
await expect(playlistSwitcherTitle(page)).toContainText(
xtreamAName
);
await page.keyboard.press('ControlOrMeta+p');
await expect(search).toBeFocused();
// No list shows a credential: the Xtream row names the account
// on purpose, never its password.
await expect(panel).not.toContainText(defaultXtreamPassword);
await expect(panel).not.toContainText('password=');
await page.keyboard.type('switcher-local');
await expect(switcherOptions(page)).toHaveCount(2);
// A search highlights its first match; ↓ moves on, ↑ wraps.
const options = switcherOptions(page);
const firstId = (await options.nth(0).getAttribute('id')) ?? '';
const secondId = (await options.nth(1).getAttribute('id')) ?? '';
await expect(search).toHaveAttribute(
'aria-activedescendant',
firstId
);
await page.keyboard.press('ArrowDown');
await expect(search).toHaveAttribute(
'aria-activedescendant',
secondId
);
await page.keyboard.press('ArrowDown');
await expect(search).toHaveAttribute(
'aria-activedescendant',
firstId
);
await page.keyboard.press('ArrowUp');
await expect(search).toHaveAttribute(
'aria-activedescendant',
secondId
);
const targetName =
(
await options
.nth(1)
.locator('.source-row__name')
.textContent()
)?.trim() ?? '';
await page.keyboard.press('Enter');
await expect(panel).toHaveCount(0);
await waitForM3uCatalog(page);
await expect(playlistSwitcherTitle(page)).toContainText(targetName);
// Escape closes and puts focus back on the trigger.
await page.keyboard.press('ControlOrMeta+p');
await expect(search).toBeFocused();
await page.keyboard.press('Escape');
await expect(panel).toHaveCount(0);
await expect(trigger).toBeFocused();
// In a short window only the list shrinks and scrolls; the
// search field and the type filter keep their height.
const viewport = page.viewportSize();
await page.setViewportSize({
width: viewport?.width ?? 1280,
height: 320,
});
await page.keyboard.press('ControlOrMeta+p');
await expect(search).toBeFocused();
const chrome = await panel.evaluate((host) => {
const height = (element: Element | null) =>
element?.getBoundingClientRect().height ?? 0;
const list = host.querySelector('.switcher__list');
return {
search: height(host.querySelector('.switcher__search')),
toggles: Array.from(
host.querySelectorAll('.mat-button-toggle'),
height
),
listScrolls:
!!list && list.scrollHeight > list.clientHeight,
};
});
expect(chrome.search, JSON.stringify(chrome)).toBeCloseTo(36, 0);
expect(chrome.toggles.length, JSON.stringify(chrome)).toBe(3);
for (const toggle of chrome.toggles) {
expect(toggle, JSON.stringify(chrome)).toBeGreaterThanOrEqual(
30
);
}
expect(chrome.listScrolls, JSON.stringify(chrome)).toBe(true);
await page.keyboard.press('Escape');
await expect(panel).toHaveCount(0);
if (viewport) await page.setViewportSize(viewport);
// A delete from the keyboard: the popover closes, since its
// focus trap would keep Tab from Undo; Undo takes the focus and
// gives it back to the trigger.
await page.keyboard.press('ControlOrMeta+p');
await expect(search).toBeFocused();
await page.keyboard.press('ArrowDown');
await page.keyboard.press('Shift+F10');
const rowMenu = page.locator('.mat-mdc-menu-panel');
await expect(rowMenu).toBeVisible();
await page.keyboard.press('End');
await expect(
rowMenu.getByRole('menuitem', { name: /Remove/ })
).toBeFocused();
await page.keyboard.press('Enter');
await expect(panel).toHaveCount(0);
const undo = page.locator('.source-removal-toast__undo');
await expect(undo).toBeFocused();
await page.keyboard.press('Enter');
await expect(undo).toHaveCount(0);
await expect(trigger).toBeFocused();
await openSources(page);
await expect(
page.locator('app-workspace-sources app-source-row')
).toHaveCount(3);
await expect(
page.locator('app-workspace-sources')
).not.toContainText(defaultXtreamPassword);
} finally {
await closeElectronApp(app);
}
@@ -446,12 +600,12 @@ test.describe('Electron Playlist Switcher', () => {
});
async function openPlaylistSwitcher(page: Page): Promise<void> {
await page
.locator('app-playlist-switcher .playlist-switcher-trigger')
.click();
await expect(
page.locator('.cdk-overlay-pane .playlist-item').first()
).toBeVisible();
await page.getByTestId('playlist-switcher-trigger').click();
await expect(switcherOptions(page).first()).toBeVisible();
}
function switcherOptions(page: Page) {
return page.locator('app-playlist-switcher-panel [role="option"]');
}
async function switchPlaylistFromHeader(
@@ -459,16 +613,11 @@ async function switchPlaylistFromHeader(
title: string
): Promise<void> {
await openPlaylistSwitcher(page);
const item = page
.locator('.cdk-overlay-pane .playlist-item')
.filter({ hasText: title })
.first();
const item = switcherOptions(page).filter({ hasText: title }).first();
await expect(item).toBeVisible();
await item.click();
await expect(page.locator('.cdk-overlay-pane .playlist-item')).toHaveCount(
0
);
await expect(page.locator('app-playlist-switcher-panel')).toHaveCount(0);
}
async function expectPathname(page: Page, pattern: RegExp): Promise<void> {
@@ -503,16 +652,21 @@ async function selectSettingsOption(
}
async function expectXtreamUnavailableWarning(page: Page): Promise<void> {
await expect(
page.getByTestId('xtream-offline-warning')
).toBeVisible({ timeout: 20000 });
await expect(page.getByTestId('xtream-offline-warning')).toBeVisible({
timeout: 20000,
});
}
async function toggleFavoriteForChannel(page: Page, title: string): Promise<void> {
async function toggleFavoriteForChannel(
page: Page,
title: string
): Promise<void> {
const item = channelItemByTitle(page, title).first();
await expect(item).toBeVisible({ timeout: 20000 });
await item.hover();
await item.locator('.favorite-button').first().click();
await expect(item.locator('.favorite-button mat-icon').first()).toHaveText(/star/);
await expect(item.locator('.favorite-button mat-icon').first()).toHaveText(
/star/
);
}
@@ -31,7 +31,7 @@ type RowRing = {
function readRowRing(page: Page): Promise<RowRing> {
return page.evaluate(() => {
const focused = document.activeElement as HTMLElement;
const row = focused.closest('.playlist-item') as HTMLElement;
const row = focused.closest('.source-row') as HTMLElement;
const probe = document.createElement('span');
probe.style.color = 'var(--app-focus-ring)';
row.append(probe);
@@ -93,16 +93,19 @@ test.describe('Electron Sources keyboard', () => {
timeout: 30_000,
});
await openSources(page);
await expect(page.locator('app-playlist-item')).toHaveCount(2, {
await expect(
page.locator('app-workspace-sources app-source-row')
).toHaveCount(2, {
timeout: 30_000,
});
// Bravo opened last, so it is the active source; open the other.
const row = sourceRowByTitle(page, 'keyboard-alpha').first();
await expect(row.locator('.playlist-item')).not.toHaveClass(
/\bselected\b/
await expect(row.locator('.source-row__body')).not.toHaveAttribute(
'aria-current',
'true'
);
const title = (
await row.locator('.playlist-title').textContent()
await row.locator('.source-row__name').textContent()
)?.trim();
expect(title).toBeTruthy();
@@ -153,7 +156,7 @@ test.describe('Electron Sources keyboard', () => {
await expect(open).not.toBeFocused();
expect(
await row
.locator('.playlist-item')
.locator('.source-row')
.evaluate((el) => getComputedStyle(el).outlineStyle)
).toBe('none');
await page.keyboard.press('Shift+Tab');
@@ -172,4 +175,69 @@ test.describe('Electron Sources keyboard', () => {
await closeElectronApp(app);
}
});
test('@m3u @electron deletes from the row menu and undoes from the toast, keyboard only', async ({
dataDir,
}) => {
const alpha = writeTemporaryM3uFile(dataDir, 'undo-alpha.m3u', [
{
groupTitle: 'News',
name: 'Undo Alpha News',
url: 'https://streams.example.test/undo-alpha.m3u8',
},
]);
const bravo = writeTemporaryM3uFile(dataDir, 'undo-bravo.m3u', [
{
groupTitle: 'Sports',
name: 'Undo Bravo Sports',
url: 'https://streams.example.test/undo-bravo.m3u8',
},
]);
const app = await launchElectronApp(dataDir, {
appArgs: [alpha, bravo],
});
const page = app.mainWindow;
const rows = page.locator('app-workspace-sources app-source-row');
try {
await page.waitForURL(/\/workspace\/playlists\/.+/, {
timeout: 30_000,
});
await openSources(page);
await expect(rows).toHaveCount(2, { timeout: 30_000 });
const before = await rows
.locator('.source-row__name')
.allTextContents();
// Focus the first row's ⋯ and open its menu from the keyboard.
const row = rows.first();
await row.locator('.source-row__more').focus();
await page.keyboard.press('Enter');
const remove = page.getByRole('menuitem', {
name: 'Remove playlist',
});
await expect(remove).toBeVisible();
await page.keyboard.press('End');
await expect(remove).toBeFocused();
await page.keyboard.press('Enter');
// The row leaves at once; Undo takes the focus the row had.
await expect(rows).toHaveCount(1);
const undo = page.locator(
'mat-snack-bar-container.app-toast .source-removal-toast__undo'
);
await expect(undo).toBeFocused();
await page.keyboard.press('Enter');
await expect(rows).toHaveCount(2);
expect(
await rows.locator('.source-row__name').allTextContents()
).toEqual(before);
await expect(
page.locator('mat-snack-bar-container.app-toast')
).toHaveCount(0);
} finally {
await closeElectronApp(app);
}
});
});
+27 -16
View File
@@ -717,14 +717,14 @@ https://streams.example.test/url-omega.m3u8
await expect(
sourceRowByTitle(app.mainWindow, 'Delete Me Stalker').locator(
'app-source-health-indicator [data-state]'
'.source-tile__dot[data-state]'
)
).toHaveAttribute('data-state', 'active', { timeout: 20000 });
).toHaveAttribute('data-state', 'ok', { timeout: 20000 });
await expect(
sourceRowByTitle(app.mainWindow, 'Delete Me Xtream').locator(
'app-source-health-indicator [data-state]'
'.source-tile__dot[data-state]'
)
).toHaveAttribute('data-state', 'active', { timeout: 20000 });
).toHaveAttribute('data-state', 'ok', { timeout: 20000 });
await deleteSource(app.mainWindow, deletableLocalSourceDisplayName);
await expect(
@@ -785,11 +785,20 @@ https://streams.example.test/original-url.m3u8
try {
await dropM3uPlaylistOntoWorkspace(app.mainWindow, localFilePath);
await waitForM3uCatalog(app.mainWindow);
// Refresh for the active source lives in its switcher row menu.
await app.mainWindow.getByTestId('playlist-switcher-trigger').click();
const currentRow = app.mainWindow.locator(
'app-playlist-switcher-panel [role="option"][aria-current="true"]'
);
await currentRow.locator('.source-row__more').click();
await expect(
app.mainWindow.locator(
'app-playlist-switcher .trigger-refresh-button'
)
app.mainWindow.getByRole('menuitem', { name: 'Refresh playlist' })
).toBeVisible();
await app.mainWindow.keyboard.press('Escape');
await app.mainWindow.keyboard.press('Escape');
await expect(
app.mainWindow.locator('app-playlist-switcher-panel')
).toHaveCount(0);
await goToDashboard(app.mainWindow);
const sourceCard = app.mainWindow
@@ -822,8 +831,8 @@ https://streams.example.test/original-url.m3u8
await expect(
sourceRowByTitle(app.mainWindow, refreshLocalSourceDisplayName)
.first()
.locator('.refresh-btn')
).toBeVisible();
.locator('.source-row__refresh')
).toBeAttached();
writeTemporaryM3uFile(dataDir, 'refresh-local-source.m3u', [
{
@@ -865,8 +874,8 @@ https://streams.example.test/refreshed-url.m3u8
await expect(
sourceRowByTitle(app.mainWindow, 'refresh-url-source.m3u')
.first()
.locator('.meta')
).toContainText('Updated:');
.locator('.source-row__meta')
).toContainText('updated');
await waitForSourceRowIdle(
app.mainWindow,
'refresh-url-source.m3u'
@@ -876,9 +885,11 @@ https://streams.example.test/refreshed-url.m3u8
'refresh-url-source.m3u'
).first();
await expect(
refreshedUrlRow.locator('.playlist-item')
).not.toHaveClass(/is-busy/);
await expect(refreshedUrlRow.locator('.refresh-btn')).toBeEnabled();
refreshedUrlRow.locator('.source-row')
).not.toHaveClass(/source-row--busy/);
await expect(
refreshedUrlRow.locator('.source-row__refresh')
).toBeEnabled();
await refreshedUrlRow
.getByText('refresh-url-source.m3u', { exact: true })
.click();
@@ -904,8 +915,8 @@ https://streams.example.test/refreshed-url.m3u8
await expect(
sourceRowByTitle(app.mainWindow, 'Refresh Xtream Source')
.first()
.locator('.meta')
).toContainText('Updated:');
.locator('.source-row__meta')
).toContainText('updated');
} finally {
await closeElectronApp(app);
await urlServer.close();
@@ -0,0 +1,132 @@
import { Page } from '@playwright/test';
import {
addXtreamPortal,
closeElectronApp,
expect,
goToDashboard,
launchElectronApp,
resetMockServers,
test,
waitForXtreamWorkspaceReady,
} from './electron-test-fixtures';
/**
* Leaving a portal section for the dashboard must replace the routed page
* in one task. With the @angular/animations renderer the router inserted the
* dashboard at once but the old page (and the context panel) left only at
* the end of the next, rAF-coalesced change detection: in between Chromium
* laid out the dashboard below the portal page, at y=720 in a 720 px window,
* and scored its jump into view as a layout shift of 0.337 at 1280 px.
*/
type RouteSwapProbe = {
overlaps: string[][];
shiftSources: string[];
};
declare global {
interface Window {
__routeSwapProbe?: RouteSwapProbe;
}
}
/** Sources whose shift the old page's late removal caused. */
const SWAP_SHIFT_SOURCES = [
'lib-workspace-dashboard-rails',
'main.workspace-content',
];
async function armRouteSwapProbe(page: Page): Promise<void> {
await page.evaluate(() => {
const content = document.querySelector('main.workspace-content');
if (!content) {
throw new Error('workspace content pane is missing');
}
const probe: RouteSwapProbe = { overlaps: [], shiftSources: [] };
window.__routeSwapProbe = probe;
// Mutation callbacks run at the microtask checkpoint after the
// router's activation, before any frame or deferred change detection.
new MutationObserver(() => {
const pages = [...content.children]
.filter((child) => child.tagName !== 'ROUTER-OUTLET')
.map((child) => child.tagName.toLowerCase());
if (pages.length > 1) {
probe.overlaps.push(pages);
}
}).observe(content, { childList: true });
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const sources =
(entry as PerformanceEntry & {
sources?: { node?: Node | null }[];
}).sources ?? [];
for (const { node } of sources) {
if (node instanceof Element) {
const className = node.classList.contains(
'workspace-content'
)
? '.workspace-content'
: '';
probe.shiftSources.push(
node.tagName.toLowerCase() + className
);
}
}
}
}).observe({ type: 'layout-shift' });
});
}
async function waitForTwoFrames(page: Page): Promise<void> {
await page.evaluate(
() =>
new Promise<void>((resolve) =>
requestAnimationFrame(() =>
requestAnimationFrame(() => setTimeout(resolve, 0))
)
)
);
}
test.describe('Workspace route swap', () => {
test('replaces a portal page with the dashboard without a frame showing both', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const app = await launchElectronApp(dataDir);
const page = app.mainWindow;
try {
await app.electronApp.evaluate(({ BrowserWindow }) => {
BrowserWindow.getAllWindows()[0].setContentSize(1280, 720);
});
await addXtreamPortal(page);
await waitForXtreamWorkspaceReady(page);
await expect(page).toHaveURL(/\/workspace\/xtreams\/[^/]+\/vod/);
await expect(
page.locator('app-workspace-shell-context-sidebar')
).toBeVisible();
await armRouteSwapProbe(page);
await goToDashboard(page);
await expect(
page.locator('lib-workspace-dashboard-rails')
).toBeVisible();
await expect(
page.locator('app-workspace-shell-context-sidebar')
).toHaveCount(0);
await waitForTwoFrames(page);
const probe = await page.evaluate(() => window.__routeSwapProbe);
expect(probe?.overlaps).toEqual([]);
expect(
probe?.shiftSources.filter((source) =>
SWAP_SHIFT_SOURCES.includes(source)
)
).toEqual([]);
} finally {
await closeElectronApp(app);
}
});
});
@@ -25,7 +25,7 @@ const dbWorkerBatchDelayMs = '20';
const dbWorkerRowsPerTransaction = '100';
function playlistRowByName(name: string) {
return `app-playlist-item:has-text("${name}")`;
return `app-workspace-sources app-source-row:has-text("${name}")`;
}
async function openSources(page: Parameters<typeof addXtreamPortal>[0]) {
@@ -287,14 +287,13 @@ test.describe('Electron Xtream Responsiveness', () => {
);
await expect(playlistRow).toBeVisible({ timeout: 20000 });
await playlistRow.locator('.delete-btn').click();
const confirmDialog = app.mainWindow.locator(
'mat-dialog-container'
);
await expect(confirmDialog).toBeVisible();
await confirmDialog
.getByRole('button', { name: 'Remove playlist', exact: true })
// Delete from the row menu; the delete starts when the Undo
// toast closes, and the row comes back to show its progress.
await playlistRow.hover();
await playlistRow.locator('.source-row__more').click();
await app.mainWindow
.locator('.cdk-overlay-pane [mat-menu-item]')
.filter({ hasText: 'Remove playlist' })
.click();
await waitForDbOperationEvent(app.mainWindow, {
@@ -303,10 +302,10 @@ test.describe('Electron Xtream Responsiveness', () => {
timeoutMs: 20000,
});
await expect(
playlistRow.locator('.busy-state__message')
).toBeVisible({ timeout: 20000 });
const cancel = playlistRow.locator('.cancel-btn');
await expect(playlistRow.locator('.source-row__busy')).toBeVisible({
timeout: 20000,
});
const cancel = playlistRow.locator('.source-row__cancel');
await expect(cancel).toBeVisible();
// The row's action buttons inherit its color; the cancel action
// must still read as destructive on the selected row.