@olomonhelpdesk2/helpdesk 0.1.18 → 0.1.19
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/helpdesk-capture.mjs +611 -130
- package/helpdesk-seed.mjs +7 -1
- package/helpdesk.mjs +38 -9
- package/package.json +1 -1
package/helpdesk-capture.mjs
CHANGED
|
@@ -158,8 +158,14 @@ var CAPTURE_CONTRACT = {
|
|
|
158
158
|
timezone: "UTC",
|
|
159
159
|
/**
|
|
160
160
|
* Injected before every shot. Necessary, not sufficient — see above.
|
|
161
|
+
*
|
|
162
|
+
* Every CSS animation ends at once, on its last frame, rather than being
|
|
163
|
+
* removed. An entrance starts its element at opacity 0 and keeps the last
|
|
164
|
+
* frame it animates to, and `animation: none` left that element at opacity
|
|
165
|
+
* 0, so a site's hero was blank in every frame. One that loops ends after a
|
|
166
|
+
* single instant pass, back at its element's own style.
|
|
161
167
|
*/
|
|
162
|
-
motionOff: "*,*::before,*::after{animation:
|
|
168
|
+
motionOff: "*,*::before,*::after{animation-duration:0s!important;animation-delay:0s!important;animation-iteration-count:1!important;transition:none!important;caret-color:transparent!important}",
|
|
163
169
|
/**
|
|
164
170
|
* What an app uses to leave its own floating widgets out of the pictures.
|
|
165
171
|
*
|
|
@@ -221,7 +227,13 @@ function subjectOf(path5) {
|
|
|
221
227
|
const segments = path5.split("/").filter(Boolean).filter((segment) => !/^(api|v\d+)$/.test(segment));
|
|
222
228
|
while (segments.length > 1 && looksLikeAnId(segments[segments.length - 1])) segments.pop();
|
|
223
229
|
const last = segments[segments.length - 1] ?? "record";
|
|
224
|
-
return last.replace(/[-_]/g, " ")
|
|
230
|
+
return singular(last.replace(/[-_]/g, " "));
|
|
231
|
+
}
|
|
232
|
+
function singular(word) {
|
|
233
|
+
if (/ies$/.test(word)) return word.replace(/ies$/, "y");
|
|
234
|
+
if (/(ss|sh|ch|x|z|us)es$/.test(word)) return word.replace(/es$/, "");
|
|
235
|
+
if (/(ss|us)$/.test(word)) return word;
|
|
236
|
+
return word.replace(/s$/, "");
|
|
225
237
|
}
|
|
226
238
|
function looksLikeAnId(segment) {
|
|
227
239
|
return /^\[{1,2}[^\]]+\]{1,2}$/.test(segment) || /^\d+$/.test(segment) || /^[0-9a-f]{8}-[0-9a-f]{4}-/i.test(segment) || // `inv_123`, `cus_9f2a`: a prefix, a separator, and something with a digit
|
|
@@ -348,6 +360,35 @@ function firstSentence(text2) {
|
|
|
348
360
|
const sentence = text2.trim().split(/(?<=[.!?])\s|\n/)[0] ?? text2;
|
|
349
361
|
return sentence.length > 160 ? `${sentence.slice(0, 157)}\u2026` : sentence;
|
|
350
362
|
}
|
|
363
|
+
var OPAQUE_SCRIPT_ERROR = /^script error\.?$/i;
|
|
364
|
+
function frameUrls(stack) {
|
|
365
|
+
return stack.split("\n").filter((line) => /^\s*at\s/.test(line)).flatMap((line) => {
|
|
366
|
+
const url = /\bhttps?:\/\/[^\s()]+?(?=:\d+(?::\d+)?\)?$)/.exec(line.trim())?.[0];
|
|
367
|
+
return url ? [url] : [];
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
function siteOf(host) {
|
|
371
|
+
return /^[\d.]+$|^\[|^[^.]+$/.test(host) ? host : host.split(".").slice(-2).join(".");
|
|
372
|
+
}
|
|
373
|
+
function thrownElsewhere(error, pageUrl) {
|
|
374
|
+
let page;
|
|
375
|
+
try {
|
|
376
|
+
page = new URL(pageUrl);
|
|
377
|
+
} catch {
|
|
378
|
+
return void 0;
|
|
379
|
+
}
|
|
380
|
+
const urls = frameUrls(error.stack ?? "");
|
|
381
|
+
if (urls.length === 0) return OPAQUE_SCRIPT_ERROR.test(error.message.trim()) ? "a script from another site" : void 0;
|
|
382
|
+
const hosts = urls.flatMap((url) => {
|
|
383
|
+
try {
|
|
384
|
+
return [new URL(url).hostname];
|
|
385
|
+
} catch {
|
|
386
|
+
return [];
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
if (hosts.length === 0 || hosts.some((host) => siteOf(host) === siteOf(page.hostname))) return void 0;
|
|
390
|
+
return hosts[0];
|
|
391
|
+
}
|
|
351
392
|
function surveyLine(outcomes) {
|
|
352
393
|
const count = (result) => outcomes.filter((outcome) => outcome.result === result).length;
|
|
353
394
|
const crashed = count("crashed");
|
|
@@ -487,6 +528,15 @@ function runIdentityFromEnv(env) {
|
|
|
487
528
|
}
|
|
488
529
|
|
|
489
530
|
// core/capture/run-verdict.ts
|
|
531
|
+
function clipSuccessRate(clips) {
|
|
532
|
+
const played = clips.recorded + clips.unchanged;
|
|
533
|
+
const tried = played + clips.refused + clips.failed;
|
|
534
|
+
return tried ? { played, tried, percent: Math.round(played / tried * 100) } : null;
|
|
535
|
+
}
|
|
536
|
+
function clipCounts(clips) {
|
|
537
|
+
const rate = clipSuccessRate(clips);
|
|
538
|
+
return `${clips.recorded} recorded, ${clips.unchanged} unchanged, ${clips.deferred} deferred, ${clips.refused} refused, ${clips.failed} failed${rate ? `: ${rate.played} of ${rate.tried} play (${rate.percent}%)` : ""}`;
|
|
539
|
+
}
|
|
490
540
|
function runCounts(run2) {
|
|
491
541
|
return `${run2.captured} photographed, ${run2.unchanged} unchanged, ${run2.skipped} skipped, ${run2.refused} refused, ${run2.failed} failed`;
|
|
492
542
|
}
|
|
@@ -859,6 +909,70 @@ function paeth(a, b, c) {
|
|
|
859
909
|
const pc = Math.abs(p - c);
|
|
860
910
|
return pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
|
|
861
911
|
}
|
|
912
|
+
function encodePng(image) {
|
|
913
|
+
const stride = image.width * 4;
|
|
914
|
+
const raw = Buffer.alloc(image.height * (stride + 1));
|
|
915
|
+
const previous = Buffer.alloc(stride);
|
|
916
|
+
const tries = [0, 1, 2, 3, 4].map(() => Buffer.alloc(stride));
|
|
917
|
+
for (let y = 0; y < image.height; y += 1) {
|
|
918
|
+
const line = image.pixels.subarray(y * stride, (y + 1) * stride);
|
|
919
|
+
let best = 0;
|
|
920
|
+
let bestCost = Number.POSITIVE_INFINITY;
|
|
921
|
+
for (let filter = 0; filter <= 4; filter += 1) {
|
|
922
|
+
const out = tries[filter];
|
|
923
|
+
let cost = 0;
|
|
924
|
+
for (let index = 0; index < stride; index += 1) {
|
|
925
|
+
const x = line[index];
|
|
926
|
+
const a = index >= 4 ? line[index - 4] : 0;
|
|
927
|
+
const b = previous[index];
|
|
928
|
+
const c = index >= 4 ? previous[index - 4] : 0;
|
|
929
|
+
const value = filter === 0 ? x : filter === 1 ? x - a & 255 : filter === 2 ? x - b & 255 : filter === 3 ? x - (a + b >> 1) & 255 : x - paeth(a, b, c) & 255;
|
|
930
|
+
out[index] = value;
|
|
931
|
+
cost += value < 128 ? value : 256 - value;
|
|
932
|
+
}
|
|
933
|
+
if (cost < bestCost) {
|
|
934
|
+
bestCost = cost;
|
|
935
|
+
best = filter;
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
raw[y * (stride + 1)] = best;
|
|
939
|
+
tries[best].copy(raw, y * (stride + 1) + 1);
|
|
940
|
+
line.copy(previous);
|
|
941
|
+
}
|
|
942
|
+
const header = Buffer.alloc(13);
|
|
943
|
+
header.writeUInt32BE(image.width, 0);
|
|
944
|
+
header.writeUInt32BE(image.height, 4);
|
|
945
|
+
header.writeUInt8(8, 8);
|
|
946
|
+
header.writeUInt8(6, 9);
|
|
947
|
+
return Buffer.concat([
|
|
948
|
+
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
|
|
949
|
+
chunk("IHDR", header),
|
|
950
|
+
chunk("IDAT", deflateSync(raw, { level: 9 })),
|
|
951
|
+
chunk("IEND", Buffer.alloc(0))
|
|
952
|
+
]);
|
|
953
|
+
}
|
|
954
|
+
function chunk(type, body) {
|
|
955
|
+
const head = Buffer.alloc(8);
|
|
956
|
+
head.writeUInt32BE(body.length, 0);
|
|
957
|
+
head.write(type, 4, "ascii");
|
|
958
|
+
const tail = Buffer.alloc(4);
|
|
959
|
+
tail.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), body])), 0);
|
|
960
|
+
return Buffer.concat([head, body, tail]);
|
|
961
|
+
}
|
|
962
|
+
var crcTable;
|
|
963
|
+
function crc32(bytes) {
|
|
964
|
+
if (!crcTable) {
|
|
965
|
+
crcTable = new Uint32Array(256);
|
|
966
|
+
for (let index = 0; index < 256; index += 1) {
|
|
967
|
+
let value = index;
|
|
968
|
+
for (let bit = 0; bit < 8; bit += 1) value = value & 1 ? 3988292384 ^ value >>> 1 : value >>> 1;
|
|
969
|
+
crcTable[index] = value >>> 0;
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
let crc = 4294967295;
|
|
973
|
+
for (const byte of bytes) crc = crcTable[(crc ^ byte) & 255] ^ crc >>> 8;
|
|
974
|
+
return (crc ^ 4294967295) >>> 0;
|
|
975
|
+
}
|
|
862
976
|
function coveredFraction(image) {
|
|
863
977
|
const total = image.width * image.height;
|
|
864
978
|
if (total === 0) return 0;
|
|
@@ -913,8 +1027,14 @@ async function takeInventory(page, regions) {
|
|
|
913
1027
|
const rendered = (element) => {
|
|
914
1028
|
const style = styleOf(element);
|
|
915
1029
|
if (style.visibility === "hidden" || style.display === "none") return false;
|
|
1030
|
+
const clip = style.clip.replace(/\s|px/g, "");
|
|
1031
|
+
if (clip === "rect(0,0,0,0)" || clip === "rect(0000)") return false;
|
|
1032
|
+
if (/\binset\(\s*50%/.test(style.clipPath)) return false;
|
|
1033
|
+
const view = element.ownerDocument?.defaultView ?? window;
|
|
916
1034
|
for (const box of Array.from(element.getClientRects())) {
|
|
917
|
-
if (box.width
|
|
1035
|
+
if (box.width <= 1 || box.height <= 1) continue;
|
|
1036
|
+
if (box.bottom + view.scrollY <= 0 || box.right + view.scrollX <= 0) continue;
|
|
1037
|
+
return true;
|
|
918
1038
|
}
|
|
919
1039
|
return false;
|
|
920
1040
|
};
|
|
@@ -1180,6 +1300,61 @@ async function readPageLayout(page, cover = []) {
|
|
|
1180
1300
|
}
|
|
1181
1301
|
return contentOf(element) || clean(element.getAttribute("title"));
|
|
1182
1302
|
};
|
|
1303
|
+
const readable = (node) => {
|
|
1304
|
+
const box = node.getBoundingClientRect();
|
|
1305
|
+
return !unseen(node) && box.width >= 4 && box.height >= 4;
|
|
1306
|
+
};
|
|
1307
|
+
const labelText = (label) => {
|
|
1308
|
+
let text2 = label.innerText ?? "";
|
|
1309
|
+
for (const inner of Array.from(label.querySelectorAll("select, textarea, button, [contenteditable]"))) {
|
|
1310
|
+
const own = inner.innerText ?? "";
|
|
1311
|
+
if (own) text2 = text2.replace(own, " ");
|
|
1312
|
+
}
|
|
1313
|
+
return clean(text2);
|
|
1314
|
+
};
|
|
1315
|
+
const labelsOf = (element, role) => {
|
|
1316
|
+
const found = [];
|
|
1317
|
+
const add = (text2) => {
|
|
1318
|
+
if (text2 && !found.includes(text2)) found.push(text2);
|
|
1319
|
+
};
|
|
1320
|
+
const tag = element.tagName.toLowerCase();
|
|
1321
|
+
const native = tag === "input" || tag === "textarea" || tag === "select";
|
|
1322
|
+
for (const id of clean(element.getAttribute("aria-labelledby")).split(" ").filter(Boolean)) {
|
|
1323
|
+
const target = document.getElementById(id);
|
|
1324
|
+
if (target && readable(target)) add(labelText(target));
|
|
1325
|
+
}
|
|
1326
|
+
if (native) {
|
|
1327
|
+
const field = element;
|
|
1328
|
+
const type = clean(field.getAttribute("type")).toLowerCase();
|
|
1329
|
+
if (type === "submit" || type === "reset" || type === "button") add(clean(field.value));
|
|
1330
|
+
for (const label of Array.from(field.labels ?? [])) if (readable(label)) add(labelText(label));
|
|
1331
|
+
if (tag !== "select" && field.value === "") add(clean(field.getAttribute("placeholder")));
|
|
1332
|
+
} else if (role === "textbox" || role === "searchbox") {
|
|
1333
|
+
if (!clean(element.innerText)) add(clean(element.getAttribute("aria-placeholder")));
|
|
1334
|
+
} else {
|
|
1335
|
+
add(clean(element.innerText));
|
|
1336
|
+
}
|
|
1337
|
+
return found.map((text2) => text2.slice(0, 200)).slice(0, 4);
|
|
1338
|
+
};
|
|
1339
|
+
const fieldOf = (element, role) => {
|
|
1340
|
+
if (role !== "textbox" && role !== "searchbox" && role !== "spinbutton" && role !== "combobox") return void 0;
|
|
1341
|
+
const tag = element.tagName.toLowerCase();
|
|
1342
|
+
if (tag !== "input" && tag !== "textarea") {
|
|
1343
|
+
if (role !== "textbox") return void 0;
|
|
1344
|
+
return { kind: "textarea", empty: !clean(element.innerText) };
|
|
1345
|
+
}
|
|
1346
|
+
const field = element;
|
|
1347
|
+
const attribute = (name) => clean(field.getAttribute(name)).toLowerCase();
|
|
1348
|
+
const maxLength = field.maxLength > 0 ? field.maxLength : void 0;
|
|
1349
|
+
return {
|
|
1350
|
+
kind: tag === "textarea" ? "textarea" : attribute("type") || "text",
|
|
1351
|
+
empty: field.value === "",
|
|
1352
|
+
...attribute("autocomplete") ? { autocomplete: attribute("autocomplete").slice(0, 60) } : {},
|
|
1353
|
+
...attribute("inputmode") ? { inputmode: attribute("inputmode").slice(0, 20) } : {},
|
|
1354
|
+
...field.required || element.getAttribute("aria-required") === "true" ? { required: true } : {},
|
|
1355
|
+
...maxLength ? { maxLength } : {}
|
|
1356
|
+
};
|
|
1357
|
+
};
|
|
1183
1358
|
const stateOf = (element, role) => {
|
|
1184
1359
|
const state = {};
|
|
1185
1360
|
const aria = (name) => element.getAttribute(`aria-${name}`);
|
|
@@ -1289,7 +1464,8 @@ async function readPageLayout(page, cover = []) {
|
|
|
1289
1464
|
if (box.width < 2 || box.height < 2) continue;
|
|
1290
1465
|
if (CONTROLS.includes(role)) {
|
|
1291
1466
|
const href = role === "link" ? element.href || element.getAttribute("href") : null;
|
|
1292
|
-
|
|
1467
|
+
const field = fieldOf(element, role);
|
|
1468
|
+
controls.push({ role, name: nameOf(element), labels: labelsOf(element, role), box, state: stateOf(element, role), ...href ? { href } : {}, ...field ? { field } : {} });
|
|
1293
1469
|
} else if (LANDMARKS.includes(role)) {
|
|
1294
1470
|
regions.push({ kind: role === "dialog" || role === "alertdialog" ? "dialog" : "landmark", role, label: labelOf(element), box });
|
|
1295
1471
|
} else if (role === "heading") {
|
|
@@ -1458,7 +1634,27 @@ function ownPath(href, origin) {
|
|
|
1458
1634
|
return void 0;
|
|
1459
1635
|
}
|
|
1460
1636
|
}
|
|
1461
|
-
|
|
1637
|
+
var MAX_LABELS = 4;
|
|
1638
|
+
function readLabels(value) {
|
|
1639
|
+
if (!Array.isArray(value)) return void 0;
|
|
1640
|
+
return [...new Set(value.map(textOf).filter(Boolean))].slice(0, MAX_LABELS);
|
|
1641
|
+
}
|
|
1642
|
+
function readField(value) {
|
|
1643
|
+
const given = recordOf(value);
|
|
1644
|
+
const kind = typeof given["kind"] === "string" ? given["kind"].toLowerCase() : "";
|
|
1645
|
+
if (!/^[a-z-]{1,30}$/.test(kind) || typeof given["empty"] !== "boolean") return void 0;
|
|
1646
|
+
const word = (key, limit) => typeof given[key] === "string" && given[key] ? { [key]: given[key].toLowerCase().slice(0, limit) } : {};
|
|
1647
|
+
const maxLength = given["maxLength"];
|
|
1648
|
+
return {
|
|
1649
|
+
kind,
|
|
1650
|
+
empty: given["empty"],
|
|
1651
|
+
...word("autocomplete", 60),
|
|
1652
|
+
...word("inputmode", 20),
|
|
1653
|
+
...given["required"] === true ? { required: true } : {},
|
|
1654
|
+
...typeof maxLength === "number" && Number.isInteger(maxLength) && maxLength > 0 ? { maxLength } : {}
|
|
1655
|
+
};
|
|
1656
|
+
}
|
|
1657
|
+
function buildLayoutManifest(layout, frame, options = {}) {
|
|
1462
1658
|
const { viewport, scale } = layout;
|
|
1463
1659
|
const regions = [];
|
|
1464
1660
|
for (const raw of layout.regions.slice(0, MAX_REGIONS)) {
|
|
@@ -1475,7 +1671,18 @@ function buildLayoutManifest(layout, frame) {
|
|
|
1475
1671
|
if (!where) continue;
|
|
1476
1672
|
const href = raw.role === "link" ? ownPath(raw.href, frame.origin) : void 0;
|
|
1477
1673
|
const state = readState(raw.state);
|
|
1478
|
-
|
|
1674
|
+
const labels = options.live ? readLabels(raw.labels) : void 0;
|
|
1675
|
+
const field = options.live ? readField(raw.field) : void 0;
|
|
1676
|
+
controls.push({
|
|
1677
|
+
role: raw.role,
|
|
1678
|
+
name: textOf(raw.name),
|
|
1679
|
+
box: where.box,
|
|
1680
|
+
clipped: where.clipped,
|
|
1681
|
+
...state ? { state } : {},
|
|
1682
|
+
...href ? { href } : {},
|
|
1683
|
+
...labels ? { labels } : {},
|
|
1684
|
+
...field ? { field } : {}
|
|
1685
|
+
});
|
|
1479
1686
|
}
|
|
1480
1687
|
return {
|
|
1481
1688
|
schema: LAYOUT_MANIFEST_SCHEMA,
|
|
@@ -1521,7 +1728,9 @@ function assemble(regions, controls) {
|
|
|
1521
1728
|
...region ? { region } : {},
|
|
1522
1729
|
...control.state ? { state: control.state } : {},
|
|
1523
1730
|
...control.href ? { href: control.href } : {},
|
|
1524
|
-
...control.clipped ? { clipped: true } : {}
|
|
1731
|
+
...control.clipped ? { clipped: true } : {},
|
|
1732
|
+
...control.labels ? { labels: control.labels } : {},
|
|
1733
|
+
...control.field ? { field: control.field } : {}
|
|
1525
1734
|
};
|
|
1526
1735
|
})
|
|
1527
1736
|
};
|
|
@@ -1540,7 +1749,7 @@ var CLIP_MAX_GIF_BYTES = 8 * 1024 * 1024;
|
|
|
1540
1749
|
var CLIP_GIF_WIDTH = 960;
|
|
1541
1750
|
var CLIP_ACTIONS = ["click", "fill", "select", "check", "auto"];
|
|
1542
1751
|
function actionForRole(role, value) {
|
|
1543
|
-
if (role === "textbox" || role === "searchbox") return "fill";
|
|
1752
|
+
if (role === "textbox" || role === "searchbox" || role === "spinbutton") return "fill";
|
|
1544
1753
|
if ((role === "combobox" || role === "listbox") && value) return "select";
|
|
1545
1754
|
if (role === "checkbox" || role === "radio" || role === "switch") return "check";
|
|
1546
1755
|
return "click";
|
|
@@ -1667,7 +1876,16 @@ function readClipScripts(raw, limit = MAX_SEEDED_SCRIPTS) {
|
|
|
1667
1876
|
return { scripts, refused };
|
|
1668
1877
|
}
|
|
1669
1878
|
function comparable(text2) {
|
|
1670
|
-
return cleanText(text2).toLowerCase().replace(/(…|\.\.\.)$/, "").trim();
|
|
1879
|
+
return cleanText(text2).toLowerCase().replace(/\s*\*$/, "").replace(/(…|\.\.\.)$/, "").trim();
|
|
1880
|
+
}
|
|
1881
|
+
function wordsFor(control, step) {
|
|
1882
|
+
return !step.control.role && control.labels ? control.labels : [control.name];
|
|
1883
|
+
}
|
|
1884
|
+
function emptyFieldsBeside(manifest, control) {
|
|
1885
|
+
const region = control.region ? /* @__PURE__ */ new Set([control.region]) : void 0;
|
|
1886
|
+
const empty = manifest.controls.filter((other) => other !== control && other.field?.empty && (!region || within(manifest, other, region)));
|
|
1887
|
+
const required = empty.filter((other) => other.field?.required);
|
|
1888
|
+
return (required.length ? required : empty).map((other) => other.labels?.[0] ?? other.name).filter(Boolean).slice(0, 4);
|
|
1671
1889
|
}
|
|
1672
1890
|
var CHROME_ROLES = ["navigation", "complementary", "banner", "contentinfo"];
|
|
1673
1891
|
var IN_CHROME = /\b(sidebar|side bar|menu|navigation|nav bar|top bar|header)\b/i;
|
|
@@ -1700,8 +1918,8 @@ function resolveStep(manifest, step, options = {}) {
|
|
|
1700
1918
|
const role = step.control.role;
|
|
1701
1919
|
const what = role ?? "control";
|
|
1702
1920
|
const candidates = manifest.controls.filter((control) => !role || control.role === role);
|
|
1703
|
-
let matches = candidates.filter((control) =>
|
|
1704
|
-
if (!role && !matches.length) matches = candidates.filter((control) =>
|
|
1921
|
+
let matches = candidates.filter((control) => wordsFor(control, step).some((word) => comparable(word) === name));
|
|
1922
|
+
if (!role && !matches.length) matches = candidates.filter((control) => wordsFor(control, step).some((word) => namesControl(word, step.control.name)));
|
|
1705
1923
|
const scope = step.control.scope;
|
|
1706
1924
|
if (scope && matches.length > 1) {
|
|
1707
1925
|
const regions = new Set(manifest.regions.filter((region) => region.id === scope || comparable(region.label) === comparable(scope)).map((region) => region.id));
|
|
@@ -1719,7 +1937,10 @@ function resolveStep(manifest, step, options = {}) {
|
|
|
1719
1937
|
if (matches.length > 1) {
|
|
1720
1938
|
return { ok: false, kind: "ambiguous", because: `${matches.length} of ${what} "${step.control.name}" on ${where}${scope ? ` in ${scope}` : ""}, so the step does not say which` };
|
|
1721
1939
|
}
|
|
1722
|
-
if (matches[0].state?.disabled)
|
|
1940
|
+
if (matches[0].state?.disabled) {
|
|
1941
|
+
const waiting = emptyFieldsBeside(manifest, matches[0]);
|
|
1942
|
+
return { ok: false, kind: "disabled", because: `${what} "${step.control.name}" on ${where} is disabled${waiting.length ? `, with nothing yet in ${waiting.join(", ")} beside it` : ""}` };
|
|
1943
|
+
}
|
|
1723
1944
|
return { ok: true, control: matches[0] };
|
|
1724
1945
|
}
|
|
1725
1946
|
function onScreen(route, location2) {
|
|
@@ -1966,7 +2187,8 @@ function readRenderedFactRecords(html, options = {}) {
|
|
|
1966
2187
|
})
|
|
1967
2188
|
);
|
|
1968
2189
|
};
|
|
1969
|
-
|
|
2190
|
+
const markup = visibleMarkup(html);
|
|
2191
|
+
for (const element of elements(markup)) {
|
|
1970
2192
|
const { tag, attributes, inner, landmark } = element;
|
|
1971
2193
|
const role = attributes["role"]?.toLowerCase() ?? "";
|
|
1972
2194
|
const label = accessibleName(element);
|
|
@@ -1987,11 +2209,12 @@ function readRenderedFactRecords(html, options = {}) {
|
|
|
1987
2209
|
continue;
|
|
1988
2210
|
}
|
|
1989
2211
|
if (tag === "input" || tag === "textarea" || tag === "select") {
|
|
1990
|
-
const field = fieldFrom(element, html);
|
|
2212
|
+
const field = fieldFrom(element, html, markup);
|
|
1991
2213
|
if (field) {
|
|
1992
2214
|
add("field", field.label, landmark, {
|
|
1993
2215
|
fieldKind: field.kind,
|
|
1994
2216
|
...field.choices ? { choices: field.choices } : {},
|
|
2217
|
+
...field.placeholder ? { placeholder: field.placeholder } : {},
|
|
1995
2218
|
...controlState(element)
|
|
1996
2219
|
});
|
|
1997
2220
|
}
|
|
@@ -2040,6 +2263,7 @@ function toScreenFacts(facts, from) {
|
|
|
2040
2263
|
case "field": {
|
|
2041
2264
|
const field = { label: entry.value, kind: entry.fieldKind ?? "text" };
|
|
2042
2265
|
if (entry.choices?.length) field.choices = [...entry.choices];
|
|
2266
|
+
if (entry.placeholder) field.placeholder = entry.placeholder;
|
|
2043
2267
|
fields.push(field);
|
|
2044
2268
|
break;
|
|
2045
2269
|
}
|
|
@@ -2225,20 +2449,32 @@ function submitLike(element) {
|
|
|
2225
2449
|
const type = element.attributes["type"]?.toLowerCase();
|
|
2226
2450
|
return type === "submit" || type === "button" || type === "reset";
|
|
2227
2451
|
}
|
|
2228
|
-
function fieldFrom(element, body) {
|
|
2452
|
+
function fieldFrom(element, body, markup) {
|
|
2229
2453
|
const { tag, attributes } = element;
|
|
2230
2454
|
const type = attributes["type"]?.toLowerCase() ?? "text";
|
|
2231
2455
|
if (tag === "input" && (type === "hidden" || submitLike(element))) return void 0;
|
|
2232
2456
|
const kind = tag === "input" ? type : tag;
|
|
2233
|
-
const label = attributes["aria-label"]?.trim() || attributes["placeholder"]?.trim() || labelFor(body, attributes["id"]) || humanise(attributes["name"] ?? "");
|
|
2457
|
+
const label = attributes["aria-label"]?.trim() || labelAround(markup, element.at) || attributes["placeholder"]?.trim() || labelFor(body, attributes["id"]) || humanise(attributes["name"] ?? "");
|
|
2234
2458
|
if (!label) return void 0;
|
|
2235
2459
|
const field = { label: trim(label.replace(/\s+/g, " "), LIMITS.label), kind };
|
|
2460
|
+
const placeholder = attributes["placeholder"]?.trim().replace(/\s+/g, " ");
|
|
2461
|
+
if (placeholder && placeholder !== label.trim()) field.placeholder = trim(placeholder, LIMITS.label);
|
|
2236
2462
|
if (tag === "select") {
|
|
2237
2463
|
const choices = [...element.inner.matchAll(/<option\b[^>]*>([\s\S]*?)<\/option>/gi)].map((match) => text(match[1] ?? "")).filter(Boolean).slice(0, LIMITS.choices);
|
|
2238
2464
|
if (choices.length) field.choices = choices;
|
|
2239
2465
|
}
|
|
2240
2466
|
return field;
|
|
2241
2467
|
}
|
|
2468
|
+
function labelAround(markup, at) {
|
|
2469
|
+
const open = markup.lastIndexOf("<label", at);
|
|
2470
|
+
if (open < 0 || !/^<label[\s>]/i.test(markup.slice(open, open + 7))) return "";
|
|
2471
|
+
const start = markup.indexOf(">", open) + 1;
|
|
2472
|
+
const close = markup.indexOf("</label>", start);
|
|
2473
|
+
if (start <= 0 || close < at) return "";
|
|
2474
|
+
const inner = markup.slice(start, close).replace(/<(select|textarea|button)\b[\s\S]*?<\/\1>/gi, " ").replace(/<input\b[^>]*>/gi, " ").trim();
|
|
2475
|
+
const first = /^(?:<(\w+)\b[^>]*>([\s\S]*?)<\/\1>|([^<]+))/.exec(inner);
|
|
2476
|
+
return text(first?.[2] ?? first?.[3] ?? "");
|
|
2477
|
+
}
|
|
2242
2478
|
function labelFor(body, id) {
|
|
2243
2479
|
if (!id) return "";
|
|
2244
2480
|
const match = new RegExp(
|
|
@@ -3091,6 +3327,8 @@ async function playwrightBrowser(options = {}) {
|
|
|
3091
3327
|
// on who took it.
|
|
3092
3328
|
timezoneId: CAPTURE_CONTRACT.timezone,
|
|
3093
3329
|
locale: CAPTURE_CONTRACT.locale,
|
|
3330
|
+
// A page that offers a still version of itself to somebody who asked for less motion shows that one.
|
|
3331
|
+
reducedMotion: "reduce",
|
|
3094
3332
|
/**
|
|
3095
3333
|
* A session, when one is supplied — and only ever from a file on the
|
|
3096
3334
|
* machine running this.
|
|
@@ -3112,7 +3350,11 @@ async function playwrightBrowser(options = {}) {
|
|
|
3112
3350
|
*/
|
|
3113
3351
|
...options.sessionPath ? { storageState: options.sessionPath } : { storageState: void 0 }
|
|
3114
3352
|
};
|
|
3353
|
+
const elsewhere = /* @__PURE__ */ new Map();
|
|
3115
3354
|
return {
|
|
3355
|
+
thrownElsewhere() {
|
|
3356
|
+
return [...elsewhere.values()].map((held) => ({ by: held.by, message: held.message, screens: [...held.screens] }));
|
|
3357
|
+
},
|
|
3116
3358
|
async page(wanted) {
|
|
3117
3359
|
const phone = phoneViewport(wanted?.width);
|
|
3118
3360
|
const context = await browser.newContext(
|
|
@@ -3127,7 +3369,17 @@ async function playwrightBrowser(options = {}) {
|
|
|
3127
3369
|
const consoleErrors = [];
|
|
3128
3370
|
const failedRequests = [];
|
|
3129
3371
|
let status;
|
|
3130
|
-
page.on("pageerror", (error) =>
|
|
3372
|
+
page.on("pageerror", (error) => {
|
|
3373
|
+
const by = thrownElsewhere(error, page.url());
|
|
3374
|
+
if (!by) {
|
|
3375
|
+
errors.push(error.message);
|
|
3376
|
+
return;
|
|
3377
|
+
}
|
|
3378
|
+
const key = `${by}\0${error.message}`;
|
|
3379
|
+
const held = elsewhere.get(key) ?? { by, message: error.message, screens: /* @__PURE__ */ new Set() };
|
|
3380
|
+
held.screens.add(pathOf(page.url()));
|
|
3381
|
+
elsewhere.set(key, held);
|
|
3382
|
+
});
|
|
3131
3383
|
page.on("console", (message) => {
|
|
3132
3384
|
if (message.type() === "error") consoleErrors.push(message.text());
|
|
3133
3385
|
});
|
|
@@ -3146,6 +3398,16 @@ async function playwrightBrowser(options = {}) {
|
|
|
3146
3398
|
await page.addStyleTag({ content: CAPTURE_CONTRACT.hideOff }).catch(() => void 0);
|
|
3147
3399
|
await page.evaluate((name) => document.documentElement.setAttribute(name, ""), CAPTURE_CONTRACT.marker).catch(() => void 0);
|
|
3148
3400
|
await page.evaluate(() => document.fonts?.ready).catch(() => void 0);
|
|
3401
|
+
await page.evaluate(() => {
|
|
3402
|
+
for (const animation of document.getAnimations()) {
|
|
3403
|
+
const end = animation.effect?.getComputedTiming().endTime;
|
|
3404
|
+
if (typeof end !== "number" || !Number.isFinite(end)) continue;
|
|
3405
|
+
try {
|
|
3406
|
+
animation.finish();
|
|
3407
|
+
} catch {
|
|
3408
|
+
}
|
|
3409
|
+
}
|
|
3410
|
+
}).catch(() => void 0);
|
|
3149
3411
|
},
|
|
3150
3412
|
async text() {
|
|
3151
3413
|
return page.locator("body").innerText().catch(() => "");
|
|
@@ -3244,6 +3506,16 @@ async function playwrightBrowser(options = {}) {
|
|
|
3244
3506
|
...masks.length > 0 ? { mask: masks, maskColor: PUBLICATION_REDACTION_CSS } : {}
|
|
3245
3507
|
});
|
|
3246
3508
|
},
|
|
3509
|
+
async frame(cover) {
|
|
3510
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
3511
|
+
const before = await coversOf(page, cover ?? []).catch(() => void 0);
|
|
3512
|
+
const bytes = before ? await page.screenshot({ type: "png", fullPage: false }).catch(() => void 0) : void 0;
|
|
3513
|
+
const after = bytes ? await coversOf(page, cover ?? []).catch(() => void 0) : void 0;
|
|
3514
|
+
if (before && bytes && after && sameCovers(before, after)) return paintCovers(bytes, after);
|
|
3515
|
+
if (attempt >= 4) throw new Error("the page kept changing while its frame was taken, so no frame of it could be covered");
|
|
3516
|
+
await page.waitForLoadState("load", { timeout: 1e4 }).catch(() => void 0);
|
|
3517
|
+
}
|
|
3518
|
+
},
|
|
3247
3519
|
async close() {
|
|
3248
3520
|
await context.close();
|
|
3249
3521
|
}
|
|
@@ -3285,6 +3557,43 @@ function coverageOf(image) {
|
|
|
3285
3557
|
return 0;
|
|
3286
3558
|
}
|
|
3287
3559
|
}
|
|
3560
|
+
async function coversOf(page, selectors) {
|
|
3561
|
+
const found = await page.evaluate((wanted) => {
|
|
3562
|
+
const boxes = [];
|
|
3563
|
+
for (const selector of wanted) {
|
|
3564
|
+
let elements2;
|
|
3565
|
+
try {
|
|
3566
|
+
elements2 = Array.from(document.querySelectorAll(selector));
|
|
3567
|
+
} catch {
|
|
3568
|
+
return { unreadable: selector };
|
|
3569
|
+
}
|
|
3570
|
+
for (const element of elements2) {
|
|
3571
|
+
for (const box of Array.from(element.getClientRects())) boxes.push({ x: box.x, y: box.y, width: box.width, height: box.height });
|
|
3572
|
+
}
|
|
3573
|
+
}
|
|
3574
|
+
return { document: performance.timeOrigin, scale: window.devicePixelRatio, boxes };
|
|
3575
|
+
}, [...selectors]);
|
|
3576
|
+
if ("unreadable" in found) throw new Error(`the cover ${found.unreadable} is not a selector this page can find`);
|
|
3577
|
+
return found;
|
|
3578
|
+
}
|
|
3579
|
+
function sameCovers(before, after) {
|
|
3580
|
+
return before.document === after.document && before.scale === after.scale && JSON.stringify(before.boxes) === JSON.stringify(after.boxes);
|
|
3581
|
+
}
|
|
3582
|
+
function paintCovers(bytes, covers) {
|
|
3583
|
+
if (covers.boxes.length === 0) return bytes;
|
|
3584
|
+
const image = decodePng(Buffer.from(bytes));
|
|
3585
|
+
const { r, g, b } = PUBLICATION_REDACTION;
|
|
3586
|
+
for (const box of covers.boxes) {
|
|
3587
|
+
const left = Math.max(0, Math.floor(box.x * covers.scale));
|
|
3588
|
+
const top = Math.max(0, Math.floor(box.y * covers.scale));
|
|
3589
|
+
const right = Math.min(image.width, Math.ceil((box.x + box.width) * covers.scale));
|
|
3590
|
+
const bottom = Math.min(image.height, Math.ceil((box.y + box.height) * covers.scale));
|
|
3591
|
+
for (let y = top; y < bottom; y += 1) {
|
|
3592
|
+
for (let x = left; x < right; x += 1) image.pixels.set([r, g, b, 255], (y * image.width + x) * 4);
|
|
3593
|
+
}
|
|
3594
|
+
}
|
|
3595
|
+
return new Uint8Array(encodePng(image));
|
|
3596
|
+
}
|
|
3288
3597
|
var PUBLICATION_REDACTION_CSS = `#${[PUBLICATION_REDACTION.r, PUBLICATION_REDACTION.g, PUBLICATION_REDACTION.b].map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
|
|
3289
3598
|
async function readOpaqueSurfaces(page, regions) {
|
|
3290
3599
|
return page.evaluate(
|
|
@@ -3463,6 +3772,34 @@ import { spawn } from "node:child_process";
|
|
|
3463
3772
|
import { createHash as createHash3 } from "node:crypto";
|
|
3464
3773
|
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
3465
3774
|
import path2 from "node:path";
|
|
3775
|
+
|
|
3776
|
+
// core/capture/demo-fill.ts
|
|
3777
|
+
var DEMO_KEY = "demo-key-not-real";
|
|
3778
|
+
var UNTYPED = /* @__PURE__ */ new Set(["date", "datetime-local", "month", "week", "time", "color", "range", "file", "checkbox", "radio", "hidden"]);
|
|
3779
|
+
function demoValueForField(control) {
|
|
3780
|
+
const field = control.field;
|
|
3781
|
+
const kind = field?.kind ?? (control.role === "spinbutton" ? "number" : control.role === "searchbox" ? "search" : "text");
|
|
3782
|
+
const autocomplete = field?.autocomplete ?? "";
|
|
3783
|
+
const inputmode = field?.inputmode ?? "";
|
|
3784
|
+
const words = [...control.labels ?? [], control.name].join(" ").toLowerCase();
|
|
3785
|
+
const value = (() => {
|
|
3786
|
+
if (UNTYPED.has(kind)) return void 0;
|
|
3787
|
+
if (kind === "password" || autocomplete.includes("password") || /\b(key|token|secret|password|passphrase)\b/.test(words)) return DEMO_KEY;
|
|
3788
|
+
if (kind === "email" || autocomplete.includes("email") || inputmode === "email" || /\be-?mail\b/.test(words) || /[^\s@]+@[^\s@]+\.[a-z]{2,}/.test(words)) return "demo@example.com";
|
|
3789
|
+
if (/\bsub-?domain\b|\bslug\b/.test(words)) return "demo";
|
|
3790
|
+
if (kind === "url" || autocomplete === "url" || inputmode === "url" || /\b(url|website|web address|domain|link)\b/.test(words) || /\bhttps?:\/\/|\bwww\./.test(words)) return "https://demo.example.com";
|
|
3791
|
+
if (kind === "tel" || autocomplete.startsWith("tel") || inputmode === "tel" || /\bphone\b/.test(words)) return "555-0100";
|
|
3792
|
+
if (kind === "number" || inputmode === "numeric" || inputmode === "decimal") return "3";
|
|
3793
|
+
if (/\bjson\b/.test(words)) return '{"demo": true}';
|
|
3794
|
+
if (autocomplete === "username" || /\b(user ?name|login|handle)\b/.test(words)) return "demo";
|
|
3795
|
+
if (kind === "search") return "demo";
|
|
3796
|
+
if (kind === "textarea") return "Demo text, typed for this recording.";
|
|
3797
|
+
return "Demo";
|
|
3798
|
+
})();
|
|
3799
|
+
return value && field?.maxLength ? value.slice(0, field.maxLength) : value;
|
|
3800
|
+
}
|
|
3801
|
+
|
|
3802
|
+
// adapters/capture-clips.ts
|
|
3466
3803
|
var FRAME_MS = 1e3 / CLIP_FPS;
|
|
3467
3804
|
var OPENING_MS = 700;
|
|
3468
3805
|
var MOVE_FRAMES = 6;
|
|
@@ -3476,6 +3813,7 @@ var CLOSING_MS = 1200;
|
|
|
3476
3813
|
var SCROLL_TO = 0.45;
|
|
3477
3814
|
var WHOLE_SHIFT = 1e5;
|
|
3478
3815
|
var START = { x: Math.round(CLIP_VIEWPORT.width / 2), y: Math.round(CLIP_VIEWPORT.height * 0.66) };
|
|
3816
|
+
var CLIP_DEADLINE_MS = 12e4;
|
|
3479
3817
|
function wholePage(raw) {
|
|
3480
3818
|
const down = (item) => ({ ...item, box: { ...item.box, y: item.box.y + WHOLE_SHIFT } });
|
|
3481
3819
|
return { ...raw, viewport: { width: raw.viewport.width, height: 2 * WHOLE_SHIFT }, regions: raw.regions.map(down), controls: raw.controls.map(down) };
|
|
@@ -3483,14 +3821,40 @@ function wholePage(raw) {
|
|
|
3483
3821
|
function chunksOf(text2, size) {
|
|
3484
3822
|
return Array.from({ length: Math.ceil(text2.length / size) }, (_, index) => text2.slice(index * size, (index + 1) * size));
|
|
3485
3823
|
}
|
|
3824
|
+
var CLIP_LATE = "so its page was closed and the run went on";
|
|
3486
3825
|
async function recordClip(options, script, touched) {
|
|
3826
|
+
const limit = options.deadlineMs ?? CLIP_DEADLINE_MS;
|
|
3827
|
+
const late = { ok: false, status: "failed", because: `it did not finish within ${limit / 1e3} s, ${CLIP_LATE}` };
|
|
3828
|
+
let page;
|
|
3829
|
+
let expired = false;
|
|
3830
|
+
let deadline;
|
|
3831
|
+
const lapsed = new Promise((resolve) => {
|
|
3832
|
+
deadline = setTimeout(() => {
|
|
3833
|
+
expired = true;
|
|
3834
|
+
void page?.close().catch(() => void 0);
|
|
3835
|
+
resolve("late");
|
|
3836
|
+
}, limit);
|
|
3837
|
+
});
|
|
3838
|
+
const opening = options.browser.page({ variant: "clip" }).catch(() => void 0);
|
|
3839
|
+
void opening.then((opened) => expired ? opened?.close().catch(() => void 0) : void 0);
|
|
3840
|
+
try {
|
|
3841
|
+
const opened = await Promise.race([opening, lapsed]);
|
|
3842
|
+
if (opened === "late") return late;
|
|
3843
|
+
if (!opened) return { ok: false, status: "failed", because: "the browser would not open a page to record in" };
|
|
3844
|
+
page = opened;
|
|
3845
|
+
const result = await Promise.race([recordOn(options, script, touched, opened), lapsed]);
|
|
3846
|
+
return result === "late" || expired ? late : result;
|
|
3847
|
+
} finally {
|
|
3848
|
+
clearTimeout(deadline);
|
|
3849
|
+
if (page) await Promise.race([page.close().catch(() => void 0), new Promise((resolve) => setTimeout(resolve, 5e3))]);
|
|
3850
|
+
}
|
|
3851
|
+
}
|
|
3852
|
+
async function recordOn(options, script, touched, page) {
|
|
3487
3853
|
const dir = path2.join(options.workDir, script.id);
|
|
3488
3854
|
const frames = [];
|
|
3489
3855
|
const checked = /* @__PURE__ */ new Set();
|
|
3490
3856
|
const fail = (because) => ({ ok: false, status: "failed", because });
|
|
3491
3857
|
const refuse = (because) => ({ ok: false, status: "refused", because });
|
|
3492
|
-
const page = await options.browser.page({ variant: "clip" }).catch(() => void 0);
|
|
3493
|
-
if (!page) return fail("the browser would not open a page to record in");
|
|
3494
3858
|
try {
|
|
3495
3859
|
await rm(dir, { recursive: true, force: true });
|
|
3496
3860
|
await mkdir(dir, { recursive: true });
|
|
@@ -3530,13 +3894,14 @@ async function recordClip(options, script, touched) {
|
|
|
3530
3894
|
at += ms;
|
|
3531
3895
|
return file;
|
|
3532
3896
|
};
|
|
3533
|
-
const
|
|
3897
|
+
const picture = (covered) => page.frame ? page.frame(covered) : page.screenshot(covered);
|
|
3898
|
+
const shoot = async (ms) => keep(await picture(cover), ms);
|
|
3534
3899
|
const hold = (ms) => {
|
|
3535
3900
|
frames[frames.length - 1].ms += ms;
|
|
3536
3901
|
at += ms;
|
|
3537
3902
|
};
|
|
3538
3903
|
const film = async () => {
|
|
3539
|
-
const next = await
|
|
3904
|
+
const next = await picture(cover);
|
|
3540
3905
|
if (sameBytes(next, last)) {
|
|
3541
3906
|
hold(FRAME_MS);
|
|
3542
3907
|
return false;
|
|
@@ -3547,19 +3912,23 @@ async function recordClip(options, script, touched) {
|
|
|
3547
3912
|
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
3548
3913
|
let redirected;
|
|
3549
3914
|
const isOn = (route, where) => onScreen(route, where) || redirected?.route === route && where.split("?")[0] === redirected.path;
|
|
3550
|
-
const manifestOf = (raw, step, where) => buildLayoutManifest(
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
3556
|
-
|
|
3557
|
-
|
|
3915
|
+
const manifestOf = (raw, step, where) => buildLayoutManifest(
|
|
3916
|
+
raw,
|
|
3917
|
+
{
|
|
3918
|
+
shotId: script.id,
|
|
3919
|
+
route: step.route,
|
|
3920
|
+
path: where.split("?")[0],
|
|
3921
|
+
variant: "desktop",
|
|
3922
|
+
image: { width: raw.viewport.width, height: raw.viewport.height },
|
|
3923
|
+
origin: new URL(options.baseUrl).origin
|
|
3924
|
+
},
|
|
3925
|
+
{ live: true }
|
|
3926
|
+
);
|
|
3558
3927
|
const steady = async () => {
|
|
3559
|
-
let previous = await
|
|
3928
|
+
let previous = await picture(cover);
|
|
3560
3929
|
for (let waited = 0; waited < SETTLE_MS; waited += FRAME_MS) {
|
|
3561
3930
|
await pause(FRAME_MS);
|
|
3562
|
-
const next = await
|
|
3931
|
+
const next = await picture(cover);
|
|
3563
3932
|
if (sameBytes(next, previous)) return;
|
|
3564
3933
|
previous = next;
|
|
3565
3934
|
}
|
|
@@ -3620,11 +3989,26 @@ async function recordClip(options, script, touched) {
|
|
|
3620
3989
|
() => null,
|
|
3621
3990
|
(error) => error instanceof Error ? error.message : String(error)
|
|
3622
3991
|
);
|
|
3992
|
+
const landed = async (typedInto, step, where) => {
|
|
3993
|
+
const at2 = centreOf(typedInto.box);
|
|
3994
|
+
const field = manifestOf(await layout(cover), step, where).controls.find(
|
|
3995
|
+
(control) => control.field && at2.x >= control.box.x && at2.x <= control.box.x + control.box.width && at2.y >= control.box.y && at2.y <= control.box.y + control.box.height
|
|
3996
|
+
);
|
|
3997
|
+
return field?.name !== typedInto.name || !field.field?.empty;
|
|
3998
|
+
};
|
|
3999
|
+
const here = async (step, filming) => {
|
|
4000
|
+
if (step.control.role || step.action !== "auto" || step.follows) return void 0;
|
|
4001
|
+
const where = await location2();
|
|
4002
|
+
if (isOn(step.route, where)) return void 0;
|
|
4003
|
+
const resolution = await find(step, where, filming);
|
|
4004
|
+
return resolution.ok ? { where, resolution } : void 0;
|
|
4005
|
+
};
|
|
3623
4006
|
const performedOn = (step, where) => isOn(step.route, where) ? step.route : [...script.before ?? [], ...script.steps].map((each) => each.route).find((route) => isOn(route, where)) ?? where.split("?")[0];
|
|
3624
4007
|
const lead = async (step) => {
|
|
3625
|
-
const
|
|
3626
|
-
|
|
3627
|
-
|
|
4008
|
+
const shown = await here(step, false);
|
|
4009
|
+
const where = shown?.where ?? (step.follows ? await settled() : await reach(step.route, step.opens));
|
|
4010
|
+
if (!shown && !step.follows && !isOn(step.route, where)) return `the browser is on ${where}, not ${step.route}`;
|
|
4011
|
+
let resolution = shown?.resolution ?? await find(step, where, false);
|
|
3628
4012
|
for (let waited = 0; !resolution.ok && waited < STEP_WAIT_MS; waited += FRAME_MS) {
|
|
3629
4013
|
await pause(FRAME_MS);
|
|
3630
4014
|
resolution = await find(step, where, false);
|
|
@@ -3633,12 +4017,13 @@ async function recordClip(options, script, touched) {
|
|
|
3633
4017
|
lastRegion = resolution.control.region;
|
|
3634
4018
|
const target = centreOf(resolution.control.box);
|
|
3635
4019
|
const action = step.action === "auto" ? actionForRole(resolution.control.role, step.value) : step.action;
|
|
3636
|
-
const typed = step.value ?? step.demo;
|
|
4020
|
+
const typed = step.value ?? step.demo ?? demoValueForField(resolution.control);
|
|
3637
4021
|
if (action === "fill") {
|
|
3638
4022
|
if (!typed) return `it types into ${resolution.control.name}, and says nothing to type`;
|
|
3639
4023
|
const unfocused = await focus(target);
|
|
3640
4024
|
if (unfocused) return unfocused;
|
|
3641
4025
|
await type(typed);
|
|
4026
|
+
if (!await landed(resolution.control, step, where)) return `what was typed into ${resolution.control.name} did not land, and the field still holds nothing`;
|
|
3642
4027
|
} else if (action === "select") {
|
|
3643
4028
|
await pointer({ kind: "select", at: target, value: step.value ?? "" });
|
|
3644
4029
|
} else {
|
|
@@ -3661,8 +4046,9 @@ async function recordClip(options, script, touched) {
|
|
|
3661
4046
|
if (problem) return fail(`before the recording, step ${index + 1}: ${problem}`);
|
|
3662
4047
|
}
|
|
3663
4048
|
const first = script.steps[0];
|
|
3664
|
-
const
|
|
3665
|
-
|
|
4049
|
+
const already = script.before?.length ? await here(first, false) : void 0;
|
|
4050
|
+
const starting = !script.before?.length ? opened : already?.where ?? (first.follows ? await settled() : await reach(first.route, first.opens));
|
|
4051
|
+
if (!already && !first.follows && !isOn(first.route, starting)) return fail(`once the steps before it were done, the browser is on ${starting}, not ${first.route}`);
|
|
3666
4052
|
const before = await judge(starting);
|
|
3667
4053
|
if (before) return before;
|
|
3668
4054
|
let poster;
|
|
@@ -3685,11 +4071,12 @@ async function recordClip(options, script, touched) {
|
|
|
3685
4071
|
await cursor(resting);
|
|
3686
4072
|
checked.add(await shoot(OPENING_MS));
|
|
3687
4073
|
for (const [index, step] of script.steps.entries()) {
|
|
3688
|
-
const
|
|
3689
|
-
|
|
3690
|
-
|
|
4074
|
+
const shown = await here(step, true);
|
|
4075
|
+
const where = shown?.where ?? await arrive(step.route, step.follows === true, step.opens);
|
|
4076
|
+
if (!shown && !step.follows && !isOn(step.route, where)) return fail(`step ${index + 1}: the browser is on ${where}, not ${step.route}`);
|
|
4077
|
+
const arrived = shown ? null : await judge(where);
|
|
3691
4078
|
if (arrived) return arrived;
|
|
3692
|
-
let resolution = await find(step, where, true);
|
|
4079
|
+
let resolution = shown?.resolution ?? await find(step, where, true);
|
|
3693
4080
|
for (let waited = 0; !resolution.ok && waited < STEP_WAIT_MS; waited += FRAME_MS) {
|
|
3694
4081
|
await cursor(resting);
|
|
3695
4082
|
await film();
|
|
@@ -3700,7 +4087,7 @@ async function recordClip(options, script, touched) {
|
|
|
3700
4087
|
const target = centreOf(resolution.control.box);
|
|
3701
4088
|
const startedAt = at;
|
|
3702
4089
|
const action = step.action === "auto" ? actionForRole(resolution.control.role, step.value) : step.action;
|
|
3703
|
-
const typed = step.value ?? step.demo;
|
|
4090
|
+
const typed = step.value ?? step.demo ?? demoValueForField(resolution.control);
|
|
3704
4091
|
if (action === "fill" && !typed) return fail(`step ${index + 1}: it types into ${resolution.control.name}, and says nothing to type`);
|
|
3705
4092
|
for (const point of cursorPath(resting, target, MOVE_FRAMES)) {
|
|
3706
4093
|
await cursor(point);
|
|
@@ -3716,10 +4103,11 @@ async function recordClip(options, script, touched) {
|
|
|
3716
4103
|
const unfocused = await focus(target);
|
|
3717
4104
|
if (unfocused) return fail(`step ${index + 1}: ${unfocused}`);
|
|
3718
4105
|
await cursor(target, false);
|
|
3719
|
-
for (const
|
|
3720
|
-
await type(
|
|
4106
|
+
for (const chunk2 of chunksOf(value, Math.max(CHARS_PER_FRAME, Math.ceil(value.length / TYPING_FRAMES)))) {
|
|
4107
|
+
await type(chunk2);
|
|
3721
4108
|
await shoot(FRAME_MS);
|
|
3722
4109
|
}
|
|
4110
|
+
if (!await landed(resolution.control, step, where)) return fail(`step ${index + 1}: what was typed into ${resolution.control.name} did not land, and the field still holds nothing`);
|
|
3723
4111
|
} else if (action === "select") {
|
|
3724
4112
|
await pointer({ kind: "select", at: target, value: step.value ?? "" });
|
|
3725
4113
|
} else {
|
|
@@ -3747,9 +4135,9 @@ async function recordClip(options, script, touched) {
|
|
|
3747
4135
|
});
|
|
3748
4136
|
}
|
|
3749
4137
|
if (script.lands) {
|
|
3750
|
-
const
|
|
3751
|
-
if (!isOn(script.lands,
|
|
3752
|
-
const seen = await judge(
|
|
4138
|
+
const landed2 = await arrive(script.lands);
|
|
4139
|
+
if (!isOn(script.lands, landed2)) return fail(`the last step left the browser on ${landed2}, not ${script.lands}`);
|
|
4140
|
+
const seen = await judge(landed2);
|
|
3753
4141
|
if (seen) return seen;
|
|
3754
4142
|
}
|
|
3755
4143
|
hold(CLOSING_MS);
|
|
@@ -3786,15 +4174,18 @@ async function recordClip(options, script, touched) {
|
|
|
3786
4174
|
} catch (error) {
|
|
3787
4175
|
return fail(error instanceof Error ? error.message : String(error));
|
|
3788
4176
|
} finally {
|
|
3789
|
-
await page.close().catch(() => void 0);
|
|
3790
4177
|
await rm(dir, { recursive: true, force: true }).catch(() => void 0);
|
|
3791
4178
|
}
|
|
3792
4179
|
}
|
|
4180
|
+
function leavesBrowserWaiting(because) {
|
|
4181
|
+
return because.includes(CLIP_LATE) || /execution context was destroyed|because of a navigation/i.test(because);
|
|
4182
|
+
}
|
|
3793
4183
|
var CLIP_BUDGET_MS = 20 * 6e4;
|
|
3794
4184
|
async function runClips(options) {
|
|
3795
4185
|
const started = Date.now();
|
|
3796
4186
|
const outcomes = [];
|
|
3797
4187
|
let spent;
|
|
4188
|
+
let browser = options.browser;
|
|
3798
4189
|
for (const [index, script] of options.scripts.entries()) {
|
|
3799
4190
|
const touched = clipRoutes(script).map((route) => ({
|
|
3800
4191
|
route,
|
|
@@ -3811,9 +4202,16 @@ async function runClips(options) {
|
|
|
3811
4202
|
} else if (!options.force && options.held.get(script.id) === fingerprint) {
|
|
3812
4203
|
outcome = { ...base, status: "unchanged", reason: "neither its steps nor a screen they touch has changed" };
|
|
3813
4204
|
} else {
|
|
3814
|
-
const result = await recordClip({ ...options, screens: options.screens ?? [...options.frames.keys()] }, script, touched);
|
|
4205
|
+
const result = await recordClip({ ...options, browser, screens: options.screens ?? [...options.frames.keys()] }, script, touched);
|
|
3815
4206
|
if (!result.ok) {
|
|
3816
4207
|
outcome = { ...base, status: result.status, reason: result.because };
|
|
4208
|
+
if (options.relaunch && leavesBrowserWaiting(result.because)) {
|
|
4209
|
+
const fresh = await options.relaunch().catch(() => void 0);
|
|
4210
|
+
if (fresh) {
|
|
4211
|
+
if (browser !== options.browser) void browser.close().catch(() => void 0);
|
|
4212
|
+
browser = fresh;
|
|
4213
|
+
}
|
|
4214
|
+
}
|
|
3817
4215
|
} else {
|
|
3818
4216
|
const delivered = await options.send(result.clip);
|
|
3819
4217
|
if (delivered.ok) {
|
|
@@ -3835,14 +4233,15 @@ async function runClips(options) {
|
|
|
3835
4233
|
outcomes.push(outcome);
|
|
3836
4234
|
options.onProgress?.(outcome, index + 1, options.scripts.length);
|
|
3837
4235
|
}
|
|
4236
|
+
if (browser !== options.browser) await Promise.race([browser.close().catch(() => void 0), new Promise((resolve) => setTimeout(resolve, 5e3))]);
|
|
3838
4237
|
return outcomes;
|
|
3839
4238
|
}
|
|
3840
4239
|
function run(binary, args) {
|
|
3841
4240
|
return new Promise((resolve, reject) => {
|
|
3842
4241
|
const child = spawn(binary, args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
3843
4242
|
let stderr = "";
|
|
3844
|
-
child.stderr.on("data", (
|
|
3845
|
-
stderr = `${stderr}${
|
|
4243
|
+
child.stderr.on("data", (chunk2) => {
|
|
4244
|
+
stderr = `${stderr}${chunk2.toString()}`.slice(-2e3);
|
|
3846
4245
|
});
|
|
3847
4246
|
child.on(
|
|
3848
4247
|
"error",
|
|
@@ -3982,10 +4381,10 @@ function leadsToTheSamePlaces(children) {
|
|
|
3982
4381
|
}
|
|
3983
4382
|
function parameterName(segments, depth2) {
|
|
3984
4383
|
const parent = depth2 > 0 ? segments[depth2 - 1] : "";
|
|
3985
|
-
const base = parent && !parent.startsWith(":") ?
|
|
4384
|
+
const base = parent && !parent.startsWith(":") ? singular2(parent) : "id";
|
|
3986
4385
|
return base.replace(/[^a-z0-9]+/gi, "") || "id";
|
|
3987
4386
|
}
|
|
3988
|
-
function
|
|
4387
|
+
function singular2(word) {
|
|
3989
4388
|
const lower = word.toLowerCase();
|
|
3990
4389
|
if (/(analysis|status|address|series|species)$/.test(lower)) return lower;
|
|
3991
4390
|
if (lower.endsWith("ies")) return `${lower.slice(0, -3)}y`;
|
|
@@ -4045,7 +4444,7 @@ var MISS_STATUS = RECORDING_MISS_STATUS;
|
|
|
4045
4444
|
function bodyOf(message) {
|
|
4046
4445
|
return new Promise((resolve, reject) => {
|
|
4047
4446
|
const chunks = [];
|
|
4048
|
-
message.on("data", (
|
|
4447
|
+
message.on("data", (chunk2) => chunks.push(chunk2));
|
|
4049
4448
|
message.on("end", () => resolve(Buffer.concat(chunks)));
|
|
4050
4449
|
message.on("error", reject);
|
|
4051
4450
|
});
|
|
@@ -4219,18 +4618,12 @@ async function waitForApp(url, deadline, onLog, gaveUp) {
|
|
|
4219
4618
|
}
|
|
4220
4619
|
return false;
|
|
4221
4620
|
}
|
|
4222
|
-
async function
|
|
4223
|
-
const replay = await replayFrom({
|
|
4224
|
-
cassette: options.cassette,
|
|
4225
|
-
...options.replayPort ? { port: options.replayPort } : {},
|
|
4226
|
-
onMiss: (miss) => options.onLog?.(` the recording has no ${miss.method} ${miss.path}${miss.from ? ` (wanted by ${miss.from})` : ""}`)
|
|
4227
|
-
});
|
|
4228
|
-
const variable = options.appEnv ?? DEFAULT_APP_ENV;
|
|
4621
|
+
async function startProduct(options) {
|
|
4229
4622
|
let app;
|
|
4230
4623
|
let died;
|
|
4231
4624
|
const clock = options.appCommand ? await productClock(options.pinnedClock) : { env: {} };
|
|
4232
4625
|
if (options.appCommand) {
|
|
4233
|
-
options.onLog?.(
|
|
4626
|
+
options.onLog?.(options.starting);
|
|
4234
4627
|
app = spawn2(options.appCommand, {
|
|
4235
4628
|
shell: true,
|
|
4236
4629
|
// Its own process group, so it can be taken down as one.
|
|
@@ -4241,22 +4634,19 @@ async function stageAgainstRecording(options) {
|
|
|
4241
4634
|
// the address occupied by the last one — and on a build agent, the job
|
|
4242
4635
|
// never finishes.
|
|
4243
4636
|
detached: true,
|
|
4244
|
-
env: { ...process.env,
|
|
4637
|
+
env: { ...process.env, ...options.env, ...clock.env },
|
|
4245
4638
|
stdio: ["ignore", "pipe", "pipe"]
|
|
4246
4639
|
});
|
|
4247
|
-
app.stdout?.on("data", (
|
|
4248
|
-
app.stderr?.on("data", (
|
|
4640
|
+
app.stdout?.on("data", (chunk2) => options.onLog?.(` [app] ${chunk2.toString().trimEnd()}`));
|
|
4641
|
+
app.stderr?.on("data", (chunk2) => options.onLog?.(` [app] ${chunk2.toString().trimEnd()}`));
|
|
4249
4642
|
app.on("exit", (code) => {
|
|
4250
4643
|
died = code ?? 1;
|
|
4251
4644
|
});
|
|
4252
|
-
} else {
|
|
4253
|
-
options.onLog?.(`The recording is at ${replay.url}. Start your product with ${variable} set to it.`);
|
|
4254
4645
|
}
|
|
4255
4646
|
const up = await waitForApp(options.appUrl, Date.now() + (options.waitMs ?? 6e4), options.onLog, () => died !== void 0);
|
|
4256
4647
|
if (app) await new Promise((resolve) => setTimeout(resolve, 250));
|
|
4257
4648
|
if (died === void 0 && app?.exitCode !== null && app?.exitCode !== void 0) died = app.exitCode;
|
|
4258
4649
|
if (died !== void 0) {
|
|
4259
|
-
await replay.close();
|
|
4260
4650
|
stop(app);
|
|
4261
4651
|
await clock.written?.remove();
|
|
4262
4652
|
throw new Error(
|
|
@@ -4266,25 +4656,66 @@ Something else is already answering on ${options.appUrl}. That is almost always
|
|
|
4266
4656
|
);
|
|
4267
4657
|
}
|
|
4268
4658
|
if (!up) {
|
|
4269
|
-
await replay.close();
|
|
4270
4659
|
stop(app);
|
|
4271
4660
|
await clock.written?.remove();
|
|
4272
4661
|
throw new Error(
|
|
4273
|
-
`${options.appUrl} never answered. ` + (options.appCommand ? "Check the start command above, and that your product listens on that address." :
|
|
4662
|
+
`${options.appUrl} never answered. ` + (options.appCommand ? "Check the start command above, and that your product listens on that address." : options.unstarted)
|
|
4274
4663
|
);
|
|
4275
4664
|
}
|
|
4665
|
+
return {
|
|
4666
|
+
close: async () => {
|
|
4667
|
+
stop(app);
|
|
4668
|
+
await clock.written?.remove();
|
|
4669
|
+
}
|
|
4670
|
+
};
|
|
4671
|
+
}
|
|
4672
|
+
async function stageAgainstRecording(options) {
|
|
4673
|
+
const replay = await replayFrom({
|
|
4674
|
+
cassette: options.cassette,
|
|
4675
|
+
...options.replayPort ? { port: options.replayPort } : {},
|
|
4676
|
+
onMiss: (miss) => options.onLog?.(` the recording has no ${miss.method} ${miss.path}${miss.from ? ` (wanted by ${miss.from})` : ""}`)
|
|
4677
|
+
});
|
|
4678
|
+
const variable = options.appEnv ?? DEFAULT_APP_ENV;
|
|
4679
|
+
if (!options.appCommand) options.onLog?.(`The recording is at ${replay.url}. Start your product with ${variable} set to it.`);
|
|
4680
|
+
let running;
|
|
4681
|
+
try {
|
|
4682
|
+
running = await startProduct({
|
|
4683
|
+
appUrl: options.appUrl,
|
|
4684
|
+
appCommand: options.appCommand,
|
|
4685
|
+
env: { [variable]: replay.url },
|
|
4686
|
+
pinnedClock: options.pinnedClock,
|
|
4687
|
+
waitMs: options.waitMs,
|
|
4688
|
+
onLog: options.onLog,
|
|
4689
|
+
starting: `Starting your product with ${variable}=${replay.url}${clockNote(options.pinnedClock)}`,
|
|
4690
|
+
unstarted: `Start your product with ${variable}=${replay.url}, then run this again.`
|
|
4691
|
+
});
|
|
4692
|
+
} catch (error) {
|
|
4693
|
+
await replay.close();
|
|
4694
|
+
throw error;
|
|
4695
|
+
}
|
|
4276
4696
|
return {
|
|
4277
4697
|
appUrl: options.appUrl,
|
|
4278
4698
|
replayUrl: replay.url,
|
|
4279
4699
|
replay,
|
|
4280
4700
|
cassette: options.cassette,
|
|
4281
4701
|
close: async () => {
|
|
4282
|
-
|
|
4702
|
+
await running.close();
|
|
4283
4703
|
await replay.close();
|
|
4284
|
-
await clock.written?.remove();
|
|
4285
4704
|
}
|
|
4286
4705
|
};
|
|
4287
4706
|
}
|
|
4707
|
+
async function stageApp(options) {
|
|
4708
|
+
const running = await startProduct({
|
|
4709
|
+
appUrl: options.appUrl,
|
|
4710
|
+
appCommand: options.appCommand,
|
|
4711
|
+
pinnedClock: options.pinnedClock,
|
|
4712
|
+
waitMs: options.waitMs,
|
|
4713
|
+
onLog: options.onLog,
|
|
4714
|
+
starting: `Starting your product with ${options.appCommand}${clockNote(options.pinnedClock)}`,
|
|
4715
|
+
unstarted: ""
|
|
4716
|
+
});
|
|
4717
|
+
return { appUrl: options.appUrl, close: () => running.close() };
|
|
4718
|
+
}
|
|
4288
4719
|
function clockNote(pinnedClock) {
|
|
4289
4720
|
return pinnedClock ? `, its clock pinned to ${pinnedClock}` : "";
|
|
4290
4721
|
}
|
|
@@ -4325,51 +4756,21 @@ Nothing has been photographed. A run that carried on from here would sign in to
|
|
|
4325
4756
|
const named = options.examplesOut ? await examplesFrom(options.examplesOut, onLog) : void 0;
|
|
4326
4757
|
const examples = named?.examples;
|
|
4327
4758
|
const clips = named?.clips ?? [];
|
|
4328
|
-
|
|
4329
|
-
|
|
4330
|
-
|
|
4331
|
-
|
|
4332
|
-
|
|
4333
|
-
|
|
4334
|
-
|
|
4335
|
-
|
|
4336
|
-
|
|
4337
|
-
stdio: ["ignore", "pipe", "pipe"]
|
|
4338
|
-
});
|
|
4339
|
-
app.stdout?.on("data", (chunk) => onLog?.(` [app] ${chunk.toString().trimEnd()}`));
|
|
4340
|
-
app.stderr?.on("data", (chunk) => onLog?.(` [app] ${chunk.toString().trimEnd()}`));
|
|
4341
|
-
app.on("exit", (code) => {
|
|
4342
|
-
died = code ?? 1;
|
|
4343
|
-
});
|
|
4344
|
-
}
|
|
4345
|
-
const up = await waitForApp(options.appUrl, Date.now() + (options.waitMs ?? 6e4), onLog, () => died !== void 0);
|
|
4346
|
-
if (app) await new Promise((resolve) => setTimeout(resolve, 250));
|
|
4347
|
-
if (died === void 0 && app?.exitCode !== null && app?.exitCode !== void 0) died = app.exitCode;
|
|
4348
|
-
if (died !== void 0) {
|
|
4349
|
-
stop(app);
|
|
4350
|
-
await clock.written?.remove();
|
|
4351
|
-
throw new Error(
|
|
4352
|
-
`The start command exited (${died}) before the product was ready. Its output is above.` + (up ? `
|
|
4353
|
-
|
|
4354
|
-
Something else is already answering on ${options.appUrl}, which is almost always a server left behind by an earlier run. This will not photograph it, because it is not the product this run started.` : "")
|
|
4355
|
-
);
|
|
4356
|
-
}
|
|
4357
|
-
if (!up) {
|
|
4358
|
-
stop(app);
|
|
4359
|
-
await clock.written?.remove();
|
|
4360
|
-
throw new Error(
|
|
4361
|
-
`${options.appUrl} never answered. Check the start command above, and that your product listens on that address.`
|
|
4362
|
-
);
|
|
4363
|
-
}
|
|
4759
|
+
const running = await startProduct({
|
|
4760
|
+
appUrl: options.appUrl,
|
|
4761
|
+
appCommand: options.appCommand,
|
|
4762
|
+
pinnedClock: options.pinnedClock,
|
|
4763
|
+
waitMs: options.waitMs,
|
|
4764
|
+
onLog,
|
|
4765
|
+
starting: `Starting your product with ${options.appCommand}${clockNote(options.pinnedClock)}`,
|
|
4766
|
+
unstarted: "Check that your product listens on that address."
|
|
4767
|
+
});
|
|
4364
4768
|
return {
|
|
4365
4769
|
appUrl: options.appUrl,
|
|
4366
4770
|
...sessionPath ? { sessionPath } : {},
|
|
4367
4771
|
...examples ? { examples } : {},
|
|
4368
4772
|
...clips.length ? { clips } : {},
|
|
4369
|
-
close:
|
|
4370
|
-
stop(app);
|
|
4371
|
-
await clock.written?.remove();
|
|
4372
|
-
}
|
|
4773
|
+
close: () => running.close()
|
|
4373
4774
|
};
|
|
4374
4775
|
}
|
|
4375
4776
|
async function examplesFrom(path5, onLog) {
|
|
@@ -4413,8 +4814,8 @@ async function runToCompletion(command, timeoutMs, env, onLog) {
|
|
|
4413
4814
|
stop(child);
|
|
4414
4815
|
resolve({ ok: false, timedOut: true });
|
|
4415
4816
|
}, timeoutMs);
|
|
4416
|
-
child.stdout?.on("data", (
|
|
4417
|
-
child.stderr?.on("data", (
|
|
4817
|
+
child.stdout?.on("data", (chunk2) => onLog?.(` [seed] ${chunk2.toString().trimEnd()}`));
|
|
4818
|
+
child.stderr?.on("data", (chunk2) => onLog?.(` [seed] ${chunk2.toString().trimEnd()}`));
|
|
4418
4819
|
child.on("error", (error) => {
|
|
4419
4820
|
clearTimeout(timer);
|
|
4420
4821
|
onLog?.(` [seed] ${error.message}`);
|
|
@@ -4652,6 +5053,34 @@ function depth(path5) {
|
|
|
4652
5053
|
return path5 === "/" ? 0 : path5.split("/").filter(Boolean).length;
|
|
4653
5054
|
}
|
|
4654
5055
|
|
|
5056
|
+
// core/discovery/apps.ts
|
|
5057
|
+
var ROOT_SHARE = 0.8;
|
|
5058
|
+
function appRoot(routes) {
|
|
5059
|
+
const screens = documentable(routes);
|
|
5060
|
+
const root = [];
|
|
5061
|
+
for (; ; ) {
|
|
5062
|
+
const counts = /* @__PURE__ */ new Map();
|
|
5063
|
+
for (const route of screens) {
|
|
5064
|
+
const segments = staticSegments(route.path);
|
|
5065
|
+
const next = segments[root.length];
|
|
5066
|
+
if (next && root.every((segment2, index) => segments[index] === segment2)) counts.set(next, (counts.get(next) ?? 0) + 1);
|
|
5067
|
+
}
|
|
5068
|
+
const [segment, count = 0] = [...counts].sort((a, b) => b[1] - a[1])[0] ?? [];
|
|
5069
|
+
if (!segment || count < Math.max(3, screens.length * ROOT_SHARE)) return root;
|
|
5070
|
+
root.push(segment);
|
|
5071
|
+
}
|
|
5072
|
+
}
|
|
5073
|
+
function belowAppRoot(path5, root) {
|
|
5074
|
+
const segments = path5.split("/").filter(Boolean);
|
|
5075
|
+
return root.length && root.every((segment, index) => segments[index] === segment) ? segments.slice(root.length) : segments;
|
|
5076
|
+
}
|
|
5077
|
+
function staticSegments(path5) {
|
|
5078
|
+
return path5.split("/").filter((segment) => segment && !segment.startsWith("["));
|
|
5079
|
+
}
|
|
5080
|
+
function documentable(routes) {
|
|
5081
|
+
return routes.filter((route) => !route.skipped);
|
|
5082
|
+
}
|
|
5083
|
+
|
|
4655
5084
|
// core/discovery/article-plan.ts
|
|
4656
5085
|
var MONEY_OR_ACCESS = /* @__PURE__ */ new Set([
|
|
4657
5086
|
"billing",
|
|
@@ -4730,15 +5159,15 @@ function slugFor(path5) {
|
|
|
4730
5159
|
const segments = path5.split("/").filter(Boolean).filter((segment) => !segment.startsWith("["));
|
|
4731
5160
|
return segments.length ? segments.join("/") : "overview";
|
|
4732
5161
|
}
|
|
4733
|
-
function titleFor(route) {
|
|
4734
|
-
const segments = route.path
|
|
4735
|
-
if (!segments.length) return "Getting around";
|
|
5162
|
+
function titleFor(route, root) {
|
|
5163
|
+
const segments = belowAppRoot(route.path, root).filter((s) => !s.startsWith("["));
|
|
5164
|
+
if (!segments.length) return route.params.length ? "Working with a record" : "Getting around";
|
|
4736
5165
|
const words = segments[segments.length - 1].replace(/[-_]/g, " ");
|
|
4737
5166
|
const subject = words.charAt(0).toUpperCase() + words.slice(1);
|
|
4738
5167
|
if (!route.params.length) return subject;
|
|
4739
|
-
return `Working with ${article(
|
|
5168
|
+
return `Working with ${article(singular3(subject.toLowerCase()))}`;
|
|
4740
5169
|
}
|
|
4741
|
-
function
|
|
5170
|
+
function singular3(word) {
|
|
4742
5171
|
if (/(sis|ss|us|is)$/.test(word)) return word;
|
|
4743
5172
|
if (/(ches|shes|xes|zes)$/.test(word)) return word.slice(0, -2);
|
|
4744
5173
|
if (/[^aeiou]ies$/.test(word)) return `${word.slice(0, -3)}y`;
|
|
@@ -4784,6 +5213,7 @@ function buildArticlePlan(options) {
|
|
|
4784
5213
|
}
|
|
4785
5214
|
}
|
|
4786
5215
|
let covered = 0;
|
|
5216
|
+
const root = appRoot(options.routes);
|
|
4787
5217
|
const needs = [...merged.entries()].map(([slug, group], index) => {
|
|
4788
5218
|
const rank = index + 1;
|
|
4789
5219
|
const claimed = [group.entry.route.path, ...group.routes.slice(1) ?? []].map((path5) => options.documented?.[path5]).find(Boolean);
|
|
@@ -4791,7 +5221,7 @@ function buildArticlePlan(options) {
|
|
|
4791
5221
|
if (already) covered += 1;
|
|
4792
5222
|
return {
|
|
4793
5223
|
slug,
|
|
4794
|
-
title: group.title ?? titleFor(group.entry.route),
|
|
5224
|
+
title: group.title ?? titleFor(group.entry.route, root),
|
|
4795
5225
|
route: group.entry.route.path,
|
|
4796
5226
|
...group.routes.length > 1 ? { alsoCovers: group.routes.slice(1) } : {},
|
|
4797
5227
|
tier: tierFor(rank, merged.size),
|
|
@@ -4826,8 +5256,8 @@ function shotIdFor(route) {
|
|
|
4826
5256
|
const parts = route.split("/").filter(Boolean).map((segment) => segment.replace(/^\[+\.{0,3}|\]+$/g, ""));
|
|
4827
5257
|
return parts.length ? parts.join("-").toLowerCase() : "overview";
|
|
4828
5258
|
}
|
|
4829
|
-
function describe(route, isEntry) {
|
|
4830
|
-
const segments = route.path
|
|
5259
|
+
function describe(route, isEntry, root) {
|
|
5260
|
+
const segments = belowAppRoot(route.path, root);
|
|
4831
5261
|
const last = segments[segments.length - 1] ?? "";
|
|
4832
5262
|
const screen = route.title ? `The ${route.title} screen` : last.startsWith("[") ? `The screen for a single ${(segments[segments.length - 2] ?? "record").replace(/s$/, "")}` : last ? `The ${last.replace(/[-_]/g, " ")} screen` : "The home screen";
|
|
4833
5263
|
return isEntry ? `${screen} as someone arrives on it` : `${screen}, reached from ${segments.slice(0, -1).join("/") || "the top level"}`;
|
|
@@ -4845,10 +5275,11 @@ function buildStoryboard(options) {
|
|
|
4845
5275
|
const own = [...covered].map((path5) => byPath.get(path5)).filter((route) => Boolean(route) && !route.skipped);
|
|
4846
5276
|
const ordered = options.inOrder ? own : own.sort((a, b) => a.path.length - b.path.length).concat(beneath.sort((a, b) => a.path.length - b.path.length));
|
|
4847
5277
|
const limit = options.maxSteps ?? 5;
|
|
5278
|
+
const root = appRoot(options.routes);
|
|
4848
5279
|
const steps = ordered.slice(0, limit).map((route, index) => ({
|
|
4849
5280
|
route: route.path,
|
|
4850
5281
|
shotId: shotIdFor(route.path),
|
|
4851
|
-
depicts: describe(route, index === 0),
|
|
5282
|
+
depicts: describe(route, index === 0, root),
|
|
4852
5283
|
kind: kindFor(route, index === 0),
|
|
4853
5284
|
needsExampleFor: route.params
|
|
4854
5285
|
}));
|
|
@@ -4901,7 +5332,7 @@ function parseArgs(argv) {
|
|
|
4901
5332
|
if (args.out) {
|
|
4902
5333
|
args.key ??= "";
|
|
4903
5334
|
args.to ??= "";
|
|
4904
|
-
if (!args.baseUrl) return "Missing --base-url. That is where your product is running.";
|
|
5335
|
+
if (!args.baseUrl && !args.appCommand && !args.seedCommand && !args.cassette) return "Missing --base-url. That is where your product is running.";
|
|
4905
5336
|
return args;
|
|
4906
5337
|
}
|
|
4907
5338
|
if (!args.key) return "Missing --key, or --out <dir> to keep the frames here instead.";
|
|
@@ -5080,6 +5511,7 @@ The seed command named ${args.named} clip(s)${asked ? `, and the help desk's how
|
|
|
5080
5511
|
const outcomes = await runClips({
|
|
5081
5512
|
baseUrl: args.baseUrl,
|
|
5082
5513
|
browser: args.browser,
|
|
5514
|
+
...args.relaunch ? { relaunch: args.relaunch } : {},
|
|
5083
5515
|
encoder: ffmpegEncoder(),
|
|
5084
5516
|
workDir: path4.join(tmpdir2(), `helpdesk-clips-${process.pid}`),
|
|
5085
5517
|
clearance: args.clearance,
|
|
@@ -5104,6 +5536,9 @@ The seed command named ${args.named} clip(s)${asked ? `, and the help desk's how
|
|
|
5104
5536
|
const kb = (bytes) => `${Math.round(bytes / 1024)} KB`;
|
|
5105
5537
|
const detail = outcome.status === "recorded" ? `${((outcome.durationMs ?? 0) / 1e3).toFixed(1)} s, ${kb(outcome.mp4Bytes ?? 0)} MP4, ${outcome.gifBytes ? `${kb(outcome.gifBytes)} GIF` : "no GIF"}` : outcome.reason ?? "";
|
|
5106
5538
|
console.log(`${mark} clip ${index}/${total} ${outcome.title}: ${detail}`);
|
|
5539
|
+
if (outcome.status === "refused" || outcome.status === "failed") {
|
|
5540
|
+
for (const line of clipStepLines(args.scripts.find((script) => script.id === outcome.clipId))) console.log(` ${line}`);
|
|
5541
|
+
}
|
|
5107
5542
|
}
|
|
5108
5543
|
});
|
|
5109
5544
|
if (args.local) {
|
|
@@ -5115,15 +5550,30 @@ The seed command named ${args.named} clip(s)${asked ? `, and the help desk's how
|
|
|
5115
5550
|
await writeFile3(path4.join(args.local, "clips", "manifest.json"), `${JSON.stringify({ schema: "olomon.clip-manifest/1", clips: kept }, null, 2)}
|
|
5116
5551
|
`, "utf8");
|
|
5117
5552
|
}
|
|
5118
|
-
const count = (status) => outcomes.filter((outcome) => outcome.status === status).length;
|
|
5119
5553
|
console.log(`
|
|
5120
|
-
Clips: ${
|
|
5554
|
+
Clips: ${clipCounts(countClips(outcomes))}.`);
|
|
5121
5555
|
const missed = outcomes.filter((outcome) => outcome.status === "refused" || outcome.status === "failed");
|
|
5122
5556
|
if (missed.length && process.env["GITHUB_ACTIONS"] === "true") {
|
|
5123
5557
|
console.log(`::warning title=${missed.length} clip(s) not recorded::${missed.map((outcome) => `${outcome.clipId}: ${outcome.reason}`).join("; ")}`);
|
|
5124
5558
|
}
|
|
5125
5559
|
return outcomes;
|
|
5126
5560
|
}
|
|
5561
|
+
function countClips(outcomes) {
|
|
5562
|
+
const count = (status) => outcomes.filter((outcome) => outcome.status === status).length;
|
|
5563
|
+
return { recorded: count("recorded"), unchanged: count("unchanged"), deferred: count("deferred"), refused: count("refused"), failed: count("failed") };
|
|
5564
|
+
}
|
|
5565
|
+
function clipStepLines(script) {
|
|
5566
|
+
if (!script) return [];
|
|
5567
|
+
const line = (step, label) => `${label}: ${step.route} "${step.control.name}"${step.control.role ? ` (${step.control.role})` : ""}${step.control.scope ? ` in ${step.control.scope}` : ""}${step.follows ? ", after the press before it" : ""}`;
|
|
5568
|
+
return [...(script.before ?? []).map((step, index) => line(step, `before ${index + 1}`)), ...script.steps.map((step, index) => line(step, `step ${index + 1}`))];
|
|
5569
|
+
}
|
|
5570
|
+
function elsewhereLines(thrown) {
|
|
5571
|
+
if (thrown.length === 0) return [];
|
|
5572
|
+
return [
|
|
5573
|
+
"Scripts other sites serve into these screens threw, and no screen was counted as crashed for it:",
|
|
5574
|
+
...thrown.map((held) => ` ${held.by}, on ${held.screens.length} screen${held.screens.length === 1 ? "" : "s"}: ${held.message}`)
|
|
5575
|
+
];
|
|
5576
|
+
}
|
|
5127
5577
|
function frameForm(frame) {
|
|
5128
5578
|
const form = new FormData();
|
|
5129
5579
|
form.set(
|
|
@@ -5218,7 +5668,13 @@ Approve one on the plan page, then run this again.`
|
|
|
5218
5668
|
return 1;
|
|
5219
5669
|
}
|
|
5220
5670
|
}
|
|
5221
|
-
const
|
|
5671
|
+
const signedOut = orders.capture?.signedOut === true;
|
|
5672
|
+
if (signedOut && parsed.session) {
|
|
5673
|
+
console.log(`This workspace approved the screens anybody sees with nobody signed in, so ${parsed.session} is not used.
|
|
5674
|
+
`);
|
|
5675
|
+
delete parsed.session;
|
|
5676
|
+
}
|
|
5677
|
+
const baseUrl = parsed.cassette || parsed.seedCommand || parsed.appCommand ? parsed.baseUrl ?? "http://127.0.0.1:3000" : parsed.baseUrl ?? orders.baseUrl;
|
|
5222
5678
|
if (!baseUrl) {
|
|
5223
5679
|
console.error("No product address. Pass --base-url, or set one on the help desk's plan page.");
|
|
5224
5680
|
return 2;
|
|
@@ -5242,7 +5698,7 @@ Approve one on the plan page, then run this again.`
|
|
|
5242
5698
|
examplesOut: path4.join(process.cwd(), "helpdesk-examples.json"),
|
|
5243
5699
|
onLog: (line) => console.log(line)
|
|
5244
5700
|
});
|
|
5245
|
-
if (seeded.sessionPath) parsed.session = seeded.sessionPath;
|
|
5701
|
+
if (seeded.sessionPath && !signedOut) parsed.session = seeded.sessionPath;
|
|
5246
5702
|
else delete parsed.session;
|
|
5247
5703
|
console.log("");
|
|
5248
5704
|
} catch (error) {
|
|
@@ -5269,6 +5725,16 @@ Approve one on the plan page, then run this again.`
|
|
|
5269
5725
|
return 1;
|
|
5270
5726
|
}
|
|
5271
5727
|
}
|
|
5728
|
+
let app;
|
|
5729
|
+
if (parsed.appCommand && !seeded && !stage) {
|
|
5730
|
+
try {
|
|
5731
|
+
app = await stageApp({ appUrl: baseUrl, appCommand: parsed.appCommand, ...pinnedClock, onLog: (line) => console.log(line) });
|
|
5732
|
+
console.log("");
|
|
5733
|
+
} catch (error) {
|
|
5734
|
+
console.error(String(error.message));
|
|
5735
|
+
return 1;
|
|
5736
|
+
}
|
|
5737
|
+
}
|
|
5272
5738
|
const browser = await playwrightBrowser({
|
|
5273
5739
|
...parsed.session ? { sessionPath: parsed.session } : {},
|
|
5274
5740
|
...parsed.liveClock ? { liveClock: true } : {}
|
|
@@ -5291,6 +5757,7 @@ Approve one on the plan page, then run this again.`
|
|
|
5291
5757
|
await browser.close();
|
|
5292
5758
|
await stage?.close();
|
|
5293
5759
|
await seeded?.close();
|
|
5760
|
+
await app?.close();
|
|
5294
5761
|
return 2;
|
|
5295
5762
|
}
|
|
5296
5763
|
if (stage) {
|
|
@@ -5416,6 +5883,7 @@ ${unclassified.join("\n")}`);
|
|
|
5416
5883
|
console.log(`
|
|
5417
5884
|
Written to ${local}: ${kept.length} frame(s) and manifest.json.`);
|
|
5418
5885
|
}
|
|
5886
|
+
let clips;
|
|
5419
5887
|
if (parsed.clips) {
|
|
5420
5888
|
const named = seeded?.clips ?? [];
|
|
5421
5889
|
const articles = seeded ? articleClipsToRecord(orders.clipScripts, seeded.examples) : { scripts: [], refused: [] };
|
|
@@ -5427,10 +5895,14 @@ The help desk's articles ask for ${asked} clip(s), and none is recorded: clips a
|
|
|
5427
5895
|
const unrecorded = seeded ? unrecordedLines(articles.refused, Array.isArray(orders.clipsSkipped) ? orders.clipsSkipped : []) : [];
|
|
5428
5896
|
if (unrecorded.length) console.log(`
|
|
5429
5897
|
${unrecorded.join("\n")}`);
|
|
5430
|
-
await recordSeededClips({
|
|
5898
|
+
const recorded = await recordSeededClips({
|
|
5431
5899
|
scripts: clipsToRecord(named, articles.scripts),
|
|
5432
5900
|
named: named.length,
|
|
5433
5901
|
browser,
|
|
5902
|
+
relaunch: () => playwrightBrowser({
|
|
5903
|
+
...parsed.session ? { sessionPath: parsed.session } : {},
|
|
5904
|
+
...parsed.liveClock ? { liveClock: true } : {}
|
|
5905
|
+
}),
|
|
5434
5906
|
baseUrl,
|
|
5435
5907
|
clearance,
|
|
5436
5908
|
brands: parsed.brands,
|
|
@@ -5441,6 +5913,7 @@ ${unrecorded.join("\n")}`);
|
|
|
5441
5913
|
held: new Map((orders.clips ?? []).map((clip) => [clip.id, clip.fingerprint])),
|
|
5442
5914
|
force: Boolean(parsed.rebaseline)
|
|
5443
5915
|
});
|
|
5916
|
+
if (recorded.length) clips = countClips(recorded);
|
|
5444
5917
|
}
|
|
5445
5918
|
if (report.survey.length) {
|
|
5446
5919
|
const survey = captureReport(report.survey);
|
|
@@ -5461,6 +5934,9 @@ whether they changed. Usually a spinner that never resolves or a looping animati
|
|
|
5461
5934
|
);
|
|
5462
5935
|
}
|
|
5463
5936
|
}
|
|
5937
|
+
const elsewhere = elsewhereLines(browser.thrownElsewhere?.() ?? []);
|
|
5938
|
+
if (elsewhere.length) console.log(`
|
|
5939
|
+
${elsewhere.join("\n")}`);
|
|
5464
5940
|
const gated = report.outcomes.filter((outcome) => outcome.needsAccount);
|
|
5465
5941
|
if (gated.length) {
|
|
5466
5942
|
console.log(
|
|
@@ -5498,7 +5974,8 @@ ${gapReport(gapsFrom([...stage.replay.misses])).join("\n")}`);
|
|
|
5498
5974
|
unchanged: report.unchanged,
|
|
5499
5975
|
skipped: report.skipped,
|
|
5500
5976
|
refused: report.refused,
|
|
5501
|
-
failed: report.failed
|
|
5977
|
+
failed: report.failed,
|
|
5978
|
+
...clips ? { clips } : {}
|
|
5502
5979
|
};
|
|
5503
5980
|
if (runId) {
|
|
5504
5981
|
await told;
|
|
@@ -5531,6 +6008,7 @@ ${verdict.sentence}`);
|
|
|
5531
6008
|
await browser.close();
|
|
5532
6009
|
await stage?.close();
|
|
5533
6010
|
await seeded?.close();
|
|
6011
|
+
await app?.close();
|
|
5534
6012
|
}
|
|
5535
6013
|
}
|
|
5536
6014
|
async function openRun(to, headers, total) {
|
|
@@ -5595,7 +6073,10 @@ if (runningAsCommand(import.meta.url)) {
|
|
|
5595
6073
|
export {
|
|
5596
6074
|
articleClipsToRecord,
|
|
5597
6075
|
clipForm,
|
|
6076
|
+
clipStepLines,
|
|
5598
6077
|
clipsToRecord,
|
|
6078
|
+
countClips,
|
|
6079
|
+
elsewhereLines,
|
|
5599
6080
|
frameForm,
|
|
5600
6081
|
framesOnDisk,
|
|
5601
6082
|
main,
|