@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.
- package/CHANGELOG.md +13 -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-frontend-syntax.md +97 -0
- package/docs/gates/checks-lint.md +3 -1
- package/package.json +36 -4
- package/src/core/gates.ts +3 -0
- 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,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
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Config, Effect, Schema } from "effect";
|
|
2
|
+
import type { Browser, BrowserContext, CDPSession, Page } from "playwright-core";
|
|
3
|
+
import type { Visit } from "./declaration.ts";
|
|
4
|
+
|
|
5
|
+
export class BrowserFailure extends Schema.TaggedError<BrowserFailure>()("BrowserFailure", {
|
|
6
|
+
message: Schema.String,
|
|
7
|
+
}) {}
|
|
8
|
+
|
|
9
|
+
export const attempt = <A>(doing: string, run: () => Promise<A>): Effect.Effect<A, BrowserFailure> =>
|
|
10
|
+
Effect.tryPromise({
|
|
11
|
+
try: run,
|
|
12
|
+
catch: (cause) => new BrowserFailure({ message: `${doing}: ${cause instanceof Error ? cause.message : String(cause)}` }),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// Playwright's own Chromium build is a separate download, so the runner drives the Chrome the machine already has.
|
|
16
|
+
export const launchChrome = Effect.gen(function* () {
|
|
17
|
+
const { chromium } = yield* attempt("cannot load playwright-core, which a product that runs the browser checks installs beside the kit", () =>
|
|
18
|
+
import("playwright-core"),
|
|
19
|
+
);
|
|
20
|
+
const executablePath = yield* Config.String("CHROME_PATH").pipe(Config.withDefault(""));
|
|
21
|
+
return yield* Effect.acquireRelease(
|
|
22
|
+
attempt("cannot launch Chrome, so set CHROME_PATH to its executable", () =>
|
|
23
|
+
chromium.launch(executablePath === "" ? { channel: "chrome" } : { executablePath }),
|
|
24
|
+
),
|
|
25
|
+
(browser) => attempt("closing Chrome", () => browser.close()).pipe(Effect.catchTag("BrowserFailure", () => Effect.void)),
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
type Opened = {
|
|
30
|
+
readonly context: BrowserContext;
|
|
31
|
+
readonly page: Page;
|
|
32
|
+
readonly cdp: CDPSession;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type Preparation = (context: BrowserContext) => Promise<unknown>;
|
|
36
|
+
|
|
37
|
+
const NO_PREPARATION: Preparation = () => Promise.resolve();
|
|
38
|
+
|
|
39
|
+
export const openVisit = Effect.fn("openVisit")(function* (browser: Browser, { viewport, state }: Visit, prepare: Preparation = NO_PREPARATION) {
|
|
40
|
+
const touch = viewport.touch === true;
|
|
41
|
+
const context = yield* Effect.acquireRelease(
|
|
42
|
+
attempt("cannot open a browser context", () =>
|
|
43
|
+
browser.newContext({
|
|
44
|
+
viewport: { width: viewport.width, height: viewport.height },
|
|
45
|
+
hasTouch: touch,
|
|
46
|
+
isMobile: touch,
|
|
47
|
+
reducedMotion: state.reducedMotion ?? "no-preference",
|
|
48
|
+
}),
|
|
49
|
+
),
|
|
50
|
+
(opened) => attempt("closing a browser context", () => opened.close()).pipe(Effect.catchTag("BrowserFailure", () => Effect.void)),
|
|
51
|
+
);
|
|
52
|
+
yield* attempt("cannot prepare the browser context", () => prepare(context));
|
|
53
|
+
const page = yield* attempt("cannot open a page", () => context.newPage());
|
|
54
|
+
const cdp = yield* attempt("cannot open a DevTools session", () => context.newCDPSession(page));
|
|
55
|
+
yield* attempt("cannot enable the DevTools page domain", () => cdp.send("Page.enable"));
|
|
56
|
+
if (state.textPx !== undefined) {
|
|
57
|
+
const standard = state.textPx;
|
|
58
|
+
yield* attempt("cannot enlarge the text", () => cdp.send("Page.setFontSizes", { fontSizes: { standard } }));
|
|
59
|
+
}
|
|
60
|
+
return { context, page, cdp } satisfies Opened;
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
export const load = Effect.fn("load")(function* (page: Page, url: string) {
|
|
64
|
+
yield* attempt(`cannot load ${url}`, () => page.goto(url, { waitUntil: "load" }));
|
|
65
|
+
yield* attempt(`fonts never settle on ${url}`, () => page.evaluate("document.fonts.ready.then(() => undefined)"));
|
|
66
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describeVisit, type Declaration, type Visit } from "./declaration.ts";
|
|
2
|
+
|
|
3
|
+
export const NAME = "browser";
|
|
4
|
+
|
|
5
|
+
export type Run = {
|
|
6
|
+
readonly check: string;
|
|
7
|
+
readonly visit: Visit;
|
|
8
|
+
readonly inventory: readonly string[];
|
|
9
|
+
readonly found: readonly string[];
|
|
10
|
+
readonly unverified: readonly string[];
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type Outcome = {
|
|
14
|
+
readonly runs: readonly Run[];
|
|
15
|
+
readonly missingRoutes: readonly string[];
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function counted(names: readonly string[]): readonly string[] {
|
|
19
|
+
const counts = new Map<string, number>();
|
|
20
|
+
for (const name of names) counts.set(name, (counts.get(name) ?? 0) + 1);
|
|
21
|
+
return [...counts].map(([name, count]) => `${name} ${count}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function failuresOf({ runs, missingRoutes }: Outcome): readonly string[] {
|
|
25
|
+
return [
|
|
26
|
+
...missingRoutes.map((path) => `route ${path} serves no page from the declared site`),
|
|
27
|
+
...(runs.length === 0 ? ["the declaration leaves no check to run"] : []),
|
|
28
|
+
...runs.flatMap(({ check, visit, found }) => found.map((one) => `${check} at ${describeVisit(visit)}: ${one}`)),
|
|
29
|
+
];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function passes(outcome: Outcome): boolean {
|
|
33
|
+
return failuresOf(outcome).length === 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function report(declaration: Declaration, outcome: Outcome): string {
|
|
37
|
+
const failures = failuresOf(outcome);
|
|
38
|
+
const unverified = outcome.runs.flatMap(({ check, visit, unverified: some }) => some.map((one) => `${check} at ${describeVisit(visit)}: ${one}`));
|
|
39
|
+
const scope = [
|
|
40
|
+
`${outcome.runs.length} check run(s) over ${declaration.routes.length} route(s), ${declaration.viewports.length} viewport(s),`,
|
|
41
|
+
`${declaration.states.length} state(s) and ${declaration.targets.length} target(s)`,
|
|
42
|
+
].join(" ");
|
|
43
|
+
return [
|
|
44
|
+
...outcome.runs.map(({ check, visit, inventory }) => `${NAME}: ${check} at ${describeVisit(visit)} scanned ${inventory.length === 0 ? "nothing" : inventory.join(", ")}`),
|
|
45
|
+
...(unverified.length === 0 ? [] : [`${NAME}: ${unverified.length} contrast check(s) axe cannot measure, reported as unverified:`, ...unverified.map((one) => ` ${one}`)]),
|
|
46
|
+
...(failures.length === 0 ? [`${NAME}: ${scope} pass`] : [`${NAME}: ${failures.length} failure(s) in ${scope}:`, ...failures.map((one) => ` ${one}`)]),
|
|
47
|
+
].join("\n");
|
|
48
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Effect, FileSystem, Path } from "effect";
|
|
2
|
+
import { Usage } from "../../core/main.ts";
|
|
3
|
+
|
|
4
|
+
const INDEX = "index.html";
|
|
5
|
+
|
|
6
|
+
export type Site = {
|
|
7
|
+
readonly files: ReadonlyMap<string, string>;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export const readSite = Effect.fn("readSite")(function* (root: string, site: string) {
|
|
11
|
+
const fs = yield* FileSystem.FileSystem;
|
|
12
|
+
const path = yield* Path.Path;
|
|
13
|
+
const dir = path.resolve(root, site);
|
|
14
|
+
const isDirectory = yield* fs.stat(dir).pipe(
|
|
15
|
+
Effect.map(({ type }) => type === "Directory"),
|
|
16
|
+
Effect.orElseSucceed(() => false),
|
|
17
|
+
);
|
|
18
|
+
if (!isDirectory) return yield* new Usage({ message: `the declared site ${site} is not a directory, so build the site before the browser checks run` });
|
|
19
|
+
const files = new Map<string, string>();
|
|
20
|
+
for (const entry of yield* fs.readDirectory(dir, { recursive: true })) {
|
|
21
|
+
const file = path.join(dir, entry);
|
|
22
|
+
if ((yield* fs.stat(file)).type !== "File") continue;
|
|
23
|
+
const url = `/${entry.split(path.sep).join("/")}`;
|
|
24
|
+
files.set(url, file);
|
|
25
|
+
if (url.endsWith(`/${INDEX}`)) files.set(url.slice(0, -INDEX.length), file);
|
|
26
|
+
}
|
|
27
|
+
return { files } satisfies Site;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
function answer(site: Site, request: Request): Response {
|
|
31
|
+
const url = new URL(request.url);
|
|
32
|
+
const pathname = decodeURIComponent(url.pathname);
|
|
33
|
+
const file = site.files.get(pathname);
|
|
34
|
+
if (file !== undefined) return new Response(Bun.file(file));
|
|
35
|
+
if (site.files.has(`${pathname}/`)) {
|
|
36
|
+
url.pathname = `${url.pathname}/`;
|
|
37
|
+
return Response.redirect(url.href, 301);
|
|
38
|
+
}
|
|
39
|
+
return new Response("not found", { status: 404 });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const serveSite = Effect.fn("serveSite")(function* (site: Site) {
|
|
43
|
+
const server = yield* Effect.acquireRelease(
|
|
44
|
+
Effect.sync(() => Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: (request) => answer(site, request) })),
|
|
45
|
+
(served) => Effect.promise(() => served.stop()),
|
|
46
|
+
);
|
|
47
|
+
return `http://127.0.0.1:${server.port}/`;
|
|
48
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import type { Page } from "playwright-core";
|
|
3
|
+
import type { Target } from "./declaration.ts";
|
|
4
|
+
import { attempt, type BrowserFailure } from "./page.ts";
|
|
5
|
+
|
|
6
|
+
type TargetsReport = {
|
|
7
|
+
readonly found: readonly string[];
|
|
8
|
+
readonly visible: ReadonlyMap<string, number>;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const countVisible = (page: Page, selector: string): Effect.Effect<number, BrowserFailure> =>
|
|
12
|
+
attempt(`cannot query ${selector}`, () => page.locator(selector).filter({ visible: true }).count());
|
|
13
|
+
|
|
14
|
+
export const judgeTargets = Effect.fn("judgeTargets")(function* (page: Page, targets: readonly Target[]) {
|
|
15
|
+
const found: string[] = [];
|
|
16
|
+
const visible = new Map<string, number>();
|
|
17
|
+
for (const target of targets) {
|
|
18
|
+
const shown = yield* countVisible(page, target.selector);
|
|
19
|
+
visible.set(target.name, shown);
|
|
20
|
+
if (shown === 0) found.push(`${target.name} (${target.selector}) matches no visible element`);
|
|
21
|
+
}
|
|
22
|
+
return { found, visible } satisfies TargetsReport;
|
|
23
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { Console, Effect, FileSystem, Path, Schema } from "effect";
|
|
3
|
+
import type { LintResult } from "stylelint";
|
|
4
|
+
import { runMain, Usage } from "../core/main.ts";
|
|
5
|
+
|
|
6
|
+
const NAME = "frontend-syntax";
|
|
7
|
+
|
|
8
|
+
const DECLARATION = "frontend-syntax.json";
|
|
9
|
+
|
|
10
|
+
const TRANSITION_ALL = "declaration-property-value-disallowed-list";
|
|
11
|
+
const BODY_WIDE_USER_SELECT = "rule-selector-property-disallowed-list";
|
|
12
|
+
const UNPARSED = "CssSyntaxError";
|
|
13
|
+
|
|
14
|
+
const RULES = {
|
|
15
|
+
[TRANSITION_ALL]: [{ "/^(?:-[a-z]+-)?transition(?:-property)?$/": ["/(?:^|[\\s,])all(?:[\\s,]|$)/i"] }],
|
|
16
|
+
[BODY_WIDE_USER_SELECT]: [{ "/(?:^|,)\\s*(?:html|body|:root|\\*|:global\\(\\s*(?:html|body|:root|\\*)\\s*\\))\\s*(?:,|$)/": ["/^(?:-[a-z]+-)?user-select$/"] }],
|
|
17
|
+
} as const;
|
|
18
|
+
|
|
19
|
+
const ADVICE: Readonly<Record<string, string>> = {
|
|
20
|
+
[TRANSITION_ALL]: "Name the properties the transition animates.",
|
|
21
|
+
[BODY_WIDE_USER_SELECT]: "Leave text selectable across the page, and turn selection off only on the control that needs it.",
|
|
22
|
+
[UNPARSED]: "The gate judges only what parses.",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const MARKUP = ["**/*.html", "**/*.astro", "**/*.vue", "**/*.svelte"];
|
|
26
|
+
|
|
27
|
+
const Declaration = Schema.Struct({ inputs: Schema.NonEmptyArray(Schema.NonEmptyString) });
|
|
28
|
+
|
|
29
|
+
const decodeDeclaration = Schema.decodeUnknownEffect(Schema.fromJsonString(Declaration));
|
|
30
|
+
|
|
31
|
+
export type Violation = {
|
|
32
|
+
readonly file: string;
|
|
33
|
+
readonly line: number;
|
|
34
|
+
readonly column: number;
|
|
35
|
+
readonly text: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type Input = {
|
|
39
|
+
readonly glob: string;
|
|
40
|
+
readonly files: readonly string[];
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type Scan = {
|
|
44
|
+
readonly inputs: readonly Input[];
|
|
45
|
+
readonly violations: readonly Violation[];
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
class FrontendSyntaxError extends Schema.TaggedError<FrontendSyntaxError>()("FrontendSyntaxError", {
|
|
49
|
+
message: Schema.String,
|
|
50
|
+
}) {}
|
|
51
|
+
|
|
52
|
+
class NoFilesFound extends Schema.TaggedError<NoFilesFound>()("NoFilesFound", {}) {}
|
|
53
|
+
|
|
54
|
+
function isNoFilesFound(cause: unknown): boolean {
|
|
55
|
+
return cause instanceof Error && cause.name === "NoFilesFoundError";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const loadPeer = <A>(name: string, load: () => Promise<A>) =>
|
|
59
|
+
Effect.tryPromise({
|
|
60
|
+
try: load,
|
|
61
|
+
catch: () =>
|
|
62
|
+
new Usage({ message: `cannot load ${name}, which a repository that declares ${DECLARATION} installs beside the kit as an optional peer` }),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const readDeclaration = Effect.fn("readDeclaration")(function* (root: string) {
|
|
66
|
+
const fs = yield* FileSystem.FileSystem;
|
|
67
|
+
const file = (yield* Path.Path).join(root, DECLARATION);
|
|
68
|
+
if (!(yield* fs.exists(file))) {
|
|
69
|
+
return yield* new Usage({ message: `${DECLARATION} is missing, and a repository opts in to this gate by declaring its inputs there` });
|
|
70
|
+
}
|
|
71
|
+
return yield* decodeDeclaration(yield* fs.readFileString(file)).pipe(
|
|
72
|
+
Effect.mapError(({ message }) => new Usage({ message: `${DECLARATION} does not decode: ${message}` })),
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
function violationsOf(result: LintResult, relative: (file: string) => string): readonly Violation[] {
|
|
77
|
+
const file = relative(result.source ?? "");
|
|
78
|
+
return result.warnings.map(({ line, column, rule, text }) => ({
|
|
79
|
+
file,
|
|
80
|
+
line,
|
|
81
|
+
column,
|
|
82
|
+
text: `${text.replace(` (${rule})`, "")}. ${ADVICE[rule] ?? ""}`.trim(),
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export const scanInputs = Effect.fn("scanInputs")(function* (root: string, globs: readonly string[]) {
|
|
87
|
+
const path = yield* Path.Path;
|
|
88
|
+
const stylelint = (yield* loadPeer("stylelint", () => import("stylelint"))).default;
|
|
89
|
+
const html = (yield* loadPeer("postcss-html", () => import("postcss-html"))).default;
|
|
90
|
+
const config = { rules: RULES, overrides: [{ files: MARKUP, customSyntax: html }] };
|
|
91
|
+
const relative = (file: string): string => path.relative(root, file);
|
|
92
|
+
const inputs: Input[] = [];
|
|
93
|
+
const violations = new Map<string, Violation>();
|
|
94
|
+
for (const glob of globs) {
|
|
95
|
+
const linted: readonly LintResult[] = yield* Effect.tryPromise({
|
|
96
|
+
try: () => stylelint.lint({ files: glob, cwd: root, config }),
|
|
97
|
+
catch: (cause) => (isNoFilesFound(cause) ? new NoFilesFound() : new FrontendSyntaxError({ message: `stylelint fails on ${glob}: ${String(cause)}` })),
|
|
98
|
+
}).pipe(
|
|
99
|
+
Effect.map(({ results }) => results),
|
|
100
|
+
Effect.catchTag("NoFilesFound", () => Effect.succeed([])),
|
|
101
|
+
);
|
|
102
|
+
const results = linted.filter(({ ignored }) => ignored !== true);
|
|
103
|
+
const invalid = results.flatMap(({ invalidOptionWarnings }) => invalidOptionWarnings.map(({ text }) => text));
|
|
104
|
+
if (invalid.length > 0) return yield* new FrontendSyntaxError({ message: `stylelint refuses the kit's rule options: ${invalid.join("; ")}` });
|
|
105
|
+
inputs.push({ glob, files: results.map((result) => relative(result.source ?? "")).toSorted() });
|
|
106
|
+
for (const violation of results.flatMap((result) => violationsOf(result, relative))) {
|
|
107
|
+
violations.set(`${violation.file}:${violation.line}:${violation.column}:${violation.text}`, violation);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const byPlace = (a: Violation, b: Violation): number => a.file.localeCompare(b.file) || a.line - b.line || a.column - b.column;
|
|
111
|
+
return { inputs, violations: [...violations.values()].toSorted(byPlace) } satisfies Scan;
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
export function passes({ inputs, violations }: Scan): boolean {
|
|
115
|
+
return violations.length === 0 && inputs.every(({ files }) => files.length > 0);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function report({ inputs, violations }: Scan): string {
|
|
119
|
+
const files = new Set(inputs.flatMap((input) => input.files)).size;
|
|
120
|
+
const scanned = `${files} file(s) from ${inputs.length} declared input(s)`;
|
|
121
|
+
const empty = inputs.filter((input) => input.files.length === 0);
|
|
122
|
+
if (violations.length === 0 && empty.length === 0) return `${NAME}: no violation in ${scanned}`;
|
|
123
|
+
return [
|
|
124
|
+
`${NAME}: ${violations.length + empty.length} problem(s) in ${scanned}:`,
|
|
125
|
+
...empty.map(({ glob }) => ` ${glob}: the declared input matches no file`),
|
|
126
|
+
...violations.map(({ file, line, column, text }) => ` ${file}:${line}:${column} ${text}`),
|
|
127
|
+
].join("\n");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const gate = Effect.gen(function* () {
|
|
131
|
+
const root = (yield* Path.Path).resolve(process.argv[2] ?? ".");
|
|
132
|
+
const { inputs } = yield* readDeclaration(root);
|
|
133
|
+
const scan = yield* scanInputs(root, inputs);
|
|
134
|
+
yield* Console.log(report(scan));
|
|
135
|
+
return passes(scan);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
if (import.meta.main) runMain(NAME, gate);
|
package/stryker.preset.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { availableParallelism } from "node:os";
|
|
1
2
|
import { fileURLToPath } from "node:url";
|
|
2
3
|
import { GUARD_IGNORER } from "./src/testing/mutation-guard-plugin.js";
|
|
3
4
|
import { fullRunRefusal } from "./src/testing/mutation-scope.js";
|
|
@@ -6,6 +7,8 @@ const [, , command, ...args] = process.argv;
|
|
|
6
7
|
const refusal = command === "run" ? fullRunRefusal(args, process.env.CI ?? "") : undefined;
|
|
7
8
|
if (refusal !== undefined) throw new Error(refusal);
|
|
8
9
|
|
|
10
|
+
export const halfAvailableCores = (cores = availableParallelism()) => Math.max(1, Math.floor(cores / 2));
|
|
11
|
+
|
|
9
12
|
export default {
|
|
10
13
|
packageManager: "npm",
|
|
11
14
|
plugins: [
|
|
@@ -21,6 +24,6 @@ export default {
|
|
|
21
24
|
coverageAnalysis: "perTest",
|
|
22
25
|
reporters: ["html", "json", "clear-text", "progress"],
|
|
23
26
|
timeoutMS: 60000,
|
|
24
|
-
concurrency:
|
|
27
|
+
concurrency: halfAvailableCores(),
|
|
25
28
|
thresholds: { high: 85, low: 70, break: null },
|
|
26
29
|
};
|