mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 14:16:16 -08:00
Merge remote-tracking branch 'origin/master' into claude/iptvnator-vod-hero-redesign-2c8950
# Conflicts: # apps/electron-backend-e2e/src/detail-back-lane.e2e.ts # apps/web-e2e/src/m3u-movie-details.e2e.ts # docs/architecture/iptvnator-ui-guidelines.md # libs/ui/components/src/lib/content-hero/content-hero.component.scss # libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.scss
This commit is contained in:
commit
87d7e1d46c
122 files changed
+2080
-1040
No files matched your search
@@ -590,12 +590,9 @@ async function firstVisibleGridTitle(page: Page): Promise<string> {
|
||||
}
|
||||
|
||||
async function goBackFromDetail(page: Page): Promise<void> {
|
||||
// Return to the list: the shell's sticky Back is route-level in browse
|
||||
// and watch alike (closing the player is the bar's own Close button).
|
||||
const backButton = page
|
||||
.locator('app-portal-detail-shell')
|
||||
.first()
|
||||
.getByRole('button', { name: 'Back', exact: true });
|
||||
// Return to the list: the header's Back is route-level in browse and
|
||||
// watch alike (closing the player is the bar's own Close button).
|
||||
const backButton = page.getByTestId('workspace-header-back');
|
||||
|
||||
await expect(backButton).toBeVisible({ timeout: 20000 });
|
||||
try {
|
||||
|
||||
@@ -551,7 +551,9 @@ async function refreshFromWorkspaceHeader(page: Page): Promise<void> {
|
||||
|
||||
const dialog = page.locator('mat-dialog-container');
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('button', { name: 'Yes', exact: true }).click();
|
||||
await dialog
|
||||
.getByRole('button', { name: 'Refresh playlist', exact: true })
|
||||
.click();
|
||||
|
||||
const refreshOverlay = page.locator('app-workspace-shell-import-overlay');
|
||||
await expect(refreshOverlay).toBeVisible({ timeout: 5000 });
|
||||
|
||||
@@ -306,12 +306,9 @@ function dashboardRailCardByTitle(
|
||||
}
|
||||
|
||||
async function goBackFromDetail(page: Page): Promise<void> {
|
||||
// Return to the list: the shell's sticky Back is route-level in browse
|
||||
// and watch alike (closing the player is the bar's own Close button).
|
||||
const backButton = page
|
||||
.locator('app-portal-detail-shell')
|
||||
.first()
|
||||
.getByRole('button', { name: 'Back', exact: true });
|
||||
// Return to the list: the header's Back is route-level in browse and
|
||||
// watch alike (closing the player is the bar's own Close button).
|
||||
const backButton = page.getByTestId('workspace-header-back');
|
||||
|
||||
await expect(backButton).toBeVisible({ timeout: 20000 });
|
||||
try {
|
||||
@@ -350,9 +347,9 @@ async function expectInlineCollectionDetail(
|
||||
await expectPathname(page, params.pathname);
|
||||
await expect(page.locator('app-workspace-context-panel')).toHaveCount(0);
|
||||
await expect(page.locator('app-content-hero')).toContainText(params.title);
|
||||
await expect(
|
||||
page.locator('app-portal-detail-shell .shell__back-button').first()
|
||||
).toBeVisible({ timeout: 20000 });
|
||||
await expect(page.getByTestId('workspace-header-back')).toBeVisible({
|
||||
timeout: 20000,
|
||||
});
|
||||
}
|
||||
|
||||
async function playCurrentDetail(page: Page): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { Locator, Page } from '@playwright/test';
|
||||
import {
|
||||
closeElectronApp,
|
||||
expect,
|
||||
goToDashboard,
|
||||
importM3uPlaylistFromNativeDialog,
|
||||
launchElectronApp,
|
||||
test,
|
||||
writeTemporaryM3uFile,
|
||||
} from './electron-test-fixtures';
|
||||
|
||||
const sourcesRailId = 'dashboard-recent-sources-rail';
|
||||
const sourceCount = 4;
|
||||
/** The rail's stable `data-test-id` hooks, as CSS selectors. */
|
||||
const hooks = {
|
||||
viewport: `[data-test-id="${sourcesRailId}-viewport"]`,
|
||||
track: `[data-test-id="${sourcesRailId}-track"]`,
|
||||
card: `[data-test-id="${sourcesRailId}-card"]`,
|
||||
cardLink: `[data-test-id="${sourcesRailId}-card-link"]`,
|
||||
};
|
||||
|
||||
/**
|
||||
* Sizes the sources rail's cards so the last one is half visible: the rail
|
||||
* overflows by less than a card, and Chromium skips its own focus scroll for
|
||||
* an element that already shows 32px or more. Returns the card width.
|
||||
*/
|
||||
async function makeLastCardHalfVisible(rail: Locator): Promise<number> {
|
||||
const width = await rail.evaluate(
|
||||
(section, { count, selectors }) => {
|
||||
const host = section.parentElement as HTMLElement;
|
||||
const viewport = section.querySelector(
|
||||
selectors.viewport
|
||||
) as HTMLElement;
|
||||
const track = section.querySelector(selectors.track) as HTMLElement;
|
||||
const gap = parseFloat(getComputedStyle(track).columnGap) || 0;
|
||||
// count cards + (count - 1) gaps = viewport + half a card.
|
||||
const cardWidth = Math.floor(
|
||||
(viewport.clientWidth - gap * (count - 1)) / (count - 0.5)
|
||||
);
|
||||
host.style.setProperty('--cover-rail-width', `${cardWidth}px`);
|
||||
track.scrollTo({ left: 0, behavior: 'auto' });
|
||||
return cardWidth;
|
||||
},
|
||||
{ count: sourceCount, selectors: hooks }
|
||||
);
|
||||
await expect
|
||||
.poll(() => lastCardVisibleWidth(rail))
|
||||
.toBeGreaterThan(Math.min(64, width / 4));
|
||||
return width;
|
||||
}
|
||||
|
||||
/** Pixels of the last card inside the rail's visible viewport. */
|
||||
function lastCardVisibleWidth(rail: Locator): Promise<number> {
|
||||
return rail.evaluate((section, selectors) => {
|
||||
const viewport = section
|
||||
.querySelector(selectors.viewport)
|
||||
?.getBoundingClientRect();
|
||||
const cards = section.querySelectorAll(selectors.card);
|
||||
const card = cards[cards.length - 1]?.getBoundingClientRect();
|
||||
if (!viewport || !card) return 0;
|
||||
return (
|
||||
Math.min(card.right, viewport.right) -
|
||||
Math.max(card.left, viewport.left)
|
||||
);
|
||||
}, hooks);
|
||||
}
|
||||
|
||||
async function lastCardFullyVisible(rail: Locator): Promise<boolean> {
|
||||
return rail.evaluate((section, selectors) => {
|
||||
const viewport = section
|
||||
.querySelector(selectors.viewport)
|
||||
?.getBoundingClientRect();
|
||||
const cards = section.querySelectorAll(selectors.card);
|
||||
const card = cards[cards.length - 1]?.getBoundingClientRect();
|
||||
if (!viewport || !card) return false;
|
||||
return (
|
||||
card.left >= viewport.left - 1 && card.right <= viewport.right + 1
|
||||
);
|
||||
}, hooks);
|
||||
}
|
||||
|
||||
function lastCardLinkFocused(page: Page): Promise<boolean> {
|
||||
return page.evaluate((selector) => {
|
||||
const links = document.querySelectorAll(selector);
|
||||
return document.activeElement === links[links.length - 1];
|
||||
}, hooks.cardLink);
|
||||
}
|
||||
|
||||
test.describe('Dashboard rail focus', () => {
|
||||
test('reveals a partly hidden card on keyboard and programmatic focus, and keeps mouse clicks on it', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
for (let index = 1; index <= sourceCount; index++) {
|
||||
const filePath = writeTemporaryM3uFile(
|
||||
dataDir,
|
||||
`rail-focus-source-${index}.m3u`,
|
||||
[
|
||||
{
|
||||
groupTitle: 'News',
|
||||
name: `Rail Focus Channel ${index}`,
|
||||
url: `https://streams.example.test/rail-${index}.m3u8`,
|
||||
},
|
||||
]
|
||||
);
|
||||
await importM3uPlaylistFromNativeDialog(app, filePath);
|
||||
}
|
||||
|
||||
await goToDashboard(app.mainWindow);
|
||||
const rail = app.mainWindow.getByTestId(sourcesRailId);
|
||||
await expect(rail.locator(hooks.card)).toHaveCount(sourceCount);
|
||||
const cardLinks = rail.locator(hooks.cardLink);
|
||||
|
||||
// Tab from the first card to the last card's link.
|
||||
await makeLastCardHalfVisible(rail);
|
||||
await cardLinks.first().focus();
|
||||
for (
|
||||
let presses = 0;
|
||||
presses < sourceCount * 3 &&
|
||||
!(await lastCardLinkFocused(app.mainWindow));
|
||||
presses++
|
||||
) {
|
||||
await app.mainWindow.keyboard.press('Tab');
|
||||
}
|
||||
expect(await lastCardLinkFocused(app.mainWindow)).toBe(true);
|
||||
await expect.poll(() => lastCardFullyVisible(rail)).toBe(true);
|
||||
|
||||
// `focus()` from script after a mouse click elsewhere, starting
|
||||
// at the rail's start again.
|
||||
await rail.getByRole('heading').click();
|
||||
await rail.evaluate((section, selector) => {
|
||||
(document.activeElement as HTMLElement | null)?.blur();
|
||||
section
|
||||
.querySelector(selector)
|
||||
?.scrollTo({ left: 0, behavior: 'auto' });
|
||||
}, hooks.track);
|
||||
await expect.poll(() => lastCardFullyVisible(rail)).toBe(false);
|
||||
await cardLinks.last().evaluate((link) => {
|
||||
(link as HTMLElement).focus();
|
||||
});
|
||||
await expect.poll(() => lastCardFullyVisible(rail)).toBe(true);
|
||||
|
||||
// A mouse press focuses the link too; the rail must not slide it
|
||||
// away from under the pointer before the click lands.
|
||||
await rail.evaluate((section, selector) => {
|
||||
(document.activeElement as HTMLElement | null)?.blur();
|
||||
section
|
||||
.querySelector(selector)
|
||||
?.scrollTo({ left: 0, behavior: 'auto' });
|
||||
}, hooks.track);
|
||||
await expect.poll(() => lastCardFullyVisible(rail)).toBe(false);
|
||||
const box = await cardLinks.last().boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
await app.mainWindow.mouse.move(
|
||||
(box?.x ?? 0) + 24,
|
||||
(box?.y ?? 0) + (box?.height ?? 0) / 2
|
||||
);
|
||||
await app.mainWindow.mouse.down();
|
||||
expect(await lastCardLinkFocused(app.mainWindow)).toBe(true);
|
||||
expect(
|
||||
await rail
|
||||
.locator(hooks.track)
|
||||
.evaluate((track) => track.scrollLeft)
|
||||
).toBe(0);
|
||||
await app.mainWindow.mouse.up();
|
||||
await expect(app.mainWindow).not.toHaveURL(
|
||||
/\/workspace\/dashboard$/
|
||||
);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,343 +0,0 @@
|
||||
import type { Locator, Page } from '@playwright/test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
addXtreamPortal,
|
||||
clickFirstGridListCard,
|
||||
closeElectronApp,
|
||||
expect,
|
||||
launchElectronApp,
|
||||
openSettings,
|
||||
resetMockServers,
|
||||
saveSettings,
|
||||
test,
|
||||
waitForXtreamWorkspaceReady,
|
||||
} from './electron-test-fixtures';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The detail shell's sticky Back control floats over its scroll owner. While
|
||||
// it is shown, every content column reserves the control's lane, so no
|
||||
// scroll position may put it over the "Episodes" heading or the inline
|
||||
// player's top-left corner. Tested at a wide and a narrow window; the
|
||||
// workspace rail and category panel leave the detail pane far narrower than
|
||||
// either, and the heading used to wrap beside its actions (two lines at
|
||||
// 1280px, three at 780px) with its first word under the arrow.
|
||||
//
|
||||
// A pane under 400px cannot spare the lane: a 700px window beside the
|
||||
// category panel, or a phone. There the control sits in an opaque sticky bar,
|
||||
// and whatever reaches the arrow's band must be hidden beneath the bar, never
|
||||
// visible beside the arrow.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const widths = [1280, 780];
|
||||
const compactWidths = [700, 375];
|
||||
const playerCorner = 56;
|
||||
/** The widest translation of the heading; it must fit wherever English does. */
|
||||
const widestLocale = 'nl';
|
||||
const widestHeading = (
|
||||
JSON.parse(
|
||||
readFileSync(
|
||||
join(__dirname, `../../web/src/assets/i18n/${widestLocale}.json`),
|
||||
'utf8'
|
||||
)
|
||||
) as { PORTALS: { DETAIL: { EPISODES: string } } }
|
||||
).PORTALS.DETAIL.EPISODES;
|
||||
|
||||
type Sweep = {
|
||||
overlaps: string[];
|
||||
/** Scroll positions where the target shared the arrow's vertical band. */
|
||||
beside: { player: number; title: number };
|
||||
};
|
||||
|
||||
/**
|
||||
* Scrolls the shell from top to bottom in steps shorter than the arrow and
|
||||
* records every position where the arrow's box intersects the heading or the
|
||||
* player's top-left corner. Reads layout synchronously after each scroll
|
||||
* write, so a hidden or throttled window cannot skip frames.
|
||||
*/
|
||||
function sweepDetail(shell: Locator): Promise<Sweep> {
|
||||
return shell.evaluate((element, corner) => {
|
||||
const back = element.querySelector('.shell__back-button');
|
||||
if (!back) throw new Error('The detail shows no Back control.');
|
||||
const sweep = {
|
||||
overlaps: [] as string[],
|
||||
beside: { player: 0, title: 0 },
|
||||
};
|
||||
const max = element.scrollHeight - element.clientHeight;
|
||||
for (let top = 0; ; top = Math.min(max, top + 24)) {
|
||||
element.scrollTop = top;
|
||||
const arrow = back.getBoundingClientRect();
|
||||
const title = element
|
||||
.querySelector('[data-test-id="episodes-heading"]')
|
||||
?.getBoundingClientRect();
|
||||
const player = element
|
||||
.querySelector('app-portal-inline-player')
|
||||
?.getBoundingClientRect();
|
||||
const targets = {
|
||||
title,
|
||||
player: player && {
|
||||
left: player.left,
|
||||
top: player.top,
|
||||
right: player.left + corner,
|
||||
bottom: player.top + corner,
|
||||
},
|
||||
};
|
||||
for (const [name, box] of Object.entries(targets)) {
|
||||
if (!box) continue;
|
||||
const sameBand =
|
||||
box.top < arrow.bottom && arrow.top < box.bottom;
|
||||
if (!sameBand) continue;
|
||||
sweep.beside[name as keyof Sweep['beside']] += 1;
|
||||
if (box.left < arrow.right && arrow.left < box.right) {
|
||||
sweep.overlaps.push(`${name} at scrollTop ${top}`);
|
||||
}
|
||||
}
|
||||
if (top >= max) break;
|
||||
}
|
||||
element.scrollTop = 0;
|
||||
return sweep;
|
||||
}, playerCorner);
|
||||
}
|
||||
|
||||
type BarSweep = {
|
||||
background: string;
|
||||
/** Scroll positions where a target reached the bar's band. */
|
||||
beneath: number;
|
||||
/** Points of a target in the bar's band that hit content, not the bar. */
|
||||
exposed: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Compact counterpart of {@link sweepDetail}: the bar sits in flow and
|
||||
* content scrolls beneath it, so boxes do intersect. Instead, every sampled
|
||||
* point of the heading or the player's corner that is inside the bar's band
|
||||
* must hit-test to the bar: hidden beneath it, and unreachable by a tap.
|
||||
*/
|
||||
function sweepBar(shell: Locator): Promise<BarSweep> {
|
||||
return shell.evaluate((element, corner) => {
|
||||
const bar = element.querySelector('.shell__navigation');
|
||||
if (!bar) throw new Error('The detail shows no Back control.');
|
||||
const sweep = {
|
||||
background: getComputedStyle(bar).backgroundColor,
|
||||
beneath: 0,
|
||||
exposed: [] as string[],
|
||||
};
|
||||
const max = element.scrollHeight - element.clientHeight;
|
||||
for (let top = 0; ; top = Math.min(max, top + 24)) {
|
||||
element.scrollTop = top;
|
||||
const band = bar.getBoundingClientRect();
|
||||
const title = element
|
||||
.querySelector('[data-test-id="episodes-heading"]')
|
||||
?.getBoundingClientRect();
|
||||
const player = element
|
||||
.querySelector('app-portal-inline-player')
|
||||
?.getBoundingClientRect();
|
||||
const targets = {
|
||||
title,
|
||||
player: player && {
|
||||
left: player.left,
|
||||
top: player.top,
|
||||
right: player.left + corner,
|
||||
bottom: player.top + corner,
|
||||
},
|
||||
};
|
||||
for (const [name, box] of Object.entries(targets)) {
|
||||
if (!box) continue;
|
||||
const from = Math.max(box.top, band.top);
|
||||
const to = Math.min(box.bottom, band.bottom);
|
||||
if (to <= from) continue;
|
||||
sweep.beneath += 1;
|
||||
const y = (from + to) / 2;
|
||||
for (const x of [box.left + 2, box.right - 2]) {
|
||||
const hit = element.ownerDocument.elementFromPoint(x, y);
|
||||
if (!hit || !bar.contains(hit)) {
|
||||
sweep.exposed.push(
|
||||
`${name} at scrollTop ${top} hits ${hit?.tagName}.${String(hit?.className)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (top >= max) break;
|
||||
}
|
||||
element.scrollTop = 0;
|
||||
return sweep;
|
||||
}, playerCorner);
|
||||
}
|
||||
|
||||
/** Line boxes of the heading's text; 1 means it did not wrap. */
|
||||
function headingLineCount(page: Page): Promise<number> {
|
||||
return page
|
||||
.locator('[data-test-id="episodes-heading"]')
|
||||
.evaluate((heading) => {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(heading);
|
||||
const lines = [...range.getClientRects()]
|
||||
.filter((rect) => rect.width > 0)
|
||||
.map((rect) => Math.round(rect.top));
|
||||
return new Set(lines).size;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Lets the browse↔watch morph, the player's fade-in and the workspace's own
|
||||
* transitions settle — crossing into the phone layout slides the category
|
||||
* drawer out over the page for 200ms. Bounded, so a paused animation
|
||||
* elsewhere cannot stall the test.
|
||||
*/
|
||||
async function settle(shell: Locator): Promise<void> {
|
||||
await shell.evaluate((element) =>
|
||||
Promise.race([
|
||||
Promise.all(
|
||||
element.ownerDocument
|
||||
.getAnimations()
|
||||
.filter(
|
||||
(animation) =>
|
||||
animation.effect?.getTiming().iterations !==
|
||||
Infinity
|
||||
)
|
||||
.map((animation) =>
|
||||
animation.finished.catch(() => undefined)
|
||||
)
|
||||
),
|
||||
new Promise((resolve) => setTimeout(resolve, 2_000)),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
async function expectBackClearOfContent(
|
||||
page: Page,
|
||||
state: 'browse' | 'watch'
|
||||
): Promise<void> {
|
||||
const shell = page.locator('app-portal-detail-shell');
|
||||
for (const width of widths) {
|
||||
await page.setViewportSize({ width, height: 800 });
|
||||
// Fail on the mode first: a lane assertion against the bar would
|
||||
// only report confusing intersections.
|
||||
await expect(shell, `${state} at ${width}px`).not.toHaveClass(
|
||||
/shell-host--compact/
|
||||
);
|
||||
await settle(shell);
|
||||
const sweep = await sweepDetail(shell);
|
||||
|
||||
expect(sweep.overlaps, `${state} at ${width}px`).toEqual([]);
|
||||
// The sweep has to carry the heading (and the player) past the arrow,
|
||||
// or the empty overlap list proves nothing.
|
||||
expect(sweep.beside.title, `${state} at ${width}px`).toBeGreaterThan(0);
|
||||
if (state === 'watch') {
|
||||
expect(
|
||||
sweep.beside.player,
|
||||
`${state} at ${width}px`
|
||||
).toBeGreaterThan(0);
|
||||
}
|
||||
expect(await headingLineCount(page), `${state} at ${width}px`).toBe(1);
|
||||
}
|
||||
|
||||
for (const width of compactWidths) {
|
||||
await page.setViewportSize({ width, height: 800 });
|
||||
await expect(shell, `${state} at ${width}px`).toHaveClass(
|
||||
/shell-host--compact/
|
||||
);
|
||||
await settle(shell);
|
||||
const sweep = await sweepBar(shell);
|
||||
|
||||
expect(sweep.exposed, `${state} at ${width}px`).toEqual([]);
|
||||
expect(sweep.beneath, `${state} at ${width}px`).toBeGreaterThan(0);
|
||||
// Opaque, or what scrolls beneath would show through.
|
||||
expect(sweep.background, `${state} at ${width}px`).toMatch(/^rgb\(/);
|
||||
expect(await headingLineCount(page), `${state} at ${width}px`).toBe(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-checks the heading in the widest translation at the lane and bar widths.
|
||||
* Below them (a ~220px header beside the category panel) a translation wider
|
||||
* than the pane itself wraps by design rather than losing words to an
|
||||
* ellipsis.
|
||||
*/
|
||||
async function expectWidestHeadingOnOneLine(
|
||||
page: Page,
|
||||
detailUrl: string
|
||||
): Promise<void> {
|
||||
await page.setViewportSize({ width: widths[0], height: 800 });
|
||||
await openSettings(page);
|
||||
await page.getByTestId('select-language').click();
|
||||
await page.getByTestId(widestLocale).click();
|
||||
await saveSettings(page);
|
||||
await page.goBack();
|
||||
await page.waitForURL(detailUrl);
|
||||
await expect(page.locator('[data-test-id="episodes-heading"]')).toHaveText(
|
||||
widestHeading,
|
||||
{
|
||||
timeout: 20_000,
|
||||
}
|
||||
);
|
||||
for (const width of [...widths, ...compactWidths]) {
|
||||
await page.setViewportSize({ width, height: 800 });
|
||||
expect(
|
||||
await headingLineCount(page),
|
||||
`${widestLocale} at ${width}px`
|
||||
).toBe(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The actions move onto their own row before the heading wraps, at every
|
||||
* pane width that can hold the heading at all — including the widths where
|
||||
* the lane gives way to the bar.
|
||||
*/
|
||||
async function expectHeadingOnOneLine(page: Page): Promise<void> {
|
||||
const wrapped: number[] = [];
|
||||
for (let width = 680; width <= 1600; width += 20) {
|
||||
await page.setViewportSize({ width, height: 800 });
|
||||
if ((await headingLineCount(page)) !== 1) wrapped.push(width);
|
||||
}
|
||||
expect(wrapped).toEqual([]);
|
||||
}
|
||||
|
||||
test.describe('Portal detail Back lane', () => {
|
||||
test('@xtream @electron keeps the Back arrow off the heading and the player while scrolling', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
const page = app.mainWindow;
|
||||
await addXtreamPortal(page);
|
||||
await waitForXtreamWorkspaceReady(page);
|
||||
await page
|
||||
.getByRole('link', { name: 'Series', exact: true })
|
||||
.click();
|
||||
await clickFirstGridListCard(page);
|
||||
await page.waitForURL(
|
||||
/\/workspace\/xtreams\/[^/]+\/series\/[^/]+\/[^/]+$/
|
||||
);
|
||||
|
||||
const detailUrl = page.url();
|
||||
const shell = page.locator('app-portal-detail-shell');
|
||||
const episodes = page.locator('.episode-card');
|
||||
await expect(
|
||||
page.locator('[data-test-id="episodes-heading"]')
|
||||
).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(episodes.first()).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
await expectBackClearOfContent(page, 'browse');
|
||||
await expectHeadingOnOneLine(page);
|
||||
|
||||
await page.setViewportSize({ width: widths[0], height: 800 });
|
||||
await episodes.first().click();
|
||||
await expect(shell).toHaveClass(/shell-host--watch/);
|
||||
await expect(
|
||||
shell.locator('app-portal-inline-player app-web-player-view')
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
await expectBackClearOfContent(page, 'watch');
|
||||
await expectWidestHeadingOnOneLine(page, detailUrl);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,283 @@
|
||||
import type { Locator, Page } from '@playwright/test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
addXtreamPortal,
|
||||
clickFirstGridListCard,
|
||||
closeElectronApp,
|
||||
expect,
|
||||
launchElectronApp,
|
||||
openSettings,
|
||||
resetMockServers,
|
||||
saveSettings,
|
||||
test,
|
||||
waitForXtreamWorkspaceReady,
|
||||
} from './electron-test-fixtures';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// A detail page's Back lives in the workspace header's leading slot, not in
|
||||
// the page. Nothing floats over the scroll owner, so the content keeps its
|
||||
// full width: every column has symmetric insets instead of a reserved Back
|
||||
// lane. Checked at a wide and a narrow desktop window and at phone width,
|
||||
// where Back takes the context drawer toggle's slot.
|
||||
//
|
||||
// The "Seasons and Episodes" heading must also stay on one line: the detail
|
||||
// pane is far narrower than the window beside the rail and category panel,
|
||||
// and the heading used to wrap beside its actions.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const widths = [1280, 780, 375];
|
||||
const phoneWidth = 640;
|
||||
/** The widest translation of the heading; it must fit wherever English does. */
|
||||
const widestLocale = 'nl';
|
||||
const widestHeading = (
|
||||
JSON.parse(
|
||||
readFileSync(
|
||||
join(__dirname, `../../web/src/assets/i18n/${widestLocale}.json`),
|
||||
'utf8'
|
||||
)
|
||||
) as { PORTALS: { SEASONS_AND_EPISODES: string } }
|
||||
).PORTALS.SEASONS_AND_EPISODES;
|
||||
|
||||
const detailUrlPattern = /\/workspace\/xtreams\/[^/]+\/series\/[^/]+\/[^/]+$/;
|
||||
|
||||
function headerBack(page: Page): Locator {
|
||||
return page.getByTestId('workspace-header-back');
|
||||
}
|
||||
|
||||
/** Line boxes of the heading's text; 1 means it did not wrap. */
|
||||
function headingLineCount(page: Page): Promise<number> {
|
||||
return page.locator('.section-title').evaluate((heading) => {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(heading);
|
||||
const lines = [...range.getClientRects()]
|
||||
.filter((rect) => rect.width > 0)
|
||||
.map((rect) => Math.round(rect.top));
|
||||
return new Set(lines).size;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Columns whose start inset differs from their end inset. A reserved Back
|
||||
* lane shows up here as a wider start inset.
|
||||
*/
|
||||
function asymmetricColumns(shell: Locator): Promise<string[]> {
|
||||
return shell.evaluate((element) =>
|
||||
[
|
||||
'.hero__content',
|
||||
'.shell__player--active',
|
||||
'.shell__episodes',
|
||||
'.shell__extras',
|
||||
'app-content-about .about',
|
||||
].flatMap((selector) => {
|
||||
const column = element.querySelector(selector);
|
||||
if (!column || !column.getClientRects().length) return [];
|
||||
const style = getComputedStyle(column);
|
||||
return style.paddingLeft === style.paddingRight
|
||||
? []
|
||||
: [`${selector}: ${style.paddingLeft} / ${style.paddingRight}`];
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lets the browse↔watch morph, the player's fade-in and the workspace's own
|
||||
* transitions settle — crossing into the phone layout slides the category
|
||||
* drawer out over the page for 200ms. Bounded, so a paused animation
|
||||
* elsewhere cannot stall the test.
|
||||
*/
|
||||
async function settle(shell: Locator): Promise<void> {
|
||||
await shell.evaluate((element) =>
|
||||
Promise.race([
|
||||
Promise.all(
|
||||
element.ownerDocument
|
||||
.getAnimations()
|
||||
.filter(
|
||||
(animation) =>
|
||||
animation.effect?.getTiming().iterations !==
|
||||
Infinity
|
||||
)
|
||||
.map((animation) =>
|
||||
animation.finished.catch(() => undefined)
|
||||
)
|
||||
),
|
||||
new Promise((resolve) => setTimeout(resolve, 2_000)),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
async function expectBackInHeader(
|
||||
page: Page,
|
||||
state: 'browse' | 'watch'
|
||||
): Promise<void> {
|
||||
const shell = page.locator('app-portal-detail-shell');
|
||||
for (const width of widths) {
|
||||
const label = `${state} at ${width}px`;
|
||||
await page.setViewportSize({ width, height: 800 });
|
||||
await settle(shell);
|
||||
|
||||
const back = headerBack(page);
|
||||
await expect(back, label).toBeVisible();
|
||||
await expect(back, label).toHaveAccessibleName('Back');
|
||||
// Escape unwinds the page in browse; in watch it closes the player.
|
||||
if (state === 'browse') {
|
||||
await expect(back, label).toHaveAttribute(
|
||||
'aria-keyshortcuts',
|
||||
'Escape'
|
||||
);
|
||||
} else {
|
||||
await expect(back, label).not.toHaveAttribute('aria-keyshortcuts');
|
||||
}
|
||||
await expect(
|
||||
page
|
||||
.locator('app-workspace-shell-header')
|
||||
.getByTestId('workspace-header-back'),
|
||||
label
|
||||
).toHaveCount(1);
|
||||
// The page itself carries no second arrow.
|
||||
await expect(
|
||||
shell.getByRole('button', { name: 'Back', exact: true }),
|
||||
label
|
||||
).toHaveCount(0);
|
||||
expect(await asymmetricColumns(shell), label).toEqual([]);
|
||||
// On a phone, Back takes the drawer toggle's slot in the header.
|
||||
await expect(
|
||||
page.getByTestId('context-drawer-toggle'),
|
||||
label
|
||||
).toHaveCount(0);
|
||||
if (width <= phoneWidth) {
|
||||
const [backBox, switcherBox] = await Promise.all([
|
||||
back.boundingBox(),
|
||||
page.locator('app-playlist-switcher').boundingBox(),
|
||||
]);
|
||||
expect(backBox?.x ?? Infinity, label).toBeLessThan(
|
||||
switcherBox?.x ?? -Infinity
|
||||
);
|
||||
}
|
||||
expect(await headingLineCount(page), label).toBe(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-checks the heading in the widest translation. Below these widths (a
|
||||
* ~220px header beside the category panel) a translation wider than the pane
|
||||
* itself wraps by design rather than losing words to an ellipsis.
|
||||
*/
|
||||
async function expectWidestHeadingOnOneLine(
|
||||
page: Page,
|
||||
detailUrl: string
|
||||
): Promise<void> {
|
||||
await page.setViewportSize({ width: widths[0], height: 800 });
|
||||
await openSettings(page);
|
||||
await page.getByTestId('select-language').click();
|
||||
await page.getByTestId(widestLocale).click();
|
||||
await saveSettings(page);
|
||||
await page.goBack();
|
||||
await page.waitForURL(detailUrl);
|
||||
await expect(page.locator('.section-title')).toHaveText(widestHeading, {
|
||||
timeout: 20_000,
|
||||
});
|
||||
for (const width of [...widths, 700]) {
|
||||
await page.setViewportSize({ width, height: 800 });
|
||||
expect(
|
||||
await headingLineCount(page),
|
||||
`${widestLocale} at ${width}px`
|
||||
).toBe(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The actions move onto their own row before the heading wraps, at every
|
||||
* pane width that can hold the heading at all.
|
||||
*/
|
||||
async function expectHeadingOnOneLine(page: Page): Promise<void> {
|
||||
const wrapped: number[] = [];
|
||||
for (let width = 680; width <= 1600; width += 20) {
|
||||
await page.setViewportSize({ width, height: 800 });
|
||||
if ((await headingLineCount(page)) !== 1) wrapped.push(width);
|
||||
}
|
||||
expect(wrapped).toEqual([]);
|
||||
}
|
||||
|
||||
async function openFirstSeries(page: Page): Promise<string> {
|
||||
await page.getByRole('link', { name: 'Series', exact: true }).click();
|
||||
await clickFirstGridListCard(page);
|
||||
await page.waitForURL(detailUrlPattern);
|
||||
await expect(page.locator('.section-title')).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(page.locator('.episode-card').first()).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
return page.url();
|
||||
}
|
||||
|
||||
async function startFirstEpisode(page: Page): Promise<void> {
|
||||
const shell = page.locator('app-portal-detail-shell');
|
||||
await page.locator('.episode-card').first().click();
|
||||
await expect(shell).toHaveClass(/shell-host--watch/);
|
||||
await expect(
|
||||
shell.locator('app-portal-inline-player app-web-player-view')
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
}
|
||||
|
||||
/** The header Back leaves the detail and is gone from the list it opens. */
|
||||
async function expectHeaderBackReturnsToList(page: Page): Promise<void> {
|
||||
await headerBack(page).click();
|
||||
await expect(page).not.toHaveURL(detailUrlPattern);
|
||||
await expect(page.locator('app-portal-detail-shell')).toHaveCount(0);
|
||||
await expect(headerBack(page)).toHaveCount(0);
|
||||
}
|
||||
|
||||
test.describe('Portal detail header Back', () => {
|
||||
test('@xtream @electron keeps Back in the header and the detail columns at full width', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
const page = app.mainWindow;
|
||||
await addXtreamPortal(page);
|
||||
await waitForXtreamWorkspaceReady(page);
|
||||
await expect(headerBack(page)).toHaveCount(0);
|
||||
|
||||
const detailUrl = await openFirstSeries(page);
|
||||
await expectBackInHeader(page, 'browse');
|
||||
await expectHeadingOnOneLine(page);
|
||||
|
||||
await page.setViewportSize({ width: widths[0], height: 800 });
|
||||
await startFirstEpisode(page);
|
||||
await expectBackInHeader(page, 'watch');
|
||||
await expectWidestHeadingOnOneLine(page, detailUrl);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('@xtream @electron returns to the list from the header in browse and watch', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
const page = app.mainWindow;
|
||||
await addXtreamPortal(page);
|
||||
await waitForXtreamWorkspaceReady(page);
|
||||
|
||||
await openFirstSeries(page);
|
||||
await expectHeaderBackReturnsToList(page);
|
||||
|
||||
await clickFirstGridListCard(page);
|
||||
await page.waitForURL(detailUrlPattern);
|
||||
await startFirstEpisode(page);
|
||||
// Route-level in watch too: it leaves the page, not just the player.
|
||||
await expectHeaderBackReturnsToList(page);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1013,7 +1013,7 @@ export async function saveSettings(page: Page): Promise<void> {
|
||||
|
||||
export async function goToDashboard(page: Page): Promise<void> {
|
||||
const dashboardLink = page
|
||||
.locator('a.brand[href$="/workspace/dashboard"]')
|
||||
.locator('app-workspace-shell-rail a[href$="/workspace/dashboard"]')
|
||||
.first();
|
||||
|
||||
await expect(dashboardLink).toBeVisible();
|
||||
@@ -1266,7 +1266,7 @@ export async function clearCurrentUnifiedCollection(page: Page): Promise<void> {
|
||||
|
||||
const dialog = page.locator('mat-dialog-container').last();
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('button', { name: /^Yes$/i }).click();
|
||||
await dialog.getByRole('button', { name: 'Clear', exact: true }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
}
|
||||
|
||||
@@ -1664,7 +1664,7 @@ export async function deleteSource(page: Page, title: string): Promise<void> {
|
||||
|
||||
await expect(row).toBeVisible();
|
||||
await row.locator('.delete-btn').click();
|
||||
await confirmDialog(page);
|
||||
await confirmDialog(page, 'Remove playlist');
|
||||
}
|
||||
|
||||
export async function refreshSource(
|
||||
@@ -1681,7 +1681,7 @@ export async function refreshSource(
|
||||
await row.locator('.refresh-btn').click();
|
||||
|
||||
if (confirm) {
|
||||
await confirmDialog(page);
|
||||
await confirmDialog(page, 'Refresh playlist');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2098,7 +2098,7 @@ async function openCommandPalette(page: Page): Promise<Locator> {
|
||||
return dialog;
|
||||
}
|
||||
|
||||
async function confirmDialog(page: Page, buttonLabel = 'Yes'): Promise<void> {
|
||||
async function confirmDialog(page: Page, buttonLabel: string): Promise<void> {
|
||||
const dialog = page.locator('mat-dialog-container');
|
||||
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
@@ -606,12 +606,9 @@ async function addCurrentDetailToFavorites(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
async function goBackFromDetail(page: Page): Promise<void> {
|
||||
// Return to the list: the shell's sticky Back is route-level in browse
|
||||
// and watch alike (closing the player is the bar's own Close button).
|
||||
const backButton = page
|
||||
.locator('app-portal-detail-shell')
|
||||
.first()
|
||||
.getByRole('button', { name: 'Back', exact: true });
|
||||
// Return to the list: the header's Back is route-level in browse and
|
||||
// watch alike (closing the player is the bar's own Close button).
|
||||
const backButton = page.getByTestId('workspace-header-back');
|
||||
|
||||
await expect(backButton).toBeVisible({ timeout: 20000 });
|
||||
try {
|
||||
@@ -637,9 +634,9 @@ async function expectInlineCollectionDetail(
|
||||
);
|
||||
await expect(page.locator('app-workspace-context-panel')).toHaveCount(0);
|
||||
await expect(page.locator('app-content-hero')).toContainText(params.title);
|
||||
await expect(
|
||||
page.locator('app-portal-detail-shell .shell__back-button').first()
|
||||
).toBeVisible({ timeout: 20000 });
|
||||
await expect(page.getByTestId('workspace-header-back')).toBeVisible({
|
||||
timeout: 20000,
|
||||
});
|
||||
}
|
||||
|
||||
async function expectInlinePlayerWithoutDialog(page: Page): Promise<void> {
|
||||
|
||||
@@ -163,7 +163,10 @@ test.describe('Live channel rail collapse (#1458)', () => {
|
||||
await app.mainWindow
|
||||
.getByRole('button', { name: 'Clear recently viewed Live TV' })
|
||||
.click();
|
||||
await app.mainWindow.getByRole('button', { name: 'Yes' }).click();
|
||||
await app.mainWindow
|
||||
.locator('mat-dialog-container')
|
||||
.getByRole('button', { name: 'Clear', exact: true })
|
||||
.click();
|
||||
await expect(
|
||||
channelItemByTitle(app.mainWindow, 'Channel Alpha')
|
||||
).toHaveCount(0);
|
||||
|
||||
@@ -35,7 +35,8 @@ describe('Xtream scenario driver source wiring', () => {
|
||||
"'app-playlist-item'",
|
||||
"'.refresh-btn'",
|
||||
"'.delete-btn'",
|
||||
"'Yes'",
|
||||
"'Refresh playlist'",
|
||||
"'Remove playlist'",
|
||||
"'.busy-state__message'",
|
||||
"'.action-spinner'",
|
||||
"'.cancel-btn'",
|
||||
|
||||
@@ -157,10 +157,15 @@ describe('Xtream Playwright scenario driver', () => {
|
||||
});
|
||||
|
||||
it('does not require a misleading portal-status badge before refresh or delete', async () => {
|
||||
for (const [scenarioId, selector] of [
|
||||
[XTREAM_SCENARIO_ID.REFRESH_LARGE, '.refresh-btn'],
|
||||
[XTREAM_SCENARIO_ID.DELETE_LARGE, '.delete-btn'],
|
||||
for (const [scenarioId, selector, confirmLabel] of [
|
||||
[
|
||||
XTREAM_SCENARIO_ID.REFRESH_LARGE,
|
||||
'.refresh-btn',
|
||||
'Refresh playlist',
|
||||
],
|
||||
[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(),
|
||||
@@ -168,7 +173,7 @@ describe('Xtream Playwright scenario driver', () => {
|
||||
);
|
||||
|
||||
assert.equal(fake.clicksMatching(selector), 1);
|
||||
assert.equal(fake.clicksMatching('name=Yes;exact=true'), 0);
|
||||
assert.equal(fake.clicksMatching(confirmClick), 0);
|
||||
const portalReadyIndex = fake.log.findIndex((entry) =>
|
||||
entry.includes('[aria-label="Portal status: active"]:visible')
|
||||
);
|
||||
@@ -179,7 +184,7 @@ describe('Xtream Playwright scenario driver', () => {
|
||||
assert.equal(portalReadyIndex, -1);
|
||||
assert.ok(sourceActionIndex >= 0);
|
||||
await prepared.trigger();
|
||||
assert.equal(fake.clicksMatching('name=Yes;exact=true'), 1);
|
||||
assert.equal(fake.clicksMatching(confirmClick), 1);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -83,7 +83,10 @@ export async function prepareXtreamSourceActionTrigger(
|
||||
const dialog = page.locator('mat-dialog-container').last();
|
||||
await dialog.waitFor({ state: 'visible' });
|
||||
const confirmation = dialog
|
||||
.getByRole('button', { name: 'Yes', exact: true })
|
||||
.getByRole('button', {
|
||||
name: action === 'refresh' ? 'Refresh playlist' : 'Remove playlist',
|
||||
exact: true,
|
||||
})
|
||||
.last();
|
||||
await assertTriggerReady(confirmation, `xtream-source-${action}-confirm`);
|
||||
return confirmation;
|
||||
|
||||
@@ -87,7 +87,7 @@ export async function runXtreamBackgroundUiActions(
|
||||
async () => {
|
||||
const start = await clickSelector(
|
||||
page,
|
||||
'a.brand[href$="/workspace/dashboard"]'
|
||||
'app-workspace-shell-rail a[href$="/workspace/dashboard"]'
|
||||
);
|
||||
await page.waitForFunction(() => {
|
||||
const overlay = document.querySelector(
|
||||
|
||||
@@ -245,7 +245,9 @@ async function refreshRecentXtreamSourceFromDashboard(
|
||||
|
||||
const dialog = page.locator('mat-dialog-container');
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('button', { name: 'Yes', exact: true }).click();
|
||||
await dialog
|
||||
.getByRole('button', { name: 'Refresh playlist', exact: true })
|
||||
.click();
|
||||
|
||||
const refreshOverlay = page.locator('app-workspace-shell-import-overlay');
|
||||
await expect(refreshOverlay).toBeVisible({ timeout: 5000 });
|
||||
|
||||
@@ -676,7 +676,10 @@ async function clearRecentItems(page: Page, typeLabel: string): Promise<void> {
|
||||
await page
|
||||
.getByRole('button', { name: `Clear recently viewed ${typeLabel}` })
|
||||
.click();
|
||||
await page.getByRole('button', { name: 'Yes' }).click();
|
||||
await page
|
||||
.locator('mat-dialog-container')
|
||||
.getByRole('button', { name: 'Clear', exact: true })
|
||||
.click();
|
||||
}
|
||||
|
||||
async function expectUnifiedLiveDetailOpen(
|
||||
@@ -694,12 +697,9 @@ async function expectUnifiedLiveDetailOpen(
|
||||
}
|
||||
|
||||
async function goBackFromDetail(page: Page): Promise<void> {
|
||||
// Return to the list: the shell's sticky Back is route-level in browse
|
||||
// and watch alike (closing the player is the bar's own Close button).
|
||||
const backButton = page
|
||||
.locator('app-portal-detail-shell')
|
||||
.first()
|
||||
.getByRole('button', { name: 'Back', exact: true });
|
||||
// Return to the list: the header's Back is route-level in browse and
|
||||
// watch alike (closing the player is the bar's own Close button).
|
||||
const backButton = page.getByTestId('workspace-header-back');
|
||||
|
||||
await expect(backButton).toBeVisible({ timeout: 20000 });
|
||||
try {
|
||||
@@ -725,9 +725,9 @@ async function expectInlineCollectionDetail(
|
||||
);
|
||||
await expect(page.locator('app-workspace-context-panel')).toHaveCount(0);
|
||||
await expect(page.locator('app-content-hero')).toContainText(params.title);
|
||||
await expect(
|
||||
page.locator('app-portal-detail-shell .shell__back-button').first()
|
||||
).toBeVisible({ timeout: 20000 });
|
||||
await expect(page.getByTestId('workspace-header-back')).toBeVisible({
|
||||
timeout: 20000,
|
||||
});
|
||||
}
|
||||
|
||||
async function expectInlinePlayerWithoutDialog(page: Page): Promise<void> {
|
||||
|
||||
@@ -1540,12 +1540,9 @@ async function addCurrentDetailToFavorites(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
async function goBackFromDetail(page: Page): Promise<void> {
|
||||
// Return to the list: the shell's sticky Back is route-level in browse
|
||||
// and watch alike (closing the player is the bar's own Close button).
|
||||
const backButton = page
|
||||
.locator('app-portal-detail-shell')
|
||||
.first()
|
||||
.getByRole('button', { name: 'Back', exact: true });
|
||||
// Return to the list: the header's Back is route-level in browse and
|
||||
// watch alike (closing the player is the bar's own Close button).
|
||||
const backButton = page.getByTestId('workspace-header-back');
|
||||
|
||||
await expect(backButton).toBeVisible({ timeout: 20000 });
|
||||
await backButton.click();
|
||||
|
||||
@@ -657,10 +657,11 @@ test.describe('Electron Settings', () => {
|
||||
exact: true,
|
||||
})
|
||||
).toHaveCount(0);
|
||||
await expect(secondLaunch.mainWindow.locator('a.brand')).toHaveAttribute(
|
||||
'href',
|
||||
/\/workspace\/sources$/
|
||||
);
|
||||
await expect(
|
||||
secondLaunch.mainWindow
|
||||
.locator('app-workspace-shell-rail a.nav-item')
|
||||
.first()
|
||||
).toHaveAttribute('href', /\/workspace\/sources$/);
|
||||
} finally {
|
||||
await closeElectronApp(secondLaunch);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
expect,
|
||||
launchElectronApp,
|
||||
openSettings,
|
||||
openSettingsSection,
|
||||
test,
|
||||
} from './electron-test-fixtures';
|
||||
import { applyTheme } from './theme-contrast';
|
||||
@@ -193,4 +194,55 @@ test.describe('Theme tokens', () => {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('@theme @electron destructive actions name the action and use the error color', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const app = await launchElectronApp(dataDir);
|
||||
const page = app.mainWindow;
|
||||
try {
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, 'epg');
|
||||
|
||||
for (const theme of ['light', 'dark'] as const) {
|
||||
await applyTheme(page, theme);
|
||||
const error = await resolveColor(page, 'var(--mat-sys-error)');
|
||||
const trigger = page.getByRole('button', {
|
||||
name: 'Clear EPG data',
|
||||
exact: true,
|
||||
});
|
||||
// The `warn` color input was a no-op with M3: the trigger
|
||||
// rendered in the primary color.
|
||||
await expect
|
||||
.poll(() =>
|
||||
trigger.evaluate((el) => getComputedStyle(el).color)
|
||||
)
|
||||
.toBe(error);
|
||||
await trigger.click();
|
||||
|
||||
const dialog = page.locator('mat-dialog-container');
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(
|
||||
dialog.getByRole('button', { name: 'Yes' })
|
||||
).toHaveCount(0);
|
||||
const confirm = dialog.getByTestId('confirm-dialog-confirm');
|
||||
await expect(confirm).toHaveText('Clear EPG data');
|
||||
await expect(confirm).toHaveClass(/app-destructive-button/);
|
||||
await expect
|
||||
.poll(() =>
|
||||
confirm.evaluate(
|
||||
(el) => getComputedStyle(el).backgroundColor
|
||||
)
|
||||
)
|
||||
.toBe(error);
|
||||
|
||||
await dialog
|
||||
.getByRole('button', { name: 'Cancel', exact: true })
|
||||
.click();
|
||||
await expect(dialog).toBeHidden();
|
||||
}
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
waitForXtreamCatalog,
|
||||
xtreamMockServer,
|
||||
} from './electron-test-fixtures';
|
||||
import { applyTheme } from './theme-contrast';
|
||||
|
||||
const stressPortalName = 'Stress Xtream Portal';
|
||||
const stressXtreamUsername = 'stress';
|
||||
@@ -292,7 +293,7 @@ test.describe('Electron Xtream Responsiveness', () => {
|
||||
);
|
||||
await expect(confirmDialog).toBeVisible();
|
||||
await confirmDialog
|
||||
.getByRole('button', { name: 'Yes', exact: true })
|
||||
.getByRole('button', { name: 'Remove playlist', exact: true })
|
||||
.click();
|
||||
|
||||
await waitForDbOperationEvent(app.mainWindow, {
|
||||
@@ -304,7 +305,25 @@ test.describe('Electron Xtream Responsiveness', () => {
|
||||
await expect(
|
||||
playlistRow.locator('.busy-state__message')
|
||||
).toBeVisible({ timeout: 20000 });
|
||||
await expect(playlistRow.locator('.cancel-btn')).toBeVisible();
|
||||
const cancel = playlistRow.locator('.cancel-btn');
|
||||
await expect(cancel).toBeVisible();
|
||||
// The row's action buttons inherit its color; the cancel action
|
||||
// must still read as destructive on the selected row.
|
||||
for (const theme of ['light', 'dark'] as const) {
|
||||
await applyTheme(app.mainWindow, theme);
|
||||
const error = await app.mainWindow.evaluate(() => {
|
||||
const probe = document.createElement('div');
|
||||
probe.style.color = 'var(--mat-sys-error)';
|
||||
document.body.appendChild(probe);
|
||||
const color = getComputedStyle(probe).color;
|
||||
probe.remove();
|
||||
return color;
|
||||
});
|
||||
expect(
|
||||
await cancel.evaluate((el) => getComputedStyle(el).color),
|
||||
`cancel color in ${theme} theme`
|
||||
).toBe(error);
|
||||
}
|
||||
|
||||
await waitForDbOperationEvent(app.mainWindow, {
|
||||
operation: 'delete-playlist',
|
||||
|
||||
@@ -247,10 +247,10 @@ for (const theme of ['light', 'dark']) {
|
||||
await expect
|
||||
.poll(() => shell.evaluate((el) => el.scrollTop))
|
||||
.toBeGreaterThan(0);
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(
|
||||
shell.locator('.shell__back-button')
|
||||
).toBeFocused();
|
||||
// Back lives in the header, which never scrolls away.
|
||||
const back = page.getByTestId('workspace-header-back');
|
||||
await expect(back).toBeInViewport();
|
||||
await back.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(shell).toHaveCount(0);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user