@avi2dg/checks 0.34.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,134 @@
1
+ #!/usr/bin/env bun
2
+ import { Console, Effect, Path } from "effect";
3
+ import type { Browser } from "playwright-core";
4
+ import { runMain } from "../../core/main.ts";
5
+ import { watchAssets } from "./assets.ts";
6
+ import { judgeAxe } from "./axe.ts";
7
+ import { readDeclaration, targetsOn, visitsOf, type CheckName, type Declaration, type Visit } from "./declaration.ts";
8
+ import { loadHooks, runHook, type ProductCheck } from "./hooks.ts";
9
+ import { judgeKeyboard } from "./keyboard.ts";
10
+ import { judgeLayout } from "./layout.ts";
11
+ import { judgeStillness, recordMotion } from "./motion.ts";
12
+ import { judgeNesting } from "./nesting.ts";
13
+ import { attempt, launchChrome, load, openVisit, type BrowserFailure } from "./page.ts";
14
+ import { counted, NAME, passes, report, type Run } from "./report.ts";
15
+ import { readSite, serveSite } from "./serve.ts";
16
+ import { judgeTargets } from "./targets.ts";
17
+
18
+ const NO_TARGET = "scanned no target";
19
+
20
+ type Visiting = {
21
+ readonly browser: Browser;
22
+ readonly declaration: Declaration;
23
+ readonly visit: Visit;
24
+ readonly url: string;
25
+ };
26
+
27
+ const run = (check: string, visit: Visit, inventory: readonly string[], found: readonly string[], unverified: readonly string[] = []): Run => ({
28
+ check,
29
+ visit,
30
+ inventory,
31
+ found,
32
+ unverified,
33
+ });
34
+
35
+ const targetsRun = Effect.fn("targetsRun")(function* ({ browser, declaration, visit, url }: Visiting) {
36
+ const targets = targetsOn(declaration, visit.route.path);
37
+ const { page } = yield* openVisit(browser, visit);
38
+ yield* load(page, url);
39
+ const { found, visible } = yield* judgeTargets(page, targets);
40
+ const inventory = [...visible].map(([name, count]) => `${name} ${count}`);
41
+ return run("targets", visit, inventory, targets.length === 0 ? [...found, NO_TARGET] : found);
42
+ }, Effect.scoped);
43
+
44
+ const layoutRun = Effect.fn("layoutRun")(function* ({ browser, declaration, visit, url }: Visiting) {
45
+ const { cdp, page } = yield* openVisit(browser, visit);
46
+ yield* load(page, url);
47
+ const { found, scanned, parked } = yield* judgeLayout(cdp, targetsOn(declaration, visit.route.path));
48
+ const inventory = [...counted(scanned.map(({ name }) => name)), ...counted(parked).map((one) => `${one} parked until focused`)];
49
+ return run("layout", visit, inventory, scanned.length === 0 ? [...found, NO_TARGET] : found);
50
+ }, Effect.scoped);
51
+
52
+ const keyboardRun = Effect.fn("keyboardRun")(function* ({ browser, declaration, visit, url }: Visiting) {
53
+ const targets = targetsOn(declaration, visit.route.path).filter(({ focusable }) => focusable === true);
54
+ const { page } = yield* openVisit(browser, visit);
55
+ yield* load(page, url);
56
+ const { found, reached } = yield* judgeKeyboard(page, targets);
57
+ const inventory = [...reached].map(([name, times]) => `${name} ${times}`);
58
+ return run("keyboard", visit, inventory, targets.length === 0 || reached.size === 0 ? [...found, NO_TARGET] : found);
59
+ }, Effect.scoped);
60
+
61
+ const motionRun = Effect.fn("motionRun")(function* ({ browser, visit, url }: Visiting) {
62
+ const { page } = yield* openVisit(browser, visit, recordMotion);
63
+ yield* load(page, url);
64
+ const { found, controls, unhovered } = yield* judgeStillness(page);
65
+ const inventory = [`${controls} control(s)`, ...(unhovered === 0 ? [] : [`${unhovered} it cannot hover, judged on focus alone`])];
66
+ return run("motion", visit, inventory, controls === 0 ? [...found, "scanned no control"] : found);
67
+ }, Effect.scoped);
68
+
69
+ const axeRun = Effect.fn("axeRun")(function* ({ browser, visit, url }: Visiting) {
70
+ const { page } = yield* openVisit(browser, visit);
71
+ yield* load(page, url);
72
+ const { found, unverified, rules } = yield* judgeAxe(page);
73
+ return run("axe", visit, [`${rules} rule(s)`], rules === 0 ? [...found, "axe ran no rule"] : found, unverified);
74
+ }, Effect.scoped);
75
+
76
+ const nestingRun = Effect.fn("nestingRun")(function* ({ browser, visit, url }: Visiting) {
77
+ const { page } = yield* openVisit(browser, visit);
78
+ yield* load(page, url);
79
+ const { found, elements } = yield* judgeNesting(page);
80
+ return run("nesting", visit, [`${elements} element(s)`], found);
81
+ }, Effect.scoped);
82
+
83
+ const assetsRun = Effect.fn("assetsRun")(function* ({ browser, visit, url }: Visiting) {
84
+ const { page } = yield* openVisit(browser, visit);
85
+ const watch = watchAssets(page);
86
+ yield* attempt(`cannot load ${url}`, () => page.goto(url, { waitUntil: "networkidle" }));
87
+ return run("assets", visit, [`${watch.requested()} request(s)`], watch.found());
88
+ }, Effect.scoped);
89
+
90
+ const hookRun = Effect.fn("hookRun")(function* (check: ProductCheck, { browser, visit, url }: Visiting) {
91
+ const { page } = yield* openVisit(browser, visit);
92
+ const { targets, found } = yield* runHook(check, page, { ...visit, url });
93
+ return run(`hook ${check.name}`, visit, counted(targets), targets.length === 0 ? [...found, NO_TARGET] : found);
94
+ }, Effect.scoped);
95
+
96
+ const BUILT_IN: Readonly<Record<CheckName, (visiting: Visiting) => Effect.Effect<Run, BrowserFailure>>> = {
97
+ layout: layoutRun,
98
+ keyboard: keyboardRun,
99
+ motion: motionRun,
100
+ axe: axeRun,
101
+ nesting: nestingRun,
102
+ assets: assetsRun,
103
+ };
104
+
105
+ function applies(check: CheckName, { state }: Visit): boolean {
106
+ return check !== "motion" || state.reducedMotion === "reduce";
107
+ }
108
+
109
+ const checkSite = Effect.fn("checkSite")(function* (root: string, declaration: Declaration) {
110
+ const site = yield* readSite(root, declaration.site);
111
+ const paths = declaration.routes.map(({ path }) => path);
112
+ const hooks = declaration.hooks === undefined ? [] : yield* loadHooks(root, declaration.hooks, paths);
113
+ const missingRoutes = paths.filter((path) => !site.files.has(path));
114
+ const origin = yield* serveSite(site);
115
+ const browser = yield* launchChrome;
116
+ const runs: Run[] = [];
117
+ for (const visit of visitsOf(declaration).filter(({ route }) => !missingRoutes.includes(route.path))) {
118
+ const visiting = { browser, declaration, visit, url: new URL(visit.route.path, origin).href };
119
+ runs.push(yield* targetsRun(visiting));
120
+ for (const check of declaration.checks.filter((one) => applies(one, visit))) runs.push(yield* BUILT_IN[check](visiting));
121
+ for (const hook of hooks.filter(({ routes }) => routes === undefined || routes.includes(visit.route.path))) runs.push(yield* hookRun(hook, visiting));
122
+ }
123
+ return { runs, missingRoutes };
124
+ }, Effect.scoped);
125
+
126
+ const browserChecks = Effect.gen(function* () {
127
+ const root = (yield* Path.Path).resolve(process.argv[2] ?? ".");
128
+ const declaration = yield* readDeclaration(root);
129
+ const outcome = yield* checkSite(root, declaration);
130
+ yield* Console.log(report(declaration, outcome));
131
+ return passes(outcome);
132
+ });
133
+
134
+ if (import.meta.main) runMain(NAME, browserChecks);
@@ -0,0 +1,107 @@
1
+ import { Effect, FileSystem, Path, Schema } from "effect";
2
+ import { Usage } from "../../core/main.ts";
3
+
4
+ const DECLARATION = "browser-checks.json";
5
+
6
+ const CHECKS = ["layout", "keyboard", "motion", "axe", "nesting", "assets"] as const;
7
+
8
+ export type CheckName = (typeof CHECKS)[number];
9
+
10
+ const Name = Schema.NonEmptyString;
11
+ const Pixels = Schema.Int.check(Schema.isGreaterThan(0));
12
+
13
+ const Route = Schema.Struct({
14
+ path: Schema.String.check(Schema.isPattern(/^\//, { message: "is not a path from the site root, such as /ru/" })),
15
+ locale: Name,
16
+ });
17
+
18
+ const Viewport = Schema.Struct({ name: Name, width: Pixels, height: Pixels, touch: Schema.optionalKey(Schema.Boolean) });
19
+
20
+ const State = Schema.Struct({
21
+ name: Name,
22
+ textPx: Schema.optionalKey(Pixels),
23
+ reducedMotion: Schema.optionalKey(Schema.Literals(["reduce", "no-preference"])),
24
+ });
25
+
26
+ const Target = Schema.Struct({
27
+ name: Name,
28
+ selector: Name,
29
+ routes: Schema.optionalKey(Schema.NonEmptyArray(Name)),
30
+ focusable: Schema.optionalKey(Schema.Boolean),
31
+ });
32
+
33
+ const Declaration = Schema.Struct({
34
+ site: Name,
35
+ routes: Schema.NonEmptyArray(Route),
36
+ viewports: Schema.NonEmptyArray(Viewport),
37
+ states: Schema.NonEmptyArray(State),
38
+ targets: Schema.NonEmptyArray(Target),
39
+ checks: Schema.NonEmptyArray(Schema.Literals(CHECKS)),
40
+ hooks: Schema.optionalKey(Name),
41
+ });
42
+
43
+ export type Route = typeof Route.Type;
44
+ export type Viewport = typeof Viewport.Type;
45
+ export type State = typeof State.Type;
46
+ export type Target = typeof Target.Type;
47
+ export type Declaration = typeof Declaration.Type;
48
+
49
+ export type Visit = {
50
+ readonly route: Route;
51
+ readonly viewport: Viewport;
52
+ readonly state: State;
53
+ };
54
+
55
+ const decodeDeclaration = Schema.decodeUnknownEffect(Schema.fromJsonString(Declaration));
56
+
57
+ function repeated(names: readonly string[]): readonly string[] {
58
+ return [...new Set(names.filter((name, index) => names.indexOf(name) !== index))];
59
+ }
60
+
61
+ export function inconsistencies(declaration: Declaration): readonly string[] {
62
+ const paths = declaration.routes.map(({ path }) => path);
63
+ const found = [
64
+ ...repeated(paths).map((path) => `route ${path} is declared twice`),
65
+ ...repeated(declaration.viewports.map(({ name }) => name)).map((name) => `viewport ${name} is declared twice`),
66
+ ...repeated(declaration.states.map(({ name }) => name)).map((name) => `state ${name} is declared twice`),
67
+ ...repeated(declaration.targets.map(({ name }) => name)).map((name) => `target ${name} is declared twice`),
68
+ ...repeated(declaration.checks).map((check) => `check ${check} is declared twice`),
69
+ ...declaration.targets.flatMap(({ name, routes = [] }) =>
70
+ routes.filter((path) => !paths.includes(path)).map((path) => `target ${name} names route ${path}, which no route declares`),
71
+ ),
72
+ ];
73
+ if (declaration.checks.includes("motion") && !declaration.states.some(({ reducedMotion }) => reducedMotion === "reduce")) {
74
+ found.push("check motion judges a visitor who turns motion off, and no state sets reducedMotion to reduce");
75
+ }
76
+ if (declaration.checks.includes("keyboard") && !declaration.targets.some(({ focusable }) => focusable === true)) {
77
+ found.push("check keyboard judges the targets Tab reaches, and no target sets focusable");
78
+ }
79
+ return found;
80
+ }
81
+
82
+ export const readDeclaration = Effect.fn("readDeclaration")(function* (root: string) {
83
+ const fs = yield* FileSystem.FileSystem;
84
+ const file = (yield* Path.Path).join(root, DECLARATION);
85
+ if (!(yield* fs.exists(file))) {
86
+ return yield* new Usage({ message: `${DECLARATION} is missing, and a product opts in to the browser checks by declaring its pages there` });
87
+ }
88
+ const declaration = yield* decodeDeclaration(yield* fs.readFileString(file)).pipe(
89
+ Effect.mapError(({ message }) => new Usage({ message: `${DECLARATION} does not decode: ${message}` })),
90
+ );
91
+ const found = inconsistencies(declaration);
92
+ if (found.length > 0) return yield* new Usage({ message: `${DECLARATION}: ${found.join("; ")}` });
93
+ return declaration;
94
+ });
95
+
96
+ export function visitsOf({ routes, viewports, states }: Declaration): readonly Visit[] {
97
+ return routes.flatMap((route) => viewports.flatMap((viewport) => states.map((state) => ({ route, viewport, state }))));
98
+ }
99
+
100
+ export function targetsOn(declaration: Declaration, path: string): readonly Target[] {
101
+ return declaration.targets.filter(({ routes }) => routes === undefined || routes.includes(path));
102
+ }
103
+
104
+ export function describeVisit({ route, viewport, state }: Visit): string {
105
+ const touch = viewport.touch === true ? " touch" : "";
106
+ return `${route.path} (${route.locale}), ${viewport.name} ${viewport.width}x${viewport.height}${touch}, ${state.name}`;
107
+ }
@@ -0,0 +1,121 @@
1
+ import { Effect } from "effect";
2
+ import type { CDPSession } from "playwright-core";
3
+ import { attempt, BrowserFailure } from "./page.ts";
4
+
5
+ export type Rect = {
6
+ readonly left: number;
7
+ readonly top: number;
8
+ readonly right: number;
9
+ readonly bottom: number;
10
+ };
11
+
12
+ type TreeNode = {
13
+ readonly nodeId: number;
14
+ readonly backendNodeId: number;
15
+ readonly children?: readonly TreeNode[];
16
+ readonly pseudoElements?: readonly TreeNode[];
17
+ readonly shadowRoots?: readonly TreeNode[];
18
+ readonly contentDocument?: TreeNode;
19
+ };
20
+
21
+ export type Dom = {
22
+ readonly cdp: CDPSession;
23
+ readonly rootId: number;
24
+ readonly backendOf: ReadonlyMap<number, number>;
25
+ readonly parentOf: ReadonlyMap<number, number>;
26
+ };
27
+
28
+ export const readDom = Effect.fn("readDom")(function* (cdp: CDPSession) {
29
+ const { root } = yield* attempt("cannot read the DOM", () => cdp.send("DOM.getDocument", { depth: -1, pierce: true }));
30
+ const backendOf = new Map<number, number>();
31
+ const parentOf = new Map<number, number>();
32
+ const visit = (node: TreeNode): void => {
33
+ backendOf.set(node.nodeId, node.backendNodeId);
34
+ const { children = [], pseudoElements = [], shadowRoots = [], contentDocument } = node;
35
+ for (const child of [...children, ...pseudoElements, ...shadowRoots, ...(contentDocument === undefined ? [] : [contentDocument])]) {
36
+ parentOf.set(child.backendNodeId, node.backendNodeId);
37
+ visit(child);
38
+ }
39
+ };
40
+ visit(root);
41
+ return { cdp, rootId: root.nodeId, backendOf, parentOf } satisfies Dom;
42
+ });
43
+
44
+ export const querySelectorAll = Effect.fn("querySelectorAll")(function* (dom: Dom, selector: string) {
45
+ const { nodeIds } = yield* attempt(`cannot query ${selector}`, () => dom.cdp.send("DOM.querySelectorAll", { nodeId: dom.rootId, selector }));
46
+ return nodeIds.flatMap((nodeId) => {
47
+ const backend = dom.backendOf.get(nodeId);
48
+ return backend === undefined ? [] : [backend];
49
+ });
50
+ });
51
+
52
+ export function isWithin(dom: Dom, node: number, ancestor: number): boolean {
53
+ for (let at: number | undefined = node; at !== undefined; at = dom.parentOf.get(at)) {
54
+ if (at === ancestor) return true;
55
+ }
56
+ return false;
57
+ }
58
+
59
+ function rectOfQuad(quad: readonly number[]): Rect {
60
+ const xs = quad.filter((_, index) => index % 2 === 0);
61
+ const ys = quad.filter((_, index) => index % 2 === 1);
62
+ return { left: Math.min(...xs), top: Math.min(...ys), right: Math.max(...xs), bottom: Math.max(...ys) };
63
+ }
64
+
65
+ // Chromium refuses an element with no layout box rather than answering with no quads.
66
+ const NO_LAYOUT_BOX = "Could not compute content quads";
67
+
68
+ export const lineRects = Effect.fn("lineRects")(function* (dom: Dom, backendNodeId: number) {
69
+ const { quads } = yield* attempt("cannot read an element's boxes", () => dom.cdp.send("DOM.getContentQuads", { backendNodeId })).pipe(
70
+ Effect.catchTag("BrowserFailure", (failure) => (failure.message.includes(NO_LAYOUT_BOX) ? Effect.succeed({ quads: [] }) : Effect.fail(failure))),
71
+ );
72
+ return quads.map(rectOfQuad).filter((rect) => rect.right - rect.left > 0 && rect.bottom - rect.top > 0);
73
+ });
74
+
75
+ export function overlapArea(a: Rect, b: Rect): number {
76
+ const width = Math.min(a.right, b.right) - Math.max(a.left, b.left);
77
+ const height = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
78
+ return width > 0 && height > 0 ? width * height : 0;
79
+ }
80
+
81
+ export type Viewport = {
82
+ readonly width: number;
83
+ readonly height: number;
84
+ readonly contentWidth: number;
85
+ readonly scrollX: number;
86
+ readonly scrollY: number;
87
+ };
88
+
89
+ export const viewportOf = Effect.fn("viewportOf")(function* (cdp: CDPSession) {
90
+ const { cssLayoutViewport, cssContentSize } = yield* attempt("cannot read the layout metrics", () => cdp.send("Page.getLayoutMetrics"));
91
+ return {
92
+ width: cssLayoutViewport.clientWidth,
93
+ height: cssLayoutViewport.clientHeight,
94
+ contentWidth: cssContentSize.width,
95
+ scrollX: cssLayoutViewport.pageX,
96
+ scrollY: cssLayoutViewport.pageY,
97
+ } satisfies Viewport;
98
+ });
99
+
100
+ // Content quads are relative to the viewport, and the hit test takes coordinates relative to the document.
101
+ export const nodeAt = Effect.fn("nodeAt")(function* (dom: Dom, viewport: Viewport, x: number, y: number) {
102
+ const { backendNodeId } = yield* attempt("cannot hit-test the page", () =>
103
+ dom.cdp.send("DOM.getNodeForLocation", { x: Math.round(x + viewport.scrollX), y: Math.round(y + viewport.scrollY) }),
104
+ );
105
+ return backendNodeId;
106
+ });
107
+
108
+ export const callOn = Effect.fn("callOn")(function* (dom: Dom, backendNodeId: number, functionDeclaration: string) {
109
+ const { object } = yield* attempt("cannot resolve an element", () => dom.cdp.send("DOM.resolveNode", { backendNodeId }));
110
+ const { objectId } = object;
111
+ if (objectId === undefined) return yield* new BrowserFailure({ message: `node ${backendNodeId} resolved to no object` });
112
+ const { result } = yield* attempt("cannot call into the page", () =>
113
+ dom.cdp.send("Runtime.callFunctionOn", { objectId, functionDeclaration, returnByValue: true }),
114
+ );
115
+ const value: unknown = result.value;
116
+ return value;
117
+ });
118
+
119
+ export const evaluate = Effect.fn("evaluate")(function* (cdp: CDPSession, expression: string) {
120
+ yield* attempt("cannot evaluate in the page", () => cdp.send("Runtime.evaluate", { expression }));
121
+ });
@@ -0,0 +1,57 @@
1
+ import { Effect, FileSystem, Path, Schema } from "effect";
2
+ import type { Page } from "playwright-core";
3
+ import { Usage } from "../../core/main.ts";
4
+ import type { Route, State, Viewport } from "./declaration.ts";
5
+ import { attempt } from "./page.ts";
6
+
7
+ export type HookVisit = {
8
+ readonly route: Route;
9
+ readonly viewport: Viewport;
10
+ readonly state: State;
11
+ readonly url: string;
12
+ };
13
+
14
+ const HookResult = Schema.Struct({ targets: Schema.Array(Schema.String), found: Schema.Array(Schema.String) });
15
+
16
+ export type HookResult = typeof HookResult.Type;
17
+
18
+ type RunHook = (page: Page, visit: HookVisit) => Promise<HookResult>;
19
+
20
+ const isRunHook = (value: unknown): value is RunHook => typeof value === "function";
21
+
22
+ const ProductCheck = Schema.Struct({
23
+ name: Schema.NonEmptyString,
24
+ routes: Schema.optionalKey(Schema.NonEmptyArray(Schema.NonEmptyString)),
25
+ run: Schema.declare(isRunHook, { expected: "a function from a page and a visit to a promised result" }),
26
+ });
27
+
28
+ export type ProductCheck = typeof ProductCheck.Type;
29
+
30
+ const decodeHookModule = Schema.decodeUnknownEffect(Schema.Struct({ checks: Schema.NonEmptyArray(ProductCheck) }));
31
+
32
+ const decodeHookResult = Schema.decodeUnknownEffect(HookResult);
33
+
34
+ export const loadHooks = Effect.fn("loadHooks")(function* (root: string, hooks: string, paths: readonly string[]) {
35
+ const file = (yield* Path.Path).resolve(root, hooks);
36
+ if (!(yield* (yield* FileSystem.FileSystem).exists(file))) return yield* new Usage({ message: `the declared hooks module ${hooks} is missing` });
37
+ const loaded: unknown = yield* attempt(`cannot load the hooks module ${hooks}`, () => import(file)).pipe(
38
+ Effect.mapError(({ message }) => new Usage({ message })),
39
+ );
40
+ const { checks } = yield* decodeHookModule(loaded).pipe(
41
+ Effect.mapError(({ message }) => new Usage({ message: `the hooks module ${hooks} exports no checks a runner can call: ${message}` })),
42
+ );
43
+ const names = checks.map(({ name }) => name);
44
+ const problems = [
45
+ ...names.filter((name, index) => names.indexOf(name) !== index).map((name) => `hook ${name} is exported twice`),
46
+ ...checks.flatMap(({ name, routes = [] }) => routes.filter((path) => !paths.includes(path)).map((path) => `hook ${name} names route ${path}, which no route declares`)),
47
+ ];
48
+ if (problems.length > 0) return yield* new Usage({ message: `${hooks}: ${problems.join("; ")}` });
49
+ return checks;
50
+ });
51
+
52
+ export const runHook = Effect.fn("runHook")(function* (check: ProductCheck, page: Page, visit: HookVisit) {
53
+ return yield* attempt(`hook ${check.name} throws`, () => check.run(page, visit)).pipe(
54
+ Effect.flatMap((result) => decodeHookResult(result).pipe(Effect.mapError(({ message }) => ({ message: `hook ${check.name} returns no result: ${message}` })))),
55
+ Effect.catch(({ message }) => Effect.succeed({ targets: [], found: [message] })),
56
+ );
57
+ });
@@ -0,0 +1,72 @@
1
+ import { Effect } from "effect";
2
+ import type { Page } from "playwright-core";
3
+ import type { Target } from "./declaration.ts";
4
+ import { attempt } from "./page.ts";
5
+ import { countVisible } from "./targets.ts";
6
+
7
+ const FOCUS_RING_MARGIN_PX = 6;
8
+
9
+ // A focus trap would hold Tab on the page forever, so the walk gives up past this many stops.
10
+ const STOP_LIMIT = 500;
11
+
12
+ type KeyboardReport = {
13
+ readonly found: readonly string[];
14
+ readonly reached: ReadonlyMap<string, number>;
15
+ };
16
+
17
+ const count = (page: Page, selector: string) => attempt(`cannot query ${selector}`, () => page.locator(selector).count());
18
+
19
+ const focusedTargets = Effect.fn("focusedTargets")(function* (page: Page, targets: readonly Target[]) {
20
+ const focused: Target[] = [];
21
+ for (const target of targets) {
22
+ const matches = yield* attempt(`cannot query ${target.selector}`, () => page.locator(target.selector).and(page.locator(":focus")).count());
23
+ if (matches > 0) focused.push(target);
24
+ }
25
+ return focused;
26
+ });
27
+
28
+ const focusRingShows = Effect.fn("focusRingShows")(function* (page: Page) {
29
+ const focused = yield* attempt("cannot hold the focused element", () => page.locator(":focus").elementHandle());
30
+ const box = yield* attempt("cannot measure the focused element", () => focused.boundingBox());
31
+ if (box === null) return false;
32
+ const clip = {
33
+ x: Math.max(0, box.x - FOCUS_RING_MARGIN_PX),
34
+ y: Math.max(0, box.y - FOCUS_RING_MARGIN_PX),
35
+ width: box.width + 2 * FOCUS_RING_MARGIN_PX,
36
+ height: box.height + 2 * FOCUS_RING_MARGIN_PX,
37
+ };
38
+ const withFocus = yield* attempt("cannot capture the focused element", () => page.screenshot({ clip }));
39
+ yield* attempt("cannot blur the focused element", () => page.evaluate("document.activeElement.blur()"));
40
+ const withoutFocus = yield* attempt("cannot capture the blurred element", () => page.screenshot({ clip }));
41
+ yield* attempt("cannot focus the element again", () => focused.focus());
42
+ return !withFocus.equals(withoutFocus);
43
+ });
44
+
45
+ // Past the last stop Tab leaves the page for the browser, and the next press starts over at the first stop.
46
+ const tabToNextStop = Effect.fn("tabToNextStop")(function* (page: Page) {
47
+ yield* attempt("cannot press Tab", () => page.keyboard.press("Tab"));
48
+ return (yield* count(page, ":focus")) > 0;
49
+ });
50
+
51
+ export const judgeKeyboard = Effect.fn("judgeKeyboard")(function* (page: Page, targets: readonly Target[]) {
52
+ const found: string[] = [];
53
+ const reached = new Map<string, number>();
54
+ let stops = 0;
55
+ while (yield* tabToNextStop(page)) {
56
+ stops += 1;
57
+ if (stops > STOP_LIMIT) {
58
+ found.push(`Tab never leaves the page within ${STOP_LIMIT} stops`);
59
+ break;
60
+ }
61
+ const focused = yield* focusedTargets(page, targets);
62
+ for (const target of focused) reached.set(target.name, (reached.get(target.name) ?? 0) + 1);
63
+ if (focused.length > 0 && !(yield* focusRingShows(page))) {
64
+ found.push(`${focused.map(({ name }) => name).join(" and ")} shows no visible focus when Tab reaches it`);
65
+ }
66
+ }
67
+ for (const target of targets) {
68
+ const present = yield* countVisible(page, target.selector);
69
+ if ((reached.get(target.name) ?? 0) !== present) found.push(`Tab reached ${target.name} ${reached.get(target.name) ?? 0} of ${present} time(s)`);
70
+ }
71
+ return { found, reached } satisfies KeyboardReport;
72
+ });