import { execFileSync } from "node:child_process"; import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; /** * MIN-783 — Cross-file accessibility rules (WCAG 2.2 AA) that no single * component test holds. DESIGN.md (Accessibility) states them; the runtime * behavior has its own tests (`components/app-shell.test.tsx`, * `lib/keyboard-modality.test.ts`, `components/editor/document-editor.test.tsx`). * These scans cannot prove conformance: docs/accessibility.md lists the * manual checks that go with them. */ const COMPONENT_SOURCE = /^(?:app|components)\/.*\.tsx$/; function trackedComponents(): string[] { return execFileSync("git", ["ls-files"], { encoding: "utf8" }) .split("\n") .filter((file) => COMPONENT_SOURCE.test(file) && !file.includes(".test.")); } function lineOf(source: string, index: number): number { return source.slice(0, index).split("\n").length; } /** Brace depth at `index`, comments excluded: 0 is the stylesheet's top level. */ function cssDepthAt(css: string, index: number): number { const code = css.slice(0, index).replace(/\/\*[\s\S]*?\*\//g, ""); return [...code].reduce((depth, char) => depth + (char === "{" ? 1 : char === "}" ? -1 : 0), 0); } /** A paragraph rendered by `&&` or a ternary, its parenthesis on the same line or the next. */ const CONDITIONAL_PARAGRAPH = /(?:&&|\?)\s*\(?\s*
]*)>/g; /** Literal `aria-label`s that are proper names, the same in every language. */ const LITERAL_NAMES = new Set(["minddy", "Claude Code, Codex, Cursor"]); describe("accessibility contract", () => { it("translates every accessible name that is not a proper name (3.1.2, 4.1.2)", () => { const offenders = trackedComponents() .flatMap((file) => { const source = readFileSync(file, "utf8"); return [...source.matchAll(/aria-label="([^"]*)"/g)] .filter(([, label]) => label !== "" && !LITERAL_NAMES.has(label)) .map((match) => `${file}:${lineOf(source, match.index)} ${match[0]}`); }); expect(offenders).toEqual([]); }); it("announces an error that appears on its own (3.3.1, 4.1.3)", () => { // `{error &&
…}` (or a ternary, on one
// line or several) shows up after an action, away from focus: without a
// role, a screen reader never hears it.
const offenders = trackedComponents().flatMap((file) => {
const source = readFileSync(file, "utf8");
return [...source.matchAll(CONDITIONAL_PARAGRAPH)]
.filter(([, attributes]) => /text-destructive/.test(attributes) && !/\brole=/.test(attributes))
.map((match) => `${file}:${lineOf(source, match.index)}`);
});
expect(offenders).toEqual([]);
});
it("gives every image element a text alternative, empty when decorative (1.1.1)", () => {
const offenders = trackedComponents().flatMap((file) => {
const source = readFileSync(file, "utf8");
// JSX tags open a line; ` {error} '.matchAll(CONDITIONAL_PARAGRAPH)]).toHaveLength(1);
expect(cssDepthAt("@layer base { a { color: red } }", 14)).toBe(1);
expect(cssDepthAt("/* { */ a { color: red }\nb", 26)).toBe(0);
});
});
` inside a comment does not. Spread props
// (`getImageProps`) carry their own `alt`.
return [...source.matchAll(/^[ \t]*
/gm)]
.filter(([tag]) => !/\balt=/.test(tag) && !/\{\.\.\./.test(tag))
.map((match) => `${file}:${lineOf(source, match.index)}`);
});
expect(offenders).toEqual([]);
});
it("draws keyboard focus above the utilities that remove it (2.4.7)", () => {
const css = readFileSync("app/globals.css", "utf8");
const rule = css.indexOf('html[data-input-modality="keyboard"] :focus-visible:not(');
expect(rule).toBeGreaterThan(-1);
// Unlayered: inside `@layer base` the `focus-visible:outline-none`
// utilities of the primitives would win again.
expect(cssDepthAt(css, rule)).toBe(0);
expect(css.slice(rule, css.indexOf("}", rule))).toContain("outline: 2px solid var(--ring)");
expect(readFileSync("app/layout.tsx", "utf8")).toContain("