Merge branch 'master' into perf/j3-epg-ribbon-window

This commit is contained in:
4gray authored and GitHub committed 2026-10-05 23:11:34 +02:00
commit 7d9f1df106
171 files changed
+10425 -4483

No files matched your search

@@ -29,6 +29,11 @@ import {
routePlayableStreams,
startAndConfirmPlayback,
} from './playable-stream-fixture';
import {
addCurrentDetailToFavorites,
goBackFromDetail,
toggleFavoriteForChannel,
} from './dashboard-e2e-flows';
test.describe('Dashboard Activation', () => {
test('opens live favorites in the collection route and movies/series in global collection detail views from the dashboard', async ({
@@ -305,38 +310,6 @@ function dashboardRailCardByTitle(
.first();
}
async function goBackFromDetail(page: Page): Promise<void> {
// 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 {
await backButton.click({ timeout: 5000 });
} catch {
await backButton.evaluate((button: HTMLButtonElement) =>
button.click()
);
}
}
// By accessible name, not class: the Xtream movie detail's favorite control is
// an icon-only button that carries its label in aria-label, while series and
// Stalker details still use the labeled variant. This matches both.
async function addCurrentDetailToFavorites(page: Page): Promise<void> {
const addButton = page
.getByRole('button', { name: /add to favorites/i })
.first();
await expect(addButton).toBeVisible({ timeout: 20000 });
await addButton.click();
await expect(
page.getByRole('button', { name: /remove from favorites/i }).first()
).toBeVisible({
timeout: 20000,
});
}
async function expectInlineCollectionDetail(
page: Page,
params: {
@@ -383,20 +356,3 @@ async function playFirstSeriesEpisode(page: Page): Promise<void> {
await expect(episodeCard).toBeVisible({ timeout: 20000 });
await episodeCard.click();
}
async function toggleFavoriteForChannel(
page: Page,
title: string
): Promise<void> {
const item = page
.locator('[data-test-id="channel-item"]')
.filter({ hasText: title })
.first();
await expect(item).toBeVisible({ timeout: 20000 });
await item.hover();
await item.locator('.favorite-button').first().click();
await expect(item.locator('.favorite-button mat-icon').first()).toHaveText(
/star/
);
}
@@ -0,0 +1,54 @@
import type { Page } from '@playwright/test';
import { expect } from './electron-test-fixtures';
// Steps that put content on the dashboard: favourites from the Live TV list
// and from a detail page, and the way back from that detail page.
export async function goBackFromDetail(page: Page): Promise<void> {
// 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 {
await backButton.click({ timeout: 5000 });
} catch {
await backButton.evaluate((button: HTMLButtonElement) =>
button.click()
);
}
}
// By accessible name, not class: the Xtream movie detail's favorite control is
// an icon-only button that carries its label in aria-label, while series and
// Stalker details still use the labeled variant. This matches both.
export async function addCurrentDetailToFavorites(page: Page): Promise<void> {
const addButton = page
.getByRole('button', { name: /add to favorites/i })
.first();
await expect(addButton).toBeVisible({ timeout: 20000 });
await addButton.click();
await expect(
page.getByRole('button', { name: /remove from favorites/i }).first()
).toBeVisible({
timeout: 20000,
});
}
export async function toggleFavoriteForChannel(
page: Page,
title: string
): Promise<void> {
const item = page
.locator('[data-test-id="channel-item"]')
.filter({ hasText: title })
.first();
await expect(item).toBeVisible({ timeout: 20000 });
await item.hover();
await item.locator('.favorite-button').first().click();
await expect(item.locator('.favorite-button mat-icon').first()).toHaveText(
/star/
);
}
@@ -0,0 +1,355 @@
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('.hero__content'),
].every(
(element) =>
element &&
element
.getAnimations()
.every(
(animation) => animation.playState !== 'running'
)
)
)
)
.toBe(true);
return page.getByTestId('dashboard-hero-slide');
}
/** Sum of non-input layout shifts while the hero runs through every slide
* on its own, at a shortened interval. */
async function rotationLayoutShift(page: Page): Promise<number> {
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: number[] = [];
new PerformanceObserver((list) => {
for (const entry of list.getEntries() as (PerformanceEntry & {
value: number;
hadRecentInput: boolean;
})[]) {
if (!entry.hadRecentInput) {
shifts.push(entry.value);
}
}
}).observe({ type: 'layout-shift' });
(window as unknown as { __heroShifts: number[] }).__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: number[] }).__heroShifts.reduce(
(sum, value) => sum + value,
0
)
);
}
/** Adds what a TMDB-enriched slide shows: a rating chip and an overview. */
async function enrichSlide(slide: Locator): Promise<void> {
await slide.evaluate((content, overview) => {
const chip = content.querySelector('.hero__pill');
if (chip) {
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). Slides of
// different heights still resize the hero by a few pixels and
// move the rails below (0.005 here, 0.013 before this change);
// a scrim or heading that reflowed the slide would add lines.
const shift = await rotationLayoutShift(page);
results.push(`rotation layout shift ${shift.toFixed(3)}`);
expect(shift).toBeLessThan(0.02);
await page.getByTestId('dashboard-hero-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
.locator('.hero__content')
.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);
}
}
}
}
}
} finally {
const report = testInfo.outputPath('contrast.txt');
writeFileSync(report, results.join('\n'));
await testInfo.attach('contrast', {
path: report,
contentType: 'text/plain',
});
await closeElectronApp(app);
}
});
});
@@ -24,6 +24,11 @@ import {
// The "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.
//
// Pages without a Back of their own (the list a detail returns to) get the
// header's history fallback while an in-app previous page exists; on a
// phone it yields to the drawer toggle, and with nowhere to go the slot is
// empty rather than a disabled arrow.
// ---------------------------------------------------------------------------
const widths = [1280, 780, 375];
@@ -45,6 +50,16 @@ function headerBack(page: Page): Locator {
return page.getByTestId('workspace-header-back');
}
/**
* The generic history Back. A detail's own Back advertises Escape in browse;
* the fallback runs no page handler, so it advertises none.
*/
async function expectHistoryBack(page: Page): Promise<void> {
await expect(headerBack(page)).toBeVisible();
await expect(headerBack(page)).toHaveAccessibleName('Back');
await expect(headerBack(page)).not.toHaveAttribute('aria-keyshortcuts');
}
/** Line boxes of the heading's text; 1 means it did not wrap. */
function headingLineCount(page: Page): Promise<number> {
return page
@@ -228,12 +243,15 @@ async function startFirstEpisode(page: Page): Promise<void> {
).toBeVisible({ timeout: 20_000 });
}
/** The header Back leaves the detail and is gone from the list it opens. */
/**
* The header Back leaves the detail; the list it opens keeps only the
* history fallback (it was itself reached by navigation).
*/
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);
await expectHistoryBack(page);
}
test.describe('Portal detail header Back', () => {
@@ -248,7 +266,7 @@ test.describe('Portal detail header Back', () => {
const page = app.mainWindow;
await addXtreamPortal(page);
await waitForXtreamWorkspaceReady(page);
await expect(headerBack(page)).toHaveCount(0);
await expectHistoryBack(page);
const detailUrl = await openFirstSeries(page);
await expectBackInHeader(page, 'browse');
@@ -287,4 +305,45 @@ test.describe('Portal detail header Back', () => {
await closeElectronApp(app);
}
});
test('@xtream @electron falls back to history where no page offers Back', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const app = await launchElectronApp(dataDir);
try {
const page = app.mainWindow;
await page.waitForURL(/\/workspace\//);
const startUrl = page.url();
// The first page of the session has nowhere to go back to: the
// slot is empty, not a disabled arrow.
await expect(headerBack(page)).toHaveCount(0);
await addXtreamPortal(page);
await waitForXtreamWorkspaceReady(page);
const listUrl = page.url();
await expectHistoryBack(page);
// On a phone the list's drawer toggle keeps the slot: it is the
// only way into the categories.
await page.setViewportSize({ width: 375, height: 800 });
await expect(page.getByTestId('context-drawer-toggle')).toBeVisible();
await expect(headerBack(page)).toBeHidden();
await page.setViewportSize({ width: widths[0], height: 800 });
await headerBack(page).click();
await expect(page).toHaveURL(startUrl);
await expect(headerBack(page)).toHaveCount(0);
// Forward history is not offered; browser Forward still works and
// brings the fallback back.
await page.goForward();
await expect(page).toHaveURL(listUrl);
await expectHistoryBack(page);
} finally {
await closeElectronApp(app);
}
});
});
@@ -0,0 +1,119 @@
/* eslint-disable playwright/expect-expect -- These are Node assertion-based repository contract tests. */
import assert from 'node:assert/strict';
import { readdirSync, readFileSync } from 'node:fs';
import { join, relative, sep } from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
// docs/architecture/zoneless-migration.md lists every component that still
// opts out of OnPush. This keeps the checklist and the code in step: a new
// Eager component fails here, and so does a converted one left unticked.
const workspaceRoot = fileURLToPath(new URL('../../../../', import.meta.url));
const checklistPath = 'docs/architecture/zoneless-migration.md';
const sourceRoots = ['apps', 'libs'];
const skippedDirectories = new Set([
'node_modules',
'dist',
'coverage',
'test-stubs',
]);
// Test-only files follow the repository's `.spec` / `.test` naming, with an
// optional suffix of one or more segments (`.spec-stubs.ts`,
// `.test-helpers.ts`, `.test-data-stubs.ts`).
const testOnlyFile = /(\.(spec|test)(-\w+)*|^test-setup)\.ts$/;
function listProductionSources(directory: string): string[] {
const files: string[] = [];
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const path = join(directory, entry.name);
if (entry.isDirectory()) {
if (skippedDirectories.has(entry.name)) continue;
if (entry.name.endsWith('-e2e')) continue;
files.push(...listProductionSources(path));
} else if (
entry.name.endsWith('.ts') &&
!entry.name.endsWith('.d.ts') &&
!testOnlyFile.test(entry.name)
) {
files.push(relative(workspaceRoot, path).split(sep).join('/'));
}
}
return files;
}
function readSources(): Map<string, string> {
const sources = new Map<string, string>();
for (const root of sourceRoots) {
for (const file of listProductionSources(join(workspaceRoot, root))) {
sources.set(file, readFileSync(join(workspaceRoot, file), 'utf8'));
}
}
return sources;
}
function readEagerChecklist(): { open: string[]; done: string[] } {
const markdown = readFileSync(join(workspaceRoot, checklistPath), 'utf8');
const section = markdown.split(/^## Eager components$/m)[1];
assert.ok(
section,
`${checklistPath} must have an "Eager components" section`
);
const body = section.split(/^## /m)[0];
const open: string[] = [];
const done: string[] = [];
for (const match of body.matchAll(/^- \[( |x)\] `([^`]+\.ts)`/gm)) {
(match[1] === 'x' ? done : open).push(match[2]);
}
return { open: open.sort(), done: done.sort() };
}
const sources = readSources();
test('the zoneless checklist lists exactly the components that are still Eager', () => {
const eager = [...sources]
.filter(([, text]) => text.includes('ChangeDetectionStrategy.Eager'))
.map(([file]) => file)
.sort();
const { open } = readEagerChecklist();
assert.deepEqual(
eager,
open,
`Production files with ChangeDetectionStrategy.Eager must match the unticked entries in ${checklistPath}. ` +
'Do not add Eager components; tick an entry when its component is converted.'
);
});
test('the guard skips test-only file names and keeps production ones', () => {
for (const name of [
'player.component.spec.ts',
'serial-details.test-stubs.ts',
'dashboard.spec-stubs.ts',
'rail.test-data-stubs.ts',
'test-setup.ts',
]) {
assert.ok(testOnlyFile.test(name), `${name} is test-only`);
}
for (const name of [
'player.component.ts',
'spec-utils.ts',
'contest.ts',
'latest-setup.ts',
'testing.service.ts',
]) {
assert.ok(!testOnlyFile.test(name), `${name} ships`);
}
});
test('ticked checklist entries name files that exist', () => {
for (const file of readEagerChecklist().done) {
assert.ok(sources.has(file), `${file} is ticked but does not exist`);
}
});
test('no production component uses the deprecated Default strategy alias', () => {
for (const [file, text] of sources) {
assert.doesNotMatch(text, /ChangeDetectionStrategy\.Default\b/, file);
}
});
@@ -638,6 +638,18 @@ test.describe('Electron Workspace Search', () => {
await expect(
xtreamSearchResultCards(app.mainWindow).first()
).toBeVisible({ timeout: 20000 });
// The search page's Back is the header's leading button, not an
// arrow beside its title, and it returns to the dashboard.
const headerBack = app.mainWindow.getByTestId(
'workspace-header-back'
);
await expect(headerBack).toBeVisible();
await expect(
app.mainWindow.getByRole('button', { name: 'Back', exact: true })
).toHaveCount(1);
await headerBack.click();
await expectPathname(app.mainWindow, /\/workspace\/dashboard$/);
} finally {
await closeElectronApp(app);
}
@@ -198,3 +198,98 @@ export async function expectSkeletonContrast(
).toBeGreaterThanOrEqual(1.3);
}
}
/** Contrast of an element's own text against whatever is painted behind it
* (images, gradient scrims, translucent chips), read back from the screen.
* The text is made transparent for the capture, so its `text-shadow` stays
* in the backdrop it is meant to support; its CSS colour (with alpha and
* ancestor opacity) is then composited over every pixel under its line
* boxes. Returns the worst ratio, so one dark patch of artwork under one
* letter fails it. */
export async function measureBackdropTextContrast(
page: Page,
text: Locator
): Promise<number> {
await expect(text).toBeVisible();
const probe = await text.evaluate((element) => {
const canvas = document.createElement('canvas');
canvas.width = canvas.height = 1;
const ctx = canvas.getContext('2d')!;
ctx.fillStyle = getComputedStyle(element).color;
ctx.fillRect(0, 0, 1, 1);
const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
let alpha = a / 255;
for (
let node: Element | null = element;
node;
node = node.parentElement
) {
alpha *= Number(getComputedStyle(node).opacity);
}
const range = document.createRange();
range.selectNodeContents(element);
const box = range.getBoundingClientRect();
const style = (element as HTMLElement).style;
const previous = style.getPropertyValue('color');
style.setProperty('color', 'transparent', 'important');
(element as HTMLElement).dataset['contrastPrevious'] = previous;
return {
color: [r, g, b, alpha],
// Whole pixels inside the line boxes, clear of glyph edges that
// spill past them.
clip: {
x: Math.ceil(box.left),
y: Math.ceil(box.top),
width: Math.max(1, Math.floor(box.width) - 1),
height: Math.max(1, Math.floor(box.height) - 1),
},
};
});
try {
const { data, info } = await sharp(
await page.screenshot({ clip: probe.clip })
)
.removeAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
const [fr, fg, fb, fa] = probe.color;
const luminance = (r: number, g: number, b: number) =>
[r, g, b]
.map((value) => {
const s = value / 255;
return s <= 0.04045
? s / 12.92
: ((s + 0.055) / 1.055) ** 2.4;
})
.reduce(
(sum, value, index) =>
sum + value * [0.2126, 0.7152, 0.0722][index],
0
);
let worst = Infinity;
for (let i = 0; i < data.length; i += info.channels) {
const [br, bg, bb] = [data[i], data[i + 1], data[i + 2]];
const front = luminance(
fr * fa + br * (1 - fa),
fg * fa + bg * (1 - fa),
fb * fa + bb * (1 - fa)
);
const back = luminance(br, bg, bb);
worst = Math.min(
worst,
(Math.max(front, back) + 0.05) / (Math.min(front, back) + 0.05)
);
}
return worst;
} finally {
await text.evaluate((element) => {
const style = (element as HTMLElement).style;
const previous = (element as HTMLElement).dataset[
'contrastPrevious'
];
style.removeProperty('color');
if (previous) style.setProperty('color', previous);
delete (element as HTMLElement).dataset['contrastPrevious'];
});
}
}
@@ -187,3 +187,69 @@ test.describe('Custom window controls', () => {
}
});
});
test.describe('macOS traffic lights', () => {
test.skip(
process.platform !== 'darwin',
'Only macOS draws the native traffic lights over the rail'
);
test('@electron the first rail link starts with the content area, clear of the lights', async ({
dataDir,
}) => {
const app = await launchElectronApp(dataDir);
try {
const page = app.mainWindow;
await expect(page.locator('.app-rail')).toHaveClass(/is-macos/);
const firstLink = page.locator('.app-rail a').first();
await expect(firstLink).toBeVisible();
const [linkBox, contentBox] = await Promise.all([
firstLink.boundingBox(),
page.locator('.workspace-content').boundingBox(),
]);
// Aligned with the content area, where the dashboard hero starts.
expect(
Math.abs((linkBox?.y ?? 0) - (contentBox?.y ?? -100))
).toBeLessThanOrEqual(1);
const lights = await app.electronApp.evaluate(({ BrowserWindow }) =>
BrowserWindow.getAllWindows()[0]?.getWindowButtonPosition()
);
expect(lights, 'native window button position').toBeTruthy();
const lightsY = lights?.y ?? Number.NaN;
// The buttons are about 14pt tall; keep a visible gap below them,
// measured in window pixels (CSS pixels times the zoom factor).
const linkTopInWindowPixels = async (): Promise<number> => {
const [box, zoom] = await Promise.all([
firstLink.boundingBox(),
page.evaluate(() => window.outerWidth / window.innerWidth),
]);
return (box?.y ?? Number.NaN) * zoom;
};
expect(await linkTopInWindowPixels()).toBeGreaterThanOrEqual(
lightsY + 14 + 16
);
// App zoom scales CSS pixels but not the native buttons: at the
// smallest zoom the inset must still clear them.
for (let step = 0; step < 8; step++) {
await page.evaluate(() =>
window.electron.adjustZoomLevel('out')
);
}
await expect
.poll(() =>
page.evaluate(() => window.outerWidth / window.innerWidth)
)
.toBeLessThan(0.6);
await expect
.poll(linkTopInWindowPixels)
.toBeGreaterThanOrEqual(lightsY + 14 + 8);
await page.evaluate(() => window.electron.adjustZoomLevel('reset'));
} finally {
await closeElectronApp(app);
}
});
});