@bezda/rhp-mcp 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +222 -2
- package/bin/rhp-mcp.js +129 -0
- package/package.json +62 -4
- package/skill/SKILL.md +450 -0
- package/skill/recipes/area.html +156 -0
- package/skill/recipes/bar.html +145 -0
- package/skill/recipes/box-plot.html +164 -0
- package/skill/recipes/bubble.html +188 -0
- package/skill/recipes/bullet.html +138 -0
- package/skill/recipes/candlestick.html +179 -0
- package/skill/recipes/column.html +138 -0
- package/skill/recipes/dial.html +349 -0
- package/skill/recipes/diverging-bars.html +150 -0
- package/skill/recipes/donut.html +231 -0
- package/skill/recipes/dumbbell.html +137 -0
- package/skill/recipes/gantt.html +196 -0
- package/skill/recipes/grouped-bars.html +142 -0
- package/skill/recipes/heatmap.html +183 -0
- package/skill/recipes/histogram.html +199 -0
- package/skill/recipes/line.html +170 -0
- package/skill/recipes/live.html +255 -0
- package/skill/recipes/log-line.html +493 -0
- package/skill/recipes/lollipop.html +152 -0
- package/skill/recipes/multi-line.html +228 -0
- package/skill/recipes/pyramid.html +162 -0
- package/skill/recipes/race.html +278 -0
- package/skill/recipes/radial-bars.html +157 -0
- package/skill/recipes/scatter.html +199 -0
- package/skill/recipes/silhouettes.html +352 -0
- package/skill/recipes/slope.html +199 -0
- package/skill/recipes/sparklines.html +175 -0
- package/skill/recipes/stacked-100.html +158 -0
- package/skill/recipes/stacked-bars.html +150 -0
- package/skill/recipes/strip.html +221 -0
- package/skill/recipes/unit-stack.html +276 -0
- package/skill/recipes/violin.html +169 -0
- package/skill/recipes/waffle.html +165 -0
- package/skill/recipes/waterfall.html +127 -0
- package/skill/references/api.md +1684 -0
- package/skill/references/design.md +740 -0
- package/skill/references/environments.md +1577 -0
- package/skill/references/forms.md +197 -0
- package/skill/references/interaction.md +794 -0
- package/skill/references/looks.md +267 -0
- package/skill/references/pitfalls.md +202 -0
- package/src/check/README.md +176 -0
- package/src/check/browser.js +92 -0
- package/src/check/build.js +439 -0
- package/src/check/colors.js +324 -0
- package/src/check/findings.js +247 -0
- package/src/check/index.js +508 -0
- package/src/check/inpage.js +1332 -0
- package/src/check/interact.js +266 -0
- package/src/check/png.js +98 -0
- package/src/check/report.js +106 -0
- package/src/check/source.js +387 -0
- package/src/check/sourcemap.js +75 -0
- package/src/guard/note.js +30 -0
- package/src/guard/render.js +17 -0
- package/src/guard/rhp.js +612 -0
- package/src/guard/rules.js +125 -0
- package/src/server.js +266 -0
- package/src/skill.js +140 -0
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
// check(): renders a chart file in a headless browser and reports what is wrong with it.
|
|
2
|
+
//
|
|
3
|
+
// import { check } from "@bezda/rhp-mcp";
|
|
4
|
+
// const result = await check({ file: "chart.html" });
|
|
5
|
+
// console.log(result.text);
|
|
6
|
+
//
|
|
7
|
+
// Options
|
|
8
|
+
// file the chart: an .html page, a Solid .jsx/.tsx component, a React .jsx/.tsx component, or a .js module that
|
|
9
|
+
// uses @bezda/rhp/standalone (its default export is mounted)
|
|
10
|
+
// code the chart's code instead of a file (written to a temp file; imports resolve from the checker)
|
|
11
|
+
// format "html", "solid", "react" or "module" (found from the file, its imports and its project when left out)
|
|
12
|
+
// widths page widths in px, [1280, 390] by default; the interaction pass runs at the first, and slats are tapped
|
|
13
|
+
// at the widths of a phone (600px or less, which are drawn as a touch screen), in a window 664px tall
|
|
14
|
+
// dark emulate prefers-color-scheme: dark
|
|
15
|
+
// interact run the interaction pass and the taps (true by default)
|
|
16
|
+
// outDir where the screenshots go (a folder in the OS temp dir by default)
|
|
17
|
+
// guard check how rhp is used, in the page (true by default; false is for proving the guard draws nothing)
|
|
18
|
+
// freeze take the screenshots with CSS animations stopped (an endless one at its start), for comparing them
|
|
19
|
+
// settleTimeout readiness budget per settling wait in ms (10000 by default); an unsettled page fails explicitly
|
|
20
|
+
// offline fetch nothing from the web (fonts fall back): for tests that must not depend on the network
|
|
21
|
+
// browser a Playwright Browser to use instead of launching one (it is left open)
|
|
22
|
+
// debug also save the screenshots the color probes read (as drawn, with the text hidden, with the marks hidden)
|
|
23
|
+
//
|
|
24
|
+
// Result: { ok, file, format, rhpVersion, browser, findings, charts, interactions, interactedAt, swept, screenshots, timings,
|
|
25
|
+
// text }
|
|
26
|
+
// ok no finding is an error
|
|
27
|
+
// findings [{ level: "error" | "warning" | "info", code, message, fix, width, widths }], errors first
|
|
28
|
+
// charts [{ width, slats, blocks: { bar: 3, label: 6 }, box: { width, height } }]
|
|
29
|
+
// interactions [{ width, kind, target, changed, how, error, moved, unseen }]
|
|
30
|
+
// swept [{ width, breakpoint, slats, jumps }]: the widths at which every slat was picked in turn (each width
|
|
31
|
+
// checked, and the page's own breakpoints between them), how many slats, and how many moved the layout
|
|
32
|
+
// screenshots [{ width, dark, hover, path }]
|
|
33
|
+
// text the report as plain text (what the CLI prints)
|
|
34
|
+
import fs from "fs";
|
|
35
|
+
import os from "os";
|
|
36
|
+
import path from "path";
|
|
37
|
+
import crypto from "crypto";
|
|
38
|
+
import { pathToFileURL } from "url";
|
|
39
|
+
import { detectFormat, bundle, guardedStandalone, htmlProblems, packages, contentType } from "./build.js";
|
|
40
|
+
import { launch, NO_BROWSER_FIX } from "./browser.js";
|
|
41
|
+
import { install } from "./inpage.js";
|
|
42
|
+
import { fromProbe, fixForError, placeOf } from "./findings.js";
|
|
43
|
+
import { textContrast, faintMarks, colorBlind } from "./colors.js";
|
|
44
|
+
import { decode } from "./png.js";
|
|
45
|
+
import { interact as interactionPass } from "./interact.js";
|
|
46
|
+
import { readMap, lookup } from "./sourcemap.js";
|
|
47
|
+
import { sourceProblems } from "./source.js";
|
|
48
|
+
import { report } from "./report.js";
|
|
49
|
+
|
|
50
|
+
const ORIGIN = "http://rhp-check.localhost";
|
|
51
|
+
const CDN = /^https?:\/\/(?:cdn\.jsdelivr\.net\/npm|unpkg\.com|esm\.sh)\/@bezda\/rhp(?:@([^/]+))?\/(?:dist\/)?([^?#]*)/;
|
|
52
|
+
const EXT = { html: ".html", solid: ".jsx", react: ".jsx", module: ".js" };
|
|
53
|
+
const LEVELS = { error: 0, warning: 1, info: 2 };
|
|
54
|
+
const PHONE = 600;
|
|
55
|
+
// The height of what a phone shows of a page (Safari on a 390 by 844 iPhone, its bars shown): where the taps are tried
|
|
56
|
+
const PHONE_WINDOW = 664;
|
|
57
|
+
// With motion on, the longest wait for a page to come to rest after it opens, and after a screenshot, in ms: a page
|
|
58
|
+
// that moves on its own never does
|
|
59
|
+
const MOVING_WAIT = 3000;
|
|
60
|
+
const MOVING_CALM = 500;
|
|
61
|
+
// The most breakpoint widths a width sweeps besides itself
|
|
62
|
+
const MAX_BREAKPOINTS = 6;
|
|
63
|
+
|
|
64
|
+
const shortHash = (text) => crypto.createHash("sha1").update(text).digest("hex").slice(0, 8);
|
|
65
|
+
|
|
66
|
+
// What the web answered (fonts and their CSS), kept for the checks that follow in this process: the same bytes each
|
|
67
|
+
// time, and no wait. Only answers that came back whole are kept.
|
|
68
|
+
const fetched = new Map();
|
|
69
|
+
let fetchedBytes = 0;
|
|
70
|
+
const KEEP_BYTES = 64 * 1024 * 1024;
|
|
71
|
+
|
|
72
|
+
// The chart's file: the one given, or the code written to a temp file
|
|
73
|
+
function source({ file, code, format }) {
|
|
74
|
+
|
|
75
|
+
if (file) {
|
|
76
|
+
const abs = path.resolve(file);
|
|
77
|
+
if (!fs.existsSync(abs)) return { error: `No such file: ${abs}` };
|
|
78
|
+
return { file: abs, text: fs.readFileSync(abs, "utf8") };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (typeof code !== "string" || !code.trim()) return { error: "Give a file or code to check." };
|
|
82
|
+
|
|
83
|
+
const kind = format ?? detectFormat(null, code);
|
|
84
|
+
const dir = path.join(os.tmpdir(), "rhp-check", "input-" + shortHash(code));
|
|
85
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
86
|
+
const abs = path.join(dir, "chart" + EXT[kind]);
|
|
87
|
+
fs.writeFileSync(abs, code);
|
|
88
|
+
|
|
89
|
+
return { file: abs, text: code };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Picks every slat of the page's main Plot in turn, as a reader going from one to the next (a tap on a touch screen,
|
|
93
|
+
// the mouse elsewhere), on a page at rest with reduced motion asked for, where a pick shows at once; and lists what moved
|
|
94
|
+
// outside the plot (moved() in inpage.js): a readout that takes another line for one slat, at one width. What a pick
|
|
95
|
+
// throws is tagged with it in events, as the interaction pass does.
|
|
96
|
+
async function sweep(page, touch, events) {
|
|
97
|
+
|
|
98
|
+
const slats = await page.evaluate(() => window.__rhpProbe.sweepTargets());
|
|
99
|
+
const jumps = [];
|
|
100
|
+
if (!touch) await page.mouse.move(0, 0);
|
|
101
|
+
await page.evaluate(() => window.__rhpProbe.place());
|
|
102
|
+
for (const s of slats) {
|
|
103
|
+
// (a slat that something covers has no point: pointing there would pick what covers it)
|
|
104
|
+
const point = await page.evaluate((n) => window.__rhpProbe.pointAt(n), s.target).catch(() => null);
|
|
105
|
+
if (!Number.isFinite(point?.x) || !Number.isFinite(point?.y)) continue;
|
|
106
|
+
const name = `slat ${s.index} of ${s.of}${s.name ? ` (${s.name})` : ""}`;
|
|
107
|
+
const before = events.length;
|
|
108
|
+
try {
|
|
109
|
+
if (touch) await page.touchscreen.tap(point.x, point.y);
|
|
110
|
+
else await page.mouse.move(point.x, point.y);
|
|
111
|
+
await page.evaluate(() => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done))));
|
|
112
|
+
} catch {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
for (const e of events.slice(before)) {
|
|
116
|
+
if (e.kind === "pageerror" || e.kind === "console-error") e.during ??= `${touch ? "tap" : "hover"} ${name}`;
|
|
117
|
+
}
|
|
118
|
+
const moved = await page.evaluate((n) => window.__rhpProbe.moved(n, true), s.target).catch(() => []);
|
|
119
|
+
if (moved.length) jumps.push({ target: name, moved });
|
|
120
|
+
}
|
|
121
|
+
if (!touch) await page.mouse.move(0, 0);
|
|
122
|
+
return { slats: slats.length, jumps };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function check(options = {}) {
|
|
126
|
+
|
|
127
|
+
const started = performance.now();
|
|
128
|
+
const timings = {};
|
|
129
|
+
const lap = (name, since) => (timings[name] = Math.round(performance.now() - since));
|
|
130
|
+
const widths = (options.widths ?? [1280, 390]).map(Number).filter((w) => w > 0);
|
|
131
|
+
const dark = !!options.dark;
|
|
132
|
+
const guard = options.guard !== false;
|
|
133
|
+
const wantInteract = options.interact !== false;
|
|
134
|
+
const settleTimeout = options.settleTimeout ?? 10000;
|
|
135
|
+
const requireRest = (result) => {
|
|
136
|
+
if (!result.rest) throw Object.assign(new Error(`The page did not settle within ${settleTimeout}ms; layout and color measurements would be unreliable.`), { code: "page-unsettled" });
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const src = source(options);
|
|
140
|
+
if (src.error) return finish({ file: options.file ?? null, format: options.format ?? null, findings: [{ level: "error", code: "no-file", message: src.error, fix: "Pass the path of the chart's file (an .html page, a .jsx component or a .js module)." }], timings, started });
|
|
141
|
+
|
|
142
|
+
const file = src.file;
|
|
143
|
+
const format = options.format ?? detectFormat(file, src.text);
|
|
144
|
+
const outDir = options.outDir ? path.resolve(options.outDir) : path.join(os.tmpdir(), "rhp-check", `${path.basename(file).replace(/\.[^.]+$/, "")}-${shortHash(file)}`);
|
|
145
|
+
const findings = [];
|
|
146
|
+
const pkgs = packages(path.dirname(file));
|
|
147
|
+
const base = { file, format, rhpVersion: pkgs.rhp?.version ?? null };
|
|
148
|
+
|
|
149
|
+
// What needs no browser: the page's scripts parse, or the file bundles
|
|
150
|
+
let t = performance.now();
|
|
151
|
+
let built = null;
|
|
152
|
+
if (format === "html") {
|
|
153
|
+
findings.push(...(await htmlProblems(file, src.text)));
|
|
154
|
+
} else {
|
|
155
|
+
built = await bundle({ file, format, guard });
|
|
156
|
+
findings.push(...built.problems);
|
|
157
|
+
}
|
|
158
|
+
lap("build", t);
|
|
159
|
+
|
|
160
|
+
// What the code gets wrong without an error (a bare boolean, a handler with no parameter, two copies of Solid...).
|
|
161
|
+
// A name the standalone module doesn't export makes the build fail too: the static finding says what to use instead.
|
|
162
|
+
const statics = pkgs.rhp ? sourceProblems({ file, text: src.text, format, rhpRoot: pkgs.rhp.root }) : [];
|
|
163
|
+
const missing = new Set(statics.filter((f) => f.code === "missing-export").map((f) => f.name));
|
|
164
|
+
const replaced = (f) => f.code === "syntax-error" && [...missing].some((name) => f.message.includes(`import "${name}"`));
|
|
165
|
+
findings.splice(0, findings.length, ...findings.filter((f) => !replaced(f)), ...statics.map(({ name, ...f }) => f));
|
|
166
|
+
|
|
167
|
+
if (built && !built.ok) return finish({ ...base, findings, timings, started });
|
|
168
|
+
|
|
169
|
+
t = performance.now();
|
|
170
|
+
const launched = options.browser ? { browser: options.browser, name: `${options.browser.browserType().name()} ${options.browser.version()}` } : await launch();
|
|
171
|
+
lap("launch", t);
|
|
172
|
+
if (launched.error) {
|
|
173
|
+
findings.push({ level: "error", code: "no-browser", message: `No browser to render the chart in (tried: ${launched.error.join("; ")}).`, fix: NO_BROWSER_FIX });
|
|
174
|
+
return finish({ ...base, findings, timings, started });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
t = performance.now();
|
|
178
|
+
const standalone = format === "html" && guard ? fs.readFileSync(await guardedStandalone(pkgs)) : null;
|
|
179
|
+
lap("standalone", t);
|
|
180
|
+
|
|
181
|
+
const map = built ? readMap(built.files.get("/__rhp/bundle.js").body.toString("utf8")) : null;
|
|
182
|
+
const remote = new Map();
|
|
183
|
+
const cdnNotes = [];
|
|
184
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
185
|
+
const name = path.basename(file).replace(/\.[^.]+$/, "");
|
|
186
|
+
|
|
187
|
+
// Answers every request the page makes: the bundle and the user's files for a bundled chart, rhp from the CDN, and
|
|
188
|
+
// the rest of the web (fonts) through a short timeout, kept for the next width
|
|
189
|
+
const route = async (r) => {
|
|
190
|
+
const url = r.request().url();
|
|
191
|
+
|
|
192
|
+
if (url.startsWith(ORIGIN + "/")) {
|
|
193
|
+
const p = decodeURIComponent(new URL(url).pathname);
|
|
194
|
+
if (p === "/") return r.fulfill({ body: built.html, contentType: "text/html" });
|
|
195
|
+
if (built.files.has(p)) return r.fulfill({ body: built.files.get(p).body, contentType: built.files.get(p).type });
|
|
196
|
+
if (p === "/favicon.ico") return r.fulfill({ status: 204, body: "" });
|
|
197
|
+
const local = path.join(path.dirname(file), p);
|
|
198
|
+
if (local.startsWith(path.dirname(file)) && fs.existsSync(local) && fs.statSync(local).isFile()) return r.fulfill({ body: fs.readFileSync(local), contentType: contentType(local) });
|
|
199
|
+
return r.fulfill({ status: 404, body: "not found: " + p });
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const cdn = url.match(CDN);
|
|
203
|
+
if (cdn) {
|
|
204
|
+
const [, version, rest] = cdn;
|
|
205
|
+
const major = version && /^[\^~]?(\d+)/.exec(version)?.[1];
|
|
206
|
+
if (major && major !== String(pkgs.rhp.version.split(".")[0])) cdnNotes.push(`@bezda/rhp@${version}`);
|
|
207
|
+
const sub = rest === "standalone" || rest === "standalone.js" || rest === "" ? "standalone.js" : rest;
|
|
208
|
+
const headers = { "access-control-allow-origin": "*" };
|
|
209
|
+
if (sub === "standalone.js") return r.fulfill({ body: standalone ?? fs.readFileSync(path.join(pkgs.rhp.root, "dist/standalone.js")), contentType: "text/javascript", headers });
|
|
210
|
+
const f = path.join(pkgs.rhp.root, "dist", sub.replace(/^dist\//, ""));
|
|
211
|
+
if (fs.existsSync(f)) return r.fulfill({ body: fs.readFileSync(f), contentType: contentType(f), headers });
|
|
212
|
+
return r.fulfill({ status: 404, body: "not in @bezda/rhp: " + sub, headers });
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (!/^https?:/.test(url)) return r.continue();
|
|
216
|
+
if (options.offline) return r.abort("internetdisconnected").catch(() => {});
|
|
217
|
+
|
|
218
|
+
if (!remote.has(url)) {
|
|
219
|
+
remote.set(url, fetched.has(url) ? Promise.resolve(fetched.get(url)) : r.fetch({ timeout: 5000, maxRedirects: 5 }).then(async (res) => {
|
|
220
|
+
const got = { status: res.status(), headers: res.headers(), body: await res.body() };
|
|
221
|
+
if (got.status >= 200 && got.status < 300 && fetchedBytes + got.body.length <= KEEP_BYTES) {
|
|
222
|
+
fetched.set(url, got);
|
|
223
|
+
fetchedBytes += got.body.length;
|
|
224
|
+
}
|
|
225
|
+
return got;
|
|
226
|
+
}, (e) => ({ error: String(e.message ?? e).split("\n")[0] })));
|
|
227
|
+
}
|
|
228
|
+
const got = await remote.get(url);
|
|
229
|
+
if (got.error) return r.abort("internetdisconnected").catch(() => {});
|
|
230
|
+
return r.fulfill({ status: got.status, headers: got.headers, body: got.body }).catch(() => {});
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const browser = launched.browser;
|
|
234
|
+
const results = [];
|
|
235
|
+
const screenshots = [];
|
|
236
|
+
const isLocal = (u) => u.startsWith("file:") || u.startsWith(ORIGIN);
|
|
237
|
+
|
|
238
|
+
// A page with the chart, the probe installed and at rest (in a window of another size when asked); what the page
|
|
239
|
+
// throws or prints goes to events. The page that is measured, with reduced motion, must come to rest. With motion on
|
|
240
|
+
// (the interaction pass), a page that moves on its own (a live feed, a race that plays) never does: it gets a few
|
|
241
|
+
// seconds for its fonts and entry animations, and is then tried as it moves, as a reader gets it.
|
|
242
|
+
const open = async (context, events, reducedMotion, viewport = null) => {
|
|
243
|
+
const page = await context.newPage();
|
|
244
|
+
if (viewport) await page.setViewportSize(viewport);
|
|
245
|
+
page.on("pageerror", (e) => events.push({ kind: "pageerror", text: `${e.name && !String(e.message).startsWith(e.name) ? e.name + ": " : ""}${e.message}`, stack: e.stack }));
|
|
246
|
+
page.on("console", (m) => {
|
|
247
|
+
const text = m.text();
|
|
248
|
+
if (m.type() === "error" && !/^Failed to load resource/.test(text)) events.push({ kind: "console-error", text, stack: m.location()?.url ? `\n at ${m.location().url}:${m.location().lineNumber + 1}:${m.location().columnNumber + 1}` : "" });
|
|
249
|
+
if (m.type() === "warning" && /^rhp:/.test(text)) events.push({ kind: "rhp-warning", text });
|
|
250
|
+
});
|
|
251
|
+
page.on("requestfailed", (q) => {
|
|
252
|
+
const u = q.url();
|
|
253
|
+
events.push({ kind: isLocal(u) ? "request-failed" : "remote-failed", text: u, error: q.failure()?.errorText ?? "failed" });
|
|
254
|
+
});
|
|
255
|
+
page.on("response", (res) => {
|
|
256
|
+
const u = res.url();
|
|
257
|
+
if (isLocal(u) && res.status() >= 400 && !u.endsWith("/favicon.ico")) events.push({ kind: "request-failed", text: u, error: `HTTP ${res.status()}` });
|
|
258
|
+
});
|
|
259
|
+
await page.emulateMedia({ reducedMotion });
|
|
260
|
+
await page.route("**/*", route);
|
|
261
|
+
await page.goto(format === "html" ? pathToFileURL(file).href : ORIGIN + "/", { waitUntil: "load", timeout: 20000 }).catch((e) => events.push({ kind: "pageerror", text: "The page did not finish loading: " + String(e.message).split("\n")[0] }));
|
|
262
|
+
await page.evaluate(install);
|
|
263
|
+
const measured = reducedMotion === "reduce";
|
|
264
|
+
const rest = await page.evaluate((max) => window.__rhpProbe.settle(max), measured ? settleTimeout : Math.min(settleTimeout, MOVING_WAIT));
|
|
265
|
+
if (measured) requireRest(rest);
|
|
266
|
+
return page;
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
// A screenshot, after which the page comes to rest again: taking one can lay the page out at another size for a
|
|
270
|
+
// moment (see calm() in inpage.js). In the interaction pass (moving: true) the page may move on its own, so it waits
|
|
271
|
+
// briefly and goes on.
|
|
272
|
+
const capture = async (page, opts, moving = false) => {
|
|
273
|
+
const since = await page.evaluate(() => window.__rhpProbe.resized());
|
|
274
|
+
const png = await page.screenshot(opts);
|
|
275
|
+
const rest = await page.evaluate(([n, max]) => window.__rhpProbe.calm(n, max), [since, moving ? Math.min(settleTimeout, MOVING_CALM) : settleTimeout]);
|
|
276
|
+
if (!moving) requireRest(rest);
|
|
277
|
+
return png;
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
// The screenshot the report gives, cropped to what the page draws
|
|
281
|
+
const save = async (page, width, suffix, moving = false) => {
|
|
282
|
+
const box = await page.evaluate(() => window.__rhpProbe.contentBox());
|
|
283
|
+
const size = await page.evaluate(() => ({ w: document.documentElement.scrollWidth, h: document.documentElement.scrollHeight }));
|
|
284
|
+
const pad = 16;
|
|
285
|
+
const clip = box && {
|
|
286
|
+
x: Math.max(0, Math.floor(box.left - pad)),
|
|
287
|
+
y: Math.max(0, Math.floor(box.top - pad)),
|
|
288
|
+
width: Math.min(size.w, Math.ceil(box.right + pad)) - Math.max(0, Math.floor(box.left - pad)),
|
|
289
|
+
height: Math.min(size.h, Math.ceil(box.bottom + pad)) - Math.max(0, Math.floor(box.top - pad)),
|
|
290
|
+
};
|
|
291
|
+
const png = path.join(outDir, `${name}-${width}${dark ? "-dark" : ""}${suffix ? "-" + suffix : ""}.png`);
|
|
292
|
+
await capture(page, { type: "png", path: png, fullPage: true, animations: options.freeze ? "disabled" : "allow", ...(clip && clip.width > 0 && clip.height > 0 ? { clip } : {}) }, moving);
|
|
293
|
+
screenshots.push({ width, dark, hover: suffix === "hover", path: png });
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
t = performance.now();
|
|
297
|
+
|
|
298
|
+
try {
|
|
299
|
+
await Promise.all(widths.map(async (width, i) => {
|
|
300
|
+
const phone = width <= PHONE;
|
|
301
|
+
const context = await browser.newContext({ viewport: { width, height: 900 }, deviceScaleFactor: phone ? 2 : 1, hasTouch: phone, colorScheme: dark ? "dark" : "light" });
|
|
302
|
+
const events = [];
|
|
303
|
+
const w0 = performance.now();
|
|
304
|
+
try {
|
|
305
|
+
|
|
306
|
+
// The page is measured and pictured with reduced motion asked for: rhp's moves end at once, and a page that moves
|
|
307
|
+
// on its own (an autoplay, a live feed) waits or changes in steps, so the screenshots show what was measured
|
|
308
|
+
const page = await open(context, events, "reduce");
|
|
309
|
+
|
|
310
|
+
// first, before any probe hides or restyles anything
|
|
311
|
+
await save(page, width, "");
|
|
312
|
+
|
|
313
|
+
// The screenshots the colors are read from (in CSS px): as drawn, with the glyphs hidden, with the marks hidden
|
|
314
|
+
const shot = () => capture(page, { type: "png", fullPage: true, scale: "css", animations: "allow" });
|
|
315
|
+
const keep = (png, suffix) => {
|
|
316
|
+
if (options.debug) fs.writeFileSync(path.join(outDir, `${name}-${width}${dark ? "-dark" : ""}-${suffix}.png`), png);
|
|
317
|
+
return decode(png);
|
|
318
|
+
};
|
|
319
|
+
const drawn = keep(await shot(), "drawn");
|
|
320
|
+
// Full-page captures can trigger responsive layout handlers. Read geometry
|
|
321
|
+
// and text colors only after those handlers have come to rest again.
|
|
322
|
+
const seen = await page.evaluate(() => window.__rhpProbe.measure());
|
|
323
|
+
const items = await page.evaluate(() => window.__rhpProbe.colorItems());
|
|
324
|
+
await page.evaluate(() => window.__rhpProbe.hide("text"));
|
|
325
|
+
const noText = keep(await shot(), "no-text");
|
|
326
|
+
let noMarks = null;
|
|
327
|
+
if (items.some((x) => x.kind === "mark")) {
|
|
328
|
+
await page.evaluate(() => window.__rhpProbe.hide("marks"));
|
|
329
|
+
noMarks = keep(await shot(), "no-marks");
|
|
330
|
+
}
|
|
331
|
+
await page.evaluate(() => window.__rhpProbe.show());
|
|
332
|
+
const colorFindings = [...textContrast(items, noText, 1), ...(noMarks ? faintMarks(items, drawn, noMarks, 1) : []), ...colorBlind(items, noMarks, 1)];
|
|
333
|
+
|
|
334
|
+
// Every slat picked in turn, here and at the page's own breakpoints between this width and the next narrower
|
|
335
|
+
// one checked (a page of its own at each), where a layout that only fits at the widths checked jumps
|
|
336
|
+
const swept = [];
|
|
337
|
+
if (wantInteract) {
|
|
338
|
+
swept.push({ width, ...(await sweep(page, phone, events)) });
|
|
339
|
+
const next = [...widths].sort((a, b) => b - a).find((w) => w < width);
|
|
340
|
+
const between = next == null ? [] : (await page.evaluate(() => window.__rhpProbe.breakpoints())).filter((b) => b < width && b > next && !widths.includes(b));
|
|
341
|
+
for (const b of between.slice(0, MAX_BREAKPOINTS)) {
|
|
342
|
+
let at = null;
|
|
343
|
+
try {
|
|
344
|
+
at = await open(context, events, "reduce", { width: b, height: 900 });
|
|
345
|
+
swept.push({ width: b, breakpoint: true, ...(await sweep(at, phone, events)) });
|
|
346
|
+
} catch (error) {
|
|
347
|
+
if (error.code !== "page-unsettled") throw error;
|
|
348
|
+
} finally {
|
|
349
|
+
await at?.close();
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// with reduced motion rhp turns every block's transition off, so a slat's own is read with motion on
|
|
355
|
+
await page.emulateMedia({ reducedMotion: "no-preference" });
|
|
356
|
+
seen.found.push(...(await page.evaluate(() => window.__rhpProbe.transitions())));
|
|
357
|
+
const guarded = await page.evaluate(() => globalThis.__rhpCheck?.findings ?? []).catch(() => []);
|
|
358
|
+
await page.close();
|
|
359
|
+
|
|
360
|
+
// The interactions are tried on a fresh page with motion on, as a reader gets it: the pass at the first width,
|
|
361
|
+
// and taps at a phone's, in the window a phone shows (not when the page never finished loading)
|
|
362
|
+
const interactions = [];
|
|
363
|
+
const unfocused = [];
|
|
364
|
+
const loaded = !events.some((e) => e.kind === "pageerror" && /^The page did not finish loading/.test(e.text));
|
|
365
|
+
if (wantInteract && loaded && (i === 0 || phone)) {
|
|
366
|
+
const fresh = await open(context, events, "no-preference", phone ? { width, height: PHONE_WINDOW } : null);
|
|
367
|
+
const pass = { events, capture: (opts) => capture(fresh, opts, true), shoot: (suffix) => save(fresh, width, suffix, true) };
|
|
368
|
+
const passes = [];
|
|
369
|
+
if (i === 0) passes.push(await interactionPass(fresh, pass));
|
|
370
|
+
if (phone) passes.push(await interactionPass(fresh, { ...pass, taps: true }));
|
|
371
|
+
for (const done of passes) {
|
|
372
|
+
interactions.push(...done.interactions);
|
|
373
|
+
unfocused.push(...done.unfocused);
|
|
374
|
+
}
|
|
375
|
+
// what the slats read once, read again (the pass does it after each step too)
|
|
376
|
+
await fresh.evaluate(() => globalThis.__rhpCheck?.recheck?.()).catch(() => {});
|
|
377
|
+
guarded.push(...(await fresh.evaluate(() => globalThis.__rhpCheck?.findings ?? []).catch(() => [])));
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
results.push({ width, index: i, seen, events, guarded, colorFindings, interactions: interactions.map((x) => ({ width, ...x })), unfocused, swept, ms: Math.round(performance.now() - w0) });
|
|
381
|
+
} catch (error) {
|
|
382
|
+
if (error.code !== "page-unsettled") throw error;
|
|
383
|
+
findings.push({ level: "error", code: error.code, message: error.message, fix: "Let finite entry animations finish, honor prefers-reduced-motion for live updates, or increase the check's settleTimeout for a slow machine.", width });
|
|
384
|
+
} finally {
|
|
385
|
+
await context.close();
|
|
386
|
+
}
|
|
387
|
+
}));
|
|
388
|
+
} finally {
|
|
389
|
+
if (!options.browser) await browser.close();
|
|
390
|
+
}
|
|
391
|
+
lap("pages", t);
|
|
392
|
+
|
|
393
|
+
results.sort((a, b) => a.index - b.index);
|
|
394
|
+
|
|
395
|
+
for (const version of new Set(cdnNotes)) {
|
|
396
|
+
findings.push({ level: "error", code: "cdn-version", message: `The page loads ${version} from the CDN, but this check (and the kit) is for rhp ${pkgs.rhp.version.split(".")[0]}.`, fix: `Load https://cdn.jsdelivr.net/npm/@bezda/rhp@${pkgs.rhp.version.split(".")[0]}/dist/standalone.js in the import map.` });
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const staticSyntax = findings.some((f) => f.code === "syntax-error" || f.code === "plain-script-tag" || f.code === "missing-import-map");
|
|
400
|
+
const named = (text) => [...missing].some((name) => new RegExp(`export named '${name}'`).test(text));
|
|
401
|
+
const statically = new Set(statics.map((f) => f.code));
|
|
402
|
+
const cannotLoad = findings.some((f) => /^(syntax-error|plain-script-tag|missing-import-map|missing-export|missing-import|cdn-version)$/.test(f.code)) || results.some((r) => r.guarded.some((g) => g.code === "no-component"));
|
|
403
|
+
const where = (stack) => placeOf(stack, path.dirname(file), map && ((line, col) => lookup(map, line, col)));
|
|
404
|
+
|
|
405
|
+
for (const r of results) {
|
|
406
|
+
const errors = r.events.some((e) => e.kind === "pageerror" || e.kind === "console-error");
|
|
407
|
+
|
|
408
|
+
for (const e of r.events) {
|
|
409
|
+
const at = e.stack ? where(e.stack) : "";
|
|
410
|
+
if (e.kind === "pageerror" && named(e.text)) continue;
|
|
411
|
+
if (e.kind === "pageerror" && !(staticSyntax && /SyntaxError|Unexpected token|Failed to resolve module specifier|Cannot use import/.test(e.text))) {
|
|
412
|
+
findings.push({ level: "error", code: e.during ? "interaction-error" : /^SyntaxError/.test(e.text) ? "syntax-error" : "runtime-error", message: `${e.during ? `${e.during} threw: ` : ""}${e.text}${at ? ` (at ${at})` : ""}`, fix: fixForError(e.text, format), width: r.width });
|
|
413
|
+
} else if (e.kind === "console-error" && !(staticSyntax && /Failed to resolve module specifier|Unexpected token/.test(e.text))) {
|
|
414
|
+
findings.push({ level: "error", code: e.during ? "interaction-error" : "console-error", message: `${e.during ? `${e.during} logged: ` : "Console error: "}${e.text}${at ? ` (at ${at})` : ""}`, fix: fixForError(e.text, format), width: r.width });
|
|
415
|
+
} else if (e.kind === "rhp-warning") {
|
|
416
|
+
findings.push({ level: "error", code: "rhp-warning", message: e.text, fix: "Do what the warning says: rhp prints it when a chart is set up in a way that doesn't work as written.", width: r.width });
|
|
417
|
+
} else if (e.kind === "request-failed") {
|
|
418
|
+
findings.push({ level: "error", code: "request-failed", message: `${e.text.replace(ORIGIN, "").replace(/^file:\/\//, "")} failed to load (${e.error}).`, fix: "Fix the path, or add the file next to the chart.", width: r.width });
|
|
419
|
+
} else if (e.kind === "remote-failed") {
|
|
420
|
+
findings.push({ level: "info", code: "remote-failed", message: `${e.text} could not be fetched (${e.error}); the check went on without it.`, fix: "If the page needs it, check the URL; offline, fonts fall back to the next in the stack.", width: r.width });
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
for (const g of r.guarded) {
|
|
425
|
+
// className is found in the code already, with its line
|
|
426
|
+
if (statically.has("class-name") && /className/.test(g.message)) continue;
|
|
427
|
+
findings.push({ level: g.level, code: g.code, message: g.message, fix: g.fix, width: r.width });
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
for (const f of r.seen.found) {
|
|
431
|
+
// no chart is what code that can't load draws: the reason is reported already
|
|
432
|
+
if (f.code === "no-chart" && cannotLoad) continue;
|
|
433
|
+
findings.push({ ...fromProbe(f, { errors }), width: r.width });
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
for (const f of r.colorFindings) {
|
|
437
|
+
findings.push({ ...f, width: r.width });
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// the layout jumping when an interaction runs: the same things moving for several interactions is one finding
|
|
441
|
+
const jumps = new Map();
|
|
442
|
+
for (const x of r.interactions.filter((x) => x.moved?.length)) {
|
|
443
|
+
const key = x.moved.map((m) => m.what).join("\n");
|
|
444
|
+
if (jumps.has(key)) jumps.get(key).more++;
|
|
445
|
+
else jumps.set(key, { code: "layout-jump", kind: x.kind, target: x.target, moved: x.moved, more: 0 });
|
|
446
|
+
}
|
|
447
|
+
for (const sw of r.swept) {
|
|
448
|
+
for (const j of sw.jumps) {
|
|
449
|
+
const key = j.moved.map((m) => m.what).join("\n");
|
|
450
|
+
if (jumps.has(key)) jumps.get(key).more++;
|
|
451
|
+
else jumps.set(key, { code: "layout-jump", kind: "pick", target: sw.breakpoint ? `${j.target} at ${sw.width}px, beside one of the page's own breakpoints` : j.target, moved: j.moved, more: 0 });
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
for (const j of jumps.values()) {
|
|
455
|
+
findings.push({ ...fromProbe(j, {}), width: r.width });
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// taps whose every change of text lies outside the window: one finding, on the last of them (the furthest slat)
|
|
459
|
+
const unseen = r.interactions.filter((x) => x.unseen);
|
|
460
|
+
if (unseen.length) findings.push({ ...fromProbe({ code: "out-of-view", target: unseen.at(-1).target, ...unseen.at(-1).unseen, more: unseen.length - 1 }, {}), width: r.width });
|
|
461
|
+
|
|
462
|
+
// focus that shows nothing: one finding for the page
|
|
463
|
+
if (r.unfocused.length) findings.push({ ...fromProbe({ code: "focus-invisible", what: r.unfocused[0], more: r.unfocused.length - 1 }, {}), width: r.width });
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const charts = results.flatMap((r) => r.seen.charts.map((c) => ({ width: r.width, ...c })));
|
|
467
|
+
const interactions = results.flatMap((r) => r.interactions);
|
|
468
|
+
const swept = results.flatMap((r) => r.swept.map(({ width, breakpoint, slats, jumps }) => ({ width, breakpoint: !!breakpoint, slats, jumps: jumps.length }))).sort((a, b) => b.width - a.width);
|
|
469
|
+
timings.widths = Object.fromEntries(results.map((r) => [r.width, r.ms]));
|
|
470
|
+
|
|
471
|
+
return finish({ ...base, browser: launched.name, findings, charts, interactions, interactedAt: wantInteract ? widths[0] : null, swept, screenshots, timings, started });
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Merges the same finding at several widths, sorts errors first, and writes the report
|
|
475
|
+
function finish({ findings, started, timings, ...rest }) {
|
|
476
|
+
|
|
477
|
+
const merged = new Map();
|
|
478
|
+
|
|
479
|
+
for (const f of findings) {
|
|
480
|
+
const key = f.key ?? f.code + "\n" + f.message;
|
|
481
|
+
const had = merged.get(key);
|
|
482
|
+
if (had) {
|
|
483
|
+
if (f.width != null && !had.widths.includes(f.width)) had.widths.push(f.width);
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
merged.set(key, { level: f.level, code: f.code, message: f.message, fix: f.fix, width: f.width ?? null, widths: f.width != null ? [f.width] : [] });
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const list = [...merged.values()].sort((a, b) => LEVELS[a.level] - LEVELS[b.level]);
|
|
490
|
+
timings.total = Math.round(performance.now() - started);
|
|
491
|
+
const result = {
|
|
492
|
+
ok: !list.some((f) => f.level === "error"),
|
|
493
|
+
file: rest.file ?? null,
|
|
494
|
+
format: rest.format ?? null,
|
|
495
|
+
rhpVersion: rest.rhpVersion ?? null,
|
|
496
|
+
browser: rest.browser ?? null,
|
|
497
|
+
findings: list,
|
|
498
|
+
charts: rest.charts ?? [],
|
|
499
|
+
interactions: rest.interactions ?? [],
|
|
500
|
+
interactedAt: rest.interactedAt ?? null,
|
|
501
|
+
swept: rest.swept ?? [],
|
|
502
|
+
screenshots: rest.screenshots ?? [],
|
|
503
|
+
timings,
|
|
504
|
+
};
|
|
505
|
+
result.text = report(result);
|
|
506
|
+
|
|
507
|
+
return result;
|
|
508
|
+
}
|