@nodegx/mcp 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/backend/cli.js +102959 -0
- package/dist/cloud-bundle.cjs +34595 -1691
- package/dist/kit-extract.cjs +506 -319
- package/dist/kit-extract.inputs.json +2 -0
- package/dist/node-catalog.json +502 -59
- package/dist/noodl-mcp.cjs +52739 -16423
- package/dist/package.json +6 -0
- package/dist/preset-fonts/dm-sans/OFL.txt +93 -0
- package/dist/preset-fonts/dm-sans/dm-sans-latin-ext-wght-normal.woff2 +0 -0
- package/dist/preset-fonts/dm-sans/dm-sans-latin-wght-normal.woff2 +0 -0
- package/dist/preset-fonts/dm-sans/manifest.json +9 -0
- package/dist/preset-fonts/dm-sans/styles.css +25 -0
- package/dist/preset-fonts/nunito/OFL.txt +93 -0
- package/dist/preset-fonts/nunito/manifest.json +9 -0
- package/dist/preset-fonts/nunito/nunito-latin-ext-wght-normal.woff2 +0 -0
- package/dist/preset-fonts/nunito/nunito-latin-wght-normal.woff2 +0 -0
- package/dist/preset-fonts/nunito/styles.css +25 -0
- package/dist/preset-fonts/source-sans-3/OFL.txt +93 -0
- package/dist/preset-fonts/source-sans-3/manifest.json +9 -0
- package/dist/preset-fonts/source-sans-3/source-sans-3-latin-ext-wght-normal.woff2 +0 -0
- package/dist/preset-fonts/source-sans-3/source-sans-3-latin-wght-normal.woff2 +0 -0
- package/dist/preset-fonts/source-sans-3/styles.css +25 -0
- package/dist/render-harness/data/node-catalog-enriched.json +93883 -0
- package/dist/render-harness/data/node-catalog.json +65289 -0
- package/dist/render-harness/data/tokens.ts +674 -0
- package/dist/render-harness/drive-steps.js +2924 -0
- package/dist/render-harness/measure-from-disk.js +2476 -0
- package/dist/render-harness/node_modules/ws/LICENSE +20 -0
- package/dist/render-harness/node_modules/ws/README.md +548 -0
- package/dist/render-harness/node_modules/ws/browser.js +8 -0
- package/dist/render-harness/node_modules/ws/index.js +22 -0
- package/dist/render-harness/node_modules/ws/lib/buffer-util.js +131 -0
- package/dist/render-harness/node_modules/ws/lib/constants.js +19 -0
- package/dist/render-harness/node_modules/ws/lib/event-target.js +292 -0
- package/dist/render-harness/node_modules/ws/lib/extension.js +203 -0
- package/dist/render-harness/node_modules/ws/lib/limiter.js +55 -0
- package/dist/render-harness/node_modules/ws/lib/permessage-deflate.js +528 -0
- package/dist/render-harness/node_modules/ws/lib/receiver.js +743 -0
- package/dist/render-harness/node_modules/ws/lib/sender.js +607 -0
- package/dist/render-harness/node_modules/ws/lib/stream.js +161 -0
- package/dist/render-harness/node_modules/ws/lib/subprotocol.js +62 -0
- package/dist/render-harness/node_modules/ws/lib/validation.js +152 -0
- package/dist/render-harness/node_modules/ws/lib/websocket-server.js +562 -0
- package/dist/render-harness/node_modules/ws/lib/websocket.js +1407 -0
- package/dist/render-harness/node_modules/ws/package.json +74 -0
- package/dist/render-harness/node_modules/ws/wrapper.mjs +21 -0
- package/dist/render-harness/render-from-disk.js +7306 -0
- package/dist/src/external/viewer/global.d.ts.keep +718 -0
- package/dist/src/external/viewer/index.html +126 -0
- package/dist/src/external/viewer/load_terminator.js +1 -0
- package/dist/src/external/viewer/ndl_assets/OpenSans-Bold.ttf +0 -0
- package/dist/src/external/viewer/ndl_assets/OpenSans-Regular.ttf +0 -0
- package/dist/src/external/viewer/ndl_assets/home-icon.svg +3 -0
- package/dist/src/external/viewer/ndl_assets/make-home-instructions@2x.png +0 -0
- package/dist/src/external/viewer/ndl_assets/noodl-logo-black.svg +3 -0
- package/dist/src/external/viewer/noodl-app.png +0 -0
- package/dist/src/external/viewer/noodl.viewer.js +2 -0
- package/dist/src/external/viewer/noodl.viewer.js.LICENSE.txt +20 -0
- package/dist/src/external/viewer/react-dom.production.min.js +267 -0
- package/dist/src/external/viewer/react.production.min.js +31 -0
- package/dist/src/external/viewer/react19/VERSION +2 -0
- package/dist/src/external/viewer/react19/react-dom.production.min.js +51 -0
- package/dist/src/external/viewer/react19/react.production.min.js +21 -0
- package/dist/starter-assets/noodl_modules/inter/Inter-Bold.woff2 +0 -0
- package/dist/starter-assets/noodl_modules/inter/Inter-Medium.woff2 +0 -0
- package/dist/starter-assets/noodl_modules/inter/Inter-Regular.woff2 +0 -0
- package/dist/starter-assets/noodl_modules/inter/Inter-SemiBold.woff2 +0 -0
- package/dist/starter-assets/noodl_modules/inter/LICENSE.txt +93 -0
- package/dist/starter-assets/noodl_modules/inter/manifest.json +7 -0
- package/dist/starter-assets/noodl_modules/inter/styles.css +49 -0
- package/dist/starter-assets/noodl_modules/lucide-icons/lucide.woff2 +0 -0
- package/dist/starter-assets/noodl_modules/lucide-icons/manifest.json +229 -0
- package/dist/starter-assets/noodl_modules/lucide-icons/styles.css +2007 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/LICENCES.json +445 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/animal-cat.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/animal-dog.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/animal-horse.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/animal-sheep.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/avatar-1.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/avatar-2.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/avatar-3.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/avatar-4.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/avatar-5.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/avatar-6.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/food-bakery.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/food-board.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/food-bread.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/food-carrots.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/food-grocer.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/food-market.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/food-plate.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/ground-aurora.svg +28 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/ground-canyon.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/ground-city-dusk.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/ground-coast.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/ground-dune.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/ground-forest-fog.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/ground-red-rock.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/ground-ridge.svg +28 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/ground-shore.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/ground-skyline.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/manifest.json +4 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/people-cafe.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/people-coffee-shop.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/people-desk.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/people-market.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/people-meeting.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/portrait.svg +11 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/texture-brick.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/texture-coffee.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/texture-earth.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/texture-pebbles.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/texture-snow.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/texture-soil.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/tile-1.svg +17 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/tile-2.svg +17 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/tile-3.svg +17 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/work-baker.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/work-carpenter.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/work-chef.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/work-hands-clay.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/work-leather-bench.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/work-machine-shop.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/work-potter.webp +0 -0
- package/dist/starter-assets/noodl_modules/starter-imagery/work-welder.webp +0 -0
- package/package.json +9 -2
|
@@ -0,0 +1,2924 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __commonJS = (cb, mod) => function __require() {
|
|
9
|
+
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
|
|
10
|
+
};
|
|
11
|
+
var __copyProps = (to, from, except, desc) => {
|
|
12
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
13
|
+
for (let key of __getOwnPropNames(from))
|
|
14
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
15
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
16
|
+
}
|
|
17
|
+
return to;
|
|
18
|
+
};
|
|
19
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
20
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
21
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
22
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
23
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
24
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
25
|
+
mod
|
|
26
|
+
));
|
|
27
|
+
|
|
28
|
+
// ../../scripts/devtools/harness-paths.js
|
|
29
|
+
var require_harness_paths = __commonJS({
|
|
30
|
+
"../../scripts/devtools/harness-paths.js"(exports2, module2) {
|
|
31
|
+
var fs2 = require("fs");
|
|
32
|
+
var path2 = require("path");
|
|
33
|
+
var CHECKOUT_ROOT = path2.resolve(__dirname, "..", "..");
|
|
34
|
+
var PACKAGED_ROOT = path2.resolve(__dirname, "..");
|
|
35
|
+
var PACKAGED_DATA = path2.join(__dirname, "data");
|
|
36
|
+
function firstExisting(candidates) {
|
|
37
|
+
const probed = [];
|
|
38
|
+
for (const candidate of candidates) {
|
|
39
|
+
probed.push(candidate);
|
|
40
|
+
if (fs2.existsSync(candidate)) return { path: candidate, probed, candidates };
|
|
41
|
+
}
|
|
42
|
+
return { path: null, probed, candidates };
|
|
43
|
+
}
|
|
44
|
+
function resolveHarnessPath(checkoutRelative, packagedCandidate) {
|
|
45
|
+
return firstExisting([path2.join(CHECKOUT_ROOT, checkoutRelative), packagedCandidate]);
|
|
46
|
+
}
|
|
47
|
+
var VIEWER_DIR = resolveHarnessPath(
|
|
48
|
+
"packages/noodl-editor/src/external/viewer",
|
|
49
|
+
path2.join(PACKAGED_ROOT, "src", "external", "viewer")
|
|
50
|
+
);
|
|
51
|
+
var CATALOG_JSON = resolveHarnessPath(
|
|
52
|
+
"packages/noodl-types/src/node-catalog.json",
|
|
53
|
+
path2.join(PACKAGED_DATA, "node-catalog.json")
|
|
54
|
+
);
|
|
55
|
+
var ENRICHED_CATALOG_JSON = resolveHarnessPath(
|
|
56
|
+
"packages/noodl-types/src/node-catalog-enriched.json",
|
|
57
|
+
path2.join(PACKAGED_DATA, "node-catalog-enriched.json")
|
|
58
|
+
);
|
|
59
|
+
var TOKENS_SRC = resolveHarnessPath(
|
|
60
|
+
"packages/nodegx-project-contract/tokens.ts",
|
|
61
|
+
path2.join(PACKAGED_DATA, "tokens.ts")
|
|
62
|
+
);
|
|
63
|
+
var WS_MODULE = (() => {
|
|
64
|
+
const checkout = path2.join(CHECKOUT_ROOT, "node_modules", "ws");
|
|
65
|
+
const candidates = [checkout, "require('ws')"];
|
|
66
|
+
if (fs2.existsSync(checkout)) return { path: checkout, probed: [checkout], candidates, bare: false };
|
|
67
|
+
try {
|
|
68
|
+
require.resolve("ws");
|
|
69
|
+
return { path: "ws", probed: candidates, candidates, bare: true };
|
|
70
|
+
} catch {
|
|
71
|
+
return { path: null, probed: candidates, candidates, bare: false };
|
|
72
|
+
}
|
|
73
|
+
})();
|
|
74
|
+
module2.exports = {
|
|
75
|
+
CHECKOUT_ROOT,
|
|
76
|
+
PACKAGED_ROOT,
|
|
77
|
+
PACKAGED_DATA,
|
|
78
|
+
VIEWER_DIR,
|
|
79
|
+
CATALOG_JSON,
|
|
80
|
+
ENRICHED_CATALOG_JSON,
|
|
81
|
+
TOKENS_SRC,
|
|
82
|
+
WS_MODULE,
|
|
83
|
+
firstExisting,
|
|
84
|
+
resolveHarnessPath
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// ../nodegx-render-measure/src/index.js
|
|
90
|
+
var require_src = __commonJS({
|
|
91
|
+
"../nodegx-render-measure/src/index.js"(exports2, module2) {
|
|
92
|
+
"use strict";
|
|
93
|
+
var DEFAULT_VIEWPORTS = [
|
|
94
|
+
{ name: "desktop", width: 1280, height: 900, mobile: false },
|
|
95
|
+
{ name: "phone", width: 390, height: 844, mobile: true }
|
|
96
|
+
];
|
|
97
|
+
var NAMED_VIEWPORTS = [
|
|
98
|
+
{ name: "desktop", width: 1280, height: 900, mobile: false },
|
|
99
|
+
{ name: "tablet", width: 1024, height: 1366, mobile: false },
|
|
100
|
+
{ name: "phone", width: 390, height: 844, mobile: true }
|
|
101
|
+
];
|
|
102
|
+
var DESKTOP_WIDTH = 1024;
|
|
103
|
+
var GRID_ITEM_MIN_HEIGHT = 120;
|
|
104
|
+
var GRID_ITEM_MIN_WIDTH = 300;
|
|
105
|
+
var GRID_ITEM_PARENT_SHARE = 0.6;
|
|
106
|
+
var GRID_PARENT_VIEWPORT_SHARE = 0.5;
|
|
107
|
+
function measureExpression(placeholders, probes = []) {
|
|
108
|
+
return `(() => {
|
|
109
|
+
const PLACEHOLDERS = ${JSON.stringify(placeholders)};
|
|
110
|
+
const PROBES = ${JSON.stringify(probes)};
|
|
111
|
+
const round = (n) => Math.round(n);
|
|
112
|
+
const cls = (el) => String(el.className || '').trim().slice(0, 60);
|
|
113
|
+
const all = [...document.querySelectorAll('body *')];
|
|
114
|
+
|
|
115
|
+
// FLD-012 \u2014 one computed style per element, reused by every rule below. 'visible' now needs a
|
|
116
|
+
// style for EVERY element rather than only for the ones with no offset parent, and four rules
|
|
117
|
+
// downstream ask for the same element's style again. Without this the change would have paid for
|
|
118
|
+
// a second 'getComputedStyle' per empty box on every page.
|
|
119
|
+
const styles = new Map();
|
|
120
|
+
const styleOf = (el) => {
|
|
121
|
+
let cs = styles.get(el);
|
|
122
|
+
if (cs === undefined) {
|
|
123
|
+
cs = getComputedStyle(el);
|
|
124
|
+
styles.set(el, cs);
|
|
125
|
+
}
|
|
126
|
+
return cs;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
// FLD-012 (#32) \u2014 'visible' is the population every rule below is computed over, and it used to
|
|
130
|
+
// mean "the browser gave it a layout box". That is not the same as "a person can see it":
|
|
131
|
+
// 'offsetParent' is null for 'display: none' but perfectly non-null for 'opacity: 0' and for
|
|
132
|
+
// 'visibility: hidden'. So an invisible element was being reported as a box a person could see \u2014
|
|
133
|
+
// by 'empty-decorated-box', and inherited unchanged by 'elements-overflowing', the accent-area
|
|
134
|
+
// census, the rhythm bands and 'single-column-grid'. This is a correctness fix, not a
|
|
135
|
+
// suppression: nothing that can be seen is dropped.
|
|
136
|
+
//
|
|
137
|
+
// \u26A0\uFE0F 'visibility' is inherited, so the computed value already accounts for a hidden ancestor.
|
|
138
|
+
// 'opacity' is NOT inherited \u2014 a child of an 'opacity: 0' parent computes to 1 \u2014 so this catches
|
|
139
|
+
// the element that is itself transparent and not one buried under a faded ancestor. Walking every
|
|
140
|
+
// ancestor would cost a style read per level per element; the shipped false positives are all the
|
|
141
|
+
// first kind ('.ndl-controls-radio-2' is 'opacity: 0' on the input itself).
|
|
142
|
+
// FLD-012 AC3 \u2014 declared OUTSIDE the filter below on purpose: a reverted arm replaces only the
|
|
143
|
+
// filter, and a counter declared inside it would make the reverted arm throw a ReferenceError
|
|
144
|
+
// instead of measuring. Left at 0, which is exactly what the old predicate excluded.
|
|
145
|
+
let transparentExcluded = 0;
|
|
146
|
+
const visible = all.filter((el) => {
|
|
147
|
+
const cs = styleOf(el);
|
|
148
|
+
if (cs.visibility === 'hidden' || cs.opacity === '0') {
|
|
149
|
+
transparentExcluded += 1;
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
return el.offsetParent !== null || cs.position === 'fixed';
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const vw = document.documentElement.clientWidth;
|
|
156
|
+
const vh = document.documentElement.clientHeight;
|
|
157
|
+
const overflowing = visible
|
|
158
|
+
.map((el) => ({ el, w: el.getBoundingClientRect().width }))
|
|
159
|
+
.filter((x) => x.w > vw + 1)
|
|
160
|
+
.map((x) => ({ tag: x.el.tagName, cls: cls(x.el), width: round(x.w) }));
|
|
161
|
+
|
|
162
|
+
// P88 GAM-020 (D58) \u2014 a Text wider than the box it sits in. RKT-001's corrected clause: the text's
|
|
163
|
+
// border box against its PARENT's content box. A 'white-space: pre' Text always fits itself, so its
|
|
164
|
+
// own scrollWidth against its own clientWidth cannot fail (that read 60/60 green on the clipped
|
|
165
|
+
// build). An inline parent has no content box (clientWidth 0) and is skipped, never judged.
|
|
166
|
+
const textsWiderThanBox = visible
|
|
167
|
+
.filter((el) => el.classList.contains('ndl-visual-text') && el.parentElement)
|
|
168
|
+
.map((el) => {
|
|
169
|
+
const parent = el.parentElement;
|
|
170
|
+
const ps = styleOf(parent);
|
|
171
|
+
const box = parent.clientWidth - (parseFloat(ps.paddingLeft) || 0) - (parseFloat(ps.paddingRight) || 0);
|
|
172
|
+
return { el, width: el.getBoundingClientRect().width, box };
|
|
173
|
+
})
|
|
174
|
+
.filter((x) => x.box > 0 && x.width > x.box + 1)
|
|
175
|
+
.map((x) => ({ text: x.el.textContent.trim().slice(0, 80), width: round(x.width), parentWidth: round(x.box), cls: cls(x.el) }));
|
|
176
|
+
|
|
177
|
+
// AWP-004 \u2014 what is on screen, as opposed to what is in the DOM. The page is
|
|
178
|
+
// never scrolled when this runs, so a rect is already a document position.
|
|
179
|
+
// Kimi K3's storefront was 83 texts in the DOM and about three on screen, and
|
|
180
|
+
// the report called it clean because every check it had was about content
|
|
181
|
+
// present and wrong rather than content present and unreachable.
|
|
182
|
+
const onScreen = (el) => {
|
|
183
|
+
const r = el.getBoundingClientRect();
|
|
184
|
+
return r.width > 0 && r.height > 0 && r.bottom > 0 && r.top < vh && r.right > 0 && r.left < vw;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
// Below the fold is fine \u2014 that is what scrolling is for. Below the *scrollable
|
|
188
|
+
// extent* is not: no scroll reaches it, so it is on the page and can never be
|
|
189
|
+
// seen. Measured directly rather than inferred from a visible-count ratio,
|
|
190
|
+
// because a correct 82-text page also shows only ~19 at a time and a ratio
|
|
191
|
+
// cannot tell the two apart.
|
|
192
|
+
const pageBottom = document.documentElement.scrollHeight;
|
|
193
|
+
const unreachable = (el) => el.getBoundingClientRect().top >= pageBottom - 1;
|
|
194
|
+
|
|
195
|
+
const textEls = visible.filter((el) => el.children.length === 0 && el.textContent.trim().length > 0);
|
|
196
|
+
const fontWeights = {};
|
|
197
|
+
const fontSizes = {};
|
|
198
|
+
for (const el of textEls) {
|
|
199
|
+
const cs = styleOf(el);
|
|
200
|
+
fontWeights[cs.fontWeight] = (fontWeights[cs.fontWeight] || 0) + 1;
|
|
201
|
+
fontSizes[cs.fontSize] = (fontSizes[cs.fontSize] || 0) + 1;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const dead = textEls.filter((el) => PLACEHOLDERS.includes(el.textContent.trim()));
|
|
205
|
+
const byText = {};
|
|
206
|
+
for (const el of dead) {
|
|
207
|
+
const t = el.textContent.trim();
|
|
208
|
+
byText[t] = (byText[t] || 0) + 1;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// FLD-012 (#32) \u2014 *"I started ignoring the warning."* Five of ten pages carried this finding
|
|
212
|
+
// purely for containing a control, and noise in a signal this good is a real cost.
|
|
213
|
+
//
|
|
214
|
+
// Two residues survive the 'visible' fix above, because neither is invisible \u2014 they are drawn,
|
|
215
|
+
// they are just not the author's boxes:
|
|
216
|
+
//
|
|
217
|
+
// 1. A FORM CONTROL. It has no children and no text by construction, and the runtime styles it
|
|
218
|
+
// \u2014 the deprecated Radio Button is an '<input type="radio">' at 24x24 with a solid border
|
|
219
|
+
// ('nodes-deprecated/controls/radiobutton.tsx:62', 'assets/style.css:64'). It is not an empty
|
|
220
|
+
// box; it is a control that happens to be childless.
|
|
221
|
+
// 2. A CONTROL'S OWN FURNITURE \u2014 the parts the runtime draws INSIDE a control, beside its
|
|
222
|
+
// input, as plain unclassed '<div>'s: the Slider's track and thumb ('Slider.tsx:146-176')
|
|
223
|
+
// and the Radio Button's checked fill dot, which is the one this fixture actually caught \u2014
|
|
224
|
+
// a 16x16 absolutely-positioned black div whose parent also holds the radio input. Every
|
|
225
|
+
// one of them is drawn, so no visibility test reaches them, and every one is furniture
|
|
226
|
+
// rather than a box the author left empty.
|
|
227
|
+
//
|
|
228
|
+
// \u{1F534} #32's own suggested discriminator does not reach either of them, and the second half of
|
|
229
|
+
// this file's earlier scope note was actively dangerous:
|
|
230
|
+
// - 'closest('[class*="ndl-controls-"]')' returns null for the track and the thumb \u2014 they are
|
|
231
|
+
// unclassed and so is their parent; only their '<input>' SIBLING carries the class.
|
|
232
|
+
// - 'appearance: none' is the COMPUTED DEFAULT of an ordinary '<div>' in Chrome, measured on
|
|
233
|
+
// this fixture. Skipping on it would suppress every finding on every page, including the
|
|
234
|
+
// author's genuinely empty box \u2014 which is exactly the failure mode this rule's presence
|
|
235
|
+
// control exists to catch. It is not used here.
|
|
236
|
+
//
|
|
237
|
+
// \u26A0\uFE0F The furniture predicate is deliberately narrow, and it is TWO conditions, not one:
|
|
238
|
+
// absolutely positioned AND a direct sibling of a form control. An author's own empty Group is
|
|
239
|
+
// neither \u2014 measured on this fixture it is 'position: relative' with four plain div siblings.
|
|
240
|
+
// It would take positioning a box absolutely AND placing it beside a control in the same parent
|
|
241
|
+
// to be suppressed by this: small, real, and stated here rather than discovered later.
|
|
242
|
+
const FORM_CONTROL_TAGS = ['INPUT', 'SELECT', 'TEXTAREA', 'BUTTON', 'PROGRESS', 'METER'];
|
|
243
|
+
const CONTROL_SIBLING_SELECTOR = ':scope > input, :scope > select, :scope > textarea, :scope > button';
|
|
244
|
+
const isControlFurniture = (el) => {
|
|
245
|
+
const parent = el.parentElement;
|
|
246
|
+
if (!parent || !parent.querySelector(CONTROL_SIBLING_SELECTOR)) return false;
|
|
247
|
+
return styleOf(el).position === 'absolute';
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const emptyBoxes = visible.filter((el) => {
|
|
251
|
+
if (el.children.length !== 0 || el.textContent.trim() !== '') return false;
|
|
252
|
+
const r = el.getBoundingClientRect();
|
|
253
|
+
if (r.width <= 8 || r.height <= 8) return false;
|
|
254
|
+
if (FORM_CONTROL_TAGS.indexOf(el.tagName) !== -1) return false;
|
|
255
|
+
if (isControlFurniture(el)) return false;
|
|
256
|
+
const cs = styleOf(el);
|
|
257
|
+
return cs.backgroundColor !== 'rgba(0, 0, 0, 0)' || cs.borderStyle !== 'none';
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
const images = visible.filter((el) => el.tagName === 'IMG');
|
|
261
|
+
const brokenImages = images.filter((el) => el.complete && el.naturalWidth === 0);
|
|
262
|
+
|
|
263
|
+
// VIB-007 M3 \u2014 iconography, which no earlier finding needed and which
|
|
264
|
+
// 'no-imagery' cannot be honest without: README section 2's tell is "no
|
|
265
|
+
// imagery AND no iconography", and a page of five Lucide glyphs and no
|
|
266
|
+
// photographs satisfies only half of it.
|
|
267
|
+
//
|
|
268
|
+
// Four shapes, because IconGlyph has four branches and only two of them carry
|
|
269
|
+
// a class this could key on. Measured against the VIB-006 page rather than
|
|
270
|
+
// read off the component: the font branch renders
|
|
271
|
+
// span.lucide.icon-sprout with NO ndl-icon-glyph class on it, so a selector
|
|
272
|
+
// written from that constant would have counted zero on a page with ten.
|
|
273
|
+
//
|
|
274
|
+
// sprite / inline -> .ndl-icon-glyph
|
|
275
|
+
// the Icon node -> .ndl-visual-icon wrapper
|
|
276
|
+
// an inline svg -> the svg element itself
|
|
277
|
+
// a font glyph -> a ::before whose content is a Private Use Area
|
|
278
|
+
// codepoint, which is what an icon font IS. Restricting
|
|
279
|
+
// to U+E000..U+F8FF is what keeps a bullet, a quote mark
|
|
280
|
+
// or a CSS counter from counting as iconography.
|
|
281
|
+
//
|
|
282
|
+
// A Set of ELEMENTS, so the wrapper and the glyph it holds are not two icons.
|
|
283
|
+
// Over-counting is harmless here and under-counting is not: the predicate is
|
|
284
|
+
// "is there any iconography at all", so a wrapper counted twice still says
|
|
285
|
+
// yes, and a glyph missed entirely says no when the answer is yes.
|
|
286
|
+
const PUA_FIRST = 0xe000;
|
|
287
|
+
const PUA_LAST = 0xf8ff;
|
|
288
|
+
const isGlyphContent = (raw) => {
|
|
289
|
+
if (!raw || raw === 'none' || raw === 'normal') return false;
|
|
290
|
+
const text = raw.replace(/^["']|["']$/g, '');
|
|
291
|
+
if (!text.length) return false;
|
|
292
|
+
for (const ch of text) {
|
|
293
|
+
const pt = ch.codePointAt(0);
|
|
294
|
+
if (pt < PUA_FIRST || pt > PUA_LAST) return false;
|
|
295
|
+
}
|
|
296
|
+
return true;
|
|
297
|
+
};
|
|
298
|
+
const iconEls = new Set();
|
|
299
|
+
for (const el of visible) {
|
|
300
|
+
const list = el.classList;
|
|
301
|
+
if (list && (list.contains('ndl-icon-glyph') || list.contains('ndl-visual-icon'))) {
|
|
302
|
+
iconEls.add(el);
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
if (String(el.tagName).toLowerCase() === 'svg') {
|
|
306
|
+
iconEls.add(el);
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
if (isGlyphContent(getComputedStyle(el, '::before').content)) iconEls.add(el);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// VIB-007 M3 \u2014 the page's full-bleed surfaces and what each is painted with.
|
|
313
|
+
//
|
|
314
|
+
// README section 2's first WordPress tell is "one background colour end to
|
|
315
|
+
// end", and the reading it needs is the number of DISTINCT grounds across the
|
|
316
|
+
// bands, not across every element: a page whose sections alternate has a
|
|
317
|
+
// designed rhythm, and a page whose cards and inputs happen to carry three
|
|
318
|
+
// greys does not. The width filter is the one the rhythm block already uses,
|
|
319
|
+
// so "band" means the same thing in both readings.
|
|
320
|
+
//
|
|
321
|
+
// background-image is read BEFORE background-color and wins, because a
|
|
322
|
+
// gradient or a photograph sits on top of the colour and is the ground a
|
|
323
|
+
// person sees. Measured, not assumed: every one of the VIB-006 page's
|
|
324
|
+
// gradient bands reports backgroundColor rgba(0, 0, 0, 0) and its whole
|
|
325
|
+
// identity is in background-image, so a colour-only count read 6 grounds on a
|
|
326
|
+
// page that has 8.
|
|
327
|
+
const groundCounts = {};
|
|
328
|
+
for (const el of visible) {
|
|
329
|
+
const r = el.getBoundingClientRect();
|
|
330
|
+
if (r.width < vw * 0.6 || r.height < 8) continue;
|
|
331
|
+
const cs = styleOf(el);
|
|
332
|
+
const painted =
|
|
333
|
+
cs.backgroundImage && cs.backgroundImage !== 'none'
|
|
334
|
+
? cs.backgroundImage
|
|
335
|
+
: cs.backgroundColor && cs.backgroundColor !== 'rgba(0, 0, 0, 0)' && cs.backgroundColor !== 'transparent'
|
|
336
|
+
? cs.backgroundColor
|
|
337
|
+
: null;
|
|
338
|
+
if (!painted) continue;
|
|
339
|
+
const key = String(painted).slice(0, 120);
|
|
340
|
+
groundCounts[key] = (groundCounts[key] || 0) + 1;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// DSG-006 \xA74 \u2014 "one accent". A designed page carries one chromatic colour as
|
|
344
|
+
// a background and neutrals for everything else; five accents is decorated
|
|
345
|
+
// rather than designed. Neutral is decided by *chroma* (max minus min
|
|
346
|
+
// channel) rather than by matching a palette, so it holds for any token set
|
|
347
|
+
// and for a project that never adopted one. Area-weighted, because a 4px
|
|
348
|
+
// chromatic rule and a full-bleed hero are not the same claim.
|
|
349
|
+
const parseRgb = (s) => {
|
|
350
|
+
const m = s.match(/rgba?\\(([^)]+)\\)/);
|
|
351
|
+
if (!m) return null;
|
|
352
|
+
const p = m[1].split(',').map((x) => parseFloat(x));
|
|
353
|
+
if (p.length >= 4 && p[3] === 0) return null;
|
|
354
|
+
return [p[0], p[1], p[2]];
|
|
355
|
+
};
|
|
356
|
+
const accentArea = {};
|
|
357
|
+
const neutralArea = {};
|
|
358
|
+
for (const el of visible) {
|
|
359
|
+
const r = el.getBoundingClientRect();
|
|
360
|
+
const area = r.width * r.height;
|
|
361
|
+
if (area < 1000) continue;
|
|
362
|
+
const bg = styleOf(el).backgroundColor;
|
|
363
|
+
const c = parseRgb(bg);
|
|
364
|
+
if (!c) continue;
|
|
365
|
+
const chroma = Math.max(c[0], c[1], c[2]) - Math.min(c[0], c[1], c[2]);
|
|
366
|
+
const bucket = chroma > 12 ? accentArea : neutralArea;
|
|
367
|
+
bucket[bg] = (bucket[bg] || 0) + area;
|
|
368
|
+
}
|
|
369
|
+
// DSG-006/F41 \u2014 raw area cannot say whether a chromatic colour is an *accent*
|
|
370
|
+
// or the page's ground, and it is not comparable between viewports: 600k px\xB2
|
|
371
|
+
// is half of a desktop page and four phone screens. "share" is the fraction
|
|
372
|
+
// of the viewport the colour covers, which is the number \xA74 is actually
|
|
373
|
+
// about. The case that named this: haiku and sonnet paint the SAME hue at the
|
|
374
|
+
// SAME accent count, at 0.524 and 0.013 of the page respectively.
|
|
375
|
+
// \u26A0\uFE0F No backticks in here \u2014 this whole block is a template literal, and one
|
|
376
|
+
// in a comment ends the string. It surfaces as a syntax error hundreds of
|
|
377
|
+
// lines away, which is how it cost a debug cycle the first time.
|
|
378
|
+
const viewportArea = window.innerWidth * window.innerHeight;
|
|
379
|
+
const accents = Object.keys(accentArea)
|
|
380
|
+
.map((color) => ({
|
|
381
|
+
color,
|
|
382
|
+
area: round(accentArea[color]),
|
|
383
|
+
share: viewportArea ? Math.round((1000 * accentArea[color]) / viewportArea) / 1000 : null
|
|
384
|
+
}))
|
|
385
|
+
.sort((a, b) => b.area - a.area);
|
|
386
|
+
|
|
387
|
+
// DSG-006 \xA71/\xA78 \u2014 vertical rhythm. Measured between the full-width bands of
|
|
388
|
+
// the page's main stack, not between every pair of siblings: a designed page
|
|
389
|
+
// repeats two or three spacings, and counting every gap in the DOM would
|
|
390
|
+
// drown that signal in card padding. The widest-stack parent is chosen by
|
|
391
|
+
// how many bands it holds, which is what "the page's sections" means when
|
|
392
|
+
// nothing in the graph labels them as such.
|
|
393
|
+
// \u26A0\uFE0F Measured as gaps *and* band padding, because in this renderer the two
|
|
394
|
+
// are interchangeable and the first version of this saw nothing: a stack of
|
|
395
|
+
// bands that abut at gap 0 and carry their spacing as internal padding is the
|
|
396
|
+
// normal shape here, so gaps alone reported "1 distinct gap: 0" for a page
|
|
397
|
+
// with perfectly good rhythm. What the doctrine is about is the number of
|
|
398
|
+
// distinct vertical spacings on the page, however they are expressed.
|
|
399
|
+
const bandParents = new Map();
|
|
400
|
+
for (const el of visible) {
|
|
401
|
+
const r = el.getBoundingClientRect();
|
|
402
|
+
if (r.width < vw * 0.6 || r.height < 8) continue;
|
|
403
|
+
const p = el.parentElement;
|
|
404
|
+
if (!p) continue;
|
|
405
|
+
if (!bandParents.has(p)) bandParents.set(p, []);
|
|
406
|
+
bandParents.get(p).push({ el, r });
|
|
407
|
+
}
|
|
408
|
+
let bands = [];
|
|
409
|
+
for (const set of bandParents.values()) if (set.length > bands.length) bands = set;
|
|
410
|
+
bands = bands.slice().sort((a, b) => a.r.top - b.r.top);
|
|
411
|
+
|
|
412
|
+
const spacingCounts = {};
|
|
413
|
+
const addSpacing = (v) => {
|
|
414
|
+
const n = round(v);
|
|
415
|
+
// Zero is "these abut", not a spacing; a huge value is a page break.
|
|
416
|
+
if (n <= 0 || n > 400) return;
|
|
417
|
+
spacingCounts[n] = (spacingCounts[n] || 0) + 1;
|
|
418
|
+
};
|
|
419
|
+
for (let i = 1; i < bands.length; i++) addSpacing(bands[i].r.top - bands[i - 1].r.bottom);
|
|
420
|
+
for (const b of bands) {
|
|
421
|
+
const cs = styleOf(b.el);
|
|
422
|
+
addSpacing(parseFloat(cs.paddingTop));
|
|
423
|
+
addSpacing(parseFloat(cs.paddingBottom));
|
|
424
|
+
}
|
|
425
|
+
const spacingValues = Object.keys(spacingCounts)
|
|
426
|
+
.map(Number)
|
|
427
|
+
.sort((a, b) => a - b);
|
|
428
|
+
|
|
429
|
+
// Repeated sibling sets \u2014 a card grid, seen from the DOM. Two kinds:
|
|
430
|
+
// 'columns' \u2014 a Columns node's own item wrappers (.column-item), so the
|
|
431
|
+
// author asked for a grid explicitly and we know it.
|
|
432
|
+
// 'siblings' \u2014 identical-looking children of one parent, which is what a
|
|
433
|
+
// hand-laid or repeated card row looks like.
|
|
434
|
+
// Grouping by parent, and by an identity signature within the parent, is what
|
|
435
|
+
// separates a grid from an ordinary stack of page sections.
|
|
436
|
+
const byParent = new Map();
|
|
437
|
+
for (const el of visible) {
|
|
438
|
+
const p = el.parentElement;
|
|
439
|
+
if (!p) continue;
|
|
440
|
+
if (!byParent.has(p)) byParent.set(p, []);
|
|
441
|
+
byParent.get(p).push(el);
|
|
442
|
+
}
|
|
443
|
+
const groups = [];
|
|
444
|
+
const record = (parent, kids, kind) => {
|
|
445
|
+
if (kids.length < 3) return;
|
|
446
|
+
const rects = kids.map((k) => k.getBoundingClientRect());
|
|
447
|
+
if (rects[0].width < 100) return;
|
|
448
|
+
groups.push({
|
|
449
|
+
kind,
|
|
450
|
+
count: kids.length,
|
|
451
|
+
columns: new Set(rects.map((r) => round(r.left))).size,
|
|
452
|
+
rows: new Set(rects.map((r) => round(r.top))).size,
|
|
453
|
+
itemWidth: round(rects[0].width),
|
|
454
|
+
itemHeight: round(rects[0].height),
|
|
455
|
+
parentWidth: round(parent.getBoundingClientRect().width),
|
|
456
|
+
tag: kids[0].tagName,
|
|
457
|
+
cls: cls(kids[0])
|
|
458
|
+
});
|
|
459
|
+
};
|
|
460
|
+
for (const [parent, kids] of byParent) {
|
|
461
|
+
const items = kids.filter((k) => k.classList && k.classList.contains('column-item'));
|
|
462
|
+
if (items.length >= 3) {
|
|
463
|
+
record(parent, items, 'columns');
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
const bySignature = new Map();
|
|
467
|
+
for (const k of kids) {
|
|
468
|
+
const sig = k.tagName + '|' + cls(k) + '|' + k.querySelectorAll('*').length;
|
|
469
|
+
if (!bySignature.has(sig)) bySignature.set(sig, []);
|
|
470
|
+
bySignature.get(sig).push(k);
|
|
471
|
+
}
|
|
472
|
+
for (const set of bySignature.values()) record(parent, set, 'siblings');
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// LAS-012 \xA73. Content the graph declares for a list, looked for on the page.
|
|
476
|
+
//
|
|
477
|
+
// Built from the visible leaf text elements, NOT \`document.body.textContent\`
|
|
478
|
+
// \u2014 measured, not assumed: render-from-disk injects the whole project as
|
|
479
|
+
// \`window.projectData\` in a <script> inside <body>, so body text contains
|
|
480
|
+
// every items array verbatim and the first version of this check found all
|
|
481
|
+
// six of haiku's missing strings on a page showing none of them.
|
|
482
|
+
//
|
|
483
|
+
// \`textContent\` is unaffected by text-transform, so an uppercased heading
|
|
484
|
+
// still matches its source string.
|
|
485
|
+
const pageText = textEls.map((el) => el.textContent).join('\\n');
|
|
486
|
+
const lists = PROBES.map((p) => {
|
|
487
|
+
const found = p.strings.filter((s) => pageText.indexOf(s) !== -1);
|
|
488
|
+
return { ...p, found: found.length, missing: p.strings.filter((s) => found.indexOf(s) === -1).slice(0, 3) };
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
// How far down the page anything was actually laid out. A page pinned to the
|
|
492
|
+
// viewport height with content laid out past it is clipped; a page that is
|
|
493
|
+
// simply short has nothing past it. That difference is the whole of
|
|
494
|
+
// \`clipped-page\`, and neither number alone can tell them apart.
|
|
495
|
+
// P112-OWN019-LAPTOPGAPS (R39) \u2014 where the INK is, not where the boxes are. Richard on RL1's laptop page: a
|
|
496
|
+
// feature number in a Columns "1 4" row had a fifth of the page to itself, ~215 px of nothing before its title;
|
|
497
|
+
// L1's pricing toggle and price sat ~260 px left of their centred heading. Every box there is well-formed \u2014 the
|
|
498
|
+
// number's column box abuts the title's \u2014 so the measure is each child's ink: the union of its shown text runs
|
|
499
|
+
// (a Range, so a full-width text block counts only as far as its words reach) and its painted boxes. Desktop only:
|
|
500
|
+
// that is where a row has room to open a hole. Raw numbers here; the judgement is summarise's.
|
|
501
|
+
const layout = { rowGaps: [], drifts: [] };
|
|
502
|
+
// (The API guard is for the fake DOMs the unit suites hand this expression; every browser has both.)
|
|
503
|
+
if (vw >= 1024 && typeof document.createRange === 'function' && typeof document.createTreeWalker === 'function') {
|
|
504
|
+
const shown = new Set(visible);
|
|
505
|
+
const textInk = [];
|
|
506
|
+
const range = document.createRange();
|
|
507
|
+
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
|
508
|
+
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
509
|
+
if (!n.textContent.trim() || !shown.has(n.parentElement)) continue;
|
|
510
|
+
range.selectNodeContents(n);
|
|
511
|
+
const r = range.getBoundingClientRect();
|
|
512
|
+
if (r.width > 0 && r.height > 0) textInk.push({ node: n, r });
|
|
513
|
+
}
|
|
514
|
+
const PAINTED_TAGS = ['IMG', 'SVG', 'CANVAS', 'VIDEO', 'INPUT', 'TEXTAREA', 'SELECT', 'BUTTON', 'svg'];
|
|
515
|
+
const paintedInk = visible
|
|
516
|
+
.filter((el) => {
|
|
517
|
+
if (PAINTED_TAGS.includes(el.tagName)) return true;
|
|
518
|
+
const cs = styleOf(el);
|
|
519
|
+
const bg = cs.backgroundColor;
|
|
520
|
+
return (bg && bg !== 'transparent' && !/rgba\\([^)]*,\\s*0\\)$/.test(bg)) || cs.backgroundImage !== 'none' || parseFloat(cs.borderTopWidth) > 0;
|
|
521
|
+
})
|
|
522
|
+
.map((el) => ({ el, r: el.getBoundingClientRect() }))
|
|
523
|
+
.filter((p) => p.r.width > 0 && p.r.height > 0 && p.r.width < vw * 0.98);
|
|
524
|
+
const inkOf = (el) => {
|
|
525
|
+
let l = Infinity, t = Infinity, rr = -Infinity, b = -Infinity, text = '';
|
|
526
|
+
const take = (r) => { l = Math.min(l, r.left); t = Math.min(t, r.top); rr = Math.max(rr, r.right); b = Math.max(b, r.bottom); };
|
|
527
|
+
for (const x of textInk) if (el.contains(x.node)) { take(x.r); if (!text) text = x.node.textContent.trim().slice(0, 50); }
|
|
528
|
+
for (const p of paintedInk) if (el === p.el || el.contains(p.el)) take(p.r);
|
|
529
|
+
return l === Infinity ? null : { left: l, top: t, right: rr, bottom: b, width: rr - l, height: b - t, text };
|
|
530
|
+
};
|
|
531
|
+
const ROW_GAP_MIN_PX = 160;
|
|
532
|
+
for (const parent of visible) {
|
|
533
|
+
const kids = [...parent.children].filter((k) => shown.has(k));
|
|
534
|
+
if (kids.length < 2) continue;
|
|
535
|
+
const pr = parent.getBoundingClientRect();
|
|
536
|
+
if (pr.width < 600) continue;
|
|
537
|
+
const inks = kids.map(inkOf).filter(Boolean);
|
|
538
|
+
if (inks.length < 2) continue;
|
|
539
|
+
// Side by side: consecutive by left edge, overlapping vertically, the second starting after the first ends.
|
|
540
|
+
const byLeft = [...inks].sort((a, b) => a.left - b.left);
|
|
541
|
+
for (let i = 1; i < byLeft.length; i++) {
|
|
542
|
+
const a = byLeft[i - 1], b = byLeft[i];
|
|
543
|
+
const overlap = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
|
|
544
|
+
const gap = b.left - a.right;
|
|
545
|
+
if (overlap <= 0 || gap < ROW_GAP_MIN_PX) continue;
|
|
546
|
+
layout.rowGaps.push({ cls: cls(parent), gap: round(gap), rowWidth: round(pr.width), tallest: round(Math.max(a.height, b.height)), left: a.text, right: b.text });
|
|
547
|
+
}
|
|
548
|
+
// Stacked, each starting below the one before, and the parts of ONE section: every part under 240 px tall (a
|
|
549
|
+
// page whose children are whole sections compared section against section \u2014 measured on L1 and GL1, s5). The
|
|
550
|
+
// section's axis is set by a centred part among its first two (its heading, usually); L1's pricing is just
|
|
551
|
+
// two parts, a centred heading block and a toggle-and-price group ~260 px left of it.
|
|
552
|
+
const byTop = [...inks].sort((a, b) => a.top - b.top);
|
|
553
|
+
if (!byTop.every((k, i) => i === 0 || k.top >= byTop[i - 1].bottom - 2)) continue;
|
|
554
|
+
if (byTop.some((k) => k.height > 240)) continue;
|
|
555
|
+
const centre = pr.left + pr.width / 2;
|
|
556
|
+
const offOf = (k) => k.left + k.width / 2 - centre;
|
|
557
|
+
const centred = byTop.filter((k) => Math.abs(offOf(k)) <= 12 && k.width < pr.width * 0.9);
|
|
558
|
+
if (!centred.some((k) => byTop.indexOf(k) < 2)) continue;
|
|
559
|
+
for (const k of byTop) {
|
|
560
|
+
if (Math.abs(offOf(k)) > 40 && k.width < pr.width * 0.8) {
|
|
561
|
+
layout.drifts.push({ cls: cls(parent), offBy: round(offOf(k)), sectionWidth: round(pr.width), text: k.text, centredText: centred[0].text });
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const contentBottom = visible.reduce((lowest, el) => {
|
|
568
|
+
const r = el.getBoundingClientRect();
|
|
569
|
+
return r.width > 0 && r.height > 0 && r.bottom > lowest ? r.bottom : lowest;
|
|
570
|
+
}, 0);
|
|
571
|
+
|
|
572
|
+
return {
|
|
573
|
+
lists,
|
|
574
|
+
layout,
|
|
575
|
+
// FLD-012 AC3 \u2014 the SIZE of the population every rule below is computed over, and the number of
|
|
576
|
+
// elements the visibility correction took out of it. Reported directly because a rule's finding
|
|
577
|
+
// count can move for several reasons at once, and 'the fix changed what is measured' is a
|
|
578
|
+
// different claim from 'the fix changed what is reported'.
|
|
579
|
+
visibleCount: visible.length,
|
|
580
|
+
transparentExcluded,
|
|
581
|
+
layoutWidth: window.innerWidth,
|
|
582
|
+
clientWidth: vw,
|
|
583
|
+
clientHeight: vh,
|
|
584
|
+
scrollWidth: document.documentElement.scrollWidth,
|
|
585
|
+
pageHeight: document.documentElement.scrollHeight,
|
|
586
|
+
contentBottom: round(contentBottom),
|
|
587
|
+
overflowing: overflowing.slice(0, 10),
|
|
588
|
+
overflowingCount: overflowing.length,
|
|
589
|
+
textsWiderThanBox: textsWiderThanBox.slice(0, 10),
|
|
590
|
+
textsWiderThanBoxCount: textsWiderThanBox.length,
|
|
591
|
+
text: {
|
|
592
|
+
elements: textEls.length,
|
|
593
|
+
onScreen: textEls.filter(onScreen).length,
|
|
594
|
+
unreachable: textEls.filter(unreachable).length,
|
|
595
|
+
fontWeights,
|
|
596
|
+
fontSizes,
|
|
597
|
+
distinctFontSizes: Object.keys(fontSizes).length,
|
|
598
|
+
// VIB-007 M3 \u2014 the display-type reading. Derived here rather than in
|
|
599
|
+
// summarise so a client that only keeps the summary still has it.
|
|
600
|
+
largestFontSize: Object.keys(fontSizes).reduce((max, px) => Math.max(max, parseFloat(px) || 0), 0),
|
|
601
|
+
bodyFontFamily: getComputedStyle(document.body).fontFamily.slice(0, 80)
|
|
602
|
+
},
|
|
603
|
+
placeholders: {
|
|
604
|
+
count: dead.length,
|
|
605
|
+
byText,
|
|
606
|
+
samples: dead.slice(0, 8).map((el) => ({ text: el.textContent.trim(), tag: el.tagName, cls: cls(el) }))
|
|
607
|
+
},
|
|
608
|
+
images: {
|
|
609
|
+
total: images.length,
|
|
610
|
+
// VIB-007 M3. Deliberately inside the images block rather than beside it: every
|
|
611
|
+
// consumer that asks "does this page have any pictures" has to ask both
|
|
612
|
+
// halves, and two sibling fields is how one of them gets forgotten.
|
|
613
|
+
icons: iconEls.size,
|
|
614
|
+
onScreen: images.filter(onScreen).length,
|
|
615
|
+
unreachable: images.filter(unreachable).length,
|
|
616
|
+
broken: brokenImages.length,
|
|
617
|
+
brokenSources: brokenImages.slice(0, 8).map((el) => String(el.currentSrc || el.src).slice(0, 160))
|
|
618
|
+
},
|
|
619
|
+
emptyDecoratedBoxes: {
|
|
620
|
+
count: emptyBoxes.length,
|
|
621
|
+
samples: emptyBoxes.slice(0, 6).map((el) => {
|
|
622
|
+
const r = el.getBoundingClientRect();
|
|
623
|
+
return { tag: el.tagName, cls: cls(el), width: round(r.width), height: round(r.height) };
|
|
624
|
+
})
|
|
625
|
+
},
|
|
626
|
+
colors: {
|
|
627
|
+
distinctAccents: accents.length,
|
|
628
|
+
accents: accents.slice(0, 6),
|
|
629
|
+
distinctNeutrals: Object.keys(neutralArea).length,
|
|
630
|
+
// The denominator behind every "share", emitted so a score can be
|
|
631
|
+
// recomputed from the report without knowing which viewport produced it.
|
|
632
|
+
viewportArea: round(viewportArea)
|
|
633
|
+
},
|
|
634
|
+
grounds: {
|
|
635
|
+
distinct: Object.keys(groundCounts).length,
|
|
636
|
+
values: Object.keys(groundCounts)
|
|
637
|
+
.map((ground) => ({ ground, count: groundCounts[ground] }))
|
|
638
|
+
.sort((a, b) => b.count - a.count)
|
|
639
|
+
.slice(0, 8)
|
|
640
|
+
},
|
|
641
|
+
rhythm: {
|
|
642
|
+
bands: bands.length,
|
|
643
|
+
distinctSpacings: spacingValues.length,
|
|
644
|
+
spacings: spacingValues.slice(0, 12).map((px) => ({ px, count: spacingCounts[px] }))
|
|
645
|
+
},
|
|
646
|
+
repeatedGroups: groups.sort((a, b) => b.itemWidth * b.count - a.itemWidth * a.count).slice(0, 12)
|
|
647
|
+
};
|
|
648
|
+
})()`;
|
|
649
|
+
}
|
|
650
|
+
var RenderFinding = {
|
|
651
|
+
BlankRender: "blank-render",
|
|
652
|
+
/**
|
|
653
|
+
* LAS-012 §3 — a list whose rows the graph spells out, and the page does not
|
|
654
|
+
* contain a single one of them. The one shape this report was structurally
|
|
655
|
+
* unable to see: every other finding is about content present and wrong, and
|
|
656
|
+
* a repeater that instantiates nothing emits no elements to judge.
|
|
657
|
+
*/
|
|
658
|
+
EmptyList: "empty-list",
|
|
659
|
+
DeadPlaceholderText: "dead-placeholder-text",
|
|
660
|
+
BrokenImage: "broken-image",
|
|
661
|
+
/**
|
|
662
|
+
* AWP-004 §2 — content on the page that no scroll can reach.
|
|
663
|
+
*
|
|
664
|
+
* The direct check for F45, and the one that generalises the other two: Kimi
|
|
665
|
+
* K3's storefront put 83 texts and 10 images in the DOM and stranded 70 and 9
|
|
666
|
+
* of them below a page that does not scroll, and the report called it
|
|
667
|
+
* *"Rendered clean: 83 texts, 10 images"*.
|
|
668
|
+
*/
|
|
669
|
+
ContentNotVisible: "content-not-visible",
|
|
670
|
+
/** The mechanism behind it: a page pinned to the viewport with content past the fold. */
|
|
671
|
+
ClippedPage: "clipped-page",
|
|
672
|
+
/** Elements wider than the viewport inside a page that does not itself scroll sideways. */
|
|
673
|
+
ElementsOverflowing: "elements-overflowing",
|
|
674
|
+
/**
|
|
675
|
+
* P88 GAM-020 (D58) — a Text wider than its parent's content box, so it is cut off or spills. The
|
|
676
|
+
* text is named. Rocket School's French sentences did this with zero console errors, and every
|
|
677
|
+
* page-level finding stayed quiet because the page itself kept its width.
|
|
678
|
+
*/
|
|
679
|
+
TextWiderThanItsBox: "text-wider-than-its-box",
|
|
680
|
+
SingleColumnGrid: "single-column-grid",
|
|
681
|
+
MinimumLayoutWidth: "minimum-layout-width",
|
|
682
|
+
HorizontalOverflow: "horizontal-overflow",
|
|
683
|
+
FlatTypeScale: "flat-type-scale",
|
|
684
|
+
EmptyDecoratedBox: "empty-decorated-box",
|
|
685
|
+
ConsoleError: "console-error",
|
|
686
|
+
/**
|
|
687
|
+
* VIB-007 M3 — the three poverty findings.
|
|
688
|
+
*
|
|
689
|
+
* 🔴 **Every other finding in this list detects EXCESS or BREAKAGE**, which is
|
|
690
|
+
* register V10: a page can pass all thirteen of them, render perfectly clean,
|
|
691
|
+
* and be worth nothing to look at. That is what the entire VIB-001 baseline
|
|
692
|
+
* is — nine pages ruled SHITTY, and the report called every one of them
|
|
693
|
+
* clean.
|
|
694
|
+
*
|
|
695
|
+
* These three are not invented. Each is one of the WordPress-starter tells
|
|
696
|
+
* README section 2 lists verbatim, restricted to the ones a render can see:
|
|
697
|
+
*
|
|
698
|
+
* "one background colour end to end" -> SingleGround
|
|
699
|
+
* "no imagery and no iconography anywhere" -> NoImagery
|
|
700
|
+
* "headline under ~48px on desktop" -> NoDisplayType
|
|
701
|
+
*
|
|
702
|
+
* ⚠️ **They are warnings, not errors, and that is a decision rather than an
|
|
703
|
+
* oversight.** `error` is the severity `noodl-mcp`'s render verdict blocks
|
|
704
|
+
* "done" on (VIB-007 M1), and README section 2 exempts app-chrome pages (forms,
|
|
705
|
+
* lists, settings) from the marketing tells — a settings page needs no 72px
|
|
706
|
+
* headline and refusing to certify one would be the instrument being wrong,
|
|
707
|
+
* loudly, on every honest project. The instrument cannot currently tell a
|
|
708
|
+
* landing page from a settings page; until something can, poverty is reported
|
|
709
|
+
* and does not refuse.
|
|
710
|
+
*/
|
|
711
|
+
SingleGround: "single-ground",
|
|
712
|
+
NoImagery: "no-imagery",
|
|
713
|
+
NoDisplayType: "no-display-type",
|
|
714
|
+
/**
|
|
715
|
+
* P112-OWN019-LAPTOPGAPS (R39, Richard 2026-10-14). On a laptop, two things side by side in one row with a hole
|
|
716
|
+
* between their ink — RL1's feature number given a fifth of the page by a Columns "1 4" row, L1's text and card
|
|
717
|
+
* pushed to the row's two edges. A warning: a hole can be meant, and the screenshots are the arbiter.
|
|
718
|
+
*/
|
|
719
|
+
RowGap: "row-gap",
|
|
720
|
+
/** P112-OWN019-LAPTOPGAPS — a section that centres its heading and leaves another part off that centre (L1's price). */
|
|
721
|
+
AlignmentDrift: "alignment-drift"
|
|
722
|
+
};
|
|
723
|
+
var SEVERITY_ORDER = { error: 0, warning: 1, info: 2 };
|
|
724
|
+
var ROW_GAP_MIN_TALLEST = 48;
|
|
725
|
+
var ROW_GAP_MIN_SHARE = 0.12;
|
|
726
|
+
var POVERTY_FINDINGS = [RenderFinding.SingleGround, RenderFinding.NoImagery, RenderFinding.NoDisplayType];
|
|
727
|
+
function isPovertyFinding(finding) {
|
|
728
|
+
return Boolean(finding) && POVERTY_FINDINGS.indexOf(finding.code) !== -1;
|
|
729
|
+
}
|
|
730
|
+
var DISPLAY_TYPE_MIN_PX = 48;
|
|
731
|
+
var MIN_DISTINCT_GROUNDS = 2;
|
|
732
|
+
var POVERTY_MIN_TEXTS = 3;
|
|
733
|
+
var CLIPPED_CONTENT_SLACK = 8;
|
|
734
|
+
function plural(n, one, many) {
|
|
735
|
+
return n === 1 ? `1 ${one}` : `${n} ${many}`;
|
|
736
|
+
}
|
|
737
|
+
function widestOffender(v) {
|
|
738
|
+
const widest = (v.overflowing || []).slice().sort((a, b) => b.width - a.width)[0];
|
|
739
|
+
if (!widest) return null;
|
|
740
|
+
const named = widest.cls ? `${widest.tag.toLowerCase()}.${widest.cls.split(/\s+/)[0]}` : widest.tag.toLowerCase();
|
|
741
|
+
return `${named} at ${widest.width}px`;
|
|
742
|
+
}
|
|
743
|
+
function summarise(viewports, diagnosis, overridden = {}) {
|
|
744
|
+
const findings = [];
|
|
745
|
+
const add = (f) => findings.push(f);
|
|
746
|
+
for (const [name, v] of Object.entries(viewports)) {
|
|
747
|
+
if (!v || v.error) continue;
|
|
748
|
+
const isDesktop = v.requested.width >= DESKTOP_WIDTH;
|
|
749
|
+
if (v.text.elements === 0 && v.images.total === 0) {
|
|
750
|
+
add({
|
|
751
|
+
code: RenderFinding.BlankRender,
|
|
752
|
+
severity: "error",
|
|
753
|
+
viewport: name,
|
|
754
|
+
message: "The page rendered nothing at all \u2014 no text and no images. " + (diagnosis ? diagnosis.message : "No project was available to diagnose it against, so the cause was not determined."),
|
|
755
|
+
...diagnosis && diagnosis.cause ? { cause: diagnosis.cause } : {},
|
|
756
|
+
...diagnosis && diagnosis.evidence ? { evidence: diagnosis.evidence } : {}
|
|
757
|
+
});
|
|
758
|
+
continue;
|
|
759
|
+
}
|
|
760
|
+
for (const list of v.lists || []) {
|
|
761
|
+
if (list.found > 0) continue;
|
|
762
|
+
add({
|
|
763
|
+
code: RenderFinding.EmptyList,
|
|
764
|
+
severity: "error",
|
|
765
|
+
viewport: name,
|
|
766
|
+
relatedDiagnostic: "repeater-without-template",
|
|
767
|
+
message: `The Repeater "${list.label}" in ${list.component} has ${plural(list.rows, "row", "rows")} of ${list.source}, and none of that content is on the page \u2014 so it built nothing. A For Each instantiates the component named on its "template" port once per item and inserts each copy as its own next sibling; with no template, or with the item markup nested underneath it instead, it renders nothing at all. Looked for: ${list.missing.map((s) => `"${s}"`).join(", ")}.`,
|
|
768
|
+
evidence: { component: list.component, nodeId: list.nodeId, rows: list.rows, missing: list.missing }
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
if (v.placeholders.count > 0) {
|
|
772
|
+
const entries = Object.entries(v.placeholders.byText);
|
|
773
|
+
const listing = (pairs) => pairs.map(([text, n]) => `${n}\xD7 "${text}"`).join(", ");
|
|
774
|
+
const total = (pairs) => pairs.reduce((sum, [, n]) => sum + n, 0);
|
|
775
|
+
const fromInput = entries.filter(([text]) => overridden[text]);
|
|
776
|
+
const fromCatalog = entries.filter(([text]) => !overridden[text]);
|
|
777
|
+
if (fromInput.length) {
|
|
778
|
+
const where = fromInput.map(([text]) => `"${text}" is ${describeSites(overridden[text])}`).join("; ");
|
|
779
|
+
add({
|
|
780
|
+
code: RenderFinding.DeadPlaceholderText,
|
|
781
|
+
severity: "error",
|
|
782
|
+
viewport: name,
|
|
783
|
+
relatedDiagnostic: "interfaceless-instance",
|
|
784
|
+
message: `${plural(total(fromInput), "element shows", "elements show")} the fallback hardcoded on a port that a Component Inputs node also feeds: ${listing(fromInput)}. That value is only ever visible when the input does not arrive, so it did not arrive \u2014 ${where}.`,
|
|
785
|
+
evidence: v.placeholders.samples.filter((s) => overridden[s.text])
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
if (fromCatalog.length) {
|
|
789
|
+
add({
|
|
790
|
+
code: RenderFinding.DeadPlaceholderText,
|
|
791
|
+
severity: "error",
|
|
792
|
+
viewport: name,
|
|
793
|
+
relatedDiagnostic: "interfaceless-instance",
|
|
794
|
+
message: `${plural(total(fromCatalog), "element renders", "elements render")} a node-type default instead of content: ${listing(fromCatalog)}. Nothing set those ports. The usual cause is a component instantiated with parameters its Component Inputs node does not declare, so every value is discarded \u2014 the graph-side name for it is interfaceless-instance.`,
|
|
795
|
+
evidence: v.placeholders.samples.filter((s) => !overridden[s.text])
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
if (v.images.broken > 0) {
|
|
800
|
+
add({
|
|
801
|
+
code: RenderFinding.BrokenImage,
|
|
802
|
+
severity: "error",
|
|
803
|
+
viewport: name,
|
|
804
|
+
message: `${plural(v.images.broken, "image", "images")} of ${v.images.total} failed to load \u2014 the element is in the DOM with naturalWidth 0. A URL that returns 200 can still be the wrong picture; this is only the half a number can see, so look at the screenshot for the rest.`,
|
|
805
|
+
evidence: v.images.brokenSources
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
const measuredVisibility = typeof v.contentBottom === "number" && typeof v.text.unreachable === "number";
|
|
809
|
+
if (measuredVisibility) {
|
|
810
|
+
const strandedText = v.text.unreachable;
|
|
811
|
+
const strandedImages = v.images.unreachable || 0;
|
|
812
|
+
if (strandedText > 0 || strandedImages > 0) {
|
|
813
|
+
const parts = [];
|
|
814
|
+
if (strandedText > 0) parts.push(`${strandedText} of ${v.text.elements} text elements`);
|
|
815
|
+
if (strandedImages > 0) parts.push(`${strandedImages} of ${v.images.total} images`);
|
|
816
|
+
add({
|
|
817
|
+
code: RenderFinding.ContentNotVisible,
|
|
818
|
+
severity: "error",
|
|
819
|
+
viewport: name,
|
|
820
|
+
message: `${parts.join(" and ")} are laid out below ${v.pageHeight}px, which is as far as this page scrolls \u2014 no scroll reaches them, so they are on the page and cannot be seen. Content extends to ${v.contentBottom}px. ${v.text.onScreen} of ${v.text.elements} texts are on screen.`,
|
|
821
|
+
evidence: {
|
|
822
|
+
pageHeight: v.pageHeight,
|
|
823
|
+
contentBottom: v.contentBottom,
|
|
824
|
+
textElements: v.text.elements,
|
|
825
|
+
textOnScreen: v.text.onScreen,
|
|
826
|
+
textUnreachable: strandedText,
|
|
827
|
+
imagesUnreachable: strandedImages
|
|
828
|
+
}
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
const pinned = Math.abs(v.pageHeight - v.requested.height) <= 1;
|
|
832
|
+
if (pinned && v.contentBottom > v.pageHeight + CLIPPED_CONTENT_SLACK) {
|
|
833
|
+
add({
|
|
834
|
+
code: RenderFinding.ClippedPage,
|
|
835
|
+
severity: "warning",
|
|
836
|
+
viewport: name,
|
|
837
|
+
message: `The page is exactly ${v.pageHeight}px tall \u2014 the viewport height \u2014 and its content runs to ${v.contentBottom}px, so it is clipped at the fold rather than scrolling. A root that clips to the viewport with no scrollbar is the default; a page of this length has to opt into scrolling.`,
|
|
838
|
+
evidence: { pageHeight: v.pageHeight, contentBottom: v.contentBottom, viewport: v.requested.height }
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
if (isDesktop) {
|
|
843
|
+
for (const g of v.repeatedGroups) {
|
|
844
|
+
if (g.count < 3 || g.columns !== 1) continue;
|
|
845
|
+
if (g.itemWidth < g.parentWidth * GRID_ITEM_PARENT_SHARE) continue;
|
|
846
|
+
if (g.kind !== "columns") {
|
|
847
|
+
if (g.itemHeight < GRID_ITEM_MIN_HEIGHT || g.itemWidth < GRID_ITEM_MIN_WIDTH) continue;
|
|
848
|
+
if (g.parentWidth < v.requested.width * GRID_PARENT_VIEWPORT_SHARE) continue;
|
|
849
|
+
}
|
|
850
|
+
add({
|
|
851
|
+
code: RenderFinding.SingleColumnGrid,
|
|
852
|
+
severity: g.kind === "columns" ? "warning" : "info",
|
|
853
|
+
viewport: name,
|
|
854
|
+
message: `${g.count} repeated items are stacked in one column at ${v.requested.width}px, each ${g.itemWidth}\xD7${g.itemHeight}px inside a ${g.parentWidth}px parent` + (g.kind === "columns" ? '. They are a Columns node\u2019s items, so the layoutString asked for one column \u2014 "1 1 1" is three equal columns, and the count of numbers is the count of columns.' : ". If they are meant to be a grid, a Columns node is the only node that lays items out in one and collapses it on small screens; if they are meant to be full-width bands, this is right."),
|
|
855
|
+
evidence: g
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
if (v.layoutWidth > v.requested.width + 1) {
|
|
860
|
+
add({
|
|
861
|
+
code: RenderFinding.MinimumLayoutWidth,
|
|
862
|
+
severity: "warning",
|
|
863
|
+
viewport: name,
|
|
864
|
+
message: `The page cannot lay out below ${v.layoutWidth}px: asked for ${v.requested.width}px, the browser widened the layout viewport to ${v.layoutWidth}px and scaled the whole page down.` + (widestOffender(v) ? ` The widest element inside it is ${widestOffender(v)}.` : " Nothing wider than the viewport was captured, so the element holding the floor was not identified."),
|
|
865
|
+
evidence: v.overflowing
|
|
866
|
+
});
|
|
867
|
+
} else if (v.scrollWidth > v.clientWidth + 1) {
|
|
868
|
+
add({
|
|
869
|
+
code: RenderFinding.HorizontalOverflow,
|
|
870
|
+
severity: "warning",
|
|
871
|
+
viewport: name,
|
|
872
|
+
message: `The page scrolls sideways at ${v.requested.width}px \u2014 content is ${v.scrollWidth}px wide in a ${v.clientWidth}px viewport, with ${plural(v.overflowingCount, "element", "elements")} wider than it.`,
|
|
873
|
+
evidence: v.overflowing
|
|
874
|
+
});
|
|
875
|
+
} else if (v.overflowingCount > 0) {
|
|
876
|
+
add({
|
|
877
|
+
code: RenderFinding.ElementsOverflowing,
|
|
878
|
+
severity: "warning",
|
|
879
|
+
viewport: name,
|
|
880
|
+
message: `${plural(v.overflowingCount, "element is", "elements are")} wider than the ${v.clientWidth}px viewport, while the page itself does not scroll sideways \u2014 so each is clipped by an ancestor rather than reachable${widestOffender(v) ? `; the widest is ${widestOffender(v)}` : ""}.`,
|
|
881
|
+
evidence: v.overflowing
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
if (v.textsWiderThanBoxCount > 0) {
|
|
885
|
+
const first = v.textsWiderThanBox[0];
|
|
886
|
+
add({
|
|
887
|
+
code: RenderFinding.TextWiderThanItsBox,
|
|
888
|
+
severity: "warning",
|
|
889
|
+
viewport: name,
|
|
890
|
+
message: `${plural(v.textsWiderThanBoxCount, "text is", "texts are")} wider than the box ${v.textsWiderThanBoxCount === 1 ? "it sits" : "they sit"} in at ${v.requested.width}px, so the line is cut off or spills: "${first.text}" is ${first.width}px in a ${first.parentWidth}px box. A Text sized to its content (sizeMode "contentSize" or "contentWidth") never wraps; "contentHeight" takes its width from the parent and wraps.`,
|
|
891
|
+
evidence: v.textsWiderThanBox
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
if (v.text.elements >= 10) {
|
|
895
|
+
const weights = Object.keys(v.text.fontWeights);
|
|
896
|
+
if (weights.length <= 1 || v.text.distinctFontSizes <= 2) {
|
|
897
|
+
add({
|
|
898
|
+
code: RenderFinding.FlatTypeScale,
|
|
899
|
+
severity: "warning",
|
|
900
|
+
viewport: name,
|
|
901
|
+
message: `${v.text.elements} text elements render at ${plural(weights.length, "font weight", "font weights")} (${weights.join(", ")}) and ${plural(v.text.distinctFontSizes, "font size", "font sizes")}. Nothing is emphasised over anything else, which is what an unstyled page measures like.`
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
if (v.emptyDecoratedBoxes.count > 0) {
|
|
906
|
+
add({
|
|
907
|
+
code: RenderFinding.EmptyDecoratedBox,
|
|
908
|
+
severity: "warning",
|
|
909
|
+
viewport: name,
|
|
910
|
+
message: `${plural(v.emptyDecoratedBoxes.count, "box has", "boxes have")} a background or border and no content \u2014 a card whose contents never arrived, or a decorated Group used as a spacer.`,
|
|
911
|
+
evidence: v.emptyDecoratedBoxes.samples
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
const poorEnoughToJudge = v.text.elements >= POVERTY_MIN_TEXTS;
|
|
915
|
+
if (poorEnoughToJudge && v.images && typeof v.images.icons === "number" && v.images.total === 0 && v.images.icons === 0) {
|
|
916
|
+
add({
|
|
917
|
+
code: RenderFinding.NoImagery,
|
|
918
|
+
severity: "warning",
|
|
919
|
+
viewport: name,
|
|
920
|
+
message: `${v.text.elements} text elements and not one picture or glyph \u2014 no images, no icons. README \xA72's second WordPress-starter tell is "no imagery and no iconography anywhere on the page", and this page measures as exactly that. The Image and Icon nodes are in the kit and the starter modules ship photographs and 1,998 Lucide glyphs.`
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
if (poorEnoughToJudge && isDesktop && typeof v.text.largestFontSize === "number" && v.text.largestFontSize > 0 && v.text.largestFontSize < DISPLAY_TYPE_MIN_PX) {
|
|
924
|
+
add({
|
|
925
|
+
code: RenderFinding.NoDisplayType,
|
|
926
|
+
severity: "warning",
|
|
927
|
+
viewport: name,
|
|
928
|
+
message: `The largest text on this page renders at ${Math.round(v.text.largestFontSize)}px across ${plural(v.text.distinctFontSizes, "distinct size", "distinct sizes")}. README \xA72 calls a headline under ~${DISPLAY_TYPE_MIN_PX}px on desktop a WordPress-starter tell; nothing on this page is operating as display type.`,
|
|
929
|
+
evidence: v.text.fontSizes
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
if (poorEnoughToJudge && v.grounds && typeof v.grounds.distinct === "number" && v.grounds.distinct < MIN_DISTINCT_GROUNDS) {
|
|
933
|
+
add({
|
|
934
|
+
code: RenderFinding.SingleGround,
|
|
935
|
+
severity: "warning",
|
|
936
|
+
viewport: name,
|
|
937
|
+
message: `Every full-width band on this page is painted the same \u2014 ${plural(v.grounds.distinct, "distinct ground", "distinct grounds")} across the whole page. README \xA72's first WordPress-starter tell is "one background colour end to end". A ground can be a colour, a gradient or an image, and alternating them is what makes a page read as sections rather than as one column.`,
|
|
938
|
+
evidence: v.grounds.values
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
if (isDesktop && v.layout) {
|
|
942
|
+
const gaps = (v.layout.rowGaps || []).filter((g) => g.tallest >= ROW_GAP_MIN_TALLEST && g.gap >= g.rowWidth * ROW_GAP_MIN_SHARE);
|
|
943
|
+
const seen2 = /* @__PURE__ */ new Set();
|
|
944
|
+
for (const g of gaps) {
|
|
945
|
+
const key = `${g.left}|${g.right}`;
|
|
946
|
+
if (seen2.has(key) || seen2.size >= 3) continue;
|
|
947
|
+
seen2.add(key);
|
|
948
|
+
add({
|
|
949
|
+
code: RenderFinding.RowGap,
|
|
950
|
+
severity: "warning",
|
|
951
|
+
viewport: name,
|
|
952
|
+
message: `"${g.left}" and "${g.right}" sit side by side with ${g.gap}px of empty space between them (${Math.round(g.gap / g.rowWidth * 100)}% of the ${g.rowWidth}px row). A label or number beside text reads as one thing at its own width with a small gap \u2014 a row Group, the label at its natural width and the text growing \u2014 not a column given a share of the page (Columns widths are proportions); two parts pushed to a wide row's edges leave the same hole: give the row a max width and a gap. Look at this width's screenshot.`,
|
|
953
|
+
evidence: g
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
const drifts = (v.layout.drifts || []).slice(0, 3);
|
|
957
|
+
for (const d of drifts) {
|
|
958
|
+
add({
|
|
959
|
+
code: RenderFinding.AlignmentDrift,
|
|
960
|
+
severity: "warning",
|
|
961
|
+
viewport: name,
|
|
962
|
+
message: `This section centres "${d.centredText}", but "${d.text}" sits ${Math.abs(d.offBy)}px ${d.offBy < 0 ? "left" : "right"} of that centre. One section, one alignment: centre the rest too, or align everything to the same left edge. Look at this width's screenshot.`,
|
|
963
|
+
evidence: d
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
if (v.consoleErrors && v.consoleErrors.length) {
|
|
968
|
+
add({
|
|
969
|
+
code: RenderFinding.ConsoleError,
|
|
970
|
+
severity: "warning",
|
|
971
|
+
viewport: name,
|
|
972
|
+
message: `The runtime logged ${plural(v.consoleErrors.length, "error", "errors")} while rendering.`,
|
|
973
|
+
evidence: v.consoleErrors.slice(0, 5)
|
|
974
|
+
});
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
const seen = /* @__PURE__ */ new Set();
|
|
978
|
+
const unique = findings.filter((f) => {
|
|
979
|
+
const key = `${f.code}|${f.viewport}|${f.message}`;
|
|
980
|
+
if (seen.has(key)) return false;
|
|
981
|
+
seen.add(key);
|
|
982
|
+
return true;
|
|
983
|
+
});
|
|
984
|
+
unique.sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]);
|
|
985
|
+
return { findings: unique, summary: summaryLine(viewports, unique) };
|
|
986
|
+
}
|
|
987
|
+
function summaryLine(viewports, findings) {
|
|
988
|
+
const count = (severity) => findings.filter((f) => f.severity === severity).length;
|
|
989
|
+
const errors = count("error");
|
|
990
|
+
const warnings = count("warning");
|
|
991
|
+
const infos = count("info");
|
|
992
|
+
const live = Object.entries(viewports).filter(([, v]) => v && !v.error);
|
|
993
|
+
const shape = live.map(([name, v]) => {
|
|
994
|
+
const counts = typeof v.text.onScreen === "number" ? `${v.text.elements} texts, ${v.text.onScreen} on screen, ${v.images.total} images` : `${v.text.elements} texts, ${v.images.total} images`;
|
|
995
|
+
return `${name} ${v.requested.width}\xD7${v.pageHeight}px, ${counts}`;
|
|
996
|
+
}).join("; ");
|
|
997
|
+
if (!errors && !warnings) {
|
|
998
|
+
const measured = live.filter(([, v]) => typeof v.text.onScreen === "number");
|
|
999
|
+
const observations = infos ? ` (${plural(infos, "observation", "observations")})` : "";
|
|
1000
|
+
if (measured.length !== live.length) {
|
|
1001
|
+
return `No findings${observations}, and visibility was not measured \u2014 not a claim the page is on screen: ${shape}.`;
|
|
1002
|
+
}
|
|
1003
|
+
if (measured.some(([, v]) => v.text.onScreen === 0 && v.images.onScreen === 0)) {
|
|
1004
|
+
return `No findings${observations}, but nothing is on screen at the top of the page: ${shape}.`;
|
|
1005
|
+
}
|
|
1006
|
+
return `Rendered clean${observations}: ${shape}.`;
|
|
1007
|
+
}
|
|
1008
|
+
const worst = [...new Set(findings.filter((f) => f.severity !== "info").map((f) => f.code))].slice(0, 4).join(", ");
|
|
1009
|
+
return `${plural(errors, "error", "errors")}, ${plural(warnings, "warning", "warnings")}${infos ? `, ${plural(infos, "observation", "observations")}` : ""} (${worst}). ${shape}.`;
|
|
1010
|
+
}
|
|
1011
|
+
function describeSites(entry) {
|
|
1012
|
+
const sites = entry.sites || [];
|
|
1013
|
+
const named = sites.slice(0, 2).map((s) => `${s.port} in ${s.component}`);
|
|
1014
|
+
const rest = sites.length - named.length;
|
|
1015
|
+
return named.join(" and ") + (rest > 0 ? ` and ${plural(rest, "other place", "other places")}` : "");
|
|
1016
|
+
}
|
|
1017
|
+
var TEXT_BEARING_PORTS = /^(text|label|placeholder|title|caption|heading)$/i;
|
|
1018
|
+
function placeholderStringsFromCatalog(catalog) {
|
|
1019
|
+
const found = /* @__PURE__ */ new Set();
|
|
1020
|
+
for (const node of catalog && catalog.nodes || []) {
|
|
1021
|
+
if (!node.isVisual) continue;
|
|
1022
|
+
for (const port of node.inputs || []) {
|
|
1023
|
+
if (!TEXT_BEARING_PORTS.test(port.name)) continue;
|
|
1024
|
+
if (typeof port.default === "string" && port.default.trim()) found.add(port.default.trim());
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
return [...found];
|
|
1028
|
+
}
|
|
1029
|
+
module2.exports = {
|
|
1030
|
+
// Vocabulary — every client must agree on these.
|
|
1031
|
+
DEFAULT_VIEWPORTS,
|
|
1032
|
+
NAMED_VIEWPORTS,
|
|
1033
|
+
DESKTOP_WIDTH,
|
|
1034
|
+
RenderFinding,
|
|
1035
|
+
SEVERITY_ORDER,
|
|
1036
|
+
CLIPPED_CONTENT_SLACK,
|
|
1037
|
+
// VIB-007 M3 — exported because a spec that restates 48 is a second copy of
|
|
1038
|
+
// README §2's number, and the one that drifts is always the copy.
|
|
1039
|
+
DISPLAY_TYPE_MIN_PX,
|
|
1040
|
+
MIN_DISTINCT_GROUNDS,
|
|
1041
|
+
POVERTY_MIN_TEXTS,
|
|
1042
|
+
POVERTY_FINDINGS,
|
|
1043
|
+
isPovertyFinding,
|
|
1044
|
+
// The two halves of the loop: what to evaluate in the page, and what the
|
|
1045
|
+
// numbers that come back mean.
|
|
1046
|
+
measureExpression,
|
|
1047
|
+
summarise,
|
|
1048
|
+
summaryLine,
|
|
1049
|
+
// Helpers the callers share.
|
|
1050
|
+
plural,
|
|
1051
|
+
widestOffender,
|
|
1052
|
+
describeSites,
|
|
1053
|
+
TEXT_BEARING_PORTS,
|
|
1054
|
+
placeholderStringsFromCatalog
|
|
1055
|
+
};
|
|
1056
|
+
}
|
|
1057
|
+
});
|
|
1058
|
+
|
|
1059
|
+
// ../../scripts/devtools/render-report.js
|
|
1060
|
+
var require_render_report = __commonJS({
|
|
1061
|
+
"../../scripts/devtools/render-report.js"(exports2, module2) {
|
|
1062
|
+
var { spawn } = require("child_process");
|
|
1063
|
+
var fs2 = require("fs");
|
|
1064
|
+
var http = require("http");
|
|
1065
|
+
var net = require("net");
|
|
1066
|
+
var os = require("os");
|
|
1067
|
+
var path2 = require("path");
|
|
1068
|
+
var HARNESS_PATHS = require_harness_paths();
|
|
1069
|
+
var RENDER_SCRIPT = path2.join(__dirname, "render-from-disk.js");
|
|
1070
|
+
var VIEWER_DIR = HARNESS_PATHS.VIEWER_DIR.path;
|
|
1071
|
+
var VIEWER_BUNDLE = VIEWER_DIR && path2.join(VIEWER_DIR, "noodl.viewer.js");
|
|
1072
|
+
var CATALOG_JSON = HARNESS_PATHS.CATALOG_JSON.path;
|
|
1073
|
+
var WS_MODULE = HARNESS_PATHS.WS_MODULE.path;
|
|
1074
|
+
var {
|
|
1075
|
+
DEFAULT_VIEWPORTS,
|
|
1076
|
+
NAMED_VIEWPORTS,
|
|
1077
|
+
DESKTOP_WIDTH,
|
|
1078
|
+
RenderFinding,
|
|
1079
|
+
SEVERITY_ORDER,
|
|
1080
|
+
CLIPPED_CONTENT_SLACK,
|
|
1081
|
+
DISPLAY_TYPE_MIN_PX,
|
|
1082
|
+
MIN_DISTINCT_GROUNDS,
|
|
1083
|
+
POVERTY_MIN_TEXTS,
|
|
1084
|
+
POVERTY_FINDINGS,
|
|
1085
|
+
isPovertyFinding,
|
|
1086
|
+
measureExpression,
|
|
1087
|
+
summarise,
|
|
1088
|
+
summaryLine,
|
|
1089
|
+
plural,
|
|
1090
|
+
widestOffender,
|
|
1091
|
+
describeSites,
|
|
1092
|
+
TEXT_BEARING_PORTS,
|
|
1093
|
+
placeholderStringsFromCatalog
|
|
1094
|
+
} = require_src();
|
|
1095
|
+
function placeholderStrings(catalogPath = CATALOG_JSON) {
|
|
1096
|
+
return placeholderStringsFromCatalog(JSON.parse(fs2.readFileSync(catalogPath, "utf8")));
|
|
1097
|
+
}
|
|
1098
|
+
var PROBE_MIN_WORDS = 2;
|
|
1099
|
+
var PROBE_MIN_LENGTH = 8;
|
|
1100
|
+
var PROBE_MIN_LENGTH_SINGLE_WORD = 12;
|
|
1101
|
+
var PROBE_MAX_STRINGS = 6;
|
|
1102
|
+
function isDistinctive(s) {
|
|
1103
|
+
if (/^[^A-Za-z]*$/.test(s)) return false;
|
|
1104
|
+
const words = s.split(/\s+/).filter(Boolean).length;
|
|
1105
|
+
if (words >= PROBE_MIN_WORDS) return s.length >= PROBE_MIN_LENGTH;
|
|
1106
|
+
return s.length >= PROBE_MIN_LENGTH_SINGLE_WORD;
|
|
1107
|
+
}
|
|
1108
|
+
function readableStrings(items) {
|
|
1109
|
+
const out = [];
|
|
1110
|
+
for (const item of items) {
|
|
1111
|
+
if (!item || typeof item !== "object") {
|
|
1112
|
+
if (typeof item === "string" && isDistinctive(item.trim())) out.push(item.trim());
|
|
1113
|
+
continue;
|
|
1114
|
+
}
|
|
1115
|
+
for (const value of Object.values(item)) {
|
|
1116
|
+
if (typeof value !== "string") continue;
|
|
1117
|
+
const s = value.trim();
|
|
1118
|
+
if (/^(https?:|data:|\/|#|var\()/.test(s)) continue;
|
|
1119
|
+
if (!isDistinctive(s)) continue;
|
|
1120
|
+
out.push(s);
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
return [...new Set(out)];
|
|
1124
|
+
}
|
|
1125
|
+
function staticDataItems(node) {
|
|
1126
|
+
const p = node.parameters || {};
|
|
1127
|
+
if (Array.isArray(p.items)) return p.items;
|
|
1128
|
+
if (typeof p.json !== "string" || !p.json.trim()) return null;
|
|
1129
|
+
try {
|
|
1130
|
+
const parsed = JSON.parse(p.json);
|
|
1131
|
+
return Array.isArray(parsed) && parsed.length ? parsed : null;
|
|
1132
|
+
} catch {
|
|
1133
|
+
return null;
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
function readComponents(projectDir) {
|
|
1137
|
+
let project;
|
|
1138
|
+
try {
|
|
1139
|
+
project = JSON.parse(fs2.readFileSync(path2.join(projectDir, "nodegx.project.json"), "utf8"));
|
|
1140
|
+
} catch {
|
|
1141
|
+
return { ok: false, components: [] };
|
|
1142
|
+
}
|
|
1143
|
+
const componentsDir = path2.join(projectDir, project.structure && project.structure.componentsDir || "components");
|
|
1144
|
+
let registry;
|
|
1145
|
+
try {
|
|
1146
|
+
registry = JSON.parse(fs2.readFileSync(path2.join(componentsDir, "_registry.json"), "utf8"));
|
|
1147
|
+
} catch {
|
|
1148
|
+
return { ok: false, project, components: [] };
|
|
1149
|
+
}
|
|
1150
|
+
const components = [];
|
|
1151
|
+
for (const key of Object.keys(registry.components || {})) {
|
|
1152
|
+
const dir = path2.join(componentsDir, registry.components[key].path);
|
|
1153
|
+
try {
|
|
1154
|
+
const nodesFile = JSON.parse(fs2.readFileSync(path2.join(dir, "nodes.json"), "utf8"));
|
|
1155
|
+
const meta = JSON.parse(fs2.readFileSync(path2.join(dir, "component.json"), "utf8"));
|
|
1156
|
+
const connPath = path2.join(dir, "connections.json");
|
|
1157
|
+
components.push({
|
|
1158
|
+
name: meta.path || "/" + key,
|
|
1159
|
+
nodes: nodesFile.nodes || [],
|
|
1160
|
+
connections: fs2.existsSync(connPath) ? JSON.parse(fs2.readFileSync(connPath, "utf8")).connections || [] : [],
|
|
1161
|
+
// Absent and empty are different things and AWP-001 §3 turns on the
|
|
1162
|
+
// difference: `undefined` means nobody computed this, `[]` means a writer
|
|
1163
|
+
// did and found nothing. Carried through verbatim, decided in one place.
|
|
1164
|
+
declaredRoots: nodesFile.visualRoots
|
|
1165
|
+
});
|
|
1166
|
+
} catch {
|
|
1167
|
+
continue;
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
return { ok: true, project, components };
|
|
1171
|
+
}
|
|
1172
|
+
function listProbes(projectDir) {
|
|
1173
|
+
const probes = [];
|
|
1174
|
+
for (const component of readComponents(projectDir).components) {
|
|
1175
|
+
const { name, nodes, connections } = component;
|
|
1176
|
+
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
1177
|
+
for (const node of nodes) {
|
|
1178
|
+
if (node.type !== "For Each") continue;
|
|
1179
|
+
const parameters = node.parameters || {};
|
|
1180
|
+
let items = Array.isArray(parameters.items) && parameters.items.length ? parameters.items : null;
|
|
1181
|
+
let source = "inline items";
|
|
1182
|
+
if (!items) {
|
|
1183
|
+
const feed = connections.find((c) => (c.toId ?? c.targetId) === node.id && (c.toProperty ?? c.targetPort) === "items");
|
|
1184
|
+
const from = feed ? byId.get(feed.fromId ?? feed.sourceId) : void 0;
|
|
1185
|
+
if (from && from.type === "Static Data") {
|
|
1186
|
+
items = staticDataItems(from);
|
|
1187
|
+
source = `Static Data "${from.label || from.id}"`;
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
if (!items) continue;
|
|
1191
|
+
const strings = readableStrings(items).slice(0, PROBE_MAX_STRINGS);
|
|
1192
|
+
if (!strings.length) continue;
|
|
1193
|
+
probes.push({
|
|
1194
|
+
component: name,
|
|
1195
|
+
nodeId: node.id,
|
|
1196
|
+
label: node.label || node.id,
|
|
1197
|
+
rows: items.length,
|
|
1198
|
+
source,
|
|
1199
|
+
strings
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
return probes;
|
|
1204
|
+
}
|
|
1205
|
+
function overriddenDefaults(projectDir) {
|
|
1206
|
+
const { components } = readComponents(projectDir);
|
|
1207
|
+
const suppliedByInstances = /* @__PURE__ */ new Map();
|
|
1208
|
+
for (const component of components) {
|
|
1209
|
+
for (const node of component.nodes) {
|
|
1210
|
+
const params = node.parameters || {};
|
|
1211
|
+
for (const port of Object.keys(params)) {
|
|
1212
|
+
const value = params[port];
|
|
1213
|
+
if (typeof value !== "string" || !value.trim()) continue;
|
|
1214
|
+
const key = node.type + "\0" + port;
|
|
1215
|
+
if (!suppliedByInstances.has(key)) suppliedByInstances.set(key, /* @__PURE__ */ new Set());
|
|
1216
|
+
suppliedByInstances.get(key).add(value.trim());
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
const found = /* @__PURE__ */ new Map();
|
|
1221
|
+
for (const component of components) {
|
|
1222
|
+
const byId = new Map(component.nodes.map((n) => [n.id, n]));
|
|
1223
|
+
for (const conn of component.connections) {
|
|
1224
|
+
const from = byId.get(conn.sourceId ?? conn.fromId);
|
|
1225
|
+
const to = byId.get(conn.targetId ?? conn.toId);
|
|
1226
|
+
const port = conn.targetPort ?? conn.toProperty;
|
|
1227
|
+
const inputPort = conn.sourcePort ?? conn.fromProperty;
|
|
1228
|
+
if (!from || !to || from.type !== "Component Inputs") continue;
|
|
1229
|
+
if (!TEXT_BEARING_PORTS.test(port)) continue;
|
|
1230
|
+
const hardcoded = (to.parameters || {})[port];
|
|
1231
|
+
if (typeof hardcoded !== "string" || !hardcoded.trim()) continue;
|
|
1232
|
+
const key = hardcoded.trim();
|
|
1233
|
+
const supplied = inputPort && suppliedByInstances.get(component.name + "\0" + inputPort);
|
|
1234
|
+
if (supplied && supplied.has(key)) continue;
|
|
1235
|
+
if (!found.has(key)) found.set(key, { sites: [] });
|
|
1236
|
+
found.get(key).sites.push({ component: component.name, nodeId: to.id, port });
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
return found;
|
|
1240
|
+
}
|
|
1241
|
+
var ENRICHED_CATALOG_JSON = HARNESS_PATHS.ENRICHED_CATALOG_JSON.path;
|
|
1242
|
+
var visualTypesCache = /* @__PURE__ */ new Map();
|
|
1243
|
+
function visualTypeNames(catalogPath = ENRICHED_CATALOG_JSON) {
|
|
1244
|
+
if (visualTypesCache.has(catalogPath)) return visualTypesCache.get(catalogPath);
|
|
1245
|
+
let names;
|
|
1246
|
+
try {
|
|
1247
|
+
const catalog = JSON.parse(fs2.readFileSync(catalogPath, "utf8"));
|
|
1248
|
+
names = new Set((catalog.nodes || []).filter((n) => n.isVisual === true).map((n) => n.typeName));
|
|
1249
|
+
if (names.size === 0) names = null;
|
|
1250
|
+
} catch {
|
|
1251
|
+
names = null;
|
|
1252
|
+
}
|
|
1253
|
+
visualTypesCache.set(catalogPath, names);
|
|
1254
|
+
return names;
|
|
1255
|
+
}
|
|
1256
|
+
var CONTENT_BEARING_PORTS = /^(text|label|placeholder|title|caption|heading|src|source|icon)$/i;
|
|
1257
|
+
var CONTAINER_DESPITE_A_CONTENT_PORT = /* @__PURE__ */ new Set(["Page"]);
|
|
1258
|
+
var contentTypesCache = /* @__PURE__ */ new Map();
|
|
1259
|
+
function contentBearingTypeNames(catalogPath = ENRICHED_CATALOG_JSON) {
|
|
1260
|
+
if (contentTypesCache.has(catalogPath)) return contentTypesCache.get(catalogPath);
|
|
1261
|
+
let names;
|
|
1262
|
+
try {
|
|
1263
|
+
const catalog = JSON.parse(fs2.readFileSync(catalogPath, "utf8"));
|
|
1264
|
+
names = new Set(
|
|
1265
|
+
(catalog.nodes || []).filter(
|
|
1266
|
+
(n) => n.isVisual === true && !CONTAINER_DESPITE_A_CONTENT_PORT.has(n.typeName) && (n.inputs || []).some((p) => CONTENT_BEARING_PORTS.test(p.name))
|
|
1267
|
+
).map((n) => n.typeName)
|
|
1268
|
+
);
|
|
1269
|
+
if (names.size === 0) names = null;
|
|
1270
|
+
} catch {
|
|
1271
|
+
names = null;
|
|
1272
|
+
}
|
|
1273
|
+
contentTypesCache.set(catalogPath, names);
|
|
1274
|
+
return names;
|
|
1275
|
+
}
|
|
1276
|
+
function rootNodes(nodes) {
|
|
1277
|
+
return (nodes || []).filter((n) => n.parent === void 0);
|
|
1278
|
+
}
|
|
1279
|
+
function subtreeNodes(nodes, ids) {
|
|
1280
|
+
const byId = new Map((nodes || []).map((n) => [n.id, n]));
|
|
1281
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1282
|
+
const out = [];
|
|
1283
|
+
const stack = [...ids];
|
|
1284
|
+
while (stack.length) {
|
|
1285
|
+
const id = stack.pop();
|
|
1286
|
+
if (seen.has(id)) continue;
|
|
1287
|
+
seen.add(id);
|
|
1288
|
+
const node = byId.get(id);
|
|
1289
|
+
if (!node) continue;
|
|
1290
|
+
out.push(node);
|
|
1291
|
+
for (const child of node.children || []) stack.push(child);
|
|
1292
|
+
}
|
|
1293
|
+
return out;
|
|
1294
|
+
}
|
|
1295
|
+
function projectVisualPredicate(components) {
|
|
1296
|
+
const catalogVisual = visualTypeNames();
|
|
1297
|
+
const byName = new Map(components.map((c) => [c.name, c]));
|
|
1298
|
+
const memo = /* @__PURE__ */ new Map();
|
|
1299
|
+
const inFlight = /* @__PURE__ */ new Set();
|
|
1300
|
+
const isVisual = (typeName) => {
|
|
1301
|
+
if (catalogVisual && catalogVisual.has(typeName)) return true;
|
|
1302
|
+
if (!byName.has(typeName)) return false;
|
|
1303
|
+
if (memo.has(typeName)) return memo.get(typeName);
|
|
1304
|
+
if (inFlight.has(typeName)) return false;
|
|
1305
|
+
inFlight.add(typeName);
|
|
1306
|
+
const draws = effectiveRoots(byName.get(typeName), isVisual).length > 0;
|
|
1307
|
+
inFlight.delete(typeName);
|
|
1308
|
+
memo.set(typeName, draws);
|
|
1309
|
+
return draws;
|
|
1310
|
+
};
|
|
1311
|
+
return isVisual;
|
|
1312
|
+
}
|
|
1313
|
+
function effectiveRoots(component, isVisual) {
|
|
1314
|
+
if (component.declaredRoots !== void 0) return component.declaredRoots;
|
|
1315
|
+
return rootNodes(component.nodes).filter((n) => isVisual(n.type)).map((n) => n.id);
|
|
1316
|
+
}
|
|
1317
|
+
var BlankCause = {
|
|
1318
|
+
Unreadable: "project-unreadable",
|
|
1319
|
+
NoCatalog: "no-catalog",
|
|
1320
|
+
NoStartPage: "no-start-page",
|
|
1321
|
+
StartPageMissing: "start-page-missing",
|
|
1322
|
+
PageNotRouted: "page-not-routed",
|
|
1323
|
+
PageWithoutPageNode: "page-without-page-node",
|
|
1324
|
+
PageWithoutVisualRoot: "page-without-visual-root",
|
|
1325
|
+
PageHasNoContent: "page-has-no-content",
|
|
1326
|
+
InstancesWithoutVisualRoot: "instances-without-visual-root",
|
|
1327
|
+
Undetermined: "undetermined"
|
|
1328
|
+
};
|
|
1329
|
+
function blankDiagnosis(projectDir) {
|
|
1330
|
+
const { ok, project, components } = readComponents(projectDir);
|
|
1331
|
+
if (!ok || !components.length) {
|
|
1332
|
+
return {
|
|
1333
|
+
ok: false,
|
|
1334
|
+
cause: BlankCause.Unreadable,
|
|
1335
|
+
message: "The project could not be read from disk, so there is nothing to diagnose against.",
|
|
1336
|
+
checked: []
|
|
1337
|
+
};
|
|
1338
|
+
}
|
|
1339
|
+
if (!visualTypeNames() || !contentBearingTypeNames()) {
|
|
1340
|
+
return {
|
|
1341
|
+
ok: false,
|
|
1342
|
+
cause: BlankCause.NoCatalog,
|
|
1343
|
+
message: "No generated node catalog in this checkout, so which node types draw is not knowable here and the cause was not determined.",
|
|
1344
|
+
checked: []
|
|
1345
|
+
};
|
|
1346
|
+
}
|
|
1347
|
+
const isVisual = projectVisualPredicate(components);
|
|
1348
|
+
const contentTypes = contentBearingTypeNames();
|
|
1349
|
+
const byName = new Map(components.map((c) => [c.name, c]));
|
|
1350
|
+
const checked = [];
|
|
1351
|
+
const done = (cause, message, evidence) => ({ ok: true, cause, message, evidence, checked });
|
|
1352
|
+
const routers = [];
|
|
1353
|
+
const pageComponents = /* @__PURE__ */ new Set();
|
|
1354
|
+
for (const c of components) {
|
|
1355
|
+
for (const n of c.nodes) {
|
|
1356
|
+
if (n.type === "Router") routers.push({ component: c.name, ...n.parameters || {} });
|
|
1357
|
+
if (n.type === "Page") pageComponents.add(c.name);
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
const routed = /* @__PURE__ */ new Set();
|
|
1361
|
+
let startPage;
|
|
1362
|
+
for (const r of routers) {
|
|
1363
|
+
const pages = r.pages || {};
|
|
1364
|
+
for (const route of pages.routes || []) routed.add(route);
|
|
1365
|
+
if (!startPage && pages.startPage) startPage = pages.startPage;
|
|
1366
|
+
}
|
|
1367
|
+
if (!routers.length || !startPage) {
|
|
1368
|
+
return done(
|
|
1369
|
+
BlankCause.NoStartPage,
|
|
1370
|
+
routers.length ? `${plural(routers.length, "Router", "Routers")} in this project, and none names a startPage \u2014 nothing selects a page to show.` : "No Router anywhere in the project, so no page is ever selected to be shown.",
|
|
1371
|
+
{ routers: routers.map((r) => r.component) }
|
|
1372
|
+
);
|
|
1373
|
+
}
|
|
1374
|
+
checked.push(`a Router names "${startPage}" as its startPage`);
|
|
1375
|
+
const page = byName.get(startPage);
|
|
1376
|
+
if (!page) {
|
|
1377
|
+
return done(
|
|
1378
|
+
BlankCause.StartPageMissing,
|
|
1379
|
+
`The Router's startPage is "${startPage}" and no component of that name exists in the project.`,
|
|
1380
|
+
{ component: startPage, known: [...byName.keys()] }
|
|
1381
|
+
);
|
|
1382
|
+
}
|
|
1383
|
+
if (!routed.has(startPage)) {
|
|
1384
|
+
return done(
|
|
1385
|
+
BlankCause.PageNotRouted,
|
|
1386
|
+
`"${startPage}" is the startPage but no Router lists it in its routes, so the route is never reached.`,
|
|
1387
|
+
{ component: startPage, routes: [...routed] }
|
|
1388
|
+
);
|
|
1389
|
+
}
|
|
1390
|
+
checked.push("a Router lists it in its routes");
|
|
1391
|
+
if (!pageComponents.has(startPage)) {
|
|
1392
|
+
return done(
|
|
1393
|
+
BlankCause.PageWithoutPageNode,
|
|
1394
|
+
`"${startPage}" contains no Page node. A page component renders blank without one at its root.`,
|
|
1395
|
+
{ component: startPage }
|
|
1396
|
+
);
|
|
1397
|
+
}
|
|
1398
|
+
checked.push("it has a Page node");
|
|
1399
|
+
const pageRoots = effectiveRoots(page, isVisual);
|
|
1400
|
+
if (!pageRoots.length) {
|
|
1401
|
+
return done(
|
|
1402
|
+
BlankCause.PageWithoutVisualRoot,
|
|
1403
|
+
`"${startPage}" has no visual root \u2014 nothing in it is a node that draws, so the component renders nothing.`,
|
|
1404
|
+
{ component: startPage }
|
|
1405
|
+
);
|
|
1406
|
+
}
|
|
1407
|
+
checked.push(`its visual root set is ${JSON.stringify(pageRoots)}`);
|
|
1408
|
+
const drawn = subtreeNodes(page.nodes, pageRoots);
|
|
1409
|
+
const carriesContent = (node) => contentTypes.has(node.type) || byName.has(node.type) && componentDrawsContent(node.type);
|
|
1410
|
+
const seenComponents = /* @__PURE__ */ new Set();
|
|
1411
|
+
function componentDrawsContent(name) {
|
|
1412
|
+
if (seenComponents.has(name)) return false;
|
|
1413
|
+
seenComponents.add(name);
|
|
1414
|
+
const c = byName.get(name);
|
|
1415
|
+
if (!c) return false;
|
|
1416
|
+
const nodes = subtreeNodes(c.nodes, effectiveRoots(c, isVisual));
|
|
1417
|
+
const answer = nodes.some((n) => contentTypes.has(n.type) || byName.has(n.type) && componentDrawsContent(n.type));
|
|
1418
|
+
seenComponents.delete(name);
|
|
1419
|
+
return answer;
|
|
1420
|
+
}
|
|
1421
|
+
if (!drawn.some(carriesContent)) {
|
|
1422
|
+
const only = drawn.map((n) => n.type);
|
|
1423
|
+
return done(
|
|
1424
|
+
BlankCause.PageHasNoContent,
|
|
1425
|
+
`Nothing under the visual root of "${startPage}" shows content of its own: it draws ${plural(drawn.length, "node", "nodes")} (${[...new Set(only)].join(", ")}); nothing there is text, an image, or an instance that resolves to either. The page is an empty container.`,
|
|
1426
|
+
{ component: startPage, nodes: drawn.length, types: [...new Set(only)] }
|
|
1427
|
+
);
|
|
1428
|
+
}
|
|
1429
|
+
checked.push("it has content-bearing nodes under that root");
|
|
1430
|
+
const instanceTypes = [...new Set(drawn.filter((n) => byName.has(n.type)).map((n) => n.type))];
|
|
1431
|
+
const empty = instanceTypes.filter((t) => effectiveRoots(byName.get(t), isVisual).length === 0);
|
|
1432
|
+
if (empty.length) {
|
|
1433
|
+
return done(
|
|
1434
|
+
BlankCause.InstancesWithoutVisualRoot,
|
|
1435
|
+
`${plural(empty.length, "component has", "components have")} no visual root, so every instance of ${empty.length === 1 ? "it" : "them"} on "${startPage}" renders nothing: ${empty.join(", ")}.`,
|
|
1436
|
+
{ component: startPage, components: empty }
|
|
1437
|
+
);
|
|
1438
|
+
}
|
|
1439
|
+
return done(
|
|
1440
|
+
BlankCause.Undetermined,
|
|
1441
|
+
`Checked, and each held: ${checked.join("; ")}. The graph says this page should draw, so the cause is downstream of it \u2014 a runtime error, or content that reached the DOM and is not visible.`,
|
|
1442
|
+
{ component: startPage }
|
|
1443
|
+
);
|
|
1444
|
+
}
|
|
1445
|
+
var CHROME_CANDIDATES = {
|
|
1446
|
+
darwin: [
|
|
1447
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
1448
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
1449
|
+
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
|
|
1450
|
+
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"
|
|
1451
|
+
],
|
|
1452
|
+
linux: [
|
|
1453
|
+
"/usr/bin/google-chrome",
|
|
1454
|
+
"/usr/bin/google-chrome-stable",
|
|
1455
|
+
"/usr/bin/chromium",
|
|
1456
|
+
"/usr/bin/chromium-browser",
|
|
1457
|
+
"/snap/bin/chromium"
|
|
1458
|
+
],
|
|
1459
|
+
win32: [
|
|
1460
|
+
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
|
|
1461
|
+
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe"
|
|
1462
|
+
]
|
|
1463
|
+
};
|
|
1464
|
+
function reachableComponents(startName, byName) {
|
|
1465
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1466
|
+
const queue = [startName];
|
|
1467
|
+
while (queue.length) {
|
|
1468
|
+
const name = queue.shift();
|
|
1469
|
+
if (seen.has(name)) continue;
|
|
1470
|
+
seen.add(name);
|
|
1471
|
+
const component = byName.get(name);
|
|
1472
|
+
if (!component) continue;
|
|
1473
|
+
for (const node of component.nodes) {
|
|
1474
|
+
if (byName.has(node.type)) queue.push(node.type);
|
|
1475
|
+
const template = (node.parameters || {}).template;
|
|
1476
|
+
if (typeof template === "string" && byName.has(template)) queue.push(template);
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
return seen;
|
|
1480
|
+
}
|
|
1481
|
+
function routedPages2(projectDir) {
|
|
1482
|
+
const { ok, project, components } = readComponents(projectDir);
|
|
1483
|
+
if (!ok) return { ok: false, pages: [], startPage: void 0, pathType: "hash" };
|
|
1484
|
+
const pathType = project.settings && project.settings.navigationPathType || "hash";
|
|
1485
|
+
const byName = new Map(components.map((c) => [c.name, c]));
|
|
1486
|
+
const routes = [];
|
|
1487
|
+
let startPage;
|
|
1488
|
+
for (const c of components) {
|
|
1489
|
+
for (const n of c.nodes) {
|
|
1490
|
+
if (n.type !== "Router") continue;
|
|
1491
|
+
const pages2 = (n.parameters || {}).pages || {};
|
|
1492
|
+
for (const route of pages2.routes || []) if (!routes.includes(route)) routes.push(route);
|
|
1493
|
+
if (!startPage && pages2.startPage) startPage = pages2.startPage;
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
const pages = routes.map((component) => {
|
|
1497
|
+
const c = byName.get(component);
|
|
1498
|
+
const pageNode = c && c.nodes.find((n) => n.type === "Page");
|
|
1499
|
+
const params = pageNode && pageNode.parameters || {};
|
|
1500
|
+
const urlPath = typeof params.urlPath === "string" ? params.urlPath : void 0;
|
|
1501
|
+
const isStart = component === startPage;
|
|
1502
|
+
let unreachable;
|
|
1503
|
+
if (!c) unreachable = "no component of that name exists in the project";
|
|
1504
|
+
else if (!pageNode) unreachable = "the component has no Page node, so it has no URL of its own";
|
|
1505
|
+
else if (!isStart && !urlPath) unreachable = "its Page node sets no urlPath, so there is no URL to navigate to";
|
|
1506
|
+
else if (!isStart && /[{}]/.test(urlPath))
|
|
1507
|
+
unreachable = `its urlPath "${urlPath}" takes a route parameter, which this harness has no value for`;
|
|
1508
|
+
return {
|
|
1509
|
+
component,
|
|
1510
|
+
title: typeof params.title === "string" ? params.title : void 0,
|
|
1511
|
+
urlPath,
|
|
1512
|
+
isStart,
|
|
1513
|
+
reachable: !unreachable,
|
|
1514
|
+
...unreachable ? { unreachable } : {},
|
|
1515
|
+
url: isStart ? "/" : unreachable ? void 0 : pathType === "hash" ? `/#${urlPath}` : `/${urlPath}`
|
|
1516
|
+
};
|
|
1517
|
+
});
|
|
1518
|
+
return { ok: true, pages, startPage, pathType };
|
|
1519
|
+
}
|
|
1520
|
+
function resolvePageRequest2(routes, requested) {
|
|
1521
|
+
const raw = String(requested).trim();
|
|
1522
|
+
const normalised = raw.replace(/^\//, "").replace(/^#/, "").replace(/^\//, "");
|
|
1523
|
+
const registered = routes.pages.map((p) => `${p.isStart ? "/" : p.urlPath || "(no urlPath)"} (${p.component})`).join(", ");
|
|
1524
|
+
if (!routes.pages.length) {
|
|
1525
|
+
return {
|
|
1526
|
+
ok: false,
|
|
1527
|
+
message: `Cannot render the page "${raw}": this project registers no pages at all. A page is reachable only when a Router node lists it in its \`pages\` parameter.`
|
|
1528
|
+
};
|
|
1529
|
+
}
|
|
1530
|
+
if (normalised === "") {
|
|
1531
|
+
const start = routes.pages.find((p) => p.isStart);
|
|
1532
|
+
if (start) return { ok: true, page: start };
|
|
1533
|
+
}
|
|
1534
|
+
let match = routes.pages.find((p) => p.urlPath === normalised) || routes.pages.find((p) => p.component === raw || p.component.replace(/^\//, "") === normalised);
|
|
1535
|
+
if (!match) {
|
|
1536
|
+
const titled = routes.pages.filter((p) => p.title !== void 0 && p.title === raw);
|
|
1537
|
+
if (titled.length > 1) {
|
|
1538
|
+
return {
|
|
1539
|
+
ok: false,
|
|
1540
|
+
message: `Cannot render the page "${raw}": ${titled.length} pages have that title (${titled.map((p) => p.component).join(", ")}). Ask for one by its path.`
|
|
1541
|
+
};
|
|
1542
|
+
}
|
|
1543
|
+
match = titled[0];
|
|
1544
|
+
}
|
|
1545
|
+
if (!match) {
|
|
1546
|
+
return {
|
|
1547
|
+
ok: false,
|
|
1548
|
+
message: `Cannot render the page "${raw}": no page registered at that path. Registered pages: ${registered}.`
|
|
1549
|
+
};
|
|
1550
|
+
}
|
|
1551
|
+
if (!match.reachable) {
|
|
1552
|
+
return {
|
|
1553
|
+
ok: false,
|
|
1554
|
+
message: `The page "${raw}" is registered as ${match.component}, but this harness cannot address it: ${match.unreachable}.`
|
|
1555
|
+
};
|
|
1556
|
+
}
|
|
1557
|
+
return { ok: true, page: match };
|
|
1558
|
+
}
|
|
1559
|
+
function findChrome() {
|
|
1560
|
+
const probed = [];
|
|
1561
|
+
const check = (p) => {
|
|
1562
|
+
probed.push(p);
|
|
1563
|
+
return fs2.existsSync(p) ? p : null;
|
|
1564
|
+
};
|
|
1565
|
+
if (process.env.CHROME_PATH) {
|
|
1566
|
+
const found = check(process.env.CHROME_PATH);
|
|
1567
|
+
if (found) return { chrome: found, probed };
|
|
1568
|
+
}
|
|
1569
|
+
for (const candidate of CHROME_CANDIDATES[process.platform] || []) {
|
|
1570
|
+
const found = check(candidate);
|
|
1571
|
+
if (found) return { chrome: found, probed };
|
|
1572
|
+
}
|
|
1573
|
+
return { chrome: null, probed };
|
|
1574
|
+
}
|
|
1575
|
+
function checkPrerequisites(projectDir) {
|
|
1576
|
+
const problems = [];
|
|
1577
|
+
if (!projectDir || !fs2.existsSync(path2.join(projectDir, "nodegx.project.json"))) {
|
|
1578
|
+
problems.push(
|
|
1579
|
+
`Not a NodeGX v2 project directory (no nodegx.project.json): ${projectDir || "(none given)"}.`
|
|
1580
|
+
);
|
|
1581
|
+
}
|
|
1582
|
+
if (!VIEWER_BUNDLE || !fs2.existsSync(VIEWER_BUNDLE)) {
|
|
1583
|
+
problems.push(
|
|
1584
|
+
`The viewer bundle is missing. Build it first: cd packages/noodl-viewer-react && npx webpack --config webpack-configs/webpack.viewer.prod.js. Probed: ${HARNESS_PATHS.VIEWER_DIR.probed.join(", ")}`
|
|
1585
|
+
);
|
|
1586
|
+
}
|
|
1587
|
+
if (!WS_MODULE) {
|
|
1588
|
+
problems.push(
|
|
1589
|
+
`The "ws" package is missing. Run npm install at the repo root. Probed: ${HARNESS_PATHS.WS_MODULE.probed.join(", ")}`
|
|
1590
|
+
);
|
|
1591
|
+
}
|
|
1592
|
+
if (!CATALOG_JSON) {
|
|
1593
|
+
problems.push(
|
|
1594
|
+
`The node catalog is missing. Probed: ${HARNESS_PATHS.CATALOG_JSON.probed.join(", ")}`
|
|
1595
|
+
);
|
|
1596
|
+
}
|
|
1597
|
+
if (!ENRICHED_CATALOG_JSON) {
|
|
1598
|
+
problems.push(
|
|
1599
|
+
`The enriched node catalog is missing. Probed: ${HARNESS_PATHS.ENRICHED_CATALOG_JSON.probed.join(", ")}`
|
|
1600
|
+
);
|
|
1601
|
+
}
|
|
1602
|
+
const { chrome, probed } = findChrome();
|
|
1603
|
+
if (!chrome) {
|
|
1604
|
+
problems.push(
|
|
1605
|
+
"No Chrome or Chromium binary found. Install Google Chrome, or set CHROME_PATH to one. If you cannot install one, pass allow_unrendered to skip the render check rather than waiting on it. Probed: " + probed.join(", ")
|
|
1606
|
+
);
|
|
1607
|
+
}
|
|
1608
|
+
return { ok: problems.length === 0, problems, chrome };
|
|
1609
|
+
}
|
|
1610
|
+
function parseViewports2(spec) {
|
|
1611
|
+
if (!spec) return DEFAULT_VIEWPORTS;
|
|
1612
|
+
const known = new Map(NAMED_VIEWPORTS.map((v) => [v.name, v]));
|
|
1613
|
+
return spec.split(",").map((token) => {
|
|
1614
|
+
const trimmed = token.trim();
|
|
1615
|
+
if (known.has(trimmed)) return known.get(trimmed);
|
|
1616
|
+
const m = /^(\d+)x(\d+)$/.exec(trimmed);
|
|
1617
|
+
if (!m) {
|
|
1618
|
+
const error = new Error(`Unknown viewport "${trimmed}". Use ${[...known.keys()].join(", ")} or WIDTHxHEIGHT.`);
|
|
1619
|
+
error.problems = [error.message];
|
|
1620
|
+
error.actionable = true;
|
|
1621
|
+
error.usage = true;
|
|
1622
|
+
throw error;
|
|
1623
|
+
}
|
|
1624
|
+
const width = Number(m[1]);
|
|
1625
|
+
return { name: trimmed, width, height: Number(m[2]), mobile: width < 500 };
|
|
1626
|
+
});
|
|
1627
|
+
}
|
|
1628
|
+
function freePort() {
|
|
1629
|
+
return new Promise((resolve, reject) => {
|
|
1630
|
+
const server = net.createServer();
|
|
1631
|
+
server.once("error", reject);
|
|
1632
|
+
server.listen(0, "127.0.0.1", () => {
|
|
1633
|
+
const { port } = server.address();
|
|
1634
|
+
server.close(() => resolve(port));
|
|
1635
|
+
});
|
|
1636
|
+
});
|
|
1637
|
+
}
|
|
1638
|
+
var wait2 = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
1639
|
+
function httpJson(port, urlPath) {
|
|
1640
|
+
return new Promise((resolve, reject) => {
|
|
1641
|
+
const req = http.get({ host: "127.0.0.1", port, path: urlPath, timeout: 3e3 }, (res) => {
|
|
1642
|
+
let body = "";
|
|
1643
|
+
res.on("data", (d) => body += d);
|
|
1644
|
+
res.on("end", () => {
|
|
1645
|
+
try {
|
|
1646
|
+
resolve(JSON.parse(body));
|
|
1647
|
+
} catch (e) {
|
|
1648
|
+
reject(e);
|
|
1649
|
+
}
|
|
1650
|
+
});
|
|
1651
|
+
});
|
|
1652
|
+
req.on("timeout", () => {
|
|
1653
|
+
req.destroy();
|
|
1654
|
+
reject(new Error("timeout"));
|
|
1655
|
+
});
|
|
1656
|
+
req.on("error", reject);
|
|
1657
|
+
});
|
|
1658
|
+
}
|
|
1659
|
+
function settleSummary(settles) {
|
|
1660
|
+
if (!settles || !settles.length) return void 0;
|
|
1661
|
+
const waited = settles.reduce((n, s) => n + s.waitedMs, 0);
|
|
1662
|
+
const ceiling = settles.reduce((n, s) => n + s.ceilingMs, 0);
|
|
1663
|
+
return {
|
|
1664
|
+
count: settles.length,
|
|
1665
|
+
waitedMs: waited,
|
|
1666
|
+
/** What the fixed timers would have slept for the same run. */
|
|
1667
|
+
fixedTimerMs: ceiling,
|
|
1668
|
+
savedMs: ceiling - waited,
|
|
1669
|
+
atCeiling: settles.filter((s) => !s.quiet).length,
|
|
1670
|
+
rows: settles
|
|
1671
|
+
};
|
|
1672
|
+
}
|
|
1673
|
+
function connect(wsUrl, onEvent) {
|
|
1674
|
+
const WebSocket = require(WS_MODULE);
|
|
1675
|
+
return new Promise((resolve, reject) => {
|
|
1676
|
+
const ws = new WebSocket(wsUrl, { maxPayload: 512 * 1024 * 1024 });
|
|
1677
|
+
let nextId = 0;
|
|
1678
|
+
const pending = /* @__PURE__ */ new Map();
|
|
1679
|
+
ws.on(
|
|
1680
|
+
"open",
|
|
1681
|
+
() => resolve({
|
|
1682
|
+
send(method, params) {
|
|
1683
|
+
const id = ++nextId;
|
|
1684
|
+
ws.send(JSON.stringify({ id, method, params }));
|
|
1685
|
+
return new Promise((res, rej) => pending.set(id, { res, rej }));
|
|
1686
|
+
},
|
|
1687
|
+
close() {
|
|
1688
|
+
ws.close();
|
|
1689
|
+
}
|
|
1690
|
+
})
|
|
1691
|
+
);
|
|
1692
|
+
ws.on("error", reject);
|
|
1693
|
+
ws.on("message", (data) => {
|
|
1694
|
+
const msg = JSON.parse(data);
|
|
1695
|
+
if (msg.id && pending.has(msg.id)) {
|
|
1696
|
+
const { res, rej } = pending.get(msg.id);
|
|
1697
|
+
pending.delete(msg.id);
|
|
1698
|
+
msg.error ? rej(new Error(msg.error.message)) : res(msg.result);
|
|
1699
|
+
} else if (msg.method && onEvent) {
|
|
1700
|
+
onEvent(msg);
|
|
1701
|
+
}
|
|
1702
|
+
});
|
|
1703
|
+
});
|
|
1704
|
+
}
|
|
1705
|
+
async function evaluate(client, expression) {
|
|
1706
|
+
const r = await client.send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true });
|
|
1707
|
+
if (r.exceptionDetails) {
|
|
1708
|
+
throw new Error((r.exceptionDetails.exception || {}).description || r.exceptionDetails.text);
|
|
1709
|
+
}
|
|
1710
|
+
return r.result.value;
|
|
1711
|
+
}
|
|
1712
|
+
var BOOT_MS = 3500;
|
|
1713
|
+
var REFLOW_MS = 1200;
|
|
1714
|
+
var PAGE_NAV_MS = 2e3;
|
|
1715
|
+
var PAGE_TABS = 4;
|
|
1716
|
+
var QUIET_MS = 150;
|
|
1717
|
+
var SETTLE_POLL_MS = 25;
|
|
1718
|
+
function settleExpression(ceilingMs, rafs) {
|
|
1719
|
+
return `(async () => {
|
|
1720
|
+
const started = Date.now();
|
|
1721
|
+
const deadline = started + ${ceilingMs};
|
|
1722
|
+
const left = () => Math.max(0, deadline - Date.now());
|
|
1723
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
1724
|
+
const reasons = [];
|
|
1725
|
+
|
|
1726
|
+
// 1. The document. In hash routing a route change is not a document load at
|
|
1727
|
+
// all, so this is already 'complete' and costs nothing \u2014 which is most of
|
|
1728
|
+
// where PAGE_NAV_MS was going.
|
|
1729
|
+
if (document.readyState !== 'complete') {
|
|
1730
|
+
reasons.push('load');
|
|
1731
|
+
await Promise.race([
|
|
1732
|
+
new Promise((res) => window.addEventListener('load', res, { once: true })),
|
|
1733
|
+
sleep(left())
|
|
1734
|
+
]);
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
// 2. Fonts. Measured text is compared against the font sets, and a page read
|
|
1738
|
+
// mid-swap reports the fallback family as if the author had chosen it.
|
|
1739
|
+
try {
|
|
1740
|
+
if (document.fonts && document.fonts.status !== 'loaded') {
|
|
1741
|
+
reasons.push('fonts');
|
|
1742
|
+
await Promise.race([document.fonts.ready, sleep(left())]);
|
|
1743
|
+
}
|
|
1744
|
+
} catch (e) {
|
|
1745
|
+
/* no Font Loading API: fall through to the quiet window */
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
// 3. Images still in flight. \`broken-image\` is counted from naturalWidth, and
|
|
1749
|
+
// an image that has not loaded YET is indistinguishable from one that never
|
|
1750
|
+
// will \u2014 this is the finding a too-eager budget invents.
|
|
1751
|
+
const pending = () => Array.prototype.filter.call(document.images, (im) => !im.complete).length;
|
|
1752
|
+
if (pending()) reasons.push('images');
|
|
1753
|
+
while (left() > 0 && pending() > 0) await sleep(${SETTLE_POLL_MS});
|
|
1754
|
+
|
|
1755
|
+
// 4. Two frames \u2014 which is also what lets step 5 see anything. A transition
|
|
1756
|
+
// does not exist until the style recalc that starts it, so asking for the
|
|
1757
|
+
// running animations before a frame has passed returns an empty list and
|
|
1758
|
+
// the wait below is skipped on exactly the pages that need it.
|
|
1759
|
+
for (let i = 0; i < ${rafs} && left() > 0; i++) {
|
|
1760
|
+
// Raced against a sleep: a headless page that is never composited never
|
|
1761
|
+
// fires rAF, and an unraced await would burn the whole budget waiting for a
|
|
1762
|
+
// frame that is not coming \u2014 turning every settle back into the fixed timer.
|
|
1763
|
+
await Promise.race([new Promise((res) => requestAnimationFrame(() => res())), sleep(50)]);
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
// 5. \u{1F534} CSS transitions and animations, which are the reason this step exists.
|
|
1767
|
+
//
|
|
1768
|
+
// MEASURED, not anticipated: \`project-examples/lessons/snacks\` at 390px has
|
|
1769
|
+
// ONE element still at opacity 0 at 190ms and opaque at 1200ms. FLD-012's
|
|
1770
|
+
// \`visible\` correctly excludes a transparent element, so the eager read
|
|
1771
|
+
// counted 9 text elements where the fixed timer counted 10 \u2014 and
|
|
1772
|
+
// \`flat-type-scale\` gates at **>= 10**. A settle budget with this step
|
|
1773
|
+
// missing does not report a page differently; it DELETES a real finding and
|
|
1774
|
+
// reports the page clean.
|
|
1775
|
+
//
|
|
1776
|
+
// \u26A0\uFE0F Only animations that will actually END are waited for. A spinner
|
|
1777
|
+
// iterates forever, and \`await\`ing its \`finished\` would spend the entire
|
|
1778
|
+
// budget on every page that has one \u2014 which is the fixed timer again, on the
|
|
1779
|
+
// pages least able to afford it.
|
|
1780
|
+
try {
|
|
1781
|
+
const finite = (document.getAnimations ? document.getAnimations() : []).filter((anim) => {
|
|
1782
|
+
try {
|
|
1783
|
+
const timing = anim.effect.getComputedTiming();
|
|
1784
|
+
return Number.isFinite(timing.iterations) && Number.isFinite(timing.endTime);
|
|
1785
|
+
} catch (e) {
|
|
1786
|
+
return false;
|
|
1787
|
+
}
|
|
1788
|
+
});
|
|
1789
|
+
if (finite.length) {
|
|
1790
|
+
reasons.push('animations:' + finite.length);
|
|
1791
|
+
await Promise.race([Promise.allSettled(finite.map((anim) => anim.finished)), sleep(left())]);
|
|
1792
|
+
}
|
|
1793
|
+
} catch (e) {
|
|
1794
|
+
/* no Web Animations API: the quiet window is the only guard left */
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
// 6. And finally the quiet window.
|
|
1798
|
+
let lastChange = Date.now();
|
|
1799
|
+
const observer = new MutationObserver(() => {
|
|
1800
|
+
lastChange = Date.now();
|
|
1801
|
+
});
|
|
1802
|
+
observer.observe(document.documentElement, {
|
|
1803
|
+
subtree: true,
|
|
1804
|
+
childList: true,
|
|
1805
|
+
attributes: true,
|
|
1806
|
+
characterData: true
|
|
1807
|
+
});
|
|
1808
|
+
try {
|
|
1809
|
+
while (left() > 0 && Date.now() - lastChange < ${QUIET_MS}) await sleep(${SETTLE_POLL_MS});
|
|
1810
|
+
} finally {
|
|
1811
|
+
observer.disconnect();
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
return {
|
|
1815
|
+
waitedMs: Date.now() - started,
|
|
1816
|
+
ceilingMs: ${ceilingMs},
|
|
1817
|
+
// \u{1F534} The honest field. \`false\` means the budget ran out and this is the OLD
|
|
1818
|
+
// fixed-timer behaviour \u2014 the same wait, and the same reading. A run where
|
|
1819
|
+
// this is false everywhere has not been sped up; it has been re-measured.
|
|
1820
|
+
quiet: Date.now() - lastChange >= ${QUIET_MS},
|
|
1821
|
+
waitedFor: reasons
|
|
1822
|
+
};
|
|
1823
|
+
})()`;
|
|
1824
|
+
}
|
|
1825
|
+
var RENDER_PROFILE_PREFIX = "nodegx-render-";
|
|
1826
|
+
function reapOrphanedRenderProcesses() {
|
|
1827
|
+
const reaped = [];
|
|
1828
|
+
try {
|
|
1829
|
+
const ps = require("child_process").execFileSync("ps", ["-Ao", "pid=,ppid=,command="], {
|
|
1830
|
+
encoding: "utf8",
|
|
1831
|
+
maxBuffer: 8 * 1024 * 1024
|
|
1832
|
+
});
|
|
1833
|
+
for (const line of ps.split("\n")) {
|
|
1834
|
+
const m = line.match(/^\s*(\d+)\s+(\d+)\s+(.*)$/);
|
|
1835
|
+
if (!m) continue;
|
|
1836
|
+
const [, pid, ppid, command] = m;
|
|
1837
|
+
if (ppid !== "1") continue;
|
|
1838
|
+
const isOurServer = command.includes(RENDER_SCRIPT);
|
|
1839
|
+
const isOurChrome = command.includes(`--user-data-dir=`) && command.includes(RENDER_PROFILE_PREFIX);
|
|
1840
|
+
if (!isOurServer && !isOurChrome) continue;
|
|
1841
|
+
try {
|
|
1842
|
+
process.kill(Number(pid), "SIGKILL");
|
|
1843
|
+
reaped.push(`${pid} ${isOurServer ? "render-from-disk" : "chrome"}`);
|
|
1844
|
+
} catch {
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
} catch {
|
|
1848
|
+
return [];
|
|
1849
|
+
}
|
|
1850
|
+
try {
|
|
1851
|
+
for (const name of fs2.readdirSync(os.tmpdir())) {
|
|
1852
|
+
if (!name.startsWith(RENDER_PROFILE_PREFIX)) continue;
|
|
1853
|
+
const dir = path2.join(os.tmpdir(), name);
|
|
1854
|
+
try {
|
|
1855
|
+
if (Date.now() - fs2.statSync(dir).mtimeMs < 60 * 60 * 1e3) continue;
|
|
1856
|
+
fs2.rmSync(dir, { recursive: true, force: true });
|
|
1857
|
+
} catch {
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
} catch {
|
|
1861
|
+
}
|
|
1862
|
+
if (reaped.length) console.error(`[render] reaped ${reaped.length} orphaned process(es): ${reaped.join(", ")}`);
|
|
1863
|
+
return reaped;
|
|
1864
|
+
}
|
|
1865
|
+
async function unthrottle(client) {
|
|
1866
|
+
for (const [method, params] of [
|
|
1867
|
+
["Emulation.setFocusEmulationEnabled", { enabled: true }],
|
|
1868
|
+
["Page.setWebLifecycleState", { state: "active" }]
|
|
1869
|
+
]) {
|
|
1870
|
+
try {
|
|
1871
|
+
await client.send(method, params);
|
|
1872
|
+
} catch {
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
async function attachTab({ wsUrl, servePort, settles, label }) {
|
|
1877
|
+
const consoleErrors = [];
|
|
1878
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
1879
|
+
const client = await connect(wsUrl, (msg) => {
|
|
1880
|
+
for (const listener of listeners) listener(msg);
|
|
1881
|
+
if (msg.method === "Runtime.exceptionThrown") {
|
|
1882
|
+
const d = msg.params.exceptionDetails || {};
|
|
1883
|
+
consoleErrors.push(String(d.exception && d.exception.description || d.text || "").slice(0, 300));
|
|
1884
|
+
} else if (msg.method === "Runtime.consoleAPICalled" && msg.params.type === "error") {
|
|
1885
|
+
consoleErrors.push(
|
|
1886
|
+
msg.params.args.map((a) => String(a.value !== void 0 ? a.value : a.description || "")).join(" ").slice(0, 300)
|
|
1887
|
+
);
|
|
1888
|
+
}
|
|
1889
|
+
});
|
|
1890
|
+
await client.send("Page.enable", {});
|
|
1891
|
+
await client.send("Runtime.enable", {});
|
|
1892
|
+
const settle = async (what, ceilingMs, rafs) => {
|
|
1893
|
+
const stat = await evaluate(client, settleExpression(ceilingMs, rafs));
|
|
1894
|
+
settles.push({ what, ...label ? { tab: label } : {}, ...stat });
|
|
1895
|
+
return stat;
|
|
1896
|
+
};
|
|
1897
|
+
const goto = async (urlPath, ceilingMs) => {
|
|
1898
|
+
await client.send("Page.navigate", { url: `http://127.0.0.1:${servePort}${urlPath}` });
|
|
1899
|
+
await settle(`navigate ${urlPath}`, ceilingMs, 2);
|
|
1900
|
+
};
|
|
1901
|
+
return {
|
|
1902
|
+
client,
|
|
1903
|
+
consoleErrors,
|
|
1904
|
+
settle,
|
|
1905
|
+
goto,
|
|
1906
|
+
evaluate: (expression) => evaluate(client, expression),
|
|
1907
|
+
/**
|
|
1908
|
+
* Go to another route and let it settle.
|
|
1909
|
+
*
|
|
1910
|
+
* UNI-010 §8.2. `PAGE_NAV_MS` rather than `BOOT_MS`: the runtime is already
|
|
1911
|
+
* booted, and in the default `hash` mode this is a `hashchange` the router
|
|
1912
|
+
* listens for rather than a document load. The viewport emulation set by
|
|
1913
|
+
* `setViewport` survives it — `Emulation.setDeviceMetricsOverride` is
|
|
1914
|
+
* per-session, not per-document — so a caller that has already sized the
|
|
1915
|
+
* page does not have to size it again.
|
|
1916
|
+
*/
|
|
1917
|
+
navigate: (urlPath) => goto(urlPath, PAGE_NAV_MS),
|
|
1918
|
+
/** Hear every CDP event on this tab. Returns the function that stops it. */
|
|
1919
|
+
onEvent(listener) {
|
|
1920
|
+
listeners.add(listener);
|
|
1921
|
+
return () => listeners.delete(listener);
|
|
1922
|
+
},
|
|
1923
|
+
async setViewport(vp) {
|
|
1924
|
+
await client.send("Emulation.setDeviceMetricsOverride", {
|
|
1925
|
+
width: vp.width,
|
|
1926
|
+
height: vp.height,
|
|
1927
|
+
deviceScaleFactor: 1,
|
|
1928
|
+
mobile: Boolean(vp.mobile)
|
|
1929
|
+
});
|
|
1930
|
+
await settle(`viewport ${vp.name}`, REFLOW_MS, 2);
|
|
1931
|
+
},
|
|
1932
|
+
close() {
|
|
1933
|
+
try {
|
|
1934
|
+
client.close();
|
|
1935
|
+
} catch {
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
};
|
|
1939
|
+
}
|
|
1940
|
+
async function withRenderedPage2(options, fn) {
|
|
1941
|
+
const { projectDir, backendPort, editorTokens = false, chromeArgs = [], beforeBoot } = options;
|
|
1942
|
+
reapOrphanedRenderProcesses();
|
|
1943
|
+
const pre = checkPrerequisites(projectDir);
|
|
1944
|
+
if (!pre.ok) {
|
|
1945
|
+
const error = new Error(pre.problems.join(" "));
|
|
1946
|
+
error.problems = pre.problems;
|
|
1947
|
+
error.actionable = true;
|
|
1948
|
+
throw error;
|
|
1949
|
+
}
|
|
1950
|
+
const servePort = await freePort();
|
|
1951
|
+
const cdpPort = await freePort();
|
|
1952
|
+
const serverArgs = [RENDER_SCRIPT, projectDir, "--port", String(servePort)];
|
|
1953
|
+
if (backendPort) serverArgs.push("--backend-port", String(backendPort));
|
|
1954
|
+
if (editorTokens) serverArgs.push("--editor-tokens");
|
|
1955
|
+
const server = spawn(process.execPath, serverArgs, {
|
|
1956
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1957
|
+
env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" }
|
|
1958
|
+
});
|
|
1959
|
+
let serverLog = "";
|
|
1960
|
+
server.stdout.on("data", (d) => serverLog += d);
|
|
1961
|
+
server.stderr.on("data", (d) => serverLog += d);
|
|
1962
|
+
const profile = fs2.mkdtempSync(path2.join(os.tmpdir(), "nodegx-render-"));
|
|
1963
|
+
const chrome = spawn(
|
|
1964
|
+
pre.chrome,
|
|
1965
|
+
[
|
|
1966
|
+
"--headless=new",
|
|
1967
|
+
`--remote-debugging-port=${cdpPort}`,
|
|
1968
|
+
`--user-data-dir=${profile}`,
|
|
1969
|
+
"--no-first-run",
|
|
1970
|
+
"--no-default-browser-check",
|
|
1971
|
+
"--hide-scrollbars",
|
|
1972
|
+
"--disable-gpu",
|
|
1973
|
+
// P112-S14-KEYCHAIN: on macOS a page that touches storage makes Chrome open the login keychain. With no
|
|
1974
|
+
// keychain where `HOME` points (an empty HOME, a CI user) the OS shows "Keychain Not Found" on the person's
|
|
1975
|
+
// screen and Chrome waits for the answer, so the render hangs until its 240 s kill. A throwaway profile
|
|
1976
|
+
// needs no real keychain.
|
|
1977
|
+
"--use-mock-keychain",
|
|
1978
|
+
// Caller-supplied flags, for drives that need a capability this browser
|
|
1979
|
+
// does not have by default — a synthetic microphone and camera
|
|
1980
|
+
// (`--use-fake-device-for-media-stream`) being the case that added this.
|
|
1981
|
+
// Appended last so a drive can override a default above it.
|
|
1982
|
+
...chromeArgs,
|
|
1983
|
+
"about:blank"
|
|
1984
|
+
],
|
|
1985
|
+
{ stdio: "ignore" }
|
|
1986
|
+
);
|
|
1987
|
+
const extraTabs = [];
|
|
1988
|
+
let browserClient;
|
|
1989
|
+
let cleanedUp = false;
|
|
1990
|
+
const cleanup = (client2) => {
|
|
1991
|
+
if (cleanedUp) return;
|
|
1992
|
+
cleanedUp = true;
|
|
1993
|
+
for (const tab of extraTabs) tab.close();
|
|
1994
|
+
try {
|
|
1995
|
+
if (browserClient) browserClient.close();
|
|
1996
|
+
} catch {
|
|
1997
|
+
}
|
|
1998
|
+
try {
|
|
1999
|
+
if (client2) client2.close();
|
|
2000
|
+
} catch {
|
|
2001
|
+
}
|
|
2002
|
+
chrome.kill();
|
|
2003
|
+
server.kill();
|
|
2004
|
+
fs2.rm(profile, { recursive: true, force: true }, () => {
|
|
2005
|
+
});
|
|
2006
|
+
};
|
|
2007
|
+
const onSignal = (signal) => () => {
|
|
2008
|
+
cleanup(void 0);
|
|
2009
|
+
process.exit(signal === "SIGINT" ? 130 : 143);
|
|
2010
|
+
};
|
|
2011
|
+
const handlers = [
|
|
2012
|
+
["SIGINT", onSignal("SIGINT")],
|
|
2013
|
+
["SIGTERM", onSignal("SIGTERM")],
|
|
2014
|
+
["SIGHUP", onSignal("SIGHUP")],
|
|
2015
|
+
["exit", () => cleanup(void 0)]
|
|
2016
|
+
];
|
|
2017
|
+
for (const [event, handler] of handlers) process.once(event, handler);
|
|
2018
|
+
const releaseHandlers = () => {
|
|
2019
|
+
for (const [event, handler] of handlers) process.removeListener(event, handler);
|
|
2020
|
+
};
|
|
2021
|
+
let client;
|
|
2022
|
+
try {
|
|
2023
|
+
let targets;
|
|
2024
|
+
for (let i = 0; i < 40 && !targets; i++) {
|
|
2025
|
+
await wait2(250);
|
|
2026
|
+
try {
|
|
2027
|
+
targets = await httpJson(cdpPort, "/json/list");
|
|
2028
|
+
} catch {
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
if (!targets) throw new Error(`Chrome never opened a debugging port (${pre.chrome}).`);
|
|
2032
|
+
const target = targets.find((t) => t.type === "page");
|
|
2033
|
+
const settles = [];
|
|
2034
|
+
const main2 = await attachTab({ wsUrl: target.webSocketDebuggerUrl, servePort, settles });
|
|
2035
|
+
client = main2.client;
|
|
2036
|
+
const openTab = async (label) => {
|
|
2037
|
+
if (!browserClient) {
|
|
2038
|
+
const version = await httpJson(cdpPort, "/json/version");
|
|
2039
|
+
browserClient = await connect(version.webSocketDebuggerUrl);
|
|
2040
|
+
await unthrottle(main2.client);
|
|
2041
|
+
}
|
|
2042
|
+
const { targetId } = await browserClient.send("Target.createTarget", { url: "about:blank" });
|
|
2043
|
+
let described;
|
|
2044
|
+
for (let i = 0; i < 40 && !described; i++) {
|
|
2045
|
+
const list = await httpJson(cdpPort, "/json/list");
|
|
2046
|
+
described = list.find((t) => t.id === targetId && t.webSocketDebuggerUrl);
|
|
2047
|
+
if (!described) await wait2(50);
|
|
2048
|
+
}
|
|
2049
|
+
if (!described) throw new Error("a tab was created and never appeared in /json/list with a debugger URL");
|
|
2050
|
+
const tab = await attachTab({ wsUrl: described.webSocketDebuggerUrl, servePort, settles, label });
|
|
2051
|
+
await unthrottle(tab.client);
|
|
2052
|
+
tab.targetId = targetId;
|
|
2053
|
+
extraTabs.push(tab);
|
|
2054
|
+
return tab;
|
|
2055
|
+
};
|
|
2056
|
+
const closeTab = async (tab) => {
|
|
2057
|
+
tab.close();
|
|
2058
|
+
const at = extraTabs.indexOf(tab);
|
|
2059
|
+
if (at !== -1) extraTabs.splice(at, 1);
|
|
2060
|
+
try {
|
|
2061
|
+
if (tab.targetId) await browserClient.send("Target.closeTarget", { targetId: tab.targetId });
|
|
2062
|
+
} catch {
|
|
2063
|
+
}
|
|
2064
|
+
};
|
|
2065
|
+
if (beforeBoot) await beforeBoot(main2);
|
|
2066
|
+
await main2.client.send("Page.navigate", { url: `http://127.0.0.1:${servePort}/` });
|
|
2067
|
+
await main2.settle("boot", BOOT_MS, 2);
|
|
2068
|
+
return await fn({
|
|
2069
|
+
client,
|
|
2070
|
+
consoleErrors: main2.consoleErrors,
|
|
2071
|
+
settles,
|
|
2072
|
+
serverLog: () => serverLog,
|
|
2073
|
+
servePort,
|
|
2074
|
+
evaluate: main2.evaluate,
|
|
2075
|
+
goto: main2.goto,
|
|
2076
|
+
navigate: main2.navigate,
|
|
2077
|
+
settle: main2.settle,
|
|
2078
|
+
setViewport: main2.setViewport,
|
|
2079
|
+
onEvent: main2.onEvent,
|
|
2080
|
+
openTab,
|
|
2081
|
+
closeTab
|
|
2082
|
+
});
|
|
2083
|
+
} finally {
|
|
2084
|
+
cleanup(client);
|
|
2085
|
+
releaseHandlers();
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
async function renderReport(options) {
|
|
2089
|
+
const {
|
|
2090
|
+
projectDir,
|
|
2091
|
+
viewports = DEFAULT_VIEWPORTS,
|
|
2092
|
+
screenshot = "full",
|
|
2093
|
+
screenshotPages = "start",
|
|
2094
|
+
deviceScaleFactor = 0.5,
|
|
2095
|
+
backendPort,
|
|
2096
|
+
editorTokens = false,
|
|
2097
|
+
renderRoutedPages = true,
|
|
2098
|
+
page: requestedPage,
|
|
2099
|
+
concurrency = PAGE_TABS
|
|
2100
|
+
} = options;
|
|
2101
|
+
const started = Date.now();
|
|
2102
|
+
const routes = routedPages2(projectDir);
|
|
2103
|
+
let focus;
|
|
2104
|
+
if (requestedPage !== void 0) {
|
|
2105
|
+
const resolved = resolvePageRequest2(routes, requestedPage);
|
|
2106
|
+
if (!resolved.ok) {
|
|
2107
|
+
const error = new Error(resolved.message);
|
|
2108
|
+
error.problems = [resolved.message];
|
|
2109
|
+
error.actionable = true;
|
|
2110
|
+
throw error;
|
|
2111
|
+
}
|
|
2112
|
+
focus = resolved.page;
|
|
2113
|
+
}
|
|
2114
|
+
return withRenderedPage2({ projectDir, backendPort, editorTokens }, async (page) => {
|
|
2115
|
+
const overridden = Object.fromEntries(overriddenDefaults(projectDir));
|
|
2116
|
+
const placeholders = [.../* @__PURE__ */ new Set([...placeholderStrings(), ...Object.keys(overridden)])];
|
|
2117
|
+
const allProbes = listProbes(projectDir);
|
|
2118
|
+
const { components: projectComponents } = readComponents(projectDir);
|
|
2119
|
+
const byName = new Map(projectComponents.map((c) => [c.name, c]));
|
|
2120
|
+
const reachBy = new Map(routes.pages.map((p) => [p.component, reachableComponents(p.component, byName)]));
|
|
2121
|
+
const unplaced = allProbes.filter((probe) => ![...reachBy.values()].some((set) => set.has(probe.component)));
|
|
2122
|
+
const probesFor = (pageComponent) => {
|
|
2123
|
+
const reach = reachBy.get(pageComponent);
|
|
2124
|
+
const own = reach ? allProbes.filter((probe) => reach.has(probe.component)) : allProbes;
|
|
2125
|
+
return pageComponent === routes.startPage ? [...own, ...unplaced] : own;
|
|
2126
|
+
};
|
|
2127
|
+
const subject = focus || routes.pages.find((pg) => pg.isStart);
|
|
2128
|
+
const subjectComponent = subject ? subject.component : routes.startPage;
|
|
2129
|
+
const focusedOffStart = Boolean(focus && !focus.isStart);
|
|
2130
|
+
if (focusedOffStart) await page.navigate(focus.url);
|
|
2131
|
+
const measured = {};
|
|
2132
|
+
const screenshots = [];
|
|
2133
|
+
const capture = async (vp, raw, component, subject2, client = page.client) => {
|
|
2134
|
+
const shot = await client.send("Page.captureScreenshot", {
|
|
2135
|
+
format: "png",
|
|
2136
|
+
captureBeyondViewport: screenshot === "full",
|
|
2137
|
+
optimizeForSpeed: true,
|
|
2138
|
+
...screenshot === "full" ? {
|
|
2139
|
+
clip: {
|
|
2140
|
+
x: 0,
|
|
2141
|
+
y: 0,
|
|
2142
|
+
width: vp.width,
|
|
2143
|
+
// Chrome refuses beyond 16384px; a page taller than that is
|
|
2144
|
+
// already the finding.
|
|
2145
|
+
height: Math.min(raw.pageHeight, 16384),
|
|
2146
|
+
scale: deviceScaleFactor
|
|
2147
|
+
}
|
|
2148
|
+
} : {}
|
|
2149
|
+
});
|
|
2150
|
+
return { name: vp.name, page: component, subject: subject2, mimeType: "image/png", base64: shot.data };
|
|
2151
|
+
};
|
|
2152
|
+
const expression = measureExpression(placeholders, probesFor(subjectComponent));
|
|
2153
|
+
let loggedThrough = 0;
|
|
2154
|
+
for (const vp of viewports) {
|
|
2155
|
+
const loggedBefore = loggedThrough;
|
|
2156
|
+
await page.setViewport(vp);
|
|
2157
|
+
const raw = await page.evaluate(expression);
|
|
2158
|
+
loggedThrough = page.consoleErrors.length;
|
|
2159
|
+
measured[vp.name] = {
|
|
2160
|
+
requested: { width: vp.width, height: vp.height },
|
|
2161
|
+
...raw,
|
|
2162
|
+
consoleErrors: page.consoleErrors.slice(loggedBefore, loggedThrough)
|
|
2163
|
+
};
|
|
2164
|
+
if (screenshot !== "none") {
|
|
2165
|
+
screenshots.push(await capture(vp, raw, subjectComponent, true));
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
const blank = Object.values(measured).some((v) => v && !v.error && v.text.elements === 0 && v.images.total === 0);
|
|
2169
|
+
const diagnosable = blank && !focusedOffStart;
|
|
2170
|
+
const { findings: rawFindings, summary } = summarise(
|
|
2171
|
+
measured,
|
|
2172
|
+
diagnosable ? blankDiagnosis(projectDir) : void 0,
|
|
2173
|
+
overridden
|
|
2174
|
+
);
|
|
2175
|
+
const findings = !focusedOffStart ? rawFindings : rawFindings.map((f) => ({
|
|
2176
|
+
...f,
|
|
2177
|
+
page: subjectComponent,
|
|
2178
|
+
message: f.code === RenderFinding.BlankRender ? `The routed page "${subjectComponent}" rendered nothing at all \u2014 no text and no images. Only the start page is diagnosed against the graph, so the cause was not determined here.` : `On the routed page "${subjectComponent}": ${f.message}`
|
|
2179
|
+
}));
|
|
2180
|
+
const pageReports = [];
|
|
2181
|
+
const extraFindings = [];
|
|
2182
|
+
let sweep;
|
|
2183
|
+
if (focus) {
|
|
2184
|
+
pageReports.push({ ...focus, measured: true, viewports: measured });
|
|
2185
|
+
} else if (renderRoutedPages) {
|
|
2186
|
+
const rows = new Array(routes.pages.length);
|
|
2187
|
+
const findingsByPage = new Array(routes.pages.length);
|
|
2188
|
+
const shotsByPage = new Array(routes.pages.length);
|
|
2189
|
+
const todo = [];
|
|
2190
|
+
routes.pages.forEach((p, i) => {
|
|
2191
|
+
if (p.isStart) {
|
|
2192
|
+
rows[i] = { ...p, measured: true, viewports: measured };
|
|
2193
|
+
} else if (!p.reachable) {
|
|
2194
|
+
rows[i] = { ...p, measured: false };
|
|
2195
|
+
} else {
|
|
2196
|
+
todo.push({ p, i });
|
|
2197
|
+
}
|
|
2198
|
+
});
|
|
2199
|
+
const measureRoutedPage = async (p, i, tab, firstInTab) => {
|
|
2200
|
+
let loggedThrough2 = tab.consoleErrors.length;
|
|
2201
|
+
await tab.goto(p.url, firstInTab ? BOOT_MS : PAGE_NAV_MS);
|
|
2202
|
+
const pageExpression = measureExpression(placeholders, probesFor(p.component));
|
|
2203
|
+
const pageMeasured = {};
|
|
2204
|
+
const shots = [];
|
|
2205
|
+
for (const vp of viewports) {
|
|
2206
|
+
const loggedBefore = loggedThrough2;
|
|
2207
|
+
await tab.setViewport(vp);
|
|
2208
|
+
const raw = await tab.evaluate(pageExpression);
|
|
2209
|
+
loggedThrough2 = tab.consoleErrors.length;
|
|
2210
|
+
pageMeasured[vp.name] = {
|
|
2211
|
+
requested: { width: vp.width, height: vp.height },
|
|
2212
|
+
...raw,
|
|
2213
|
+
consoleErrors: tab.consoleErrors.slice(loggedBefore, loggedThrough2)
|
|
2214
|
+
};
|
|
2215
|
+
if (screenshot !== "none" && screenshotPages === "all") {
|
|
2216
|
+
shots.push(await capture(vp, raw, p.component, false, tab.client));
|
|
2217
|
+
}
|
|
2218
|
+
}
|
|
2219
|
+
const perPage = summarise(pageMeasured, void 0, overridden);
|
|
2220
|
+
findingsByPage[i] = perPage.findings.map((f) => ({
|
|
2221
|
+
...f,
|
|
2222
|
+
page: p.component,
|
|
2223
|
+
message: f.code === RenderFinding.BlankRender ? `The routed page "${p.component}" rendered nothing at all \u2014 no text and no images. Only the start page is diagnosed against the graph, so the cause was not determined here.` : `On the routed page "${p.component}": ${f.message}`
|
|
2224
|
+
}));
|
|
2225
|
+
shotsByPage[i] = shots;
|
|
2226
|
+
rows[i] = { ...p, measured: true, viewports: pageMeasured, summary: perPage.summary };
|
|
2227
|
+
};
|
|
2228
|
+
if (todo.length) {
|
|
2229
|
+
const lanes = Math.max(1, Math.min(Math.floor(Number(concurrency)) || 1, todo.length));
|
|
2230
|
+
let cursor = 0;
|
|
2231
|
+
const lane = async (tab, ownTab) => {
|
|
2232
|
+
let firstInTab = ownTab;
|
|
2233
|
+
for (; ; ) {
|
|
2234
|
+
const at = cursor++;
|
|
2235
|
+
if (at >= todo.length) return;
|
|
2236
|
+
await measureRoutedPage(todo[at].p, todo[at].i, tab, firstInTab);
|
|
2237
|
+
firstInTab = false;
|
|
2238
|
+
}
|
|
2239
|
+
};
|
|
2240
|
+
const helpers = [];
|
|
2241
|
+
try {
|
|
2242
|
+
for (let k = 1; k < lanes; k++) helpers.push(await page.openTab(`tab${k}`));
|
|
2243
|
+
await Promise.all([lane(page, false), ...helpers.map((tab) => lane(tab, true))]);
|
|
2244
|
+
} finally {
|
|
2245
|
+
for (const tab of helpers) await page.closeTab(tab);
|
|
2246
|
+
}
|
|
2247
|
+
sweep = { pages: todo.length, tabs: lanes };
|
|
2248
|
+
}
|
|
2249
|
+
for (const row of rows) if (row) pageReports.push(row);
|
|
2250
|
+
for (const list of findingsByPage) if (list) extraFindings.push(...list);
|
|
2251
|
+
for (const list of shotsByPage) if (list) screenshots.push(...list);
|
|
2252
|
+
if (pageReports.some((p) => p.measured && !p.isStart)) await page.navigate("/");
|
|
2253
|
+
}
|
|
2254
|
+
const allFindings = [...findings, ...extraFindings];
|
|
2255
|
+
let allSummary = extraFindings.length ? summaryLine(measured, allFindings) : summary;
|
|
2256
|
+
if (focus) {
|
|
2257
|
+
allSummary += ` (page "${focus.isStart ? "/" : focus.urlPath}" only \u2014 the rest of this project was not looked at.)`;
|
|
2258
|
+
}
|
|
2259
|
+
const skipped = pageReports.filter((p) => !p.measured);
|
|
2260
|
+
if (skipped.length) {
|
|
2261
|
+
allSummary += ` \u26A0\uFE0F ${skipped.length} of ${pageReports.length} routed ${skipped.length === 1 ? "page was" : "pages were"} not measured, so this says nothing about ${skipped.length === 1 ? "it" : "them"}: ` + skipped.map((p) => `${p.component} (${p.unreachable})`).join("; ") + ".";
|
|
2262
|
+
}
|
|
2263
|
+
const project = JSON.parse(fs2.readFileSync(path2.join(projectDir, "nodegx.project.json"), "utf8"));
|
|
2264
|
+
const log = page.serverLog();
|
|
2265
|
+
return {
|
|
2266
|
+
report: {
|
|
2267
|
+
project: projectDir,
|
|
2268
|
+
projectName: project.name,
|
|
2269
|
+
durationMs: Date.now() - started,
|
|
2270
|
+
/**
|
|
2271
|
+
* FLD-011 — what the settle budget actually cost, beside what the fixed
|
|
2272
|
+
* timers it replaced would have.
|
|
2273
|
+
*
|
|
2274
|
+
* 🔴 `atCeiling` is the field that keeps the speed claim honest: those
|
|
2275
|
+
* settles waited the full old timer and read what the old code read. A
|
|
2276
|
+
* run where `atCeiling` equals `count` was not made faster at all, and
|
|
2277
|
+
* `savedMs` on such a run is zero rather than a number to quote.
|
|
2278
|
+
*/
|
|
2279
|
+
settle: settleSummary(page.settles),
|
|
2280
|
+
...sweep ? { sweep } : {},
|
|
2281
|
+
tokens: (log.match(/\[render\] design tokens: (.*)/) || [])[1] || "unknown",
|
|
2282
|
+
components: (log.match(/\[render\] rootComponent=\S+\s+(\d+) components/) || [])[1],
|
|
2283
|
+
viewports: measured,
|
|
2284
|
+
pages: pageReports,
|
|
2285
|
+
findings: allFindings,
|
|
2286
|
+
summary: allSummary
|
|
2287
|
+
},
|
|
2288
|
+
screenshots
|
|
2289
|
+
};
|
|
2290
|
+
});
|
|
2291
|
+
}
|
|
2292
|
+
module2.exports = {
|
|
2293
|
+
renderReport,
|
|
2294
|
+
// Exported so a session-teardown hook, a CI step or a spec can reap without
|
|
2295
|
+
// starting a drive — the leak outlives the run that caused it.
|
|
2296
|
+
reapOrphanedRenderProcesses,
|
|
2297
|
+
routedPages: routedPages2,
|
|
2298
|
+
resolvePageRequest: resolvePageRequest2,
|
|
2299
|
+
reachableComponents,
|
|
2300
|
+
withRenderedPage: withRenderedPage2,
|
|
2301
|
+
summarise,
|
|
2302
|
+
measureExpression,
|
|
2303
|
+
listProbes,
|
|
2304
|
+
readComponents,
|
|
2305
|
+
blankDiagnosis,
|
|
2306
|
+
overriddenDefaults,
|
|
2307
|
+
rootNodes,
|
|
2308
|
+
effectiveRoots,
|
|
2309
|
+
projectVisualPredicate,
|
|
2310
|
+
visualTypeNames,
|
|
2311
|
+
contentBearingTypeNames,
|
|
2312
|
+
BlankCause,
|
|
2313
|
+
placeholderStrings,
|
|
2314
|
+
// VIB-007 M3 — the poverty thresholds, so a spec reads README §2's number
|
|
2315
|
+
// rather than restating it.
|
|
2316
|
+
DISPLAY_TYPE_MIN_PX,
|
|
2317
|
+
MIN_DISTINCT_GROUNDS,
|
|
2318
|
+
POVERTY_MIN_TEXTS,
|
|
2319
|
+
POVERTY_FINDINGS,
|
|
2320
|
+
isPovertyFinding,
|
|
2321
|
+
checkPrerequisites,
|
|
2322
|
+
findChrome,
|
|
2323
|
+
freePort,
|
|
2324
|
+
// HLS-015 — the CDP pair, exported so a drive that boots something OTHER than a project
|
|
2325
|
+
// directory can still talk to a page. `withRenderedPage` boots the viewer against project
|
|
2326
|
+
// FILES; `nodegx deploy`'s output is a finished site with no project in it, so it needs the
|
|
2327
|
+
// plumbing without the booting. The alternative was a second copy of `connect` in a sibling
|
|
2328
|
+
// script, which is the thing the comment above `withRenderedPage` says not to do.
|
|
2329
|
+
connect,
|
|
2330
|
+
evaluate,
|
|
2331
|
+
httpJson,
|
|
2332
|
+
parseViewports: parseViewports2,
|
|
2333
|
+
RenderFinding,
|
|
2334
|
+
DEFAULT_VIEWPORTS,
|
|
2335
|
+
DESKTOP_WIDTH,
|
|
2336
|
+
// FLD-011 — the default tab count, so a spec asserts the shipped number rather than restating it.
|
|
2337
|
+
PAGE_TABS,
|
|
2338
|
+
VIEWER_BUNDLE,
|
|
2339
|
+
RENDER_SCRIPT
|
|
2340
|
+
};
|
|
2341
|
+
}
|
|
2342
|
+
});
|
|
2343
|
+
|
|
2344
|
+
// ../../scripts/devtools/drive-steps.js
|
|
2345
|
+
var fs = require("fs");
|
|
2346
|
+
var path = require("path");
|
|
2347
|
+
var { withRenderedPage, routedPages, resolvePageRequest, parseViewports } = require_render_report();
|
|
2348
|
+
var VERBS = [
|
|
2349
|
+
"tap",
|
|
2350
|
+
"type",
|
|
2351
|
+
"key",
|
|
2352
|
+
"wait_for",
|
|
2353
|
+
"read",
|
|
2354
|
+
"navigate",
|
|
2355
|
+
"viewport",
|
|
2356
|
+
"screenshot",
|
|
2357
|
+
"seed_storage",
|
|
2358
|
+
"clear_storage",
|
|
2359
|
+
"stub_route",
|
|
2360
|
+
"reduced_motion",
|
|
2361
|
+
"reload",
|
|
2362
|
+
"watch"
|
|
2363
|
+
];
|
|
2364
|
+
var KEYS = {
|
|
2365
|
+
Enter: { key: "Enter", code: "Enter", keyCode: 13, text: "\r" },
|
|
2366
|
+
Escape: { key: "Escape", code: "Escape", keyCode: 27 },
|
|
2367
|
+
Tab: { key: "Tab", code: "Tab", keyCode: 9 },
|
|
2368
|
+
Backspace: { key: "Backspace", code: "Backspace", keyCode: 8 },
|
|
2369
|
+
Delete: { key: "Delete", code: "Delete", keyCode: 46 },
|
|
2370
|
+
Space: { key: " ", code: "Space", keyCode: 32, text: " " },
|
|
2371
|
+
ArrowUp: { key: "ArrowUp", code: "ArrowUp", keyCode: 38 },
|
|
2372
|
+
ArrowDown: { key: "ArrowDown", code: "ArrowDown", keyCode: 40 },
|
|
2373
|
+
ArrowLeft: { key: "ArrowLeft", code: "ArrowLeft", keyCode: 37 },
|
|
2374
|
+
ArrowRight: { key: "ArrowRight", code: "ArrowRight", keyCode: 39 }
|
|
2375
|
+
};
|
|
2376
|
+
var wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
2377
|
+
var UsageError = class extends Error {
|
|
2378
|
+
constructor(message) {
|
|
2379
|
+
super(message);
|
|
2380
|
+
this.usage = true;
|
|
2381
|
+
}
|
|
2382
|
+
};
|
|
2383
|
+
function verbOf(step, n) {
|
|
2384
|
+
if (!step || typeof step !== "object" || Array.isArray(step)) {
|
|
2385
|
+
throw new UsageError(`Step ${n} is not an object: ${JSON.stringify(step)}.`);
|
|
2386
|
+
}
|
|
2387
|
+
const verbs = Object.keys(step).filter((k) => VERBS.includes(k));
|
|
2388
|
+
if (verbs.length !== 1) {
|
|
2389
|
+
throw new UsageError(
|
|
2390
|
+
`Step ${n} must name exactly one of ${VERBS.join(", ")}; it names ${verbs.length ? verbs.join(" and ") : "none"} (${JSON.stringify(step)}).`
|
|
2391
|
+
);
|
|
2392
|
+
}
|
|
2393
|
+
return verbs[0];
|
|
2394
|
+
}
|
|
2395
|
+
function finder(target) {
|
|
2396
|
+
const t = typeof target === "string" ? { text: target } : target || {};
|
|
2397
|
+
if (typeof t.testid === "string") {
|
|
2398
|
+
return `(() => { const all = [...document.querySelectorAll('[data-testid=' + ${JSON.stringify(JSON.stringify(t.testid))} + ']')]; return { all }; })()`;
|
|
2399
|
+
}
|
|
2400
|
+
if (typeof t.selector === "string") {
|
|
2401
|
+
return `(() => ({ all: [...document.querySelectorAll(${JSON.stringify(t.selector)})] }))()`;
|
|
2402
|
+
}
|
|
2403
|
+
if (typeof t.text === "string" || typeof t.text_contains === "string") {
|
|
2404
|
+
const exact = typeof t.text === "string";
|
|
2405
|
+
return `(() => {
|
|
2406
|
+
const needle = ${JSON.stringify(exact ? t.text : t.text_contains)};
|
|
2407
|
+
const shown = (e) => { const r = e.getBoundingClientRect(); return r.width > 0 && r.height > 0 && getComputedStyle(e).visibility !== 'hidden'; };
|
|
2408
|
+
const own = (e) => (e.innerText || '').trim();
|
|
2409
|
+
const test = ${exact} ? (s) => s === needle : (s) => s.includes(needle);
|
|
2410
|
+
// A field is found by what a person reads on it: its placeholder or aria-label (an input has no text).
|
|
2411
|
+
const field = (e) => (e.tagName === 'INPUT' || e.tagName === 'TEXTAREA') && test((e.getAttribute('placeholder') || e.getAttribute('aria-label') || '').trim());
|
|
2412
|
+
const hits = [...document.body.querySelectorAll('*')].filter((e) => shown(e) && (test(own(e)) || field(e)));
|
|
2413
|
+
return { all: hits.filter((e) => !hits.some((o) => o !== e && e.contains(o))) };
|
|
2414
|
+
})()`;
|
|
2415
|
+
}
|
|
2416
|
+
throw new UsageError(`A target is { text }, { text_contains }, { testid } or { selector }; got ${JSON.stringify(target)}.`);
|
|
2417
|
+
}
|
|
2418
|
+
function locateExpression(target) {
|
|
2419
|
+
return `(() => {
|
|
2420
|
+
const { all } = ${finder(target)};
|
|
2421
|
+
const describe = (e) => e ? (e.tagName.toLowerCase() + (e.id ? '#' + e.id : '') + (e.getAttribute('data-testid') ? '[data-testid=' + e.getAttribute('data-testid') + ']' : '') + (e.className && typeof e.className === 'string' ? '.' + e.className.trim().split(/\\s+/).slice(0, 2).join('.') : '') + ' "' + (e.innerText || '').trim().slice(0, 40) + '"') : 'nothing';
|
|
2422
|
+
if (all.length === 0) return { found: false, matched: 0 };
|
|
2423
|
+
let last;
|
|
2424
|
+
for (const el of all) {
|
|
2425
|
+
// 'instant': under \`html { scroll-behavior: smooth }\` a plain scrollIntoView glides, the rect below is read
|
|
2426
|
+
// before it arrives, and every target off screen was refused as off screen (P112-OWN019-TAPHOLD, L1's page).
|
|
2427
|
+
el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
|
|
2428
|
+
const r = el.getBoundingClientRect();
|
|
2429
|
+
const x = r.left + r.width / 2, y = r.top + r.height / 2;
|
|
2430
|
+
if (x < 0 || y < 0 || x >= innerWidth || y >= innerHeight) { last = { x, y, offscreen: true, wouldHit: 'nothing (off screen)', target: describe(el) }; continue; }
|
|
2431
|
+
// Lent pointer-events for the one measurement: elementFromPoint skips an element that has none.
|
|
2432
|
+
const before = el.style.pointerEvents;
|
|
2433
|
+
el.style.pointerEvents = 'auto';
|
|
2434
|
+
const at = document.elementFromPoint(x, y);
|
|
2435
|
+
el.style.pointerEvents = before;
|
|
2436
|
+
if (at && el.contains(at)) return { found: true, matched: all.length, x, y, target: describe(el) };
|
|
2437
|
+
last = { x, y, wouldHit: describe(at), target: describe(el) };
|
|
2438
|
+
}
|
|
2439
|
+
return { found: true, matched: all.length, covered: true, ...last };
|
|
2440
|
+
})()`;
|
|
2441
|
+
}
|
|
2442
|
+
function waitExpression(c) {
|
|
2443
|
+
if (!c || typeof c !== "object") throw new UsageError(`wait_for takes { text | testid | selector | url | expression }; got ${JSON.stringify(c)}.`);
|
|
2444
|
+
if (typeof c.text === "string") return `(document.body.innerText || '').includes(${JSON.stringify(c.text)})`;
|
|
2445
|
+
if (typeof c.testid === "string" || typeof c.selector === "string") {
|
|
2446
|
+
return `(() => { const { all } = ${finder(c)}; return all.some((e) => e.getClientRects().length > 0); })()`;
|
|
2447
|
+
}
|
|
2448
|
+
if (typeof c.url === "string") return `(location.pathname + location.hash).includes(${JSON.stringify(c.url)})`;
|
|
2449
|
+
if (typeof c.expression === "string") return `(async () => !!(${c.expression}))()`;
|
|
2450
|
+
throw new UsageError(`wait_for takes { text | testid | selector | url | expression }; got ${JSON.stringify(c)}.`);
|
|
2451
|
+
}
|
|
2452
|
+
function parseStub(step, n) {
|
|
2453
|
+
const route = step.stub_route;
|
|
2454
|
+
if (typeof route !== "string" || !(route.startsWith("/") || /^https?:\/\//.test(route))) {
|
|
2455
|
+
throw new UsageError(`Step ${n}: stub_route takes a path ("/__garden/status") or a full http(s) URL; got ${JSON.stringify(route)}.`);
|
|
2456
|
+
}
|
|
2457
|
+
if ("json" in step && "body" in step) throw new UsageError(`Step ${n}: stub_route takes json OR body, not both.`);
|
|
2458
|
+
if ("body" in step && typeof step.body !== "string") throw new UsageError(`Step ${n}: stub_route's body is text; use json for a value.`);
|
|
2459
|
+
const status = Number(step.status ?? 200);
|
|
2460
|
+
if (!Number.isInteger(status) || status < 100 || status > 599) throw new UsageError(`Step ${n}: stub_route's status ${JSON.stringify(step.status)} is not an HTTP status.`);
|
|
2461
|
+
const isJson = !("body" in step);
|
|
2462
|
+
return {
|
|
2463
|
+
route: route.endsWith("*") ? route.slice(0, -1) : route,
|
|
2464
|
+
prefix: route.endsWith("*"),
|
|
2465
|
+
status,
|
|
2466
|
+
body: isJson ? JSON.stringify(step.json ?? {}) : step.body,
|
|
2467
|
+
contentType: String(step.content_type ?? (isJson ? "application/json" : "text/plain"))
|
|
2468
|
+
};
|
|
2469
|
+
}
|
|
2470
|
+
function routeStubs(tab) {
|
|
2471
|
+
const stubs = [];
|
|
2472
|
+
let listening = false;
|
|
2473
|
+
const where = (url) => {
|
|
2474
|
+
try {
|
|
2475
|
+
const u = new URL(url);
|
|
2476
|
+
return { path: u.pathname, full: u.origin + u.pathname };
|
|
2477
|
+
} catch {
|
|
2478
|
+
return null;
|
|
2479
|
+
}
|
|
2480
|
+
};
|
|
2481
|
+
const claim = (url) => {
|
|
2482
|
+
const at = where(url);
|
|
2483
|
+
if (!at) return void 0;
|
|
2484
|
+
return stubs.find((s) => {
|
|
2485
|
+
const subject = /^https?:/.test(s.route) ? at.full : at.path;
|
|
2486
|
+
return s.prefix ? subject.startsWith(s.route) : subject === s.route;
|
|
2487
|
+
});
|
|
2488
|
+
};
|
|
2489
|
+
const onPaused = (msg) => {
|
|
2490
|
+
if (msg.method !== "Fetch.requestPaused") return;
|
|
2491
|
+
const { requestId, request } = msg.params;
|
|
2492
|
+
const stub = claim(request.url);
|
|
2493
|
+
if (!stub) {
|
|
2494
|
+
tab.client.send("Fetch.continueRequest", { requestId }).catch(() => {
|
|
2495
|
+
});
|
|
2496
|
+
return;
|
|
2497
|
+
}
|
|
2498
|
+
stub.hits += 1;
|
|
2499
|
+
if (request.postData !== void 0) stub.lastBody = request.postData;
|
|
2500
|
+
tab.client.send("Fetch.fulfillRequest", {
|
|
2501
|
+
requestId,
|
|
2502
|
+
responseCode: stub.status,
|
|
2503
|
+
responseHeaders: [{ name: "content-type", value: stub.contentType }],
|
|
2504
|
+
body: Buffer.from(stub.body).toString("base64")
|
|
2505
|
+
}).catch(() => {
|
|
2506
|
+
});
|
|
2507
|
+
};
|
|
2508
|
+
const glob = (route) => route.replace(/[*?\\]/g, (c) => `\\${c}`);
|
|
2509
|
+
return {
|
|
2510
|
+
async add(stub) {
|
|
2511
|
+
stubs.unshift({ ...stub, hits: 0 });
|
|
2512
|
+
if (!listening) {
|
|
2513
|
+
tab.onEvent(onPaused);
|
|
2514
|
+
listening = true;
|
|
2515
|
+
}
|
|
2516
|
+
const routes = [...new Set(stubs.map((s) => s.route))];
|
|
2517
|
+
await tab.client.send("Fetch.enable", {
|
|
2518
|
+
patterns: routes.map((r) => ({ urlPattern: `${/^https?:/.test(r) ? "" : "*"}${glob(r)}*`, requestStage: "Request" }))
|
|
2519
|
+
});
|
|
2520
|
+
},
|
|
2521
|
+
/** `{ hits, body }` for the newest stub on this route, summed over every stub that named it. */
|
|
2522
|
+
read(route) {
|
|
2523
|
+
const key = route.endsWith("*") ? route.slice(0, -1) : route;
|
|
2524
|
+
const named = stubs.filter((s) => s.route === key);
|
|
2525
|
+
if (!named.length) throw new Error(`no stub_route for ${JSON.stringify(route)} was set before this read`);
|
|
2526
|
+
const last = named.find((s) => s.lastBody !== void 0)?.lastBody;
|
|
2527
|
+
let body = last ?? null;
|
|
2528
|
+
try {
|
|
2529
|
+
if (typeof body === "string") body = JSON.parse(body);
|
|
2530
|
+
} catch {
|
|
2531
|
+
}
|
|
2532
|
+
return { hits: named.reduce((n, s) => n + s.hits, 0), body };
|
|
2533
|
+
},
|
|
2534
|
+
report() {
|
|
2535
|
+
return stubs.map((s) => ({ route: s.prefix ? `${s.route}*` : s.route, status: s.status, hits: s.hits }));
|
|
2536
|
+
}
|
|
2537
|
+
};
|
|
2538
|
+
}
|
|
2539
|
+
function networkLog(tab) {
|
|
2540
|
+
const urls = /* @__PURE__ */ new Map();
|
|
2541
|
+
const errors = [];
|
|
2542
|
+
const origins = /* @__PURE__ */ new Set();
|
|
2543
|
+
tab.onEvent((msg) => {
|
|
2544
|
+
if (msg.method === "Network.requestWillBeSent") {
|
|
2545
|
+
const { requestId, request, documentURL } = msg.params;
|
|
2546
|
+
urls.set(requestId, request.url);
|
|
2547
|
+
try {
|
|
2548
|
+
const u = new URL(request.url);
|
|
2549
|
+
const home = new URL(documentURL || request.url).origin;
|
|
2550
|
+
if (/^https?:$/.test(u.protocol) && u.origin !== home) origins.add(u.origin);
|
|
2551
|
+
} catch {
|
|
2552
|
+
}
|
|
2553
|
+
} else if (msg.method === "Network.loadingFailed" && !msg.params.canceled) {
|
|
2554
|
+
errors.push(`${msg.params.errorText} ${urls.get(msg.params.requestId) ?? `(${msg.params.type})`}`);
|
|
2555
|
+
} else if (msg.method === "Network.responseReceived" && msg.params.response.status >= 400 && !/\/favicon\.ico$/.test(new URL(msg.params.response.url).pathname)) {
|
|
2556
|
+
errors.push(`${msg.params.response.status} ${msg.params.response.url}`);
|
|
2557
|
+
}
|
|
2558
|
+
});
|
|
2559
|
+
return {
|
|
2560
|
+
start: () => tab.client.send("Network.enable", {}),
|
|
2561
|
+
errors: () => errors.slice(),
|
|
2562
|
+
otherOrigins: () => [...origins].sort()
|
|
2563
|
+
};
|
|
2564
|
+
}
|
|
2565
|
+
var RUN_READS = ["stub", "console_errors", "network_errors", "other_origins"];
|
|
2566
|
+
var READ_FORMS = "text | look | variable | storage | url | stub | console_errors | network_errors | other_origins | expression";
|
|
2567
|
+
function lookExpression(target) {
|
|
2568
|
+
return `(() => {
|
|
2569
|
+
const { all } = ${finder(target)};
|
|
2570
|
+
const leaf = all[0];
|
|
2571
|
+
if (!leaf) return null;
|
|
2572
|
+
let box = leaf;
|
|
2573
|
+
const label = (leaf.innerText || '').trim();
|
|
2574
|
+
while (box.parentElement && box.parentElement !== document.body && (box.parentElement.innerText || '').trim() === label) box = box.parentElement;
|
|
2575
|
+
const rgba = (c) => { const m = String(c).match(/rgba?\\(([^)]+)\\)/); if (!m) return null; const [r, g, b, a = 1] = m[1].split(/[ ,/]+/).filter(Boolean).map(Number); return { r, g, b, a }; };
|
|
2576
|
+
const name = (c) => {
|
|
2577
|
+
const v = rgba(c);
|
|
2578
|
+
if (!v || v.a === 0) return 'none';
|
|
2579
|
+
const r = v.r / 255, g = v.g / 255, b = v.b / 255, mx = Math.max(r, g, b), mn = Math.min(r, g, b), l = (mx + mn) / 2;
|
|
2580
|
+
const s = mx === mn ? 0 : l > 0.5 ? (mx - mn) / (2 - mx - mn) : (mx - mn) / (mx + mn);
|
|
2581
|
+
if (l < 0.15) return 'black';
|
|
2582
|
+
if (l > 0.94) return 'white';
|
|
2583
|
+
if (s < 0.15 || mx - mn < 0.06) return l > 0.9 ? 'white' : 'grey';
|
|
2584
|
+
let h = mx === r ? (g - b) / (mx - mn) + (g < b ? 6 : 0) : mx === g ? (b - r) / (mx - mn) + 2 : (r - g) / (mx - mn) + 4;
|
|
2585
|
+
h *= 60;
|
|
2586
|
+
return h < 15 || h >= 345 ? 'red' : h < 45 ? 'orange' : h < 70 ? 'yellow' : h < 165 ? 'green' : h < 200 ? 'cyan' : h < 260 ? 'blue' : h < 290 ? 'purple' : 'pink';
|
|
2587
|
+
};
|
|
2588
|
+
let painted = 'rgba(0, 0, 0, 0)';
|
|
2589
|
+
for (let e = box; e && e !== document.documentElement; e = e.parentElement) {
|
|
2590
|
+
const bg = getComputedStyle(e).backgroundColor;
|
|
2591
|
+
if (rgba(bg) && rgba(bg).a > 0) { painted = bg; break; }
|
|
2592
|
+
}
|
|
2593
|
+
let opacity = 1;
|
|
2594
|
+
for (let e = box; e; e = e.parentElement) opacity *= Number(getComputedStyle(e).opacity);
|
|
2595
|
+
const st = getComputedStyle(box);
|
|
2596
|
+
const r = box.getBoundingClientRect();
|
|
2597
|
+
const visible = r.width > 0 && r.height > 0 && st.visibility !== 'hidden' && st.display !== 'none' && opacity > 0.05;
|
|
2598
|
+
const color = getComputedStyle(leaf).color;
|
|
2599
|
+
return { text: label, visible, opacity: Math.round(opacity * 100) / 100, background: painted, backgroundName: name(painted), color, colorName: name(color) };
|
|
2600
|
+
})()`;
|
|
2601
|
+
}
|
|
2602
|
+
function readExpression(r) {
|
|
2603
|
+
if (!r || typeof r !== "object") throw new UsageError(`read takes { ${READ_FORMS} }; got ${JSON.stringify(r)}.`);
|
|
2604
|
+
if (r.look !== void 0) return lookExpression(r.look);
|
|
2605
|
+
if (r.text !== void 0) {
|
|
2606
|
+
return `(() => { const { all } = ${finder(r.text)}; const e = all[0]; return e ? (e.value !== undefined && e.value !== null && e.value !== '' ? e.value : (e.innerText || '').trim()) : null; })()`;
|
|
2607
|
+
}
|
|
2608
|
+
if (typeof r.variable === "string") {
|
|
2609
|
+
return `(() => { const N = window.Noodl; if (!N || !N.Variables) throw new Error('This page has no Noodl.Variables to read.'); const v = N.Variables[${JSON.stringify(r.variable)}]; return v === undefined ? null : JSON.parse(JSON.stringify(v)); })()`;
|
|
2610
|
+
}
|
|
2611
|
+
if (typeof r.storage === "string") return `localStorage.getItem(${JSON.stringify(r.storage)})`;
|
|
2612
|
+
if (r.url) return `location.pathname + location.hash`;
|
|
2613
|
+
if (typeof r.expression === "string") return `(async () => { const v = await (${r.expression}); return v === undefined ? null : JSON.parse(JSON.stringify(v)); })()`;
|
|
2614
|
+
throw new UsageError(`read takes { ${READ_FORMS} }; got ${JSON.stringify(r)}.`);
|
|
2615
|
+
}
|
|
2616
|
+
async function seedPairs(step, n, evaluate) {
|
|
2617
|
+
const seed = step.seed_storage;
|
|
2618
|
+
if (typeof seed === "string") {
|
|
2619
|
+
if (typeof step.edit !== "string") throw new UsageError(`Step ${n}: seed_storage "${seed}" edits a saved value and needs edit: "js that changes v".`);
|
|
2620
|
+
return evaluate(`(() => { const k = ${JSON.stringify(seed)}; const raw = localStorage.getItem(k);
|
|
2621
|
+
if (raw === null) throw new Error('localStorage has no ' + JSON.stringify(k) + ' to edit');
|
|
2622
|
+
const v = JSON.parse(raw); (function (v) { ${step.edit}
|
|
2623
|
+
})(v); return { [k]: JSON.stringify(v) }; })()`);
|
|
2624
|
+
}
|
|
2625
|
+
if (!seed || typeof seed !== "object" || Array.isArray(seed)) {
|
|
2626
|
+
throw new UsageError(`Step ${n}: seed_storage takes { key: value } or a key with edit: "js that changes v".`);
|
|
2627
|
+
}
|
|
2628
|
+
return Object.fromEntries(Object.entries(seed).map(([k, v]) => [k, typeof v === "string" ? v : JSON.stringify(v)]));
|
|
2629
|
+
}
|
|
2630
|
+
function matches(value, expected) {
|
|
2631
|
+
if (expected && typeof expected === "object" && !Array.isArray(expected) && "contains" in expected) {
|
|
2632
|
+
const hay = typeof value === "string" ? value : JSON.stringify(value);
|
|
2633
|
+
return typeof hay === "string" && hay.includes(String(expected.contains));
|
|
2634
|
+
}
|
|
2635
|
+
return JSON.stringify(value) === JSON.stringify(expected);
|
|
2636
|
+
}
|
|
2637
|
+
async function runSteps(page, steps, { outDir, stubs = routeStubs(page), network, beforeBoot = 0 }) {
|
|
2638
|
+
const rows = [];
|
|
2639
|
+
const pendingSeeds = [];
|
|
2640
|
+
const { client, evaluate } = page;
|
|
2641
|
+
const frames = () => evaluate("new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r(true))))");
|
|
2642
|
+
const settledLook = async (expr) => {
|
|
2643
|
+
const t0 = Date.now();
|
|
2644
|
+
let last = JSON.stringify(await evaluate(expr));
|
|
2645
|
+
let same = 1;
|
|
2646
|
+
while (same < 3 && Date.now() - t0 < 2e3) {
|
|
2647
|
+
await wait(50);
|
|
2648
|
+
const next = JSON.stringify(await evaluate(expr));
|
|
2649
|
+
same = next === last ? same + 1 : 1;
|
|
2650
|
+
last = next;
|
|
2651
|
+
}
|
|
2652
|
+
const value = JSON.parse(last);
|
|
2653
|
+
return value && typeof value === "object" ? { ...value, settledMs: Date.now() - t0 } : value;
|
|
2654
|
+
};
|
|
2655
|
+
const SCROLLS = `(() => { const a = [scrollX, scrollY]; for (const e of document.querySelectorAll('*')) if (e.scrollTop || e.scrollLeft) a.push(e.scrollTop, e.scrollLeft); return a.join(','); })()`;
|
|
2656
|
+
const scrollSettled = async () => {
|
|
2657
|
+
const t0 = Date.now();
|
|
2658
|
+
let last = await evaluate(SCROLLS);
|
|
2659
|
+
let moved = false;
|
|
2660
|
+
for (let same = 0; same < 2 && Date.now() - t0 < 2e3; ) {
|
|
2661
|
+
await frames();
|
|
2662
|
+
const next = await evaluate(SCROLLS);
|
|
2663
|
+
if (next === last) same++;
|
|
2664
|
+
else same = 0, moved = true;
|
|
2665
|
+
last = next;
|
|
2666
|
+
}
|
|
2667
|
+
return moved ? { scrolledMs: Date.now() - t0, still: Date.now() - t0 >= 2e3 } : null;
|
|
2668
|
+
};
|
|
2669
|
+
const press = async (target) => {
|
|
2670
|
+
let p = await evaluate(locateExpression(target));
|
|
2671
|
+
for (let i = 0; i < 20 && !p.found; i++) {
|
|
2672
|
+
await wait(100);
|
|
2673
|
+
p = await evaluate(locateExpression(target));
|
|
2674
|
+
}
|
|
2675
|
+
if (!p.found) throw new Error(`nothing on the page matches ${JSON.stringify(target)}`);
|
|
2676
|
+
for (const end = Date.now() + 2e3; p.found && !p.covered && Date.now() < end; ) {
|
|
2677
|
+
await frames();
|
|
2678
|
+
const again = await evaluate(locateExpression(target));
|
|
2679
|
+
const still = again.found && !again.covered && Math.abs(again.x - p.x) < 0.5 && Math.abs(again.y - p.y) < 0.5;
|
|
2680
|
+
p = again;
|
|
2681
|
+
if (still) break;
|
|
2682
|
+
}
|
|
2683
|
+
if (!p.found) throw new Error(`nothing on the page matches ${JSON.stringify(target)}`);
|
|
2684
|
+
if (p.covered) {
|
|
2685
|
+
throw new Error(
|
|
2686
|
+
`${p.target} is there but a finger at its centre would press ${p.wouldHit}` + (p.offscreen ? " (its centre is off screen even after scrolling)" : "")
|
|
2687
|
+
);
|
|
2688
|
+
}
|
|
2689
|
+
for (const type of ["mousePressed", "mouseReleased"]) {
|
|
2690
|
+
await client.send("Input.dispatchMouseEvent", { type, x: p.x, y: p.y, button: "left", clickCount: 1 });
|
|
2691
|
+
}
|
|
2692
|
+
return p;
|
|
2693
|
+
};
|
|
2694
|
+
for (let i = 0; i < steps.length; i++) {
|
|
2695
|
+
const step = steps[i];
|
|
2696
|
+
const verb = verbOf(step, i + 1);
|
|
2697
|
+
const started = Date.now();
|
|
2698
|
+
const row = { n: i + 1, do: verb, ok: true };
|
|
2699
|
+
try {
|
|
2700
|
+
if (verb === "tap") {
|
|
2701
|
+
const p = await press(step.tap);
|
|
2702
|
+
row.pressed = p.target;
|
|
2703
|
+
if (p.matched > 1) row.matched = p.matched;
|
|
2704
|
+
await frames();
|
|
2705
|
+
const glide = await scrollSettled();
|
|
2706
|
+
if (glide) {
|
|
2707
|
+
row.scrolledMs = glide.scrolledMs;
|
|
2708
|
+
if (glide.still) row.stillScrolling = true;
|
|
2709
|
+
}
|
|
2710
|
+
} else if (verb === "type") {
|
|
2711
|
+
if (typeof step.type !== "string") throw new UsageError(`Step ${i + 1}: type takes a string.`);
|
|
2712
|
+
if (step.into !== void 0) await press(step.into);
|
|
2713
|
+
if (step.replace === true) {
|
|
2714
|
+
await evaluate(`(() => { const e = document.activeElement; if (e && typeof e.select === 'function') e.select(); return true; })()`);
|
|
2715
|
+
}
|
|
2716
|
+
await client.send("Input.insertText", { text: step.type });
|
|
2717
|
+
await frames();
|
|
2718
|
+
row.value = await evaluate(`(() => { const e = document.activeElement; return e && 'value' in e ? e.value : null; })()`);
|
|
2719
|
+
} else if (verb === "key") {
|
|
2720
|
+
const k = KEYS[step.key];
|
|
2721
|
+
if (!k) throw new UsageError(`Step ${i + 1}: key "${step.key}" is not one of ${Object.keys(KEYS).join(", ")}.`);
|
|
2722
|
+
await client.send("Input.dispatchKeyEvent", { type: "keyDown", key: k.key, code: k.code, windowsVirtualKeyCode: k.keyCode, ...k.text ? { text: k.text } : {} });
|
|
2723
|
+
await client.send("Input.dispatchKeyEvent", { type: "keyUp", key: k.key, code: k.code, windowsVirtualKeyCode: k.keyCode });
|
|
2724
|
+
await frames();
|
|
2725
|
+
} else if (verb === "wait_for") {
|
|
2726
|
+
const expr = waitExpression(step.wait_for);
|
|
2727
|
+
const timeout = Number(step.timeout_ms ?? 5e3);
|
|
2728
|
+
const end = Date.now() + timeout;
|
|
2729
|
+
let ok = await evaluate(expr);
|
|
2730
|
+
while (!ok && Date.now() < end) {
|
|
2731
|
+
await wait(100);
|
|
2732
|
+
ok = await evaluate(expr);
|
|
2733
|
+
}
|
|
2734
|
+
if (!ok) throw new Error(`not true within ${timeout} ms: ${JSON.stringify(step.wait_for)}`);
|
|
2735
|
+
} else if (verb === "read") {
|
|
2736
|
+
const r = step.read && typeof step.read === "object" ? step.read : {};
|
|
2737
|
+
const own = RUN_READS.find((k) => r[k] !== void 0);
|
|
2738
|
+
if (own && own !== "stub" && !network) throw new Error(`read ${own} needs the run's network log, which this runner was not given`);
|
|
2739
|
+
row.value = own === "stub" ? stubs.read(String(r.stub)) : own === "console_errors" ? page.consoleErrors.slice() : own === "network_errors" ? network.errors() : own === "other_origins" ? network.otherOrigins() : r.look !== void 0 ? await settledLook(readExpression(step.read)) : await evaluate(readExpression(step.read));
|
|
2740
|
+
const wanted = r.look !== void 0 && step.expect && typeof step.expect === "object" && !Array.isArray(step.expect) && !("contains" in step.expect) ? row.value && typeof row.value === "object" && Object.keys(step.expect).every((k) => matches(row.value[k], step.expect[k])) : !("expect" in step) || matches(row.value, step.expect);
|
|
2741
|
+
if (!wanted) {
|
|
2742
|
+
row.ok = false;
|
|
2743
|
+
row.error = `read ${JSON.stringify(row.value)}, expected ${JSON.stringify(step.expect)}`;
|
|
2744
|
+
}
|
|
2745
|
+
} else if (verb === "navigate") {
|
|
2746
|
+
if (typeof step.navigate !== "string") throw new UsageError(`Step ${i + 1}: navigate takes a route string.`);
|
|
2747
|
+
await page.navigate(step.navigate.startsWith("/") ? step.navigate : `/${step.navigate}`);
|
|
2748
|
+
for (const identifier of pendingSeeds.splice(0)) {
|
|
2749
|
+
await client.send("Page.removeScriptToEvaluateOnNewDocument", { identifier }).catch(() => {
|
|
2750
|
+
});
|
|
2751
|
+
}
|
|
2752
|
+
row.url = await evaluate("location.pathname + location.hash");
|
|
2753
|
+
} else if (verb === "reload") {
|
|
2754
|
+
await client.send("Page.reload", {});
|
|
2755
|
+
await page.settle("reload", 15e3, 2);
|
|
2756
|
+
for (const identifier of pendingSeeds.splice(0)) {
|
|
2757
|
+
await client.send("Page.removeScriptToEvaluateOnNewDocument", { identifier }).catch(() => {
|
|
2758
|
+
});
|
|
2759
|
+
}
|
|
2760
|
+
row.url = await evaluate("location.pathname + location.hash");
|
|
2761
|
+
} else if (verb === "watch") {
|
|
2762
|
+
const w = step.watch && typeof step.watch === "object" ? step.watch : {};
|
|
2763
|
+
if (w.look === void 0) throw new UsageError(`Step ${i + 1}: watch takes { look: T } or { look: [T, T, \u2026] }.`);
|
|
2764
|
+
const forMs = Math.min(1e4, Math.max(100, Number(step.for_ms ?? 1500)));
|
|
2765
|
+
const many = Array.isArray(w.look);
|
|
2766
|
+
const targets = many ? w.look : [w.look];
|
|
2767
|
+
if (targets.length === 0 || targets.length > 8) throw new UsageError(`Step ${i + 1}: watch looks at 1 to 8 targets.`);
|
|
2768
|
+
const expr = `Promise.all([${targets.map((t) => lookExpression(t)).join(", ")}])`;
|
|
2769
|
+
const keyOf = (v) => v && typeof v === "object" ? JSON.stringify({ text: v.text, visible: v.visible, backgroundName: v.backgroundName, colorName: v.colorName, opacity: v.opacity === void 0 ? void 0 : Math.round(v.opacity * 10) / 10 }) : "null";
|
|
2770
|
+
const states = targets.map(() => []);
|
|
2771
|
+
const t0 = Date.now();
|
|
2772
|
+
while (Date.now() - t0 <= forMs) {
|
|
2773
|
+
const vs = await evaluate(expr);
|
|
2774
|
+
const at = Date.now() - t0;
|
|
2775
|
+
vs.forEach((v, k) => {
|
|
2776
|
+
const list = states[k];
|
|
2777
|
+
const last = list[list.length - 1];
|
|
2778
|
+
if (last && last.key === keyOf(v)) last.until = at;
|
|
2779
|
+
else list.push({ key: keyOf(v), from: at, until: at, look: v && typeof v === "object" ? { text: v.text, visible: v.visible, backgroundName: v.backgroundName, colorName: v.colorName, opacity: v.opacity } : null });
|
|
2780
|
+
});
|
|
2781
|
+
await wait(25);
|
|
2782
|
+
}
|
|
2783
|
+
const shown = (list) => list.map(({ from, until, look }) => ({ from, until, look }));
|
|
2784
|
+
row.value = many ? targets.map((t, k) => ({ target: t, states: shown(states[k]) })) : shown(states[0]);
|
|
2785
|
+
const never = step.expect && typeof step.expect === "object" ? step.expect.never : void 0;
|
|
2786
|
+
if (never && typeof never === "object") {
|
|
2787
|
+
for (let k = 0; k < targets.length && row.ok; k++) {
|
|
2788
|
+
const hit = states[k].find((st) => st.look && Object.keys(never).every((key) => matches(st.look[key], never[key])));
|
|
2789
|
+
if (hit) {
|
|
2790
|
+
row.ok = false;
|
|
2791
|
+
row.error = `${many ? `${JSON.stringify(targets[k])}: ` : ""}${JSON.stringify(never)} was seen from ${hit.from} to ${hit.until} ms: ${JSON.stringify(hit.look)}`;
|
|
2792
|
+
}
|
|
2793
|
+
}
|
|
2794
|
+
}
|
|
2795
|
+
} else if (verb === "viewport") {
|
|
2796
|
+
const [vp] = parseViewports(String(step.viewport));
|
|
2797
|
+
await page.setViewport(vp);
|
|
2798
|
+
row.viewport = `${vp.width}x${vp.height}`;
|
|
2799
|
+
} else if (verb === "screenshot") {
|
|
2800
|
+
if (!outDir) throw new UsageError(`Step ${i + 1}: screenshot needs --out-dir (render_report's out_dir rule).`);
|
|
2801
|
+
const name = String(step.screenshot).replace(/[^A-Za-z0-9._-]+/g, "-") || `step-${i + 1}`;
|
|
2802
|
+
const shot = await client.send("Page.captureScreenshot", { format: "png" });
|
|
2803
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
2804
|
+
const file = path.join(outDir, `${name}.png`);
|
|
2805
|
+
fs.writeFileSync(file, Buffer.from(shot.data, "base64"));
|
|
2806
|
+
row.file = file;
|
|
2807
|
+
} else if (verb === "seed_storage") {
|
|
2808
|
+
const pairs = await seedPairs(step, i + 1, evaluate);
|
|
2809
|
+
await evaluate(`(() => { const p = ${JSON.stringify(pairs)}; for (const k of Object.keys(p)) localStorage.setItem(k, p[k]); return true; })()`);
|
|
2810
|
+
const mark = JSON.stringify(`__drive_steps_seed_${process.pid}_${i + 1}`);
|
|
2811
|
+
const { identifier } = await client.send("Page.addScriptToEvaluateOnNewDocument", {
|
|
2812
|
+
source: `(() => { try { if (sessionStorage.getItem(${mark})) return; sessionStorage.setItem(${mark}, '1'); const p = ${JSON.stringify(pairs)}; for (const k of Object.keys(p)) localStorage.setItem(k, p[k]); } catch (e) {} })();`
|
|
2813
|
+
});
|
|
2814
|
+
pendingSeeds.push(identifier);
|
|
2815
|
+
row.keys = Object.keys(pairs);
|
|
2816
|
+
} else if (verb === "clear_storage") {
|
|
2817
|
+
const origin = await evaluate("location.origin");
|
|
2818
|
+
await client.send("Storage.clearDataForOrigin", { origin, storageTypes: "local_storage,indexeddb,cache_storage,service_workers" });
|
|
2819
|
+
} else if (verb === "stub_route") {
|
|
2820
|
+
const stub = parseStub(step, i + 1);
|
|
2821
|
+
row.route = step.stub_route;
|
|
2822
|
+
if (i < beforeBoot) row.before_boot = true;
|
|
2823
|
+
else await stubs.add(stub);
|
|
2824
|
+
} else if (verb === "reduced_motion") {
|
|
2825
|
+
if (typeof step.reduced_motion !== "boolean") throw new UsageError(`Step ${i + 1}: reduced_motion takes true or false.`);
|
|
2826
|
+
await client.send("Emulation.setEmulatedMedia", {
|
|
2827
|
+
features: [{ name: "prefers-reduced-motion", value: step.reduced_motion ? "reduce" : "" }]
|
|
2828
|
+
});
|
|
2829
|
+
await frames();
|
|
2830
|
+
row.value = await evaluate(`matchMedia('(prefers-reduced-motion: reduce)').matches`);
|
|
2831
|
+
}
|
|
2832
|
+
} catch (error) {
|
|
2833
|
+
if (error.usage) throw error;
|
|
2834
|
+
row.ok = false;
|
|
2835
|
+
row.error = error instanceof Error ? error.message : String(error);
|
|
2836
|
+
}
|
|
2837
|
+
row.ms = Date.now() - started;
|
|
2838
|
+
rows.push(row);
|
|
2839
|
+
if (!row.ok) break;
|
|
2840
|
+
}
|
|
2841
|
+
return rows;
|
|
2842
|
+
}
|
|
2843
|
+
async function main() {
|
|
2844
|
+
const argv = process.argv.slice(2);
|
|
2845
|
+
const VALUE_FLAGS = /* @__PURE__ */ new Set(["--steps", "--page", "--viewport", "--out-dir", "--backend-port"]);
|
|
2846
|
+
const flag = (name) => {
|
|
2847
|
+
const i = argv.indexOf(name);
|
|
2848
|
+
return i === -1 ? void 0 : argv[i + 1];
|
|
2849
|
+
};
|
|
2850
|
+
const projectDir = argv.find((a, i) => !a.startsWith("--") && !VALUE_FLAGS.has(argv[i - 1]));
|
|
2851
|
+
const usage = (message) => {
|
|
2852
|
+
process.stdout.write(JSON.stringify({ error: { message, problems: [message], actionable: true } }));
|
|
2853
|
+
process.exit(2);
|
|
2854
|
+
};
|
|
2855
|
+
if (!projectDir) usage("drive-steps.js <project-dir> --steps <json|@file> [--page p] [--viewport v] [--out-dir d]");
|
|
2856
|
+
let steps;
|
|
2857
|
+
try {
|
|
2858
|
+
const raw = flag("--steps");
|
|
2859
|
+
if (!raw) throw new UsageError("--steps is required: a JSON array of steps.");
|
|
2860
|
+
steps = JSON.parse(raw.startsWith("@") ? fs.readFileSync(raw.slice(1), "utf8") : raw);
|
|
2861
|
+
if (!Array.isArray(steps) || steps.length === 0) throw new UsageError("--steps must be a non-empty JSON array.");
|
|
2862
|
+
steps.forEach((s, i) => {
|
|
2863
|
+
if (verbOf(s, i + 1) === "stub_route") parseStub(s, i + 1);
|
|
2864
|
+
});
|
|
2865
|
+
} catch (error) {
|
|
2866
|
+
usage(error.message);
|
|
2867
|
+
}
|
|
2868
|
+
let viewport;
|
|
2869
|
+
try {
|
|
2870
|
+
[viewport] = parseViewports(flag("--viewport") || "desktop");
|
|
2871
|
+
} catch (error) {
|
|
2872
|
+
usage(error.message);
|
|
2873
|
+
}
|
|
2874
|
+
const routes = routedPages(projectDir);
|
|
2875
|
+
let start;
|
|
2876
|
+
if (flag("--page") !== void 0) {
|
|
2877
|
+
const resolved = resolvePageRequest(routes, flag("--page"));
|
|
2878
|
+
if (!resolved.ok) usage(resolved.message);
|
|
2879
|
+
start = resolved.page;
|
|
2880
|
+
}
|
|
2881
|
+
const backendPort = flag("--backend-port") ? Number(flag("--backend-port")) : void 0;
|
|
2882
|
+
const outDir = flag("--out-dir");
|
|
2883
|
+
let leading = 0;
|
|
2884
|
+
while (leading < steps.length && verbOf(steps[leading], leading + 1) === "stub_route") leading++;
|
|
2885
|
+
let stubs;
|
|
2886
|
+
let network;
|
|
2887
|
+
const beforeBoot = async (tab) => {
|
|
2888
|
+
network = networkLog(tab);
|
|
2889
|
+
await network.start();
|
|
2890
|
+
stubs = routeStubs(tab);
|
|
2891
|
+
for (let i = 0; i < leading; i++) await stubs.add(parseStub(steps[i], i + 1));
|
|
2892
|
+
};
|
|
2893
|
+
try {
|
|
2894
|
+
const result = await withRenderedPage({ projectDir, backendPort, beforeBoot }, async (page) => {
|
|
2895
|
+
await page.setViewport(viewport);
|
|
2896
|
+
if (start && !start.isStart) await page.navigate(start.url);
|
|
2897
|
+
const rows = await runSteps(page, steps, { outDir, stubs, network, beforeBoot: leading });
|
|
2898
|
+
const pageState = await page.evaluate(`(() => {
|
|
2899
|
+
let webgl2 = false;
|
|
2900
|
+
try { webgl2 = !!document.createElement('canvas').getContext('webgl2'); } catch (e) {}
|
|
2901
|
+
return { url: location.pathname + location.hash, visibilityState: document.visibilityState, focused: document.hasFocus(), webgl2 };
|
|
2902
|
+
})()`);
|
|
2903
|
+
return {
|
|
2904
|
+
verdict: rows.length === steps.length && rows.every((r) => r.ok) ? "passed" : "failed",
|
|
2905
|
+
steps: rows,
|
|
2906
|
+
ran: rows.length,
|
|
2907
|
+
of: steps.length,
|
|
2908
|
+
viewport: `${viewport.width}x${viewport.height}`,
|
|
2909
|
+
page: pageState,
|
|
2910
|
+
...stubs.report().length ? { stubs: stubs.report() } : {},
|
|
2911
|
+
consoleErrors: page.consoleErrors.slice(0, 20),
|
|
2912
|
+
networkErrors: network.errors().slice(0, 20),
|
|
2913
|
+
otherOrigins: network.otherOrigins()
|
|
2914
|
+
};
|
|
2915
|
+
});
|
|
2916
|
+
process.stdout.write(JSON.stringify(result));
|
|
2917
|
+
} catch (error) {
|
|
2918
|
+
if (error.usage) usage(error.message);
|
|
2919
|
+
const problems = error.problems || [error instanceof Error ? error.message : String(error)];
|
|
2920
|
+
process.stdout.write(JSON.stringify({ error: { message: problems.join(" "), problems, actionable: Boolean(error.actionable) } }));
|
|
2921
|
+
}
|
|
2922
|
+
}
|
|
2923
|
+
if (require.main === module) main();
|
|
2924
|
+
module.exports = { runSteps, locateExpression, routeStubs, networkLog, parseStub, VERBS };
|