@bezda/rhp-mcp 0.1.1 → 0.1.2
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/README.md +16 -2
- package/package.json +3 -3
- package/skill/SKILL.md +4 -1
- package/skill/recipes/bar.html +3 -1
- package/skill/recipes/box-plot.html +3 -1
- package/skill/recipes/candlestick.html +3 -1
- package/skill/recipes/dial.html +2 -2
- package/skill/recipes/donut.html +3 -1
- package/skill/recipes/gantt.html +3 -1
- package/skill/recipes/heatmap.html +3 -1
- package/skill/recipes/histogram.html +3 -1
- package/skill/recipes/pyramid.html +3 -1
- package/skill/recipes/scatter.html +17 -4
- package/skill/recipes/silhouettes.html +5 -3
- package/skill/recipes/slope.html +17 -4
- package/skill/recipes/strip.html +3 -1
- package/skill/references/interaction.md +4 -1
- package/skill/references/performance.md +143 -0
- package/src/check/README.md +18 -1
- package/src/check/browser.js +11 -5
- package/src/check/findings.js +14 -0
- package/src/check/index.js +81 -19
- package/src/check/inpage.js +2 -2
- package/src/check/interact.js +1 -19
- package/src/check/network.js +168 -0
- package/src/check/png.js +18 -0
- package/src/check/selection.js +123 -0
package/src/check/browser.js
CHANGED
|
@@ -77,12 +77,18 @@ export async function launch() {
|
|
|
77
77
|
|
|
78
78
|
const failures = [];
|
|
79
79
|
|
|
80
|
+
// Chromium's own sandbox on (Playwright leaves it off): the page runs code the checker did not write. Where it
|
|
81
|
+
// cannot start (some Linux containers, or running as root), the same browser runs without it. WebRTC sends nothing
|
|
82
|
+
// outside a proxy, so it cannot reach the network around the check's router.
|
|
83
|
+
const args = ["--hide-scrollbars", "--disable-lcd-text", "--force-webrtc-ip-handling-policy=disable_non_proxied_udp"];
|
|
80
84
|
for (const t of tries) {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
85
|
+
for (const chromiumSandbox of [true, false]) {
|
|
86
|
+
try {
|
|
87
|
+
const browser = await chromium.launch({ headless: true, chromiumSandbox, ...t.options, args });
|
|
88
|
+
return { browser, name: `${t.name} ${browser.version()}`, sandboxed: chromiumSandbox };
|
|
89
|
+
} catch (e) {
|
|
90
|
+
if (!chromiumSandbox) failures.push(`${t.name}: ${String(e.message ?? e).split("\n")[0]}`);
|
|
91
|
+
}
|
|
86
92
|
}
|
|
87
93
|
}
|
|
88
94
|
|
package/src/check/findings.js
CHANGED
|
@@ -198,6 +198,20 @@ export function fromProbe(f, context) {
|
|
|
198
198
|
message: `Tab reaches ${f.what}${f.more ? ` and ${f.more} more` : ""}, but nothing on screen shows the focus there: a keyboard reader cannot see where it is.`,
|
|
199
199
|
fix: "Show focus: take off outline: none, or put a :focus-visible style that shows in its place (outline: 2px solid in the ink color, outline-offset: 2px; on a slat, in the slat type's css).",
|
|
200
200
|
};
|
|
201
|
+
case "selection-hover-stays":
|
|
202
|
+
return {
|
|
203
|
+
level: "warning",
|
|
204
|
+
code: f.code,
|
|
205
|
+
message: `After clicking ${f.selected}, hovering ${f.target} overrides that selection, but moving beside the chart (${f.what}) does not give the selected item back. Leaving the poster does.`,
|
|
206
|
+
fix: "This chart already has click selection with a temporary hover override. Unless the user explicitly requested a different behavior, end only the hover preview when the pointer leaves the chart area, including when it moves onto the poster's readout, text or margin, and show the existing click selection again. Keep hover and selection separate; do not clear the selection on pointerleave, add a new interaction, or change a different interaction style.",
|
|
207
|
+
};
|
|
208
|
+
case "selection-reset":
|
|
209
|
+
return {
|
|
210
|
+
level: "warning",
|
|
211
|
+
code: f.code,
|
|
212
|
+
message: `This chart has click selection with a temporary hover override. After selecting ${f.target}, clicking beside the chart (${f.what}), inside the poster, does not restore its initial state.`,
|
|
213
|
+
fix: "Unless the user explicitly requested persistent selection or another reset behavior, make a click or tap inside this poster but outside its chart area reset this existing selection and its hover preview. Restore the meaningful default selection the chart already had (such as the analysis subject); if it had none, clear all selections. Preserve that default rather than inventing one, and leave controls, links, other charts, and keyboard interactions alone. Do not add click selection or hover to a chart that lacks them. Apply the fix in the chart source and rerun the checker; the checker does not rewrite the file.",
|
|
214
|
+
};
|
|
201
215
|
default:
|
|
202
216
|
return { level: "error", code: f.code, message: JSON.stringify(f), fix: "" };
|
|
203
217
|
}
|
package/src/check/index.js
CHANGED
|
@@ -35,7 +35,7 @@ import fs from "fs";
|
|
|
35
35
|
import os from "os";
|
|
36
36
|
import path from "path";
|
|
37
37
|
import crypto from "crypto";
|
|
38
|
-
import { pathToFileURL } from "url";
|
|
38
|
+
import { pathToFileURL, fileURLToPath } from "url";
|
|
39
39
|
import { detectFormat, bundle, guardedStandalone, htmlProblems, packages, contentType } from "./build.js";
|
|
40
40
|
import { launch, NO_BROWSER_FIX } from "./browser.js";
|
|
41
41
|
import { install } from "./inpage.js";
|
|
@@ -43,9 +43,11 @@ import { fromProbe, fixForError, placeOf } from "./findings.js";
|
|
|
43
43
|
import { textContrast, faintMarks, colorBlind } from "./colors.js";
|
|
44
44
|
import { decode } from "./png.js";
|
|
45
45
|
import { interact as interactionPass } from "./interact.js";
|
|
46
|
+
import { selectionBehavior } from "./selection.js";
|
|
46
47
|
import { readMap, lookup } from "./sourcemap.js";
|
|
47
48
|
import { sourceProblems } from "./source.js";
|
|
48
49
|
import { report } from "./report.js";
|
|
50
|
+
import { fetchPublic, READ_METHODS, denyBrowserNetwork } from "./network.js";
|
|
49
51
|
|
|
50
52
|
const ORIGIN = "http://rhp-check.localhost";
|
|
51
53
|
const CDN = /^https?:\/\/(?:cdn\.jsdelivr\.net\/npm|unpkg\.com|esm\.sh)\/@bezda\/rhp(?:@([^/]+))?\/(?:dist\/)?([^?#]*)/;
|
|
@@ -184,18 +186,45 @@ export async function check(options = {}) {
|
|
|
184
186
|
fs.mkdirSync(outDir, { recursive: true });
|
|
185
187
|
const name = path.basename(file).replace(/\.[^.]+$/, "");
|
|
186
188
|
|
|
189
|
+
// Both the spelled path and the resolved path must remain below this root.
|
|
190
|
+
// This also rejects hidden paths and symlinks that lead outside the chart folder.
|
|
191
|
+
const scopedFile = (root, candidate) => {
|
|
192
|
+
try {
|
|
193
|
+
const inside = (base, name) => {
|
|
194
|
+
const rel = path.relative(base, name);
|
|
195
|
+
return rel && !rel.startsWith(".." + path.sep) && rel !== ".." && !path.isAbsolute(rel) && !rel.split(path.sep).some((part) => part.startsWith("."));
|
|
196
|
+
};
|
|
197
|
+
if (!inside(path.resolve(root), path.resolve(candidate))) return null;
|
|
198
|
+
const real = fs.realpathSync(candidate);
|
|
199
|
+
return inside(fs.realpathSync(root), real) && fs.statSync(real).isFile() ? real : null;
|
|
200
|
+
} catch {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
|
|
187
205
|
// Answers every request the page makes: the bundle and the user's files for a bundled chart, rhp from the CDN, and
|
|
188
206
|
// the rest of the web (fonts) through a short timeout, kept for the next width
|
|
189
207
|
const route = async (r) => {
|
|
190
208
|
const url = r.request().url();
|
|
191
209
|
|
|
210
|
+
if (url.startsWith("file:")) {
|
|
211
|
+
let local = null;
|
|
212
|
+
try {
|
|
213
|
+
const requested = fileURLToPath(url);
|
|
214
|
+
local = path.resolve(requested) === path.resolve(file) ? file : scopedFile(path.dirname(file), requested);
|
|
215
|
+
} catch {
|
|
216
|
+
// A malformed file URL is not a local asset.
|
|
217
|
+
}
|
|
218
|
+
return local ? r.fulfill({ body: fs.readFileSync(local), contentType: contentType(local) }) : r.abort("blockedbyclient");
|
|
219
|
+
}
|
|
220
|
+
|
|
192
221
|
if (url.startsWith(ORIGIN + "/")) {
|
|
193
222
|
const p = decodeURIComponent(new URL(url).pathname);
|
|
194
|
-
if (p === "/") return r.fulfill({ body: built.html, contentType: "text/html" });
|
|
195
|
-
if (built
|
|
223
|
+
if (p === "/" && built) return r.fulfill({ body: built.html, contentType: "text/html" });
|
|
224
|
+
if (built?.files.has(p)) return r.fulfill({ body: built.files.get(p).body, contentType: built.files.get(p).type });
|
|
196
225
|
if (p === "/favicon.ico") return r.fulfill({ status: 204, body: "" });
|
|
197
|
-
const local = path.join(path.dirname(file), p);
|
|
198
|
-
if (local
|
|
226
|
+
const local = scopedFile(path.dirname(file), path.join(path.dirname(file), p));
|
|
227
|
+
if (local) return r.fulfill({ body: fs.readFileSync(local), contentType: contentType(local) });
|
|
199
228
|
return r.fulfill({ status: 404, body: "not found: " + p });
|
|
200
229
|
}
|
|
201
230
|
|
|
@@ -207,32 +236,37 @@ export async function check(options = {}) {
|
|
|
207
236
|
const sub = rest === "standalone" || rest === "standalone.js" || rest === "" ? "standalone.js" : rest;
|
|
208
237
|
const headers = { "access-control-allow-origin": "*" };
|
|
209
238
|
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 (
|
|
239
|
+
const f = scopedFile(path.join(pkgs.rhp.root, "dist"), path.join(pkgs.rhp.root, "dist", sub.replace(/^dist\//, "")));
|
|
240
|
+
if (f) return r.fulfill({ body: fs.readFileSync(f), contentType: contentType(f), headers });
|
|
212
241
|
return r.fulfill({ status: 404, body: "not in @bezda/rhp: " + sub, headers });
|
|
213
242
|
}
|
|
214
243
|
|
|
215
244
|
if (!/^https?:/.test(url)) return r.continue();
|
|
216
245
|
if (options.offline) return r.abort("internetdisconnected").catch(() => {});
|
|
217
246
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
247
|
+
// The rest of the web, read only and from public addresses only (network.js); a GET answer is kept
|
|
248
|
+
const method = r.request().method().toUpperCase();
|
|
249
|
+
if (!READ_METHODS.has(method)) return r.abort("blockedbyclient").catch(() => {});
|
|
250
|
+
const key = method + " " + url;
|
|
251
|
+
if (!remote.has(key)) {
|
|
252
|
+
remote.set(key, method === "GET" && fetched.has(url) ? Promise.resolve(fetched.get(url)) : fetchPublic(url, { method, headers: r.request().headers() }).then((got) => {
|
|
253
|
+
if (method === "GET" && !got.error && got.status >= 200 && got.status < 300 && fetchedBytes + got.body.length <= KEEP_BYTES) {
|
|
222
254
|
fetched.set(url, got);
|
|
223
255
|
fetchedBytes += got.body.length;
|
|
224
256
|
}
|
|
225
257
|
return got;
|
|
226
|
-
}
|
|
258
|
+
}));
|
|
227
259
|
}
|
|
228
|
-
const got = await remote.get(
|
|
229
|
-
if (got.error) return r.abort("internetdisconnected").catch(() => {});
|
|
260
|
+
const got = await remote.get(key);
|
|
261
|
+
if (got.error) return r.abort(got.blocked ? "blockedbyclient" : "internetdisconnected").catch(() => {});
|
|
230
262
|
return r.fulfill({ status: got.status, headers: got.headers, body: got.body }).catch(() => {});
|
|
231
263
|
};
|
|
232
264
|
|
|
233
265
|
const browser = launched.browser;
|
|
234
266
|
const results = [];
|
|
235
267
|
const screenshots = [];
|
|
268
|
+
const fenced = new Set();
|
|
269
|
+
const fence = await denyBrowserNetwork((url) => fenced.add(url));
|
|
236
270
|
const isLocal = (u) => u.startsWith("file:") || u.startsWith(ORIGIN);
|
|
237
271
|
|
|
238
272
|
// A page with the chart, the probe installed and at rest (in a window of another size when asked); what the page
|
|
@@ -258,6 +292,11 @@ export async function check(options = {}) {
|
|
|
258
292
|
});
|
|
259
293
|
await page.emulateMedia({ reducedMotion });
|
|
260
294
|
await page.route("**/*", route);
|
|
295
|
+
await context.routeWebSocket(/.*/, (ws) => {
|
|
296
|
+
// A WebSocket can send arbitrary messages after its handshake, so it is not a read-only fetch.
|
|
297
|
+
events.push({ kind: "remote-failed", text: ws.url(), error: "net::ERR_BLOCKED_BY_CLIENT" });
|
|
298
|
+
ws.close({ code: 1008, reason: "WebSockets are blocked by the check" });
|
|
299
|
+
});
|
|
261
300
|
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
301
|
await page.evaluate(install);
|
|
263
302
|
const measured = reducedMotion === "reduce";
|
|
@@ -298,7 +337,8 @@ export async function check(options = {}) {
|
|
|
298
337
|
try {
|
|
299
338
|
await Promise.all(widths.map(async (width, i) => {
|
|
300
339
|
const phone = width <= PHONE;
|
|
301
|
-
|
|
340
|
+
// No service workers (they would answer requests outside the check's router) and no downloads
|
|
341
|
+
const context = await browser.newContext({ viewport: { width, height: 900 }, deviceScaleFactor: phone ? 2 : 1, hasTouch: phone, colorScheme: dark ? "dark" : "light", serviceWorkers: "block", acceptDownloads: false, proxy: fence.proxy });
|
|
302
342
|
const events = [];
|
|
303
343
|
const w0 = performance.now();
|
|
304
344
|
try {
|
|
@@ -361,8 +401,18 @@ export async function check(options = {}) {
|
|
|
361
401
|
// and taps at a phone's, in the window a phone shows (not when the page never finished loading)
|
|
362
402
|
const interactions = [];
|
|
363
403
|
const unfocused = [];
|
|
404
|
+
let selection = { findings: [], tested: null };
|
|
364
405
|
const loaded = !events.some((e) => e.kind === "pageerror" && /^The page did not finish loading/.test(e.text));
|
|
365
406
|
if (wantInteract && loaded && (i === 0 || phone)) {
|
|
407
|
+
if (i === 0) {
|
|
408
|
+
const audit = await open(context, events, "no-preference");
|
|
409
|
+
try {
|
|
410
|
+
selection = await selectionBehavior(audit, { events });
|
|
411
|
+
guarded.push(...(await audit.evaluate(() => globalThis.__rhpCheck?.findings ?? []).catch(() => [])));
|
|
412
|
+
} finally {
|
|
413
|
+
await audit.close();
|
|
414
|
+
}
|
|
415
|
+
}
|
|
366
416
|
const fresh = await open(context, events, "no-preference", phone ? { width, height: PHONE_WINDOW } : null);
|
|
367
417
|
const pass = { events, capture: (opts) => capture(fresh, opts, true), shoot: (suffix) => save(fresh, width, suffix, true) };
|
|
368
418
|
const passes = [];
|
|
@@ -377,7 +427,7 @@ export async function check(options = {}) {
|
|
|
377
427
|
guarded.push(...(await fresh.evaluate(() => globalThis.__rhpCheck?.findings ?? []).catch(() => [])));
|
|
378
428
|
}
|
|
379
429
|
|
|
380
|
-
results.push({ width, index: i, seen, events, guarded, colorFindings, interactions: interactions.map((x) => ({ width, ...x })), unfocused, swept, ms: Math.round(performance.now() - w0) });
|
|
430
|
+
results.push({ width, index: i, seen, events, guarded, colorFindings, interactions: interactions.map((x) => ({ width, ...x })), unfocused, selection, swept, ms: Math.round(performance.now() - w0) });
|
|
381
431
|
} catch (error) {
|
|
382
432
|
if (error.code !== "page-unsettled") throw error;
|
|
383
433
|
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 });
|
|
@@ -386,8 +436,13 @@ export async function check(options = {}) {
|
|
|
386
436
|
}
|
|
387
437
|
}));
|
|
388
438
|
} finally {
|
|
389
|
-
|
|
439
|
+
try {
|
|
440
|
+
if (!options.browser) await browser.close();
|
|
441
|
+
} finally {
|
|
442
|
+
await fence.close();
|
|
443
|
+
}
|
|
390
444
|
}
|
|
445
|
+
for (const url of fenced) findings.push({ level: "info", code: "remote-failed", message: `The check blocked an unrouted browser connection to ${url}; no connection was forwarded.`, fix: "Use public HTTP assets or data requests; worker sockets and other direct browser connections are blocked." });
|
|
391
446
|
lap("pages", t);
|
|
392
447
|
|
|
393
448
|
results.sort((a, b) => a.index - b.index);
|
|
@@ -417,7 +472,8 @@ export async function check(options = {}) {
|
|
|
417
472
|
} else if (e.kind === "request-failed") {
|
|
418
473
|
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
474
|
} else if (e.kind === "remote-failed") {
|
|
420
|
-
findings.push({ level: "info", code: "remote-failed", message: `${e.text}
|
|
475
|
+
if (/BLOCKED_BY_CLIENT/.test(e.error)) findings.push({ level: "info", code: "remote-failed", message: `${e.text} was not fetched: the check only reads from public addresses (GET, HEAD or OPTIONS), blocks WebSockets, and the page asked for something else; it went on without it.`, fix: "Load data and assets with plain GET requests from public URLs, or from files next to the chart; the checker does not forward request bodies or WebSocket messages.", width: r.width });
|
|
476
|
+
else 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
477
|
}
|
|
422
478
|
}
|
|
423
479
|
|
|
@@ -461,14 +517,19 @@ export async function check(options = {}) {
|
|
|
461
517
|
|
|
462
518
|
// focus that shows nothing: one finding for the page
|
|
463
519
|
if (r.unfocused.length) findings.push({ ...fromProbe({ code: "focus-invisible", what: r.unfocused[0], more: r.unfocused.length - 1 }, {}), width: r.width });
|
|
520
|
+
|
|
521
|
+
// Existing click selection with a temporary hover override, verified on an isolated page.
|
|
522
|
+
for (const f of r.selection.findings) findings.push({ ...fromProbe(f, {}), width: r.width });
|
|
523
|
+
|
|
464
524
|
}
|
|
465
525
|
|
|
466
526
|
const charts = results.flatMap((r) => r.seen.charts.map((c) => ({ width: r.width, ...c })));
|
|
527
|
+
const selectionAudits = results.flatMap((r) => r.selection.tested ? [{ width: r.width, ...r.selection.tested }] : []);
|
|
467
528
|
const interactions = results.flatMap((r) => r.interactions);
|
|
468
529
|
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
530
|
timings.widths = Object.fromEntries(results.map((r) => [r.width, r.ms]));
|
|
470
531
|
|
|
471
|
-
return finish({ ...base, browser: launched.name, findings, charts, interactions, interactedAt: wantInteract ? widths[0] : null, swept, screenshots, timings, started });
|
|
532
|
+
return finish({ ...base, browser: launched.name, findings, charts, interactions, interactedAt: wantInteract ? widths[0] : null, selectionAudits, swept, screenshots, timings, started });
|
|
472
533
|
}
|
|
473
534
|
|
|
474
535
|
// Merges the same finding at several widths, sorts errors first, and writes the report
|
|
@@ -498,6 +559,7 @@ function finish({ findings, started, timings, ...rest }) {
|
|
|
498
559
|
charts: rest.charts ?? [],
|
|
499
560
|
interactions: rest.interactions ?? [],
|
|
500
561
|
interactedAt: rest.interactedAt ?? null,
|
|
562
|
+
selectionAudits: rest.selectionAudits ?? [],
|
|
501
563
|
swept: rest.swept ?? [],
|
|
502
564
|
screenshots: rest.screenshots ?? [],
|
|
503
565
|
timings,
|
package/src/check/inpage.js
CHANGED
|
@@ -1078,10 +1078,10 @@ export function install() {
|
|
|
1078
1078
|
// point reaches it and something in the slat takes the pointer, something covers it: { covered } says what (another
|
|
1079
1079
|
// slat of its Plot, or an element). When nothing in it takes the pointer (the page reads the pointer around the
|
|
1080
1080
|
// chart), the first point on its first mark, or its center.
|
|
1081
|
-
function pointAt(n, across = 0.5) {
|
|
1081
|
+
function pointAt(n, across = 0.5, scroll = true) {
|
|
1082
1082
|
|
|
1083
1083
|
const slat = window.__rhpTargets[n];
|
|
1084
|
-
slat.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
|
|
1084
|
+
if (scroll) slat.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
|
|
1085
1085
|
const marks = [...slat.querySelectorAll(MARKS + ", .rhp-cell")].filter(visible)
|
|
1086
1086
|
.map((m) => ({ m, r: m.getBoundingClientRect(), pointer: getComputedStyle(m).cursor === "pointer" }))
|
|
1087
1087
|
.filter(({ r }) => r.width * r.height > 0);
|
package/src/check/interact.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// look), what threw, and what moved: a control other than the one used, a chart's top, or the plot while a slat is
|
|
6
6
|
// lit (the layout jumps); for a tap, also whether the reader sees none of the text it changed (unseen): all of it lies
|
|
7
7
|
// outside the window.
|
|
8
|
-
import {
|
|
8
|
+
import { seenPixels } from "./png.js";
|
|
9
9
|
|
|
10
10
|
const changes = (seen) => {
|
|
11
11
|
|
|
@@ -20,24 +20,6 @@ const changes = (seen) => {
|
|
|
20
20
|
return parts.join(", ");
|
|
21
21
|
};
|
|
22
22
|
|
|
23
|
-
// How many pixels of two screenshots of one box differ enough to see (their channels by more than 24 in all: a
|
|
24
|
-
// tint of 6% of the ink over the paper shows, drawing one page twice changes nothing)
|
|
25
|
-
function seenPixels(a, b) {
|
|
26
|
-
|
|
27
|
-
if (a.equals(b)) return 0;
|
|
28
|
-
|
|
29
|
-
const x = decode(a);
|
|
30
|
-
const y = decode(b);
|
|
31
|
-
if (x.width !== y.width || x.height !== y.height) return Infinity;
|
|
32
|
-
|
|
33
|
-
let n = 0;
|
|
34
|
-
for (let i = 0; i < x.data.length; i += 4) {
|
|
35
|
-
if (Math.abs(x.data[i] - y.data[i]) + Math.abs(x.data[i + 1] - y.data[i + 1]) + Math.abs(x.data[i + 2] - y.data[i + 2]) > 24) n++;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
return n;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
23
|
// Tab through the page (up to 8 stops, until it comes back to one): each stop's box, with room around it for an
|
|
42
24
|
// outline, is pictured focused and then blurred (Tab goes on from an element blurred). A stop where under 20 pixels
|
|
43
25
|
// change shows the keyboard reader nothing (outline: none, and nothing in its place).
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// What a checked page may reach on the network. The page runs code the checker did not write, so its requests to the
|
|
2
|
+
// web are made here, in Node, under three rules:
|
|
3
|
+
// - only public addresses: never loopback, private, link-local, shared (CGNAT), multicast or reserved ones, checked
|
|
4
|
+
// on the address actually connected to (so neither a redirect nor a DNS answer that changes can reach one)
|
|
5
|
+
// - only requests that read: GET, HEAD and OPTIONS (a chart reads fonts, scripts, styles, images and data while it
|
|
6
|
+
// renders; it has no reason to send anything)
|
|
7
|
+
// - the same 5 seconds and 5 redirects as before
|
|
8
|
+
import dns from "dns";
|
|
9
|
+
import net from "net";
|
|
10
|
+
import http from "http";
|
|
11
|
+
import https from "https";
|
|
12
|
+
import zlib from "zlib";
|
|
13
|
+
|
|
14
|
+
const MAX_BODY = 64 * 1024 * 1024;
|
|
15
|
+
|
|
16
|
+
const BLOCKED = new net.BlockList();
|
|
17
|
+
for (const [address, prefix] of [
|
|
18
|
+
["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8], ["169.254.0.0", 16], ["172.16.0.0", 12],
|
|
19
|
+
["192.0.0.0", 24], ["192.0.2.0", 24], ["192.88.99.0", 24], ["192.168.0.0", 16], ["198.18.0.0", 15],
|
|
20
|
+
["198.51.100.0", 24], ["203.0.113.0", 24], ["224.0.0.0", 4], ["240.0.0.0", 4],
|
|
21
|
+
]) BLOCKED.addSubnet(address, prefix, "ipv4");
|
|
22
|
+
for (const [address, prefix] of [
|
|
23
|
+
["::", 128], ["::1", 128], ["64:ff9b::", 96], ["100::", 64], ["2001:db8::", 32], ["fc00::", 7], ["fe80::", 10], ["ff00::", 8],
|
|
24
|
+
]) BLOCKED.addSubnet(address, prefix, "ipv6");
|
|
25
|
+
|
|
26
|
+
// Whether an IP address is one a page may not reach (an IPv4 address written as IPv6 is judged as IPv4)
|
|
27
|
+
export function isPrivate(ip) {
|
|
28
|
+
|
|
29
|
+
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(ip);
|
|
30
|
+
if (mapped) return BLOCKED.check(mapped[1], "ipv4");
|
|
31
|
+
// URL canonicalizes IPv6 spellings, including dotted and expanded IPv4-mapped addresses.
|
|
32
|
+
if (net.isIP(ip) === 6) {
|
|
33
|
+
const canonical = new URL(`http://[${ip}]/`).hostname.slice(1, -1);
|
|
34
|
+
const hex = /^::ffff:([\da-f]+):([\da-f]+)$/i.exec(canonical);
|
|
35
|
+
if (hex) {
|
|
36
|
+
const a = parseInt(hex[1], 16), b = parseInt(hex[2], 16);
|
|
37
|
+
return BLOCKED.check(`${a >> 8}.${a & 255}.${b >> 8}.${b & 255}`, "ipv4");
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const family = net.isIP(ip);
|
|
41
|
+
if (!family) return true;
|
|
42
|
+
|
|
43
|
+
return BLOCKED.check(ip, family === 4 ? "ipv4" : "ipv6");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const blocked = (why) => Object.assign(new Error(why), { code: "EBLOCKED" });
|
|
47
|
+
|
|
48
|
+
// dns.lookup that refuses names answering with an address a page may not reach. Node calls it for every connection,
|
|
49
|
+
// so the address checked is the one connected to.
|
|
50
|
+
function publicLookup(hostname, options, callback) {
|
|
51
|
+
|
|
52
|
+
dns.lookup(hostname, { ...options, all: true }, (err, addresses) => {
|
|
53
|
+
if (err) return callback(err);
|
|
54
|
+
const bad = addresses.find((a) => isPrivate(a.address));
|
|
55
|
+
if (bad) return callback(blocked(`${hostname} is at ${bad.address}, which is not a public address`));
|
|
56
|
+
if (options.all) return callback(null, addresses);
|
|
57
|
+
return callback(null, addresses[0].address, addresses[0].family);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const READ_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
|
62
|
+
|
|
63
|
+
// Request headers that belong to one connection, or that would have the server compress (the body is handed to the
|
|
64
|
+
// browser as it is, so it is asked for plain)
|
|
65
|
+
const DROP_REQUEST = new Set(["host", "connection", "keep-alive", "proxy-connection", "transfer-encoding", "upgrade", "accept-encoding", "content-length"]);
|
|
66
|
+
const DROP_RESPONSE = new Set(["connection", "keep-alive", "transfer-encoding", "content-encoding", "content-length"]);
|
|
67
|
+
|
|
68
|
+
function once(url, method, headers, deadline) {
|
|
69
|
+
|
|
70
|
+
return new Promise((resolve) => {
|
|
71
|
+
const u = new URL(url);
|
|
72
|
+
const host = u.hostname.replace(/^\[|\]$/g, "");
|
|
73
|
+
if (net.isIP(host) && isPrivate(host)) return resolve({ error: `${host} is not a public address`, blocked: true });
|
|
74
|
+
const left = deadline - Date.now();
|
|
75
|
+
if (left <= 0) return resolve({ error: "Timeout" });
|
|
76
|
+
const sendHeaders = { "accept-encoding": "identity" };
|
|
77
|
+
for (const [k, v] of Object.entries(headers)) if (!DROP_REQUEST.has(k.toLowerCase())) sendHeaders[k] = v;
|
|
78
|
+
let timer;
|
|
79
|
+
let done = false;
|
|
80
|
+
const finish = (result) => {
|
|
81
|
+
if (done) return;
|
|
82
|
+
done = true;
|
|
83
|
+
clearTimeout(timer);
|
|
84
|
+
resolve(result);
|
|
85
|
+
};
|
|
86
|
+
const req = (u.protocol === "https:" ? https : http).request(u, { method, headers: sendHeaders, lookup: publicLookup }, (res) => {
|
|
87
|
+
const chunks = [];
|
|
88
|
+
let size = 0;
|
|
89
|
+
res.on("data", (c) => {
|
|
90
|
+
size += c.length;
|
|
91
|
+
if (size > MAX_BODY) req.destroy(new Error("Response exceeds 64 MB"));
|
|
92
|
+
else chunks.push(c);
|
|
93
|
+
});
|
|
94
|
+
res.on("end", () => {
|
|
95
|
+
if (done) return;
|
|
96
|
+
try {
|
|
97
|
+
let body = Buffer.concat(chunks);
|
|
98
|
+
// Some servers compress even when identity was requested.
|
|
99
|
+
const encoding = String(res.headers["content-encoding"] ?? "identity").toLowerCase();
|
|
100
|
+
const decode = { gzip: zlib.gunzipSync, deflate: zlib.inflateSync, br: zlib.brotliDecompressSync }[encoding];
|
|
101
|
+
if (decode) body = decode(body, { maxOutputLength: MAX_BODY });
|
|
102
|
+
else if (encoding !== "identity") throw new Error(`Unsupported content encoding: ${encoding}`);
|
|
103
|
+
finish({ status: res.statusCode, headers: res.headers, body });
|
|
104
|
+
} catch (e) {
|
|
105
|
+
finish({ error: e.message });
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
res.on("error", (e) => finish({ error: e.message }));
|
|
109
|
+
});
|
|
110
|
+
// One wall-clock deadline covers DNS, headers, the body, and every redirect.
|
|
111
|
+
timer = setTimeout(() => req.destroy(new Error("Timeout while fetching chart asset")), left);
|
|
112
|
+
req.on("error", (e) => finish({ error: e.message, blocked: e.code === "EBLOCKED" }));
|
|
113
|
+
req.end();
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Fetches a URL for the page: { status, headers, body }, or { error, blocked } when it is not allowed or fails
|
|
118
|
+
export async function fetchPublic(url, { method = "GET", headers = {}, timeout = 5000, maxRedirects = 5 } = {}) {
|
|
119
|
+
|
|
120
|
+
method = method.toUpperCase();
|
|
121
|
+
if (!READ_METHODS.has(method)) return { error: `${method} requests are not sent: the check only reads from the web`, blocked: true };
|
|
122
|
+
|
|
123
|
+
const deadline = Date.now() + timeout;
|
|
124
|
+
let current = url;
|
|
125
|
+
|
|
126
|
+
for (let hop = 0; ; hop++) {
|
|
127
|
+
if (!/^https?:$/.test(new URL(current).protocol)) return { error: `${new URL(current).protocol} is not fetched`, blocked: true };
|
|
128
|
+
const got = await once(current, method, headers, deadline);
|
|
129
|
+
if (got.error) return got;
|
|
130
|
+
const location = got.headers.location;
|
|
131
|
+
if (got.status >= 300 && got.status < 400 && location && got.status !== 304) {
|
|
132
|
+
if (hop >= maxRedirects) return { error: `More than ${maxRedirects} redirects` };
|
|
133
|
+
const next = new URL(location, current);
|
|
134
|
+
if (next.origin !== new URL(current).origin) {
|
|
135
|
+
headers = Object.fromEntries(Object.entries(headers).filter(([k]) => !["authorization", "cookie", "proxy-authorization"].includes(k.toLowerCase())));
|
|
136
|
+
}
|
|
137
|
+
current = next.href;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const out = {};
|
|
141
|
+
for (const [k, v] of Object.entries(got.headers)) if (!DROP_RESPONSE.has(k)) out[k] = Array.isArray(v) ? v.join(", ") : String(v);
|
|
142
|
+
return { status: got.status, headers: out, body: got.body };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// A final network fence for renderer traffic not handled by Playwright's page routes (for example worker sockets).
|
|
147
|
+
// Known HTTP asset requests are fulfilled by the Node fetcher before reaching this proxy.
|
|
148
|
+
// Nothing accepted here is forwarded, and Chromium must proxy loopback traffic too.
|
|
149
|
+
export async function denyBrowserNetwork(onBlocked) {
|
|
150
|
+
const deny = (request, socket) => {
|
|
151
|
+
onBlocked(request.url);
|
|
152
|
+
socket.destroy();
|
|
153
|
+
};
|
|
154
|
+
const server = http.createServer((request, response) => {
|
|
155
|
+
onBlocked(request.url);
|
|
156
|
+
response.writeHead(403).end();
|
|
157
|
+
});
|
|
158
|
+
server.on("connect", deny);
|
|
159
|
+
server.on("upgrade", deny);
|
|
160
|
+
await new Promise((resolve, reject) => {
|
|
161
|
+
server.once("error", reject);
|
|
162
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
163
|
+
});
|
|
164
|
+
return {
|
|
165
|
+
proxy: { server: `http://127.0.0.1:${server.address().port}`, bypass: "<-loopback>" },
|
|
166
|
+
close: () => new Promise((resolve) => { server.close(resolve); server.closeAllConnections(); }),
|
|
167
|
+
};
|
|
168
|
+
}
|
package/src/check/png.js
CHANGED
|
@@ -96,3 +96,21 @@ export function pixelsIn(image, { left, top, right, bottom }) {
|
|
|
96
96
|
|
|
97
97
|
return out;
|
|
98
98
|
}
|
|
99
|
+
|
|
100
|
+
// How many pixels of two screenshots of one box differ enough to see (their channels by more than 24 in all: a
|
|
101
|
+
// tint of 6% of the ink over the paper shows, drawing one page twice changes nothing)
|
|
102
|
+
export function seenPixels(a, b) {
|
|
103
|
+
|
|
104
|
+
if (a.equals(b)) return 0;
|
|
105
|
+
|
|
106
|
+
const x = decode(a);
|
|
107
|
+
const y = decode(b);
|
|
108
|
+
if (x.width !== y.width || x.height !== y.height) return Infinity;
|
|
109
|
+
|
|
110
|
+
let n = 0;
|
|
111
|
+
for (let i = 0; i < x.data.length; i += 4) {
|
|
112
|
+
if (Math.abs(x.data[i] - y.data[i]) + Math.abs(x.data[i + 1] - y.data[i + 1]) + Math.abs(x.data[i + 2] - y.data[i + 2]) > 24) n++;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return n;
|
|
116
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// A conditional audit of an existing click selection with a temporary hover override.
|
|
2
|
+
// No click selection or no temporary hover means there is no interaction policy to repair here.
|
|
3
|
+
// Run on a disposable page: clicks must not change the starting state of the normal interaction pass.
|
|
4
|
+
import { seenPixels } from "./png.js";
|
|
5
|
+
|
|
6
|
+
const SAME = 20;
|
|
7
|
+
const same = (a, b) => seenPixels(a, b) < SAME;
|
|
8
|
+
const name = (s) => `slat ${s.index} of ${s.of}${s.name ? ` (${s.name})` : ""}`;
|
|
9
|
+
|
|
10
|
+
// Safe space inside this chart's poster, outside every chart and every control.
|
|
11
|
+
// Pointing at text is safe; clicking a link, a legend button or another chart is not a reset gesture.
|
|
12
|
+
function beside(target) {
|
|
13
|
+
const slat = window.__rhpTargets[target];
|
|
14
|
+
const poster = slat?.closest(".poster, figure");
|
|
15
|
+
if (!poster) return null;
|
|
16
|
+
const busy = "a, button, input, select, textarea, label, summary, [role], [tabindex], [contenteditable]";
|
|
17
|
+
const box = poster.getBoundingClientRect();
|
|
18
|
+
let margin = null;
|
|
19
|
+
for (let y = Math.max(12, box.top + 6); y < Math.min(innerHeight - 12, box.bottom - 6); y += 16) {
|
|
20
|
+
for (let x = Math.max(12, box.left + 6); x < Math.min(innerWidth - 12, box.right - 6); x += 16) {
|
|
21
|
+
const el = document.elementFromPoint(x, y);
|
|
22
|
+
if (!el || !poster.contains(el) || el.closest(".rhp-chart") || el.closest(busy)) continue;
|
|
23
|
+
const text = [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim());
|
|
24
|
+
const point = { x, y, what: text ? `"${el.textContent.trim().replace(/\s+/g, " ").slice(0, 40)}"` : "the poster's margin" };
|
|
25
|
+
if (text) return point;
|
|
26
|
+
margin ??= point;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return margin;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function selectionBehavior(page, { events }) {
|
|
33
|
+
const findings = [];
|
|
34
|
+
const audit = { findings, tested: null };
|
|
35
|
+
const start = events.length;
|
|
36
|
+
let doing = "checking existing selection and hover";
|
|
37
|
+
const settle = async () => {
|
|
38
|
+
const state = await page.evaluate(() => window.__rhpProbe.settle(1000));
|
|
39
|
+
if (!state.rest) throw new Error("The page is moving");
|
|
40
|
+
};
|
|
41
|
+
const look = async () => {
|
|
42
|
+
await settle();
|
|
43
|
+
return page.screenshot({ animations: "disabled", scale: "css" });
|
|
44
|
+
};
|
|
45
|
+
const away = async () => {
|
|
46
|
+
await page.mouse.move(-1, -1);
|
|
47
|
+
await settle();
|
|
48
|
+
};
|
|
49
|
+
const pointAt = (s) => page.evaluate((n) => window.__rhpProbe.pointAt(n, 0.5, false), s.target);
|
|
50
|
+
try {
|
|
51
|
+
// Use reachable marks, including marks inside a slat, from the main Plot.
|
|
52
|
+
// Keep the window still throughout the sequence so scrolling and sticky readouts cannot mimic a state change.
|
|
53
|
+
const slats = await page.evaluate(() => window.__rhpProbe.sweepTargets(12));
|
|
54
|
+
if (slats.length < 2) return audit;
|
|
55
|
+
await page.evaluate((n) => window.__rhpProbe.pointAt(n), slats[0].target);
|
|
56
|
+
await away();
|
|
57
|
+
const position = await page.evaluate(() => [scrollX, scrollY]);
|
|
58
|
+
const candidates = [];
|
|
59
|
+
for (const s of slats) {
|
|
60
|
+
const point = await pointAt(s);
|
|
61
|
+
if (point.covered) continue;
|
|
62
|
+
const safe = await page.evaluate(({ x, y }) => !document.elementFromPoint(x, y)?.closest("a, button, input, select, textarea, summary, [role=button], [role=slider], [contenteditable]"), point);
|
|
63
|
+
if (safe) candidates.push(s);
|
|
64
|
+
if (candidates.length === 3) break;
|
|
65
|
+
}
|
|
66
|
+
if (candidates.length < 2) return audit;
|
|
67
|
+
const off = await page.evaluate(beside, candidates[0].target);
|
|
68
|
+
if (!off) return audit;
|
|
69
|
+
const rest = await look();
|
|
70
|
+
if (!same(rest, await look())) return audit;
|
|
71
|
+
// At most three candidates, so an initial meaningful default on the first mark does not hide click selection.
|
|
72
|
+
for (const chosen of candidates) {
|
|
73
|
+
doing = `click ${name(chosen)}`;
|
|
74
|
+
const at = await pointAt(chosen);
|
|
75
|
+
if (at.covered) continue;
|
|
76
|
+
await page.mouse.click(at.x, at.y);
|
|
77
|
+
await away();
|
|
78
|
+
const pinned = await look();
|
|
79
|
+
if (same(rest, pinned)) continue;
|
|
80
|
+
for (const preview of candidates.filter((s) => s !== chosen)) {
|
|
81
|
+
doing = `hover ${name(preview)} after clicking ${name(chosen)}`;
|
|
82
|
+
const over = await pointAt(preview);
|
|
83
|
+
if (over.covered) continue;
|
|
84
|
+
await page.mouse.move(over.x, over.y);
|
|
85
|
+
const hovered = await look();
|
|
86
|
+
if (same(pinned, hovered)) continue;
|
|
87
|
+
await page.mouse.move(off.x, off.y);
|
|
88
|
+
const offChart = await look();
|
|
89
|
+
await away();
|
|
90
|
+
const restored = await look();
|
|
91
|
+
// Prove that click pins a state and hover temporarily overrides it.
|
|
92
|
+
// A hover-only chart, a click-only chart, or a scrubber that retains its last point does not qualify.
|
|
93
|
+
if (!same(pinned, restored)) continue;
|
|
94
|
+
const here = await page.evaluate(() => [scrollX, scrollY]);
|
|
95
|
+
if (position.some((v, i) => v !== here[i]) || !same(restored, await look())) return audit;
|
|
96
|
+
// Compare with the selected state at the same outside-chart pointer position (including CSS :hover).
|
|
97
|
+
await page.mouse.move(off.x, off.y);
|
|
98
|
+
const selected = await look();
|
|
99
|
+
const hoverBroken = !same(selected, offChart);
|
|
100
|
+
// This click is inside the poster, outside every chart and control.
|
|
101
|
+
doing = `click beside the chart after selecting ${name(chosen)}`;
|
|
102
|
+
await page.mouse.click(off.x, off.y);
|
|
103
|
+
await away();
|
|
104
|
+
const reset = await look();
|
|
105
|
+
const resetBroken = !same(rest, reset);
|
|
106
|
+
const end = await page.evaluate(() => [scrollX, scrollY]);
|
|
107
|
+
if (position.some((v, i) => v !== end[i]) || !same(reset, await look())) return audit;
|
|
108
|
+
audit.tested = { selected: name(chosen), preview: name(preview), hoverRestoresSelection: !hoverBroken, clickRestoresInitial: !resetBroken };
|
|
109
|
+
if (hoverBroken) findings.push({ code: "selection-hover-stays", target: name(preview), selected: name(chosen), what: off.what });
|
|
110
|
+
if (resetBroken) findings.push({ code: "selection-reset", target: name(chosen), what: off.what });
|
|
111
|
+
return audit;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
} catch {
|
|
115
|
+
// Moving pages, covered marks and navigation cannot establish this interaction contract.
|
|
116
|
+
// The normal interaction pass reports failures to reach marks and the page's exceptions.
|
|
117
|
+
} finally {
|
|
118
|
+
for (const e of events.slice(start)) {
|
|
119
|
+
if ((e.kind === "pageerror" || e.kind === "console-error") && !e.during) e.during = doing;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return audit;
|
|
123
|
+
}
|