mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 12:06:16 -08:00
Merge branch 'master' into perf/j3-epg-ribbon-window
This commit is contained in:
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user