mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* fix(release): harden prerelease E2E validation
* fix(daemon): refresh active run tool tokens
Generated-By: looper 0.11.8 (runner=fixer, agent=codex)
* chore(release): keep Feishu notification unchanged
* fix(daemon): retain media tasks for active runs
Associate token-authored media tasks with their owning run and defer terminal cleanup while that run still has an active tool token. Correct the prerelease status document to reflect the final Feishu notification topology.
Generated-By: looper 0.11.8 (runner=fixer, agent=codex)
* test(e2e): defer system theme coverage
* test(e2e): remove retired design system settings flows
* fix(e2e): close prerelease coverage gaps
* fix(release): remove unrelated UI changes
Generated-By: looper 0.11.8 (runner=fixer, agent=codex)
* fix(e2e): restore remaining prerelease coverage
* docs(e2e): record remaining scoped gaps
* test(e2e): isolate workflow command fixtures
Generated-By: looper 0.11.8 (runner=fixer, agent=codex)
* test(e2e): preserve Node in workflow fixture PATH
Generated-By: looper 0.11.8 (runner=fixer, agent=codex)
* fix(ci): skip Playwright apt deps on Blacksmith runners
Generated-By: looper 0.11.8 (runner=fixer, agent=codex)
* Revert "fix(ci): skip Playwright apt deps on Blacksmith runners"
This reverts commit 201f3f3ee3.
* fix(plan): reliably open generated artifacts
* fix(plan): reject unconfirmed artifact paths
210 lines
8.1 KiB
TypeScript
210 lines
8.1 KiB
TypeScript
import { expect, test } from '@/playwright/suite';
|
|
import type { Page } from '@playwright/test';
|
|
import { openSettingsDialog } from '../lib/playwright/amr.js';
|
|
|
|
const STORAGE_KEY = 'open-design:config';
|
|
|
|
// 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 openSettingsDialog(page);
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
// Wait for any hover transition/animation to settle before measuring. A
|
|
// passing first sample is not evidence that the final color remains AA.
|
|
await el.evaluate(async (node) => {
|
|
const animations = node.getAnimations({ subtree: true });
|
|
if (!animations.length) return;
|
|
await Promise.race([
|
|
Promise.all(animations.map((animation) => animation.finished.catch(() => undefined))),
|
|
new Promise<void>((resolve) => setTimeout(resolve, 2_000)),
|
|
]);
|
|
});
|
|
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(`[P2] Pets source tabs hover stays readable in ${theme} theme`, async ({ page }) => {
|
|
await openSettings(page, theme);
|
|
// #5517 folded Pets into General instead of keeping a standalone nav item.
|
|
await settingsNavItem(page, /^(General|通用)$/i).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);
|
|
});
|
|
|
|
// Appearance dropped out of this list in #5517 (product confirmed
|
|
// 2026-07-20): its 系统/浅色/深色 segmented control was removed, so the
|
|
// section no longer renders a `.seg-btn` at all and cannot participate in
|
|
// this measurement. The rule under test is a single shared one, so BYOK +
|
|
// Notifications still exercise it end to end; the Appearance leg is not
|
|
// replaced by a weaker proxy.
|
|
test(`[P2] seg-btn surfaces (BYOK / 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 + 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);
|
|
|
|
// Notifications now shares the General page with the other system preferences.
|
|
await settingsNavItem(page, /^(General|通用)$/i).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);
|
|
});
|
|
}
|
|
});
|