@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.
- package/LICENSE +21 -0
- package/README.md +221 -2
- package/bin/rhp-mcp.js +125 -0
- package/package.json +62 -4
- package/skill/SKILL.md +363 -0
- package/skill/recipes/area.html +150 -0
- package/skill/recipes/bar.html +139 -0
- package/skill/recipes/box-plot.html +158 -0
- package/skill/recipes/bubble.html +182 -0
- package/skill/recipes/bullet.html +132 -0
- package/skill/recipes/candlestick.html +173 -0
- package/skill/recipes/column.html +125 -0
- package/skill/recipes/diverging-bars.html +144 -0
- package/skill/recipes/donut.html +225 -0
- package/skill/recipes/dumbbell.html +131 -0
- package/skill/recipes/gantt.html +186 -0
- package/skill/recipes/grouped-bars.html +136 -0
- package/skill/recipes/heatmap.html +177 -0
- package/skill/recipes/histogram.html +193 -0
- package/skill/recipes/line.html +164 -0
- package/skill/recipes/live.html +249 -0
- package/skill/recipes/lollipop.html +146 -0
- package/skill/recipes/multi-line.html +223 -0
- package/skill/recipes/pyramid.html +156 -0
- package/skill/recipes/race.html +272 -0
- package/skill/recipes/radial-bars.html +151 -0
- package/skill/recipes/scatter.html +193 -0
- package/skill/recipes/slope.html +193 -0
- package/skill/recipes/sparklines.html +169 -0
- package/skill/recipes/stacked-100.html +152 -0
- package/skill/recipes/stacked-bars.html +144 -0
- package/skill/recipes/strip.html +215 -0
- package/skill/recipes/violin.html +163 -0
- package/skill/recipes/waffle.html +159 -0
- package/skill/recipes/waterfall.html +121 -0
- package/skill/references/api.md +1556 -0
- package/skill/references/design.md +763 -0
- package/skill/references/environments.md +1570 -0
- package/skill/references/forms.md +160 -0
- package/skill/references/interaction.md +792 -0
- package/skill/references/pitfalls.md +157 -0
- package/src/check/README.md +163 -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 +419 -0
- package/src/check/inpage.js +1268 -0
- package/src/check/interact.js +266 -0
- package/src/check/png.js +98 -0
- package/src/check/report.js +103 -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 +608 -0
- package/src/guard/rules.js +125 -0
- package/src/server.js +265 -0
- 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
|
+
}
|