Files
iptvnator/apps/web-e2e/src/e2e-helpers.ts
T
4grayandClaude Fable 5.1 43c1ceac16 test(web-e2e): reach row buttons with Option+Tab on WebKit (#1629)
The two "channel scrolling keeps focus after selection" cases (and the
Xtream "channel focus and separate scrollbar" cases, which press Tab the
same way) failed deterministically on Playwright WebKit while passing on
Chromium and Firefox. Playwright's WebKit emulates Safari's default
keyboard preference, under which plain Tab visits only text fields and
links: from the focused channel pane the key landed on the sidebar search
field instead of the first row button. Option+Tab reaches the button and
then the favorite action in the same DOM order Chromium's Tab follows, so
the app's focus contract (ChannelScrollFocusDirective) is intact and this
is Safari's Tab semantics, not an app bug.

Add a `pressTab` E2E helper that presses Alt+Tab only on webkit and keeps
the literal Tab / Shift+Tab on chromium and firefox, use it at the four
Tab presses toward buttons, and note Safari's behaviour in the keyboard
scrolling contract.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 11:44:52 +02:00

281 lines
9.8 KiB
TypeScript

import type {
APIRequestContext,
Locator,
Page,
TestInfo,
} from '@playwright/test';
import { expect } from './fixtures';
import sharp from 'sharp';
/**
* Move keyboard focus to the next (or previous) tabbable element the way a
* user of that browser does.
*
* Safari's default keyboard preference makes plain Tab visit only text fields
* and links; buttons are reached with Option+Tab, or with plain Tab once
* "Press Tab to highlight each item on a webpage" is enabled. Playwright's
* WebKit emulates that default, so a plain `Tab` from a focused channel pane
* lands on the sidebar search field instead of the first row button — the
* app's focus order is intact, Safari simply skips the button. Pressing
* Option+Tab there keeps the assertion about the app's DOM order rather than
* about Safari's preference; Chromium and Firefox treat Alt+Tab as a plain
* Tab, but they get the unmodified key so their run stays a literal user Tab.
*/
export async function pressTab(
page: Page,
browserName: string,
direction: 'forward' | 'backward' = 'forward'
): Promise<void> {
const modifiers = [
...(browserName === 'webkit' ? ['Alt'] : []),
...(direction === 'backward' ? ['Shift'] : []),
];
await page.keyboard.press([...modifiers, 'Tab'].join('+'));
}
export async function setInputValue(
input: Locator,
value: string
): Promise<void> {
await input.fill('');
await input.fill(value);
if ((await input.inputValue()) === value) {
return;
}
await input.click();
await input.press('ControlOrMeta+A');
await input.press('Backspace');
await input.type(value);
await expect(input).toHaveValue(value);
}
/**
* POST to a mock-server control endpoint, retrying transport errors.
*
* The mock servers are shared by every spec file and Playwright runs those
* files in parallel workers, so a burst of concurrent control requests can
* occasionally be met with ECONNRESET. That is a transport hiccup, not a
* failure of the test under it.
*/
export async function postWithRetry(
request: APIRequestContext,
url: string,
attempts = 3
): Promise<void> {
let lastError: unknown;
for (let attempt = 0; attempt < attempts; attempt += 1) {
try {
const response = await request.post(url);
if (response.ok()) {
return;
}
lastError = new Error(`POST ${url} failed: ${response.status()}`);
} catch (error) {
lastError = error;
}
await new Promise((resolve) =>
setTimeout(resolve, 250 * (attempt + 1))
);
}
throw lastError;
}
/** Wait for native scrolling to settle before the next discrete keyboard action. */
export async function waitForScrollIdle(scrollOwner: Locator): Promise<void> {
await scrollOwner.evaluate(
(element) =>
new Promise<void>((resolve) => {
let last = element.scrollTop;
let stableFrames = 0;
const frame = () => {
stableFrames =
element.scrollTop === last ? stableFrames + 1 : 0;
last = element.scrollTop;
if (stableFrames === 3) resolve();
else requestAnimationFrame(frame);
};
requestAnimationFrame(frame);
})
);
}
/** Measure flat UI surfaces after alpha compositing, including CSS color-mix. */
export async function surfaceContrast(locator: Locator): Promise<{
border: number;
fill: number;
}> {
return locator.evaluate((element) => {
const canvas = document.createElement('canvas');
canvas.width = canvas.height = 1;
const context = canvas.getContext('2d');
if (!context)
throw new Error('Canvas is required to measure surface contrast');
const paint = (color: string) => {
context.fillStyle = color;
context.fillRect(0, 0, 1, 1);
};
const luminance = () => {
const channels = Array.from(context.getImageData(0, 0, 1, 1).data)
.slice(0, 3)
.map((value) => {
const channel = value / 255;
return channel <= 0.04045
? channel / 12.92
: ((channel + 0.055) / 1.055) ** 2.4;
});
return (
channels[0] * 0.2126 +
channels[1] * 0.7152 +
channels[2] * 0.0722
);
};
const ratio = (first: number, second: number) =>
(Math.max(first, second) + 0.05) / (Math.min(first, second) + 0.05);
const ancestors: Element[] = [];
for (
let parent = element.parentElement;
parent;
parent = parent.parentElement
) {
ancestors.unshift(parent);
}
paint('white');
ancestors.forEach((parent) =>
paint(getComputedStyle(parent).backgroundColor)
);
const behind = luminance();
const style = getComputedStyle(element);
paint(style.backgroundColor);
const fill = luminance();
paint(
parseFloat(style.borderTopWidth) > 0
? style.borderTopColor
: 'transparent'
);
return { border: ratio(luminance(), fill), fill: ratio(fill, behind) };
});
}
/** Compare the actual hero edge with the same pixels with just its border hidden.
* Both rasters retain artwork, gradients, opacity and backdrop filters. Matching
* pixels prevent artwork detail from being mistaken for a visible button edge. */
export async function rasterizedBorderContrast(
locator: Locator
): Promise<number> {
const screenshot = () =>
locator.screenshot({ animations: 'disabled', scale: 'css' });
const visible = await screenshot();
const originalStyle = await locator.getAttribute('style');
let hidden: Buffer;
try {
await locator.evaluate((element) => {
const style = (element as HTMLElement).style;
style.setProperty('transition', 'none', 'important');
style.setProperty('border-top-color', 'transparent', 'important');
});
hidden = await screenshot();
} finally {
await locator.evaluate((element, style) => {
if (style === null) element.removeAttribute('style');
else element.setAttribute('style', style);
}, originalStyle);
}
const decode = (buffer: Buffer) =>
sharp(buffer).removeAlpha().raw().toBuffer({ resolveWithObject: true });
const [before, after] = await Promise.all([
decode(visible),
decode(hidden),
]);
expect(after.info).toEqual(before.info);
const luminance = (data: Buffer, x: number, y: number) => {
const offset = (y * before.info.width + x) * before.info.channels;
return [0.2126, 0.7152, 0.0722].reduce((sum, weight, index) => {
const value = data[offset + index] / 255;
return (
sum +
weight *
(value <= 0.04045
? value / 12.92
: ((value + 0.055) / 1.055) ** 2.4)
);
}, 0);
};
const ratios: number[] = [];
// Avoid rounded corners. Inspect three raster rows to tolerate a fractional
// CSS edge, retaining the strongest edge pixel in each sampled column.
for (
let x = Math.ceil(before.info.width / 4);
x < (before.info.width * 3) / 4;
x++
) {
let strongest = 1;
for (let y = 0; y < Math.min(3, before.info.height); y++) {
const a = luminance(before.data, x, y);
const b = luminance(after.data, x, y);
strongest = Math.max(
strongest,
(Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05)
);
}
ratios.push(strongest);
}
ratios.sort((a, b) => a - b);
return ratios[Math.floor(ratios.length / 2)];
}
/** Check the shared series UI under a provider host before testing watched state. */
export async function expectSeriesSurfacesInBothThemes(
page: Page,
testInfo: TestInfo
): Promise<void> {
// Shared episode surfaces must also resolve the Stalker host's theme.
await page.setViewportSize({ width: 1600, height: 1100 });
await expect(page.locator('.hero__content')).toHaveCSS('opacity', '1');
for (const theme of ['light', 'dark']) {
await page.evaluate(
(dark) => document.body.classList.toggle('dark-theme', dark),
theme === 'dark'
);
const shell = page.locator('app-portal-detail-shell');
await expect
.poll(() =>
rasterizedBorderContrast(shell.locator('.favorite-btn').first())
)
.toBeGreaterThan(1.1);
for (const selector of ['.episode-card', 'mat-button-toggle-group']) {
await expect
.poll(
async () =>
(await surfaceContrast(shell.locator(selector).first()))
.border
)
.toBeGreaterThan(1.15);
}
await page
.getByRole('radio', { name: 'List view', exact: true })
.click();
await expect
.poll(
async () =>
(
await surfaceContrast(
shell.locator('.episode-list-item').first()
)
).border
)
.toBeGreaterThan(1.15);
await shell.screenshot({
path: testInfo.outputPath(`stalker-series-list-${theme}.png`),
animations: 'disabled',
});
await page
.getByRole('radio', { name: 'Grid view', exact: true })
.click();
}
}