Files
iptvnator/apps/electron-backend-e2e/src/dashboard-hero-legibility.e2e.ts
T
4grayandClaude Opus 5.5 f6ad98255e fix(dashboard): keep the hero height stable across slides (#1855)
* fix(dashboard): keep the hero height stable across slides

The hero laid out only the shown slide, so every automatic rotation
between slides of different heights resized the banner and moved every
rail below it by 7px, every 8 s on an idle dashboard (a layout-shift
score of 0.005 per rotation cycle).

Every slide's content now sits in the same grid cell at the bottom of
the banner. The hero is therefore as tall as its tallest slide whichever
one is shown, and it still grows for an unusually full slide. Inactive
slides are inert and visibility: hidden, and carry no test hooks. Enter
on the hero follows the shown slide's primary action.

The rotation dots keep one fixed width. The active pill is the same
18px bar with its clip-path opened, so a slide change no longer moves
the neighbouring dots or the pill itself.

The legibility E2E now records which nodes shift. It allows under 0.001
in all, with nothing inside the hero moving. It measures 0 on this
branch and fails on master (0.0054).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(dashboard): guard the hero rotation at the narrow width too

The rotation guard ran only at the wide width, before any slide was
enriched. It now runs a second unattended rotation after the contrast
pass, at the narrow width, where slides wrap the most and every slide
carries a rating and a two-line overview. On master's hero that second
rotation also fails, because its dots move.

measureBackdropTextContrast clipped its screenshot to the range of the
element's line boxes. A title cut by the 2-line clamp has line boxes
below the visible box, so the probe measured the pill row there and
scored about 1.0. The Xtream mock gives the recently-added slide a
different title per run, so this failed whenever a long one came up.
The clip is now the line boxes intersected with the element's own box.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:36:07 +02:00

410 lines
17 KiB
TypeScript

import type { Locator, Page } from '@playwright/test';
import { writeFileSync } from 'node:fs';
import sharp = require('sharp');
import {
addXtreamPortal,
clickCategoryByNameExact,
clickFirstGridListCard,
closeElectronApp,
defaultXtreamPassword,
defaultXtreamUsername,
expect,
goToDashboard,
launchElectronApp,
openWorkspaceSection,
resetMockServers,
test,
waitForXtreamWorkspaceReady,
} from './electron-test-fixtures';
import {
fetchXtreamLiveFixture,
fetchXtreamSeriesFixture,
fetchXtreamVodFixture,
getXtreamTitle,
} from './portal-mock-fixtures';
import { applyTheme, measureBackdropTextContrast } from './theme-contrast';
import {
addCurrentDetailToFavorites,
goBackFromDetail,
toggleFavoriteForChannel,
} from './dashboard-e2e-flows';
// ---------------------------------------------------------------------------
// The dashboard hero's text must stay legible over any artwork, in both
// themes and in the narrow layout where the slide spans the whole width.
// Every mock image is replaced by a black-and-white checkerboard, the worst
// case for either theme's scrim; series images fail, so the favourited
// series falls back to the generated gradient. Each slide kind (16:9
// backdrop, blurred poster, no artwork, live channel) is measured from the
// screen at a wide and a narrow content width.
// ---------------------------------------------------------------------------
type SlideKind = 'backdrop' | 'poster' | 'fallback' | 'live';
const widths = { wide: 1280, narrow: 760 } as const;
const minimumContrast = 4.5;
const xtreamCredentials = {
username: defaultXtreamUsername,
password: defaultXtreamPassword,
};
/** A full TMDB slide has a rating and a two-line overview; the mock has no
* TMDB, so the measurement adds both, styled by the hero's own rules. */
const sampleOverview =
'A long synopsis that wraps onto a second line, so the body text of a ' +
'fully enriched slide is measured where it really sits over the artwork.';
async function busyArtwork(): Promise<Buffer> {
const width = 1280;
const height = 720;
const square = 40;
const pixels = Buffer.alloc(width * height * 3);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const white =
(Math.floor(x / square) + Math.floor(y / square)) % 2 === 0;
pixels.fill(
white ? 255 : 0,
(y * width + x) * 3,
(y * width + x) * 3 + 3
);
}
}
return sharp(pixels, { raw: { width, height, channels: 3 } })
.png()
.toBuffer();
}
/** Serves the checkerboard for every mock image except series artwork,
* which fails: an image the page has already shown is reused from memory,
* so the failure has to be in place before the series is first opened. */
async function routeArtwork(page: Page): Promise<void> {
const image = await busyArtwork();
await page.route(
(url) => url.hostname === 'picsum.photos',
(route) =>
/\/seed\/series-/.test(route.request().url())
? route.fulfill({ status: 404, body: '' })
: route.fulfill({
status: 200,
contentType: 'image/png',
body: image,
})
);
}
async function slideKinds(page: Page): Promise<SlideKind[]> {
return page.locator('.hero__backdrop').evaluateAll((backdrops) =>
backdrops.map((backdrop): SlideKind => {
if (backdrop.classList.contains('hero__backdrop--live')) {
return 'live';
}
if (!backdrop.querySelector('.hero__backdrop-image')) {
return 'fallback';
}
return backdrop.classList.contains('hero__backdrop--poster')
? 'poster'
: 'backdrop';
})
);
}
async function showSlide(page: Page, index: number): Promise<Locator> {
const dot = page.getByTestId('dashboard-hero-dot').nth(index);
await dot.click();
await expect(dot).toHaveAttribute('aria-current', 'true');
// The backdrop crossfade and the slide's entrance have finished (not the
// image's slow zoom, which never changes what is under the text).
await expect
.poll(() =>
page.evaluate(() =>
[
document.querySelector('.hero__backdrop--active'),
document.querySelector(
'[data-test-id=dashboard-hero-slide]'
),
].every(
(element) =>
element &&
element
.getAnimations()
.every(
(animation) => animation.playState !== 'running'
)
)
)
)
.toBe(true);
return page.getByTestId('dashboard-hero-slide');
}
interface RotationShift {
/** Sum of the non-input layout-shift scores. */
score: number;
/** What moved and by how much, e.g. `LIB-DASHBOARD-RAIL 0,7`. */
sources: string[];
}
/** Non-input layout shifts while the hero runs through every slide on its
* own, at a shortened interval. */
async function rotationLayoutShift(page: Page): Promise<RotationShift> {
const hero = page.getByTestId('dashboard-hero');
const dots = page.getByTestId('dashboard-hero-dot');
const count = await dots.count();
await page.mouse.move(1, 1);
await hero.evaluate((element) => {
(element as HTMLElement).style.setProperty(
'--hero-rotation-ms',
'600ms'
);
const shifts: RotationShift = { score: 0, sources: [] };
new PerformanceObserver((list) => {
for (const entry of list.getEntries() as (PerformanceEntry & {
value: number;
hadRecentInput: boolean;
sources: {
node: Node | null;
previousRect: DOMRectReadOnly;
currentRect: DOMRectReadOnly;
}[];
})[]) {
if (entry.hadRecentInput) {
continue;
}
shifts.score += entry.value;
for (const {
node,
previousRect,
currentRect,
} of entry.sources) {
const name =
node instanceof Element
? [node.nodeName, ...node.classList].join('.')
: (node?.nodeName ?? 'removed');
const dx = Math.round(currentRect.x - previousRect.x);
const dy = Math.round(currentRect.y - previousRect.y);
shifts.sources.push(`${name} ${dx},${dy}`);
}
}
}).observe({ type: 'layout-shift' });
(window as unknown as { __heroShifts: RotationShift }).__heroShifts =
shifts;
});
// Back to the first slide after one full cycle, then a quiet moment.
const first = await dots.evaluateAll((all) =>
all.findIndex((dot) => dot.getAttribute('aria-current') === 'true')
);
for (let step = 1; step <= count; step++) {
await expect(dots.nth((first + step) % count)).toHaveAttribute(
'aria-current',
'true',
{ timeout: 5_000 }
);
}
await page.waitForTimeout(500);
return page.evaluate(
() =>
(window as unknown as { __heroShifts: RotationShift }).__heroShifts
);
}
/** One unattended rotation moves next to nothing on the page, and nothing
* at all inside the hero. */
async function expectStableRotation(
page: Page,
label: string,
results: string[]
): Promise<void> {
const shift = await rotationLayoutShift(page);
const sources = shift.sources.join('; ');
results.push(
`${label} rotation layout shift ${shift.score.toFixed(5)} [${sources}]`
);
expect(shift.score, `${label}: ${sources}`).toBeLessThan(0.001);
expect(
shift.sources.filter((source) => source.includes('hero__')),
`${label}: nothing in the hero moves`
).toEqual([]);
}
/** Adds what a TMDB-enriched slide shows: a rating chip and an overview.
* Once per slide: a slide keeps its content while another one is shown. */
async function enrichSlide(slide: Locator): Promise<void> {
await slide.evaluate((content, overview) => {
const chip = content.querySelector('.hero__pill');
if (chip && !content.querySelector('.meta-chip--rating')) {
const rating = chip.cloneNode() as HTMLElement;
rating.classList.add('meta-chip--rating');
rating.textContent = '★ 7.4';
chip.before(rating);
}
// The overview takes the title's view encapsulation attribute, so the
// hero's `.hero__description` rule styles it.
const title = content.querySelector('.hero__title');
const scope = Array.from(title?.attributes ?? []).find((attribute) =>
attribute.name.startsWith('_ngcontent')
);
const actions = content.querySelector('.hero__actions');
if (scope && actions && !content.querySelector('.hero__description')) {
const description = document.createElement('p');
description.setAttribute(scope.name, '');
description.className = 'hero__description';
description.textContent = overview;
actions.before(description);
}
}, sampleOverview);
await expect(slide.locator('.hero__description')).toHaveCount(1);
}
/** Every piece of slide text a viewer reads, one element per colour. */
function slideTexts(slide: Locator): Record<string, Locator> {
return {
eyebrow: slide.locator('.hero__eyebrow > span:not(.hero__eyebrow-sep)'),
title: slide.locator('.hero__title'),
pill: slide.locator('.hero__pill'),
programme: slide.locator('.hero__programme'),
description: slide.locator('.hero__description'),
button: slide.locator('.hero__button > span'),
};
}
test.describe('Dashboard hero legibility', () => {
test('keeps slide text at 4.5:1 over any artwork in both themes and widths', async ({
dataDir,
request,
}, testInfo) => {
test.setTimeout(240_000);
await resetMockServers(request, ['xtream']);
const live = await fetchXtreamLiveFixture(request, xtreamCredentials);
const vod = await fetchXtreamVodFixture(request, xtreamCredentials);
const series = await fetchXtreamSeriesFixture(
request,
xtreamCredentials
);
const app = await launchElectronApp(dataDir);
const page = app.mainWindow;
const results: string[] = [];
try {
await routeArtwork(page);
await page.setViewportSize({ width: widths.wide, height: 800 });
await addXtreamPortal(page);
await waitForXtreamWorkspaceReady(page);
// Live slide: a favourite channel with a programme on air.
await openWorkspaceSection(page, 'Live TV');
await clickCategoryByNameExact(page, live.categoryName);
await toggleFavoriteForChannel(page, getXtreamTitle(live.items[0]));
// Backdrop slide: favouriting from the detail page stores the
// movie's 16:9 backdrop.
await page
.getByRole('link', { name: 'Movies', exact: true })
.click();
await clickCategoryByNameExact(page, vod.categoryName);
await clickFirstGridListCard(page);
await addCurrentDetailToFavorites(page);
await goBackFromDetail(page);
// No-artwork slide: series images fail, poster and backdrop.
await page
.getByRole('link', { name: 'Series', exact: true })
.click();
await clickCategoryByNameExact(page, series.categoryName);
await clickFirstGridListCard(page);
await addCurrentDetailToFavorites(page);
await goToDashboard(page);
await expect(page.getByTestId('dashboard-hero')).toBeVisible({
timeout: 20_000,
});
// Poster-only slides come from the Xtream "recently added" list.
await expect
.poll(async () => [...new Set(await slideKinds(page))].sort(), {
timeout: 20_000,
})
.toEqual(['backdrop', 'fallback', 'live', 'poster']);
// One stable page heading; the rotating slide title is an h2.
await expect(page.locator('h1')).toHaveCount(1);
await expect(page.getByTestId('dashboard-page-heading')).toHaveText(
'Dashboard'
);
await expect(
page
.getByTestId('dashboard-hero-slide')
.locator('h2.hero__title')
).toHaveCount(1);
// An unattended rotation, counted like the launch journey's
// settled layout-shift counter (non-input shifts only), moves
// nothing. The hero lays out every slide and is as tall as the
// tallest; with only the shown slide in flow, slides of different
// heights resized it and moved every rail below by 7px (a score
// of 0.005). Every rotation dot keeps its width, so the active
// one no longer pushes its neighbours either.
await expectStableRotation(page, 'wide', results);
const pause = page.getByTestId('dashboard-hero-pause');
await pause.click();
const kinds = await slideKinds(page);
for (const theme of ['light', 'dark'] as const) {
await applyTheme(page, theme);
for (const [layout, width] of Object.entries(widths)) {
await page.setViewportSize({ width, height: 800 });
// The narrow layout is the dashboard container's
// ≤720px query, not the window width.
const narrow = await page
.getByTestId('dashboard-hero-slide')
.evaluate(
(content) =>
getComputedStyle(content).maxWidth === 'none'
);
expect(narrow).toBe(layout === 'narrow');
for (const [index, kind] of kinds.entries()) {
const slide = await showSlide(page, index);
await enrichSlide(slide);
await page.mouse.move(1, 1);
const name = `${theme}-${layout}-${kind}`;
const shot = testInfo.outputPath(`hero-${name}.png`);
await page
.getByTestId('dashboard-hero')
.screenshot({ path: shot });
await testInfo.attach(name, {
path: shot,
contentType: 'image/png',
});
for (const [part, texts] of Object.entries(
slideTexts(slide)
)) {
for (const text of await texts.all()) {
const ratio = await measureBackdropTextContrast(
page,
text
);
results.push(
`${name} ${part} ${ratio.toFixed(2)}`
);
expect
.soft(ratio, `${name} ${part}`)
.toBeGreaterThanOrEqual(minimumContrast);
}
}
}
}
}
// Again at the narrow width, where slides wrap the most, now that
// every slide also carries a rating and a two-line overview.
await pause.click();
await expect(pause).toHaveAttribute('aria-pressed', 'false');
await expectStableRotation(page, 'narrow enriched', results);
} finally {
const report = testInfo.outputPath('contrast.txt');
writeFileSync(report, results.join('\n'));
await testInfo.attach('contrast', {
path: report,
contentType: 'text/plain',
});
await closeElectronApp(app);
}
});
});