Skip to the content

Capsid menu

test/helpers.ts

228 lines and 40 functions in DrDustinEdwards/capsomer, at commit d3966cc on 2026-10-24. This file on GitHub.

Functions

The functions of helpers.ts
FunctionLinesComplexityCoveredCRAPMutation
eachTheme16 to 232No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:18>18 to 211No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
installRatio33 to 451No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
lum34 to 401No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:38>38 to 381No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:38>38 to 382No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:41>41 to 441No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
visitStates49 to 563No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:53>53 to 531No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:53>53 to 531No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
expectNoAxeViolations64 to 854No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:74>74 to 801No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:78>78 to 782No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:79>79 to 791No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:79>79 to 791No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:80>80 to 801No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:83>83 to 832No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
paintedContrast97 to 1541No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:99>99 to 15120No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
parse103 to 11513No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
over116 to 1191No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
lum140 to 1461No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
f141 to 1442No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
hex149 to 1491No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:149>149 to 1491No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
expectContrast158 to 1678No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
focused170 to 1771No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:171>171 to 1766No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
nextPost182 to 2011No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:184>184 to 1981No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:185>185 to 1981No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:188>188 to 1952No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:193>193 to 1932No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
ringContrast206 to 2231No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:207>207 to 2222No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
rgb211 to 2112No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
lum212 to 2184No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
f213 to 2162No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
rowOverflow226 to 2421No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:227>227 to 24112No data: no coverage report in this mapNo data: no coverage report in this mapNo 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;
  });
}