mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 22:26:16 -08:00
Merge remote-tracking branch 'origin/master' into perf/zoneless-flip
# Conflicts: # tools/performance/journey-baselines.json
This commit is contained in:
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user