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:
4gray committed 2026-10-03 11:25:57 +02:00
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 });
+10 -10
View File
@@ -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> {
+3 -6
View File
@@ -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);
}