@avi2dg/checks 0.34.0 → 0.35.1
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.
- package/CHANGELOG.md +21 -0
- package/README.md +9 -0
- package/dependency-cruiser.config.js +2 -1
- package/docs/configs/dependency-rules.md +1 -1
- package/docs/design.md +23 -1
- package/docs/gates/checks-browser.md +201 -0
- package/docs/gates/checks-ci-wiring.md +10 -2
- package/docs/gates/checks-frontend-syntax.md +97 -0
- package/docs/gates/checks-lint.md +3 -1
- package/package.json +37 -4
- package/src/core/gates.ts +3 -0
- package/src/delivery/ci-wiring.ts +34 -38
- package/src/delivery/mutation-job.ts +131 -0
- package/src/delivery/shell-command.ts +69 -5
- package/src/docs/prose-matchers.ts +4 -4
- package/src/docs/sentence-length.ts +2 -2
- package/src/quality/browser/assets.ts +23 -0
- package/src/quality/browser/axe.ts +33 -0
- package/src/quality/browser/browser.ts +134 -0
- package/src/quality/browser/declaration.ts +107 -0
- package/src/quality/browser/dom.ts +121 -0
- package/src/quality/browser/hooks.ts +57 -0
- package/src/quality/browser/keyboard.ts +72 -0
- package/src/quality/browser/layout.ts +208 -0
- package/src/quality/browser/motion.ts +85 -0
- package/src/quality/browser/nesting.ts +23 -0
- package/src/quality/browser/page.ts +66 -0
- package/src/quality/browser/report.ts +48 -0
- package/src/quality/browser/serve.ts +48 -0
- package/src/quality/browser/targets.ts +23 -0
- package/src/quality/frontend-syntax.ts +138 -0
- package/stryker.preset.js +4 -1
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import type { CDPSession } from "playwright-core";
|
|
3
|
+
import type { Target } from "./declaration.ts";
|
|
4
|
+
import { callOn, evaluate, isWithin, lineRects, nodeAt, overlapArea, querySelectorAll, readDom, viewportOf, type Dom, type Rect, type Viewport } from "./dom.ts";
|
|
5
|
+
import { attempt } from "./page.ts";
|
|
6
|
+
|
|
7
|
+
const EDGE_TOLERANCE_PX = 0.5;
|
|
8
|
+
const OVERLAP_TOLERANCE_PX2 = 1;
|
|
9
|
+
const LABEL_CHARS = 48;
|
|
10
|
+
|
|
11
|
+
type Scanned = {
|
|
12
|
+
readonly name: string;
|
|
13
|
+
readonly text: string;
|
|
14
|
+
readonly lines: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type LayoutReport = {
|
|
18
|
+
readonly found: readonly string[];
|
|
19
|
+
readonly scanned: readonly Scanned[];
|
|
20
|
+
readonly parked: readonly string[];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type Resolved = {
|
|
24
|
+
readonly id: number;
|
|
25
|
+
readonly name: string;
|
|
26
|
+
readonly text: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function normaliseText(text: string): string {
|
|
30
|
+
return text.replace(/\s+/g, " ").trim();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function label(element: Resolved): string {
|
|
34
|
+
return `${element.name} "${element.text.slice(0, LABEL_CHARS)}"`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const textOf = Effect.fn("textOf")(function* (dom: Dom, id: number) {
|
|
38
|
+
const text = yield* callOn(dom, id, "function () { return this.textContent ?? ''; }");
|
|
39
|
+
return typeof text === "string" ? normaliseText(text) : "";
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const IS_VISIBLE = `function () {
|
|
43
|
+
const box = this.getBoundingClientRect();
|
|
44
|
+
return box.width > 0 && box.height > 0 && this.checkVisibility({ visibilityProperty: true });
|
|
45
|
+
}`;
|
|
46
|
+
|
|
47
|
+
const IS_PARKED = `function () {
|
|
48
|
+
const box = this.getBoundingClientRect();
|
|
49
|
+
return box.right + scrollX <= 0 || box.bottom + scrollY <= 0 || box.width <= 1 || box.height <= 1;
|
|
50
|
+
}`;
|
|
51
|
+
|
|
52
|
+
const parkedUntilFocused = Effect.fn("parkedUntilFocused")(function* (dom: Dom, id: number) {
|
|
53
|
+
if ((yield* callOn(dom, id, IS_PARKED)) !== true) return false;
|
|
54
|
+
yield* callOn(dom, id, "function () { this.focus(); }");
|
|
55
|
+
const parkedWhenFocused = yield* callOn(dom, id, IS_PARKED);
|
|
56
|
+
yield* evaluate(dom.cdp, "document.activeElement?.blur()");
|
|
57
|
+
return parkedWhenFocused !== true;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const resolveTargets = Effect.fn("resolveTargets")(function* (dom: Dom, targets: readonly Target[]) {
|
|
61
|
+
const resolved = new Map<number, Resolved>();
|
|
62
|
+
const parked: string[] = [];
|
|
63
|
+
for (const target of targets) {
|
|
64
|
+
for (const id of yield* querySelectorAll(dom, target.selector)) {
|
|
65
|
+
if (resolved.has(id) || (yield* callOn(dom, id, IS_VISIBLE)) !== true) continue;
|
|
66
|
+
if (target.focusable === true && (yield* parkedUntilFocused(dom, id))) {
|
|
67
|
+
parked.push(target.name);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
resolved.set(id, { id, name: target.name, text: yield* textOf(dom, id) });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return { elements: [...resolved.values()], parked };
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const findOverlaps = Effect.fn("findOverlaps")(function* (dom: Dom, elements: readonly Resolved[], found: string[]) {
|
|
77
|
+
const boxes = yield* Effect.forEach(elements, (element) => lineRects(dom, element.id).pipe(Effect.map((rects) => ({ element, rects }))));
|
|
78
|
+
for (const [index, a] of boxes.entries()) {
|
|
79
|
+
for (const b of boxes.slice(index + 1)) {
|
|
80
|
+
if (isWithin(dom, a.element.id, b.element.id) || isWithin(dom, b.element.id, a.element.id)) continue;
|
|
81
|
+
const area = a.rects.reduce((sum, ra) => sum + b.rects.reduce((inner, rb) => inner + overlapArea(ra, rb), 0), 0);
|
|
82
|
+
if (area > OVERLAP_TOLERANCE_PX2) found.push(`${label(a.element)} overlaps ${label(b.element)} by ${area.toFixed(0)}px²`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
function probePoints(rect: Rect, viewport: Viewport): readonly (readonly [number, number])[] {
|
|
88
|
+
const y = (Math.max(rect.top, 0) + Math.min(rect.bottom, viewport.height)) / 2;
|
|
89
|
+
const inset = Math.min(2, (rect.right - rect.left) / 2);
|
|
90
|
+
return [
|
|
91
|
+
[rect.left + inset, y],
|
|
92
|
+
[(rect.left + rect.right) / 2, y],
|
|
93
|
+
[rect.right - inset, y],
|
|
94
|
+
];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const judgeLine = Effect.fn("judgeLine")(function* (dom: Dom, viewport: Viewport, element: Resolved, rect: Rect) {
|
|
98
|
+
if (rect.left < -EDGE_TOLERANCE_PX || rect.right > viewport.width + EDGE_TOLERANCE_PX) {
|
|
99
|
+
return [`${label(element)} spans ${rect.left.toFixed(0)} to ${rect.right.toFixed(0)}px, past the ${viewport.width}px viewport`];
|
|
100
|
+
}
|
|
101
|
+
for (const [x, y] of probePoints(rect, viewport)) {
|
|
102
|
+
if (y < 0 || y >= viewport.height) continue;
|
|
103
|
+
const hit = yield* nodeAt(dom, viewport, x, y);
|
|
104
|
+
if (!isWithin(dom, hit, element.id)) return [`${label(element)} is covered or clipped at ${x.toFixed(0)},${y.toFixed(0)}`];
|
|
105
|
+
}
|
|
106
|
+
return [];
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const CLIPS_ITS_OWN_TEXT = `function () {
|
|
110
|
+
if (getComputedStyle(this).display === "inline") return false;
|
|
111
|
+
return this.scrollWidth > this.clientWidth + 1 || this.scrollHeight > this.clientHeight + 1;
|
|
112
|
+
}`;
|
|
113
|
+
|
|
114
|
+
const FIRST_UNSHOWN_TEXT = `function () {
|
|
115
|
+
const alphaOf = (color) => {
|
|
116
|
+
const afterSlash = /\\/\\s*([^\\s)]+)\\s*\\)$/.exec(color);
|
|
117
|
+
if (afterSlash !== null) return parseFloat(afterSlash[1]);
|
|
118
|
+
const components = color.slice(color.indexOf("(") + 1, -1).split(",");
|
|
119
|
+
return components.length === 4 ? parseFloat(components[3]) : 1;
|
|
120
|
+
};
|
|
121
|
+
const unrendered = (element) =>
|
|
122
|
+
element.tagName === "TEXTAREA" ||
|
|
123
|
+
element.closest("svg title, svg desc") !== null ||
|
|
124
|
+
(!element.checkVisibility() && getComputedStyle(element).display !== "contents");
|
|
125
|
+
if (unrendered(this)) return null;
|
|
126
|
+
const walker = document.createTreeWalker(this, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, (node) => {
|
|
127
|
+
if (node.nodeType === Node.TEXT_NODE) return NodeFilter.FILTER_ACCEPT;
|
|
128
|
+
return unrendered(node) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_SKIP;
|
|
129
|
+
});
|
|
130
|
+
for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
|
|
131
|
+
const text = node.textContent.trim();
|
|
132
|
+
if (text === "") continue;
|
|
133
|
+
const parent = node.parentElement;
|
|
134
|
+
const style = getComputedStyle(parent);
|
|
135
|
+
let boxed = parent;
|
|
136
|
+
while (getComputedStyle(boxed).display === "contents" && boxed.parentElement !== null) boxed = boxed.parentElement;
|
|
137
|
+
const range = document.createRange();
|
|
138
|
+
range.selectNodeContents(node);
|
|
139
|
+
const drawn = Array.from(range.getClientRects()).some((rect) => rect.width > 0 && rect.height > 0);
|
|
140
|
+
const shown =
|
|
141
|
+
drawn &&
|
|
142
|
+
parseFloat(style.fontSize) > 0 &&
|
|
143
|
+
style.visibility === "visible" &&
|
|
144
|
+
boxed.checkVisibility({ checkOpacity: true }) &&
|
|
145
|
+
alphaOf(style.color) !== 0;
|
|
146
|
+
if (!shown) return text;
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
}`;
|
|
150
|
+
|
|
151
|
+
const CUT_OFF_BY_UNSCROLLABLE = `function () {
|
|
152
|
+
const unscrollable = (overflow) => overflow === "hidden" || overflow === "clip";
|
|
153
|
+
const clippers = [];
|
|
154
|
+
for (let at = this.parentElement; at !== null; at = at.parentElement) {
|
|
155
|
+
const style = getComputedStyle(at);
|
|
156
|
+
const x = unscrollable(style.overflowX);
|
|
157
|
+
const y = unscrollable(style.overflowY);
|
|
158
|
+
if (!x && !y) continue;
|
|
159
|
+
at.scrollLeft = 0;
|
|
160
|
+
at.scrollTop = 0;
|
|
161
|
+
clippers.push({ at, x, y });
|
|
162
|
+
}
|
|
163
|
+
const lines = Array.from(this.getClientRects()).filter((rect) => rect.width > 0 && rect.height > 0);
|
|
164
|
+
return clippers.some(({ at, x, y }) => {
|
|
165
|
+
const box = at.getBoundingClientRect();
|
|
166
|
+
const left = box.left + at.clientLeft;
|
|
167
|
+
const top = box.top + at.clientTop;
|
|
168
|
+
return lines.some(
|
|
169
|
+
(rect) =>
|
|
170
|
+
(x && (rect.left < left - ${EDGE_TOLERANCE_PX} || rect.right > left + at.clientWidth + ${EDGE_TOLERANCE_PX})) ||
|
|
171
|
+
(y && (rect.top < top - ${EDGE_TOLERANCE_PX} || rect.bottom > top + at.clientHeight + ${EDGE_TOLERANCE_PX})),
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
}`;
|
|
175
|
+
|
|
176
|
+
const judgeReach = Effect.fn("judgeReach")(function* (dom: Dom, element: Resolved, found: string[]) {
|
|
177
|
+
const unshown = yield* callOn(dom, element.id, FIRST_UNSHOWN_TEXT);
|
|
178
|
+
if (typeof unshown === "string") {
|
|
179
|
+
found.push(`${label(element)} hides its text "${normaliseText(unshown).slice(0, LABEL_CHARS)}"`);
|
|
180
|
+
return 0;
|
|
181
|
+
}
|
|
182
|
+
yield* evaluate(dom.cdp, "window.scrollTo(0, 0)");
|
|
183
|
+
if ((yield* callOn(dom, element.id, CUT_OFF_BY_UNSCROLLABLE)) === true) found.push(`${label(element)} is cut off by a container that cannot be scrolled`);
|
|
184
|
+
yield* attempt("cannot scroll an element into view", () => dom.cdp.send("DOM.scrollIntoViewIfNeeded", { backendNodeId: element.id }));
|
|
185
|
+
const viewport = yield* viewportOf(dom.cdp);
|
|
186
|
+
if (viewport.scrollX > 0) found.push(`${label(element)} is reachable only by scrolling the page sideways`);
|
|
187
|
+
const rects = yield* lineRects(dom, element.id);
|
|
188
|
+
for (const rect of rects) found.push(...(yield* judgeLine(dom, viewport, element, rect)));
|
|
189
|
+
if ((yield* callOn(dom, element.id, CLIPS_ITS_OWN_TEXT)) === true) found.push(`${label(element)} clips its own text`);
|
|
190
|
+
return rects.length;
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
export const judgeLayout = Effect.fn("judgeLayout")(function* (cdp: CDPSession, targets: readonly Target[]) {
|
|
194
|
+
const found: string[] = [];
|
|
195
|
+
const viewport = yield* viewportOf(cdp);
|
|
196
|
+
if (viewport.contentWidth > viewport.width + EDGE_TOLERANCE_PX) {
|
|
197
|
+
found.push(`the page is ${viewport.contentWidth}px wide in a ${viewport.width}px viewport`);
|
|
198
|
+
}
|
|
199
|
+
const dom = yield* readDom(cdp);
|
|
200
|
+
const { elements, parked } = yield* resolveTargets(dom, targets);
|
|
201
|
+
yield* findOverlaps(dom, elements, found);
|
|
202
|
+
const scanned: Scanned[] = [];
|
|
203
|
+
for (const element of elements) {
|
|
204
|
+
const lines = yield* judgeReach(dom, element, found);
|
|
205
|
+
scanned.push({ name: element.name, text: element.text, lines });
|
|
206
|
+
}
|
|
207
|
+
return { found, scanned, parked } satisfies LayoutReport;
|
|
208
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { Effect, Schema } from "effect";
|
|
2
|
+
import type { BrowserContext, Page } from "playwright-core";
|
|
3
|
+
import { attempt, BrowserFailure } from "./page.ts";
|
|
4
|
+
|
|
5
|
+
const CONTROLS = "a[href], button, summary, input, select, textarea";
|
|
6
|
+
|
|
7
|
+
const HOVER_TIMEOUT_MS = 2_000;
|
|
8
|
+
|
|
9
|
+
// A motion reset for reduced motion shortens animations to near zero rather than removing them, so one that ends within a frame never moves.
|
|
10
|
+
const FRAME_MS = 1000 / 60;
|
|
11
|
+
|
|
12
|
+
const MOTION_RECORDER = `(() => {
|
|
13
|
+
const seen = new WeakSet();
|
|
14
|
+
const started = [];
|
|
15
|
+
const scan = () => {
|
|
16
|
+
for (const animation of document.getAnimations()) {
|
|
17
|
+
if (seen.has(animation)) continue;
|
|
18
|
+
seen.add(animation);
|
|
19
|
+
if ((animation.effect?.getComputedTiming().activeDuration ?? 0) <= ${FRAME_MS}) continue;
|
|
20
|
+
started.push(animation.animationName ?? animation.transitionProperty ?? "a scripted animation");
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
const everyFrame = () => {
|
|
24
|
+
scan();
|
|
25
|
+
requestAnimationFrame(everyFrame);
|
|
26
|
+
};
|
|
27
|
+
requestAnimationFrame(everyFrame);
|
|
28
|
+
window.__takeStartedAnimations = () => {
|
|
29
|
+
scan();
|
|
30
|
+
return started.splice(0);
|
|
31
|
+
};
|
|
32
|
+
})()`;
|
|
33
|
+
|
|
34
|
+
const decodeStarted = Schema.decodeUnknownEffect(Schema.Array(Schema.String));
|
|
35
|
+
|
|
36
|
+
export function recordMotion(context: BrowserContext): Promise<unknown> {
|
|
37
|
+
return context.addInitScript(MOTION_RECORDER);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const takeStartedAnimations = Effect.fn("takeStartedAnimations")(function* (page: Page) {
|
|
41
|
+
const started: unknown = yield* attempt("cannot read the motion recorder", () => page.evaluate("window.__takeStartedAnimations()"));
|
|
42
|
+
return yield* decodeStarted(started).pipe(
|
|
43
|
+
Effect.mapError(() => new BrowserFailure({ message: `the motion recorder gave ${String(started)}, so it did not run before the page opened` })),
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
type StillnessReport = {
|
|
48
|
+
readonly found: readonly string[];
|
|
49
|
+
readonly controls: number;
|
|
50
|
+
readonly unhovered: number;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const judgeStillness = Effect.fn("judgeStillness")(function* (page: Page) {
|
|
54
|
+
const found: string[] = [];
|
|
55
|
+
const animatedDuring = Effect.fn("animatedDuring")(function* (moment: string) {
|
|
56
|
+
const started = yield* takeStartedAnimations(page);
|
|
57
|
+
if (started.length > 0) found.push(`${started.join(", ")} animated ${moment}`);
|
|
58
|
+
});
|
|
59
|
+
yield* animatedDuring("during load");
|
|
60
|
+
const scrollBehavior: unknown = yield* attempt("cannot read the scroll behaviour", () => page.evaluate("getComputedStyle(document.documentElement).scrollBehavior"));
|
|
61
|
+
if (scrollBehavior === "smooth") found.push("the page scrolls smoothly");
|
|
62
|
+
const controls = page.locator(CONTROLS);
|
|
63
|
+
const count = yield* attempt("cannot count the controls", () => controls.count());
|
|
64
|
+
let judged = 0;
|
|
65
|
+
let unhovered = 0;
|
|
66
|
+
for (let index = 0; index < count; index += 1) {
|
|
67
|
+
const control = controls.nth(index);
|
|
68
|
+
if (!(yield* attempt(`cannot see control ${index + 1}`, () => control.isVisible()))) continue;
|
|
69
|
+
judged += 1;
|
|
70
|
+
const hovered = yield* attempt(`cannot hover control ${index + 1}`, () => control.hover({ timeout: HOVER_TIMEOUT_MS })).pipe(
|
|
71
|
+
Effect.as(true),
|
|
72
|
+
Effect.catchTag("BrowserFailure", () => Effect.succeed(false)),
|
|
73
|
+
);
|
|
74
|
+
if (hovered) {
|
|
75
|
+
yield* animatedDuring(`on hovering control ${index + 1}`);
|
|
76
|
+
yield* attempt("cannot move the pointer away", () => page.mouse.move(0, 0));
|
|
77
|
+
yield* animatedDuring(`on leaving control ${index + 1}`);
|
|
78
|
+
} else {
|
|
79
|
+
unhovered += 1;
|
|
80
|
+
}
|
|
81
|
+
yield* attempt(`cannot focus control ${index + 1}`, () => control.focus());
|
|
82
|
+
yield* animatedDuring(`on focusing control ${index + 1}`);
|
|
83
|
+
}
|
|
84
|
+
return { found, controls: judged, unhovered } satisfies StillnessReport;
|
|
85
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import type { Page } from "playwright-core";
|
|
3
|
+
import { attempt } from "./page.ts";
|
|
4
|
+
|
|
5
|
+
const CONTENT_MODEL = "element-permitted-content";
|
|
6
|
+
|
|
7
|
+
type NestingReport = {
|
|
8
|
+
readonly found: readonly string[];
|
|
9
|
+
readonly elements: number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// The rendered DOM rather than the served file, so markup a script builds is judged too.
|
|
13
|
+
export const judgeNesting = Effect.fn("judgeNesting")(function* (page: Page) {
|
|
14
|
+
const { HtmlValidate } = yield* attempt("cannot load html-validate, which a product that runs the nesting check installs beside the kit", () =>
|
|
15
|
+
import("html-validate"),
|
|
16
|
+
);
|
|
17
|
+
const html = yield* attempt("cannot read the rendered page", () => page.content());
|
|
18
|
+
const elements = yield* attempt("cannot count the elements", () => page.locator("*").count());
|
|
19
|
+
const validator = new HtmlValidate({ rules: { [CONTENT_MODEL]: "error" } });
|
|
20
|
+
const { results } = yield* attempt("html-validate cannot judge the page", () => validator.validateString(html));
|
|
21
|
+
const found = results.flatMap(({ messages }) => messages.map(({ line, column, message }) => `${message} at ${line}:${column} of the rendered page`));
|
|
22
|
+
return { found, elements } satisfies NestingReport;
|
|
23
|
+
});
|