test(e2e): follow the cinematic details in the Electron and web flows

The series menu trigger renders data-testid, so the Electron specs use
an attribute locator instead of getByTestId (data-test-id there). The
back-lane sweep targets the "Episodes" heading, the primary action is
read by its test id (its name now carries the runtime line), the M3U
inset assertion accepts the hero's own 32/20px inset, and the surface
contrast checks measure the flat episode card's artwork, which gets a
light hairline so its edge survives the light theme. The menu-closing
helper checks the panel rather than a backdrop locator that can match
a second overlay.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-10-03 10:14:28 +02:00
1 parent 25cc6b1ced
commit 36ce98e18c
10 files changed
+145 -66

No files matched your search

@@ -17,8 +17,8 @@ import {
// ---------------------------------------------------------------------------
// 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 "Seasons and Episodes" heading or the
// inline player's top-left corner. Tested at a wide and a narrow window; the
// 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.
@@ -40,8 +40,8 @@ const widestHeading = (
join(__dirname, `../../web/src/assets/i18n/${widestLocale}.json`),
'utf8'
)
) as { PORTALS: { SEASONS_AND_EPISODES: string } }
).PORTALS.SEASONS_AND_EPISODES;
) as { PORTALS: { DETAIL: { EPISODES: string } } }
).PORTALS.DETAIL.EPISODES;
type Sweep = {
overlaps: string[];
@@ -68,7 +68,7 @@ function sweepDetail(shell: Locator): Promise<Sweep> {
element.scrollTop = top;
const arrow = back.getBoundingClientRect();
const title = element
.querySelector('.section-title')
.querySelector('[data-test-id="episodes-heading"]')
?.getBoundingClientRect();
const player = element
.querySelector('app-portal-inline-player')
@@ -127,7 +127,7 @@ function sweepBar(shell: Locator): Promise<BarSweep> {
element.scrollTop = top;
const band = bar.getBoundingClientRect();
const title = element
.querySelector('.section-title')
.querySelector('[data-test-id="episodes-heading"]')
?.getBoundingClientRect();
const player = element
.querySelector('app-portal-inline-player')
@@ -166,14 +166,16 @@ function sweepBar(shell: Locator): Promise<BarSweep> {
/** 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;
});
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;
});
}
/**
@@ -263,9 +265,12 @@ async function expectWidestHeadingOnOneLine(
await saveSettings(page);
await page.goBack();
await page.waitForURL(detailUrl);
await expect(page.locator('.section-title')).toHaveText(widestHeading, {
timeout: 20_000,
});
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(
@@ -312,7 +317,9 @@ test.describe('Portal detail Back lane', () => {
const detailUrl = page.url();
const shell = page.locator('app-portal-detail-shell');
const episodes = page.locator('.episode-card');
await expect(page.locator('.section-title')).toBeVisible({
await expect(
page.locator('[data-test-id="episodes-heading"]')
).toBeVisible({
timeout: 20_000,
});
await expect(episodes.first()).toBeVisible({ timeout: 20_000 });
@@ -642,12 +642,12 @@ test.describe('Electron Downloads', () => {
const providerDetail = app.mainWindow.locator(
'main app-portal-detail-shell'
);
await expect(
providerDetail.getByRole('button', {
name: 'Play',
exact: true,
})
).toBeVisible();
// The primary action reads "Play" plus its runtime line.
const primaryAction = providerDetail.locator(
'[data-testid="vod-primary-action"]'
);
await expect(primaryAction).toBeVisible();
await expect(primaryAction).toContainText('Play');
await expect(
providerDetail.getByText('Offline', { exact: true })
).toHaveCount(0);
@@ -107,10 +107,9 @@ async function openSeriesDetail(
return clickedTitle;
}
/** Opens the series "…" menu and returns the row with the given test id. */
async function seriesMenuRow(page: Page, rowTestId: string) {
const trigger = page.getByTestId('series-more-menu');
const trigger = page.locator('[data-testid="series-more-menu"]');
await expect(trigger).toBeVisible({ timeout: 20_000 });
const row = page.locator(`[data-test-id="${rowTestId}"]`);
if (!(await row.isVisible().catch(() => false))) {
@@ -122,9 +121,13 @@ async function seriesMenuRow(page: Page, rowTestId: string) {
/** Closes an open menu without choosing a row. */
async function closeSeriesMenu(page: Page): Promise<void> {
if (await page.locator('.cdk-overlay-backdrop').isVisible().catch(() => false)) {
// The panel, not the backdrop: a backdrop locator can match a second
// overlay and then fail the strict check, leaving the menu open.
const panel = page.locator('[data-test-id="vod-more-menu"]');
if (await panel.isVisible().catch(() => false)) {
await page.keyboard.press('Escape');
}
await expect(panel).toBeHidden({ timeout: 10_000 });
}
test.describe('Electron Season Watched Toggle', () => {
@@ -338,15 +341,15 @@ test.describe('Electron Season Watched Toggle', () => {
app.mainWindow,
playlistId
);
expect(
new Set(seriesRows.map((row) => row.seasonNumber))
).toEqual(new Set([1, 2, 3]));
expect(
seriesRows.every((row) => row.positionSeconds > 0)
).toBe(true);
expect(new Set(seriesRows.map((row) => row.seasonNumber))).toEqual(
new Set([1, 2, 3])
);
expect(seriesRows.every((row) => row.positionSeconds > 0)).toBe(
true
);
// Unwatch-all clears all 24 rows again through the clear batch.
await seriesMenuTrigger.click();
await seriesMenuRow(app.mainWindow, 'toggle-series-watched');
await expect(seriesToggle).toContainText(
'Mark series as unwatched'
);
@@ -160,7 +160,9 @@ test.describe('Electron Series Download Queue', () => {
})
.toEqual(expectedEpisodeQueue.slice(0, 2));
await app.mainWindow.getByTestId('series-more-menu').click();
await app.mainWindow
.locator('[data-testid="series-more-menu"]')
.click();
const seasonAction = app.mainWindow.locator(
'[data-test-id="download-season"]'
);
@@ -180,8 +182,13 @@ test.describe('Electron Series Download Queue', () => {
})
.toEqual(expectedEpisodeQueue);
// Choosing the row closed the menu; reopen it to read the row.
await app.mainWindow
.locator('[data-testid="series-more-menu"]')
.click();
await expect(seasonAction).toBeDisabled();
await expect(seasonAction).toContainText('Download season (0)');
await app.mainWindow.keyboard.press('Escape');
await waitForQueueStabilityInterval();
expect(await readEpisodeQueue(app.mainWindow)).toEqual(
+8 -2
View File
@@ -245,11 +245,17 @@ export async function expectSeriesSurfacesInBothThemes(
await expect
.poll(() =>
rasterizedBorderContrast(
shell.locator('[data-testid="series-favorite-toggle"]').first()
shell
.locator('[data-testid="series-favorite-toggle"]')
.first()
)
)
.toBeGreaterThan(1.1);
for (const selector of ['.episode-card', 'mat-button-toggle-group']) {
// The flat card's visible edge is its artwork's hairline.
for (const selector of [
'.episode-card__thumbnail',
'mat-button-toggle-group',
]) {
await expect
.poll(
async () =>
+4 -3
View File
@@ -393,7 +393,7 @@ test('@web @m3u @tmdb browse and watch keep the adjusted volume', async ({
await page.keyboard.press('Escape');
await expect(inlineVideo(page)).toHaveCount(0);
const playButton = page.locator('[data-test-id="m3u-vod-play"]');
const playButton = page.locator('[data-testid="m3u-vod-play"]');
await expect(playButton).toBeVisible();
// Browse → Play remounts the engine without a channel change.
@@ -417,12 +417,13 @@ test('@web @m3u @tmdb browse and watch keep the adjusted volume', async ({
await expect(inlineVideo(page)).toHaveCount(0);
await expect(shell.locator('.shell__back-button')).toHaveCount(0);
// Without the arrow there is no lane to reserve: the hero keeps its own
// inset instead of the 72px column portal details give the Back control.
// inset (32px, or 20px in a pane narrower than 760px) instead of the
// 72px column portal details give the Back control.
expect(
await shell
.locator('.hero__content')
.evaluate((el) => getComputedStyle(el).paddingInlineStart)
).toBe('40px');
).toMatch(/^(20|32)px$/);
await shell.focus();
await page.keyboard.press('Escape');
await expect(playButton).toBeVisible();
+7 -9
View File
@@ -29,9 +29,7 @@ async function addXtreamPortal(page: Page): Promise<void> {
const dialog = page.locator('mat-dialog-container');
await expect(dialog).toBeVisible();
// v0.22 redesign: tabs were replaced with a flat 5-card radio picker.
await dialog
.getByRole('radio', { name: /Xtream credentials/i })
.click();
await dialog.getByRole('radio', { name: /Xtream credentials/i }).click();
await dialog.locator('#title').fill('Self-hosted Xtream');
await dialog.locator('#serverUrl').fill(XTREAM_MOCK_SERVER);
@@ -160,9 +158,9 @@ test('@self-hosted Xtream portal loads through web-backend proxy', async ({
await expect(vodItem).toBeVisible({ timeout: 30_000 });
await vodItem.click();
await expect(page).toHaveURL(/\/workspace\/xtreams\/[^/]+\/vod\/\d+\/\d+/);
await expect(
page.getByRole('button', { name: 'Play', exact: true })
).toBeVisible({ timeout: 15_000 });
const primaryAction = page.locator('[data-testid="vod-primary-action"]');
await expect(primaryAction).toBeVisible({ timeout: 15_000 });
await expect(primaryAction).toContainText('Play');
expect(
consoleErrors.filter((message) =>
@@ -257,9 +255,9 @@ test('@self-hosted M3U User-Agent reaches the provider on import and refresh', a
const row = sourceRowByTitle(page, 'Protected M3U');
await row.hover();
await row.locator('.refresh-btn').click();
await expect(page.locator('.mat-mdc-snack-bar-label').last()).toContainText(
'updated'
);
await expect(
page.locator('.mat-mdc-snack-bar-label').last()
).toContainText('updated');
await page.goto(catalogUrl);
await expect(page.getByText('1. Refreshed UA Channel')).toBeVisible();
expect(received.slice(1)).toEqual([userAgent, userAgent]);
+2 -2
View File
@@ -666,8 +666,8 @@ and drive the season container's presenters; the container's header is
toggle. The checked toggle uses `--app-selection-surface` and
`--app-selection-color`; hover uses the app's neutral surface treatment.
Episode cards are flat: a 16:9 thumbnail with a 3px watched bar at its
bottom edge, "N. Title", the plot clamped to two lines and a "42 min ·
Episode cards are flat: a 16:9 thumbnail (with a light hairline so its edge
survives the light theme) and a 3px watched bar at its bottom edge, "N. Title", the plot clamped to two lines and a "42 min ·
18m left / watched" line. List rows keep a subtle neutral fill. The selected
season's cover and synopsis sit in a compact strip under the header only
when present. Keep these treatments in the shared season components so
@@ -2,7 +2,7 @@
<!-- "Episodes · Season 1 · Season 2 · 8 episodes": the season pills sit
in the header; season and series actions live in the hero's menu. -->
<header class="episodes-header">
<h3 class="episodes-header__title">
<h3 class="episodes-header__title" data-test-id="episodes-heading">
{{ 'PORTALS.DETAIL.EPISODES' | translate }}
</h3>
@if (hasSeasons()) {
@@ -54,7 +54,10 @@
}
</header>
@if (hasSeasons() && (selectedSeasonPosterUrl() || selectedSeasonDescription())) {
@if (
hasSeasons() &&
(selectedSeasonPosterUrl() || selectedSeasonDescription())
) {
<!-- The selected season's own cover and synopsis, compact, under
the header. Without either the row is not rendered at all. -->
<div class="season-strip">
@@ -28,8 +28,16 @@
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.03)),
radial-gradient(circle at top, rgba(255, 255, 255, 0.06), transparent 60%);
linear-gradient(
180deg,
rgba(255, 255, 255, 0.06),
rgba(255, 255, 255, 0.03)
),
radial-gradient(
circle at top,
rgba(255, 255, 255, 0.06),
transparent 60%
);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.04),
0 18px 40px rgba(0, 0, 0, 0.22);
@@ -71,8 +79,16 @@
&--season {
border-color: rgba(255, 255, 255, 0.1);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.07), rgba(255, 255, 255, 0.04)),
radial-gradient(circle at top, rgba(255, 255, 255, 0.08), transparent 65%);
linear-gradient(
180deg,
rgba(255, 255, 255, 0.07),
rgba(255, 255, 255, 0.04)
),
radial-gradient(
circle at top,
rgba(255, 255, 255, 0.08),
transparent 65%
);
}
}
@@ -322,6 +338,8 @@
position: relative;
aspect-ratio: 16 / 9;
background: #000;
// A hairline keeps the artwork's edge visible on the light theme too.
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 10px;
overflow: hidden;
@@ -421,7 +439,11 @@
color: var(--mat-sys-tertiary) !important;
&:hover {
background: color-mix(in srgb, var(--mat-sys-tertiary) 80%, transparent);
background: color-mix(
in srgb,
var(--mat-sys-tertiary) 80%,
transparent
);
}
}
@@ -571,7 +593,11 @@
&:hover {
background: var(--app-card-hover-bg);
border-color: color-mix(in srgb, var(--app-on-surface) 22%, transparent);
border-color: color-mix(
in srgb,
var(--app-on-surface) 22%,
transparent
);
.episode-list-item__actions {
opacity: 1;
@@ -587,12 +613,24 @@
}
&--in-progress {
border-color: color-mix(in srgb, var(--mat-sys-primary) 40%, transparent);
border-color: color-mix(
in srgb,
var(--mat-sys-primary) 40%,
transparent
);
background: color-mix(in srgb, var(--mat-sys-primary) 5%, transparent);
&:hover {
border-color: color-mix(in srgb, var(--mat-sys-primary) 60%, transparent);
background: color-mix(in srgb, var(--mat-sys-primary) 8%, transparent);
border-color: color-mix(
in srgb,
var(--mat-sys-primary) 60%,
transparent
);
background: color-mix(
in srgb,
var(--mat-sys-primary) 8%,
transparent
);
}
}
@@ -773,7 +811,11 @@
color: var(--mat-sys-tertiary);
&:hover {
background: color-mix(in srgb, var(--mat-sys-tertiary) 15%, transparent);
background: color-mix(
in srgb,
var(--mat-sys-tertiary) 15%,
transparent
);
}
}
@@ -787,12 +829,24 @@
&--launching,
&--active-external {
border-color: color-mix(in srgb, var(--mat-sys-tertiary) 46%, transparent);
background: color-mix(in srgb, var(--mat-sys-tertiary-container) 12%, transparent);
border-color: color-mix(
in srgb,
var(--mat-sys-tertiary) 46%,
transparent
);
background: color-mix(
in srgb,
var(--mat-sys-tertiary-container) 12%,
transparent
);
}
&--playing {
border-color: color-mix(in srgb, var(--mat-sys-primary) 60%, transparent);
border-color: color-mix(
in srgb,
var(--mat-sys-primary) 60%,
transparent
);
background: color-mix(in srgb, var(--mat-sys-primary) 8%, transparent);
}