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