test/helpers.ts
228 lines and 40 functions in DrDustinEdwards/capsomer, at commit d3966cc on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| eachTheme | 16 to 23 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:18> | 18 to 21 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| installRatio | 33 to 45 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| lum | 34 to 40 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:38> | 38 to 38 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:38> | 38 to 38 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:41> | 41 to 44 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| visitStates | 49 to 56 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:53> | 53 to 53 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:53> | 53 to 53 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| expectNoAxeViolations | 64 to 85 | 4 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:74> | 74 to 80 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:78> | 78 to 78 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:79> | 79 to 79 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:79> | 79 to 79 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:80> | 80 to 80 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:83> | 83 to 83 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| paintedContrast | 97 to 154 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:99> | 99 to 151 | 20 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| parse | 103 to 115 | 13 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| over | 116 to 119 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| lum | 140 to 146 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| f | 141 to 144 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| hex | 149 to 149 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:149> | 149 to 149 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| expectContrast | 158 to 167 | 8 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| focused | 170 to 177 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:171> | 171 to 176 | 6 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| nextPost | 182 to 201 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:184> | 184 to 198 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:185> | 185 to 198 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:188> | 188 to 195 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:193> | 193 to 193 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| ringContrast | 206 to 223 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:207> | 207 to 222 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| rgb | 211 to 211 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| lum | 212 to 218 | 4 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| f | 213 to 216 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| rowOverflow | 226 to 242 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:227> | 227 to 241 | 12 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
Imports
It imports no other module of this repo. No module of this repo imports it.
Source
import AxeBuilder from "@axe-core/playwright";
import { expect, test, type Page } from "@playwright/test";
// Shared by every component's spec. A spec calls `eachTheme` and, inside it, names its
// tests with one of the prefixes below; the site's test panel groups results by prefix.
// "keyboard: ..." a key does what the doc page's keyboard table says
// "accessibility: ..." the axe scan, the ARIA snapshot, painted contrast
// "behaviour: ..." anything else the component promises
export const THEMES = ["light", "dark"] as const;
export type Theme = (typeof THEMES)[number];
// Runs `body` once per theme, with the browser's colour scheme and the page's data-theme
// both set, so a component is checked as each theme actually paints it. In the dark theme
// only the accessibility tests run (playwright.config.ts, grepInvert).
export function eachTheme(body: (theme: Theme) => void): void {
for (const theme of THEMES) {
test.describe(`${theme} theme`, () => {
test.use({ colorScheme: theme });
body(theme);
});
}
}
declare global {
interface Window {
capRatio(a: string, b: string): number;
}
}
// The contrast ratio of two CSS colours as the browser paints them (a canvas pixel, so any
// colour syntax works). visitStates installs it, for checks that run inside the page.
function installRatio(): void {
const lum = (c: string) => {
const x = document.createElement("canvas").getContext("2d")!;
x.fillStyle = c;
x.fillRect(0, 0, 1, 1);
const [r, g, b] = [...x.getImageData(0, 0, 1, 1).data].map((v) => v / 255).map((v) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4));
return 0.2126 * r! + 0.7152 * g! + 0.0722 * b!;
};
window.capRatio = (a, b) => {
const [x, y] = [lum(a), lum(b)];
return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05);
};
}
// Opens a component's states page in the given theme and waits for its fonts.
// With `only`, opens one page-level specimen (a <template data-specimen>) as the whole page.
export async function visitStates(page: Page, name: string, theme: Theme, only?: string): Promise<void> {
const q = new URLSearchParams({ theme });
if (only) q.set("only", only);
await page.goto(`components/${name}/states.html?${q}`);
await page.evaluate(() => document.fonts.ready.then(() => undefined));
await page.evaluate(installRatio);
if (!only) await expect(page.locator("h1").first()).toBeVisible();
}
// The automated scan, against WCAG 2.2 A and AA. Any violation fails, with its rule, its
// impact and the first offending node in the message, so a failure says where to look.
// Base UI's focus guards are visually hidden sentinels it places around an open popup on
// purpose: aria-hidden and focusable so focus can wrap. axe's aria-hidden-focus rule flags
// them; `baseUi: true` leaves exactly those elements out of the scan and nothing else.
// `exclude` leaves one element out, for a state the scan misreads; say why where it is used.
export async function expectNoAxeViolations(page: Page, include?: string, options: { baseUi?: boolean; exclude?: string } = {}): Promise<void> {
let builder = new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"]);
if (include) builder = builder.include(include);
// A states page that shows open popups in <iframe> frames is scanned frame by frame, by the
// tests that visit each frame on its own, so those frames are left out of the page scan.
if (options.baseUi) builder = builder.exclude("[data-base-ui-focus-guard]").exclude("iframe");
if (options.exclude) builder = builder.exclude(options.exclude);
// axe reads colours as painted. A dialog fading in, or a control mid-transition, is read
// blended with what is behind it, so wait for every finite animation and transition to end
// first (the first CI run caught a danger button at 4.18:1 for this reason).
await page.evaluate(() =>
Promise.all(
document
.getAnimations()
.filter((a) => Number.isFinite(a.effect?.getComputedTiming().endTime ?? Infinity))
.map((a) => a.finished.catch(() => undefined)),
).then(() => undefined),
);
const { violations } = await builder.analyze();
const found = violations.map((v) => `${v.id} (${v.impact}): ${v.help}. First at ${v.nodes[0]?.target.join(" ")} (${v.nodes.length} nodes). ${(v.nodes[0]?.failureSummary ?? "").replace(/\s+/g, " ").trim()}`);
expect(found, "axe violations").toEqual([]);
}
export interface Reading {
fg: string;
bg: string;
ratio: number;
}
// Contrast as the browser paints it: the colour of the element (its text, or its border)
// against the first opaque background behind it, compositing translucent layers. From the
// Capsid Portal's contrast spec, where it caught a selected row at 1.06:1. A selector ending in
// ::before or ::after reads that pseudo-element (a sliding highlight's edge), on its element.
export function paintedContrast(page: Page, selector: string, part: "color" | "border" | "outline" = "color"): Promise<Reading | null> {
return page.evaluate(
([full, which]) => {
const [sel = full, pseudo] = full.split(/(?=::)/);
const el = document.querySelector<HTMLElement>(sel);
if (!el) return null;
const parse = (s: string): [number, number, number, number] => {
// A color-mix() computes to color(srgb r g b / a), its channels from 0 to 1.
const srgb = s.match(/color\(srgb ([^)]+)\)/)?.[1];
if (srgb) {
const q = srgb.split(/[\s/]+/).filter(Boolean).map(Number);
return [Math.round((q[0] ?? 0) * 255), Math.round((q[1] ?? 0) * 255), Math.round((q[2] ?? 0) * 255), q.length > 3 ? (q[3] ?? 1) : 1];
}
const m = s.match(/rgba?\(([^)]+)\)/);
const inner = m?.[1];
if (!inner) return [0, 0, 0, 0];
const p = inner.split(/[\s,/]+/).filter(Boolean).map(Number);
return [p[0] ?? 0, p[1] ?? 0, p[2] ?? 0, p.length > 3 ? (p[3] ?? 1) : 1];
};
const over = (top: [number, number, number, number], under: [number, number, number]): [number, number, number] => {
const a = top[3];
return [Math.round(top[0] * a + under[0] * (1 - a)), Math.round(top[1] * a + under[1] * (1 - a)), Math.round(top[2] * a + under[2] * (1 - a))];
};
let bg: [number, number, number] = [255, 255, 255];
const layers: [number, number, number, number][] = [];
for (let n: HTMLElement | null = which === "color" || pseudo ? el : el.parentElement; n; n = n.parentElement) {
const c = parse(getComputedStyle(n).backgroundColor);
if (c[3] > 0) layers.unshift(c);
if (c[3] >= 1) break;
}
if (layers.length === 0 || (layers[0]?.[3] ?? 0) < 1) layers.unshift(parse(getComputedStyle(document.body).backgroundColor));
// SVG text is painted with fill, on the shape drawn before it in its group (a tile's
// polygon, a box's rect), not on a CSS background.
const svgText = which === "color" && (el as Element) instanceof SVGTextElement;
if (svgText) {
const shape = el.parentElement?.querySelector("polygon, rect, circle");
const fill = shape ? parse(getComputedStyle(shape).fill) : [0, 0, 0, 0];
if ((fill[3] ?? 0) > 0) layers.push(fill as [number, number, number, number]);
}
for (const l of layers) bg = over(l, bg);
const cs = getComputedStyle(el, pseudo ?? null);
const fgRaw = parse(svgText ? cs.fill : which === "color" ? cs.color : which === "border" ? cs.borderTopColor : cs.outlineColor);
const fg = over(fgRaw, bg);
const lum = (c: [number, number, number]) => {
const f = (v: number) => {
const s = v / 255;
return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
};
return 0.2126 * f(c[0]) + 0.7152 * f(c[1]) + 0.0722 * f(c[2]);
};
const a = lum(fg);
const b = lum(bg);
const hex = (c: [number, number, number]) => "#" + c.map((v) => v.toString(16).padStart(2, "0")).join("");
return { fg: hex(fg), bg: hex(bg), ratio: (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05) };
},
[selector, part] as const,
);
}
// Every selector must match and reach its bar. A missing element fails rather than
// passing vacuously.
export async function expectContrast(page: Page, checks: Array<{ sel: string; what: string; part?: "color" | "border" | "outline"; min?: number }>): Promise<void> {
const low: string[] = [];
for (const c of checks) {
const r = await paintedContrast(page, c.sel, c.part ?? "color");
expect(r, `${c.what} (${c.sel}) is on the page`).not.toBeNull();
const min = c.min ?? (c.part && c.part !== "color" ? 3 : 4.5);
if (r && r.ratio < min) low.push(`${c.what}: ${r.fg} on ${r.bg} is ${r.ratio.toFixed(2)}:1, needs ${min}:1`);
}
expect(low).toEqual([]);
}
// The element that has focus, described for an assertion message.
export function focused(page: Page): Promise<string> {
return page.evaluate(() => {
const el = document.activeElement;
if (!el || el === document.body) return "body";
const name = el.getAttribute("aria-label") ?? el.textContent?.trim().slice(0, 40) ?? "";
return `${el.tagName.toLowerCase()}${el.id ? `#${el.id}` : ""} "${name}"`;
});
}
// Catches the next submit of a form under `root` (a selector), stops it, and resolves with what the
// browser would have posted: the method, the encoding and every field, a file as "file:<name>". For
// a spec that checks a component's forms work with no script.
export function nextPost(page: Page, root: string): Promise<{ method: string; enctype: string; fields: Record<string, string[]> }> {
return page.evaluate(
(sel) =>
new Promise<{ method: string; enctype: string; fields: Record<string, string[]> }>((done) => {
document.querySelector(sel)!.addEventListener(
"submit",
(e) => {
e.preventDefault();
const form = e.target as HTMLFormElement;
const data = new FormData(form, (e as SubmitEvent).submitter);
const fields: Record<string, string[]> = {};
for (const k of new Set(data.keys())) fields[k] = data.getAll(k).map((v) => (typeof v === "string" ? v : `file:${v.name}`));
done({ method: form.method, enctype: form.enctype, fields });
},
{ capture: true, once: true },
);
}),
root,
);
}
// A focused row's ring is an inset outline over the row's own --sel tint, so its contrast is the
// outline colour against the row's background (paintedContrast measures an outline against the
// parent's background, which is not what the eye sees there).
export function ringContrast(page: Page, sel: string): Promise<{ ring: string; bg: string; ratio: number; width: string } | null> {
return page.evaluate((s) => {
const el = document.querySelector<HTMLElement>(s);
if (!el) return null;
const cs = getComputedStyle(el);
const rgb = (v: string) => (v.match(/[\d.]+/g) ?? []).slice(0, 3).map(Number);
const lum = (c: number[]) => {
const f = (x: number) => {
const t = x / 255;
return t <= 0.04045 ? t / 12.92 : Math.pow((t + 0.055) / 1.055, 2.4);
};
return 0.2126 * f(c[0] ?? 0) + 0.7152 * f(c[1] ?? 0) + 0.0722 * f(c[2] ?? 0);
};
const a = lum(rgb(cs.outlineColor));
const b = lum(rgb(cs.backgroundColor));
return { ring: cs.outlineColor, bg: cs.backgroundColor, width: cs.outlineWidth, ratio: (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05) };
}, sel);
}
// What sticks out of a list row, or leaves its title under 100px to be read: an empty list is a fit.
export function rowOverflow(page: Page): Promise<string[]> {
return page.evaluate(() => {
const out: string[] = [];
for (const row of document.querySelectorAll<HTMLElement>("li.cap-row")) {
const r = row.getBoundingClientRect();
if (r.width === 0) continue;
for (const el of row.querySelectorAll<HTMLElement>("*")) {
const b = el.getBoundingClientRect();
if (b.width === 0 || b.height === 0 || el.closest("[hidden], .cap-sr-only")) continue;
if (b.right > r.right + 1 || b.left < r.left - 1) out.push(`${row.id} ${el.className || el.tagName} sticks out of its row`);
}
const t = row.querySelector<HTMLElement>(".cap-row-title");
if (t && t.getBoundingClientRect().width < 100) out.push(`${row.id} title is ${Math.round(t.getBoundingClientRect().width)}px wide`);
}
return out;
});
}