open-design/e2e/ui/settings-hover-contrast.test.ts
shangxinyu1 95bbdbb734
[codex] test(e2e): harden settings and entry regressions (#2578)
* test(e2e): harden settings and entry regressions

* test(e2e): align entry chrome coverage with current UI

Generated-By: looper 0.6.0 (runner=fixer, agent=codex)

* fix(web): refresh saved media providers from daemon

Generated-By: looper 0.6.0 (runner=fixer, agent=codex)

* test(web): align media provider reload expectations

Generated-By: looper 0.6.0 (runner=fixer, agent=codex)

* fix(web): keep daemon media-provider reloads authoritative

Generated-By: looper 0.6.0 (runner=fixer, agent=codex)

* fix(web): make media-provider reload precedence depend on dialog edits

Generated-By: looper 0.6.0 (runner=fixer, agent=codex)

* fix(web): preserve pending media-provider edits across stale autosaves

Generated-By: looper 0.6.0 (runner=fixer, agent=codex)
2026-05-22 10:04:12 +08:00

215 lines
8.2 KiB
TypeScript

import { expect, test } from '@playwright/test';
import type { Page } from '@playwright/test';
const STORAGE_KEY = 'open-design:config';
const OPEN_SETTINGS_LABEL = /Open settings|打开设置|開啟設定/i;
const SETTINGS_MENU_LABEL = /^Settings$|^设置$|^設定$/i;
// WCAG AA threshold for normal text. We assert against this rather than AAA
// because the codebase has historically targeted AA for muted-on-subtle
// surfaces and we do not want to bake AAA into a regression guard the team
// has not committed to.
const WCAG_AA_NORMAL = 4.5;
type Theme = 'light' | 'dark';
async function openSettings(page: Page, theme: Theme) {
await page.addInitScript(
({ key, value }) => {
window.localStorage.setItem(key, JSON.stringify(value));
},
{
key: STORAGE_KEY,
value: {
theme,
accentColor: '#c96442',
mode: 'daemon',
onboardingCompleted: true,
agentId: null,
skillId: null,
designSystemId: null,
mediaProviders: {},
agentModels: {},
},
},
);
await page.route('**/api/health', async (route) => {
await route.fulfill({ status: 200, contentType: 'application/json', body: '{"ok":true}' });
});
await page.emulateMedia({ colorScheme: theme });
await page.goto('/');
await page.getByRole('button', { name: OPEN_SETTINGS_LABEL }).click();
const menu = page.getByRole('menu');
if (await menu.isVisible().catch(() => false)) {
await menu.getByRole('button', { name: SETTINGS_MENU_LABEL }).click();
}
await expect(page.getByRole('dialog')).toBeVisible();
}
/**
* Reads the composited background a user actually sees behind `selector` —
* walks ancestors and folds rgba layers front-to-back until an opaque layer
* is reached. Raw `getComputedStyle(...).backgroundColor` returns the
* element's own background (often `rgba(0, 0, 0, 0)` for transparent
* controls) which would massively overstate text contrast.
*/
async function measureContrast(page: Page, selector: string): Promise<{
ratio: number;
fg: [number, number, number];
bg: [number, number, number];
bgRaw: string;
}> {
return page.evaluate((sel) => {
function srgb(v: number) {
v /= 255;
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
}
function lum(c: [number, number, number]) {
return 0.2126 * srgb(c[0]) + 0.7152 * srgb(c[1]) + 0.0722 * srgb(c[2]);
}
function parse(s: string): number[] | null {
const m = s.match(/rgba?\(([^)]+)\)/);
if (!m || !m[1]) return null;
return m[1].split(',').map((x) => parseFloat(x.trim()));
}
function ratio(a: [number, number, number], b: [number, number, number]) {
const L1 = lum(a);
const L2 = lum(b);
const [lo, hi] = L1 < L2 ? [L1, L2] : [L2, L1];
return (hi + 0.05) / (lo + 0.05);
}
function resolveBg(n: Element): [number, number, number] {
const layers: Array<{ r: number; g: number; b: number; a: number }> = [];
let c: Element | null = n;
while (c) {
const cs = getComputedStyle(c);
const p = parse(cs.backgroundColor);
if (p && p[0] !== undefined && p[1] !== undefined && p[2] !== undefined) {
const a = p.length === 4 && p[3] !== undefined ? p[3] : 1;
if (a > 0) layers.push({ r: p[0], g: p[1], b: p[2], a });
if (a === 1) break;
}
c = c.parentElement;
}
if (!layers.length) return [255, 255, 255];
const base = layers[layers.length - 1];
if (!base) return [255, 255, 255];
let r = base.r;
let g = base.g;
let b = base.b;
for (let i = layers.length - 2; i >= 0; i--) {
const l = layers[i];
if (!l) continue;
r = l.r * l.a + r * (1 - l.a);
g = l.g * l.a + g * (1 - l.a);
b = l.b * l.a + b * (1 - l.a);
}
return [Math.round(r), Math.round(g), Math.round(b)];
}
const el = document.querySelector(sel);
if (!el) throw new Error(`selector matched nothing: ${sel}`);
const cs = getComputedStyle(el);
const fgRaw = parse(cs.color);
if (!fgRaw || fgRaw[0] === undefined || fgRaw[1] === undefined || fgRaw[2] === undefined) {
throw new Error(`unparseable color on ${sel}: ${cs.color}`);
}
const fg: [number, number, number] = [
Math.round(fgRaw[0]),
Math.round(fgRaw[1]),
Math.round(fgRaw[2]),
];
const bg = resolveBg(el);
return { ratio: +ratio(fg, bg).toFixed(2), fg, bg, bgRaw: cs.backgroundColor };
}, selector);
}
async function hoverAndMeasure(page: Page, selector: string) {
const el = page.locator(selector).first();
await el.waitFor({ state: 'visible' });
await el.scrollIntoViewIfNeeded();
await el.hover();
// Let CSS transitions settle so the measurement reflects the steady-state
// hover style. The hover rule for .subtab-pill uses a background-only
// change with no transition, so 120ms is comfortably enough.
await page.waitForTimeout(150);
return measureContrast(page, selector);
}
function settingsNavItem(page: Page, label: RegExp) {
return page
.locator('.settings-nav-item', { has: page.locator('strong', { hasText: label }) })
.first();
}
// Regression guard for #1795: hover backgrounds in Settings should not blow
// out text contrast in either theme. The original bug (filed against 0.6.0)
// used `rgba(255, 255, 255, 0.6)` for `.subtab-pill button:hover` which read
// as a near-white wash in dark mode and dropped contrast to ~1.87 — well
// below the 4.5 WCAG AA threshold. We assert WCAG AA across both themes so
// it cannot silently regress for either set of users.
const THEMES: Theme[] = ['dark', 'light'];
test.describe('Settings hover contrast (regression guard for #1795)', () => {
for (const theme of THEMES) {
test(`Pets source tabs hover stays readable in ${theme} theme`, async ({ page }) => {
await openSettings(page, theme);
const petsNav = settingsNavItem(page, /^(Pets|Pet|宠物|寵物)$/i);
await petsNav.click();
// Pet tabs render once the section is mounted; no daemon round-trip is
// required for the tab pills themselves.
await page.waitForSelector('.pet-tabs .subtab-pill button');
const inactive = '.pet-tabs .subtab-pill button:not(.active)';
const measurement = await hoverAndMeasure(page, inactive);
expect(
measurement.ratio,
`Pets source tab hover contrast ${measurement.ratio} below WCAG AA (${WCAG_AA_NORMAL}) in ${theme}. ` +
`fg=rgb(${measurement.fg.join(',')}) bg=rgb(${measurement.bg.join(',')}) bgRaw=${measurement.bgRaw}`,
).toBeGreaterThanOrEqual(WCAG_AA_NORMAL);
});
test(`seg-btn surfaces (BYOK / Appearance / Notifications) hover stays readable in ${theme} theme`, async ({
page,
}) => {
await openSettings(page, theme);
// Configure execution mode is the default landing — BYOK seg-btn lives
// here. Hovering the inactive tab is enough to exercise the seg-btn
// hover rule that covers BYOK + Appearance + Notifications.
const execMeasurement = await hoverAndMeasure(
page,
'.seg-control .seg-btn:not(.active):not(:disabled)',
);
expect(
execMeasurement.ratio,
`BYOK seg-btn hover ${execMeasurement.ratio} (${theme})`,
).toBeGreaterThanOrEqual(WCAG_AA_NORMAL);
const appearanceNav = settingsNavItem(page, /^(Appearance|外观|外觀)$/i);
await appearanceNav.click();
await page.waitForSelector('.seg-control .seg-btn');
const themeMeasurement = await hoverAndMeasure(
page,
'.seg-control .seg-btn:not(.active)',
);
expect(
themeMeasurement.ratio,
`Appearance theme hover ${themeMeasurement.ratio} (${theme})`,
).toBeGreaterThanOrEqual(WCAG_AA_NORMAL);
const notifNav = settingsNavItem(page, /^(Notifications|通知)$/i);
await notifNav.click();
await page.waitForSelector('.seg-control .seg-btn');
const notifMeasurement = await hoverAndMeasure(
page,
'.seg-control .seg-btn:not(.active):not(:disabled)',
);
expect(
notifMeasurement.ratio,
`Notifications seg-btn hover ${notifMeasurement.ratio} (${theme})`,
).toBeGreaterThanOrEqual(WCAG_AA_NORMAL);
});
}
});