mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 01:56:16 -08:00
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>
281 lines
9.8 KiB
TypeScript
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();
|
|
}
|
|
}
|