@olomonhelpdesk2/helpdesk 0.1.17 → 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 +710 -169
- 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
|
|
@@ -248,6 +260,11 @@ function helpdeskCommand(command, args = []) {
|
|
|
248
260
|
|
|
249
261
|
// core/capture/classify.ts
|
|
250
262
|
var THIN_TEXT_CHARS = 25;
|
|
263
|
+
var ERROR_PAGE_PHRASES = [
|
|
264
|
+
/\bapplication error: a (server|client)-side exception has occurred\b/i,
|
|
265
|
+
/\bthis screen did not load\b/i,
|
|
266
|
+
/\bdigest\s*:?\s*[\w-]{6,}\s+copy reference\b/i
|
|
267
|
+
];
|
|
251
268
|
var BOUNDARY_PHRASES = [
|
|
252
269
|
/\bsomething went wrong\b/i,
|
|
253
270
|
/\bapplication error\b/i,
|
|
@@ -256,8 +273,12 @@ var BOUNDARY_PHRASES = [
|
|
|
256
273
|
/\binternal server error\b/i,
|
|
257
274
|
/\bthis page (could not|couldn'?t) be loaded\b/i,
|
|
258
275
|
/\bwe('| a)re sorry[ —,-]+something\b/i,
|
|
259
|
-
/\berror (id|digest)\b/i
|
|
276
|
+
/\berror (id|digest)\b/i,
|
|
277
|
+
...ERROR_PAGE_PHRASES
|
|
260
278
|
];
|
|
279
|
+
function showsErrorPage(text2) {
|
|
280
|
+
return ERROR_PAGE_PHRASES.some((phrase) => phrase.test(text2));
|
|
281
|
+
}
|
|
261
282
|
var NOT_FOUND_PHRASES = [
|
|
262
283
|
/\b(page|it) (was )?not found\b/i,
|
|
263
284
|
/\b404\b/,
|
|
@@ -339,6 +360,35 @@ function firstSentence(text2) {
|
|
|
339
360
|
const sentence = text2.trim().split(/(?<=[.!?])\s|\n/)[0] ?? text2;
|
|
340
361
|
return sentence.length > 160 ? `${sentence.slice(0, 157)}\u2026` : sentence;
|
|
341
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
|
+
}
|
|
342
392
|
function surveyLine(outcomes) {
|
|
343
393
|
const count = (result) => outcomes.filter((outcome) => outcome.result === result).length;
|
|
344
394
|
const crashed = count("crashed");
|
|
@@ -478,6 +528,15 @@ function runIdentityFromEnv(env) {
|
|
|
478
528
|
}
|
|
479
529
|
|
|
480
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
|
+
}
|
|
481
540
|
function runCounts(run2) {
|
|
482
541
|
return `${run2.captured} photographed, ${run2.unchanged} unchanged, ${run2.skipped} skipped, ${run2.refused} refused, ${run2.failed} failed`;
|
|
483
542
|
}
|
|
@@ -850,6 +909,70 @@ function paeth(a, b, c) {
|
|
|
850
909
|
const pc = Math.abs(p - c);
|
|
851
910
|
return pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
|
|
852
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
|
+
}
|
|
853
976
|
function coveredFraction(image) {
|
|
854
977
|
const total = image.width * image.height;
|
|
855
978
|
if (total === 0) return 0;
|
|
@@ -904,8 +1027,14 @@ async function takeInventory(page, regions) {
|
|
|
904
1027
|
const rendered = (element) => {
|
|
905
1028
|
const style = styleOf(element);
|
|
906
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;
|
|
907
1034
|
for (const box of Array.from(element.getClientRects())) {
|
|
908
|
-
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;
|
|
909
1038
|
}
|
|
910
1039
|
return false;
|
|
911
1040
|
};
|
|
@@ -1010,6 +1139,7 @@ async function readPaintedMarkup(page, regions) {
|
|
|
1010
1139
|
const style = window.getComputedStyle(element);
|
|
1011
1140
|
if (style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse") return false;
|
|
1012
1141
|
if (style.display === "contents") return true;
|
|
1142
|
+
if (typeof element.checkVisibility === "function" && !element.checkVisibility()) return false;
|
|
1013
1143
|
for (const box of Array.from(element.getClientRects())) {
|
|
1014
1144
|
if (box.width > 0 && box.height > 0) return true;
|
|
1015
1145
|
}
|
|
@@ -1090,6 +1220,7 @@ async function readPageLayout(page, cover = []) {
|
|
|
1090
1220
|
};
|
|
1091
1221
|
const unseen = (element) => {
|
|
1092
1222
|
if (element.closest('[aria-hidden="true"], [inert]')) return true;
|
|
1223
|
+
if (typeof element.checkVisibility === "function" && !element.checkVisibility()) return true;
|
|
1093
1224
|
for (let node = element; node; node = node.parentElement) {
|
|
1094
1225
|
const style = window.getComputedStyle(node);
|
|
1095
1226
|
if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") return true;
|
|
@@ -1169,6 +1300,61 @@ async function readPageLayout(page, cover = []) {
|
|
|
1169
1300
|
}
|
|
1170
1301
|
return contentOf(element) || clean(element.getAttribute("title"));
|
|
1171
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
|
+
};
|
|
1172
1358
|
const stateOf = (element, role) => {
|
|
1173
1359
|
const state = {};
|
|
1174
1360
|
const aria = (name) => element.getAttribute(`aria-${name}`);
|
|
@@ -1278,7 +1464,8 @@ async function readPageLayout(page, cover = []) {
|
|
|
1278
1464
|
if (box.width < 2 || box.height < 2) continue;
|
|
1279
1465
|
if (CONTROLS.includes(role)) {
|
|
1280
1466
|
const href = role === "link" ? element.href || element.getAttribute("href") : null;
|
|
1281
|
-
|
|
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 } : {} });
|
|
1282
1469
|
} else if (LANDMARKS.includes(role)) {
|
|
1283
1470
|
regions.push({ kind: role === "dialog" || role === "alertdialog" ? "dialog" : "landmark", role, label: labelOf(element), box });
|
|
1284
1471
|
} else if (role === "heading") {
|
|
@@ -1447,7 +1634,27 @@ function ownPath(href, origin) {
|
|
|
1447
1634
|
return void 0;
|
|
1448
1635
|
}
|
|
1449
1636
|
}
|
|
1450
|
-
|
|
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 = {}) {
|
|
1451
1658
|
const { viewport, scale } = layout;
|
|
1452
1659
|
const regions = [];
|
|
1453
1660
|
for (const raw of layout.regions.slice(0, MAX_REGIONS)) {
|
|
@@ -1464,7 +1671,18 @@ function buildLayoutManifest(layout, frame) {
|
|
|
1464
1671
|
if (!where) continue;
|
|
1465
1672
|
const href = raw.role === "link" ? ownPath(raw.href, frame.origin) : void 0;
|
|
1466
1673
|
const state = readState(raw.state);
|
|
1467
|
-
|
|
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
|
+
});
|
|
1468
1686
|
}
|
|
1469
1687
|
return {
|
|
1470
1688
|
schema: LAYOUT_MANIFEST_SCHEMA,
|
|
@@ -1510,7 +1728,9 @@ function assemble(regions, controls) {
|
|
|
1510
1728
|
...region ? { region } : {},
|
|
1511
1729
|
...control.state ? { state: control.state } : {},
|
|
1512
1730
|
...control.href ? { href: control.href } : {},
|
|
1513
|
-
...control.clipped ? { clipped: true } : {}
|
|
1731
|
+
...control.clipped ? { clipped: true } : {},
|
|
1732
|
+
...control.labels ? { labels: control.labels } : {},
|
|
1733
|
+
...control.field ? { field: control.field } : {}
|
|
1514
1734
|
};
|
|
1515
1735
|
})
|
|
1516
1736
|
};
|
|
@@ -1529,7 +1749,7 @@ var CLIP_MAX_GIF_BYTES = 8 * 1024 * 1024;
|
|
|
1529
1749
|
var CLIP_GIF_WIDTH = 960;
|
|
1530
1750
|
var CLIP_ACTIONS = ["click", "fill", "select", "check", "auto"];
|
|
1531
1751
|
function actionForRole(role, value) {
|
|
1532
|
-
if (role === "textbox" || role === "searchbox") return "fill";
|
|
1752
|
+
if (role === "textbox" || role === "searchbox" || role === "spinbutton") return "fill";
|
|
1533
1753
|
if ((role === "combobox" || role === "listbox") && value) return "select";
|
|
1534
1754
|
if (role === "checkbox" || role === "radio" || role === "switch") return "check";
|
|
1535
1755
|
return "click";
|
|
@@ -1572,11 +1792,17 @@ function readStep(value, at) {
|
|
|
1572
1792
|
if (action !== "auto" && !typed && given !== void 0) return `step ${at} gives a value to ${action}, which takes none`;
|
|
1573
1793
|
if (action === "auto" && given !== void 0 && !valued) return `step ${at} has a value nothing can type`;
|
|
1574
1794
|
if (step["follows"] !== void 0 && step["follows"] !== true) return `step ${at} says neither that it follows the step before it nor that it does not`;
|
|
1795
|
+
const demo = step["demo"] === void 0 ? void 0 : cleanText(step["demo"]);
|
|
1796
|
+
const opens = step["opens"] === void 0 ? void 0 : cleanText(step["opens"]);
|
|
1797
|
+
if (demo !== void 0 && (!demo || demo.length > MAX_TEXT2 || action !== "auto" && action !== "fill")) return `step ${at} has a demo value nothing types`;
|
|
1798
|
+
if (opens !== void 0 && (!isPathLike(opens) || !fillsPattern(route, opens.split("?")[0]))) return `step ${at} opens at ${opens || "nowhere"}, which is not ${route}`;
|
|
1575
1799
|
return {
|
|
1576
1800
|
route,
|
|
1577
1801
|
control: { ...role === void 0 ? {} : { role }, name, ...scope ? { scope } : {} },
|
|
1578
1802
|
action,
|
|
1579
1803
|
...valued && (typed || action === "auto") ? { value: given } : {},
|
|
1804
|
+
...demo ? { demo } : {},
|
|
1805
|
+
...opens ? { opens } : {},
|
|
1580
1806
|
says,
|
|
1581
1807
|
...step["follows"] === true ? { follows: true } : {}
|
|
1582
1808
|
};
|
|
@@ -1650,7 +1876,16 @@ function readClipScripts(raw, limit = MAX_SEEDED_SCRIPTS) {
|
|
|
1650
1876
|
return { scripts, refused };
|
|
1651
1877
|
}
|
|
1652
1878
|
function comparable(text2) {
|
|
1653
|
-
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);
|
|
1654
1889
|
}
|
|
1655
1890
|
var CHROME_ROLES = ["navigation", "complementary", "banner", "contentinfo"];
|
|
1656
1891
|
var IN_CHROME = /\b(sidebar|side bar|menu|navigation|nav bar|top bar|header)\b/i;
|
|
@@ -1677,30 +1912,35 @@ function inChrome(manifest, control) {
|
|
|
1677
1912
|
}
|
|
1678
1913
|
return false;
|
|
1679
1914
|
}
|
|
1680
|
-
function resolveStep(manifest, step) {
|
|
1915
|
+
function resolveStep(manifest, step, options = {}) {
|
|
1681
1916
|
const name = comparable(step.control.name);
|
|
1682
1917
|
const where = manifest.screen.path;
|
|
1683
1918
|
const role = step.control.role;
|
|
1684
1919
|
const what = role ?? "control";
|
|
1685
1920
|
const candidates = manifest.controls.filter((control) => !role || control.role === role);
|
|
1686
|
-
let matches = candidates.filter((control) =>
|
|
1687
|
-
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)));
|
|
1688
1923
|
const scope = step.control.scope;
|
|
1689
1924
|
if (scope && matches.length > 1) {
|
|
1690
1925
|
const regions = new Set(manifest.regions.filter((region) => region.id === scope || comparable(region.label) === comparable(scope)).map((region) => region.id));
|
|
1691
1926
|
const scoped = matches.filter((control) => within(manifest, control, regions));
|
|
1692
1927
|
if (scoped.length) matches = scoped;
|
|
1693
1928
|
}
|
|
1694
|
-
|
|
1695
|
-
const
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
if (matches.length
|
|
1929
|
+
const narrow = (keep) => {
|
|
1930
|
+
const kept = matches.filter(keep);
|
|
1931
|
+
if (kept.length === 1) matches = kept;
|
|
1932
|
+
};
|
|
1933
|
+
if (!role && matches.length > 1 && options.after) narrow((control) => within(manifest, control, /* @__PURE__ */ new Set([options.after])));
|
|
1934
|
+
if (!role && matches.length > 1) narrow((control) => inChrome(manifest, control) === IN_CHROME.test(step.says));
|
|
1935
|
+
if (!role && matches.length > 1) narrow((control) => !control.state?.disabled);
|
|
1936
|
+
if (matches.length === 0) return { ok: false, kind: "missing", because: `no ${what} "${step.control.name}" on ${where}` };
|
|
1700
1937
|
if (matches.length > 1) {
|
|
1701
|
-
return { ok: false, because: `${matches.length} of ${what} "${step.control.name}" on ${where}${scope ? ` in ${scope}` : ""}, so the step does not say which` };
|
|
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` };
|
|
1939
|
+
}
|
|
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` : ""}` };
|
|
1702
1943
|
}
|
|
1703
|
-
if (matches[0].state?.disabled) return { ok: false, because: `${what} "${step.control.name}" on ${where} is disabled` };
|
|
1704
1944
|
return { ok: true, control: matches[0] };
|
|
1705
1945
|
}
|
|
1706
1946
|
function onScreen(route, location2) {
|
|
@@ -1715,6 +1955,7 @@ function clipFingerprint(script, touched) {
|
|
|
1715
1955
|
role: step.control.role ?? null,
|
|
1716
1956
|
name: step.control.name,
|
|
1717
1957
|
...step.control.scope ? { scope: step.control.scope } : {},
|
|
1958
|
+
...step.demo ? { demo: step.demo } : {},
|
|
1718
1959
|
action: step.action,
|
|
1719
1960
|
value: step.value ?? null,
|
|
1720
1961
|
says: step.says
|
|
@@ -1946,7 +2187,8 @@ function readRenderedFactRecords(html, options = {}) {
|
|
|
1946
2187
|
})
|
|
1947
2188
|
);
|
|
1948
2189
|
};
|
|
1949
|
-
|
|
2190
|
+
const markup = visibleMarkup(html);
|
|
2191
|
+
for (const element of elements(markup)) {
|
|
1950
2192
|
const { tag, attributes, inner, landmark } = element;
|
|
1951
2193
|
const role = attributes["role"]?.toLowerCase() ?? "";
|
|
1952
2194
|
const label = accessibleName(element);
|
|
@@ -1967,11 +2209,12 @@ function readRenderedFactRecords(html, options = {}) {
|
|
|
1967
2209
|
continue;
|
|
1968
2210
|
}
|
|
1969
2211
|
if (tag === "input" || tag === "textarea" || tag === "select") {
|
|
1970
|
-
const field = fieldFrom(element, html);
|
|
2212
|
+
const field = fieldFrom(element, html, markup);
|
|
1971
2213
|
if (field) {
|
|
1972
2214
|
add("field", field.label, landmark, {
|
|
1973
2215
|
fieldKind: field.kind,
|
|
1974
2216
|
...field.choices ? { choices: field.choices } : {},
|
|
2217
|
+
...field.placeholder ? { placeholder: field.placeholder } : {},
|
|
1975
2218
|
...controlState(element)
|
|
1976
2219
|
});
|
|
1977
2220
|
}
|
|
@@ -2020,6 +2263,7 @@ function toScreenFacts(facts, from) {
|
|
|
2020
2263
|
case "field": {
|
|
2021
2264
|
const field = { label: entry.value, kind: entry.fieldKind ?? "text" };
|
|
2022
2265
|
if (entry.choices?.length) field.choices = [...entry.choices];
|
|
2266
|
+
if (entry.placeholder) field.placeholder = entry.placeholder;
|
|
2023
2267
|
fields.push(field);
|
|
2024
2268
|
break;
|
|
2025
2269
|
}
|
|
@@ -2205,20 +2449,32 @@ function submitLike(element) {
|
|
|
2205
2449
|
const type = element.attributes["type"]?.toLowerCase();
|
|
2206
2450
|
return type === "submit" || type === "button" || type === "reset";
|
|
2207
2451
|
}
|
|
2208
|
-
function fieldFrom(element, body) {
|
|
2452
|
+
function fieldFrom(element, body, markup) {
|
|
2209
2453
|
const { tag, attributes } = element;
|
|
2210
2454
|
const type = attributes["type"]?.toLowerCase() ?? "text";
|
|
2211
2455
|
if (tag === "input" && (type === "hidden" || submitLike(element))) return void 0;
|
|
2212
2456
|
const kind = tag === "input" ? type : tag;
|
|
2213
|
-
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"] ?? "");
|
|
2214
2458
|
if (!label) return void 0;
|
|
2215
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);
|
|
2216
2462
|
if (tag === "select") {
|
|
2217
2463
|
const choices = [...element.inner.matchAll(/<option\b[^>]*>([\s\S]*?)<\/option>/gi)].map((match) => text(match[1] ?? "")).filter(Boolean).slice(0, LIMITS.choices);
|
|
2218
2464
|
if (choices.length) field.choices = choices;
|
|
2219
2465
|
}
|
|
2220
2466
|
return field;
|
|
2221
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
|
+
}
|
|
2222
2478
|
function labelFor(body, id) {
|
|
2223
2479
|
if (!id) return "";
|
|
2224
2480
|
const match = new RegExp(
|
|
@@ -3071,6 +3327,8 @@ async function playwrightBrowser(options = {}) {
|
|
|
3071
3327
|
// on who took it.
|
|
3072
3328
|
timezoneId: CAPTURE_CONTRACT.timezone,
|
|
3073
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",
|
|
3074
3332
|
/**
|
|
3075
3333
|
* A session, when one is supplied — and only ever from a file on the
|
|
3076
3334
|
* machine running this.
|
|
@@ -3092,7 +3350,11 @@ async function playwrightBrowser(options = {}) {
|
|
|
3092
3350
|
*/
|
|
3093
3351
|
...options.sessionPath ? { storageState: options.sessionPath } : { storageState: void 0 }
|
|
3094
3352
|
};
|
|
3353
|
+
const elsewhere = /* @__PURE__ */ new Map();
|
|
3095
3354
|
return {
|
|
3355
|
+
thrownElsewhere() {
|
|
3356
|
+
return [...elsewhere.values()].map((held) => ({ by: held.by, message: held.message, screens: [...held.screens] }));
|
|
3357
|
+
},
|
|
3096
3358
|
async page(wanted) {
|
|
3097
3359
|
const phone = phoneViewport(wanted?.width);
|
|
3098
3360
|
const context = await browser.newContext(
|
|
@@ -3107,7 +3369,17 @@ async function playwrightBrowser(options = {}) {
|
|
|
3107
3369
|
const consoleErrors = [];
|
|
3108
3370
|
const failedRequests = [];
|
|
3109
3371
|
let status;
|
|
3110
|
-
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
|
+
});
|
|
3111
3383
|
page.on("console", (message) => {
|
|
3112
3384
|
if (message.type() === "error") consoleErrors.push(message.text());
|
|
3113
3385
|
});
|
|
@@ -3126,6 +3398,16 @@ async function playwrightBrowser(options = {}) {
|
|
|
3126
3398
|
await page.addStyleTag({ content: CAPTURE_CONTRACT.hideOff }).catch(() => void 0);
|
|
3127
3399
|
await page.evaluate((name) => document.documentElement.setAttribute(name, ""), CAPTURE_CONTRACT.marker).catch(() => void 0);
|
|
3128
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);
|
|
3129
3411
|
},
|
|
3130
3412
|
async text() {
|
|
3131
3413
|
return page.locator("body").innerText().catch(() => "");
|
|
@@ -3195,7 +3477,14 @@ async function playwrightBrowser(options = {}) {
|
|
|
3195
3477
|
return;
|
|
3196
3478
|
}
|
|
3197
3479
|
await page.mouse.click(action.at.x, action.at.y);
|
|
3198
|
-
if (action.kind
|
|
3480
|
+
if (action.kind !== "focus") return;
|
|
3481
|
+
const field = await page.evaluate(() => {
|
|
3482
|
+
const focused = document.activeElement;
|
|
3483
|
+
const kind = focused instanceof HTMLInputElement ? focused.type : "";
|
|
3484
|
+
return Boolean(focused && (focused.isContentEditable || focused instanceof HTMLTextAreaElement || focused instanceof HTMLInputElement && !["button", "checkbox", "color", "file", "image", "radio", "range", "reset", "submit"].includes(kind)));
|
|
3485
|
+
});
|
|
3486
|
+
if (!field) throw new Error(`the press at (${action.at.x}, ${action.at.y}) focused no field, so nothing could be typed`);
|
|
3487
|
+
await page.keyboard.press("ControlOrMeta+A");
|
|
3199
3488
|
},
|
|
3200
3489
|
async type(text2) {
|
|
3201
3490
|
await page.keyboard.type(text2);
|
|
@@ -3217,6 +3506,16 @@ async function playwrightBrowser(options = {}) {
|
|
|
3217
3506
|
...masks.length > 0 ? { mask: masks, maskColor: PUBLICATION_REDACTION_CSS } : {}
|
|
3218
3507
|
});
|
|
3219
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
|
+
},
|
|
3220
3519
|
async close() {
|
|
3221
3520
|
await context.close();
|
|
3222
3521
|
}
|
|
@@ -3258,6 +3557,43 @@ function coverageOf(image) {
|
|
|
3258
3557
|
return 0;
|
|
3259
3558
|
}
|
|
3260
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
|
+
}
|
|
3261
3597
|
var PUBLICATION_REDACTION_CSS = `#${[PUBLICATION_REDACTION.r, PUBLICATION_REDACTION.g, PUBLICATION_REDACTION.b].map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
|
|
3262
3598
|
async function readOpaqueSurfaces(page, regions) {
|
|
3263
3599
|
return page.evaluate(
|
|
@@ -3436,6 +3772,34 @@ import { spawn } from "node:child_process";
|
|
|
3436
3772
|
import { createHash as createHash3 } from "node:crypto";
|
|
3437
3773
|
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
3438
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
|
|
3439
3803
|
var FRAME_MS = 1e3 / CLIP_FPS;
|
|
3440
3804
|
var OPENING_MS = 700;
|
|
3441
3805
|
var MOVE_FRAMES = 6;
|
|
@@ -3449,6 +3813,7 @@ var CLOSING_MS = 1200;
|
|
|
3449
3813
|
var SCROLL_TO = 0.45;
|
|
3450
3814
|
var WHOLE_SHIFT = 1e5;
|
|
3451
3815
|
var START = { x: Math.round(CLIP_VIEWPORT.width / 2), y: Math.round(CLIP_VIEWPORT.height * 0.66) };
|
|
3816
|
+
var CLIP_DEADLINE_MS = 12e4;
|
|
3452
3817
|
function wholePage(raw) {
|
|
3453
3818
|
const down = (item) => ({ ...item, box: { ...item.box, y: item.box.y + WHOLE_SHIFT } });
|
|
3454
3819
|
return { ...raw, viewport: { width: raw.viewport.width, height: 2 * WHOLE_SHIFT }, regions: raw.regions.map(down), controls: raw.controls.map(down) };
|
|
@@ -3456,14 +3821,40 @@ function wholePage(raw) {
|
|
|
3456
3821
|
function chunksOf(text2, size) {
|
|
3457
3822
|
return Array.from({ length: Math.ceil(text2.length / size) }, (_, index) => text2.slice(index * size, (index + 1) * size));
|
|
3458
3823
|
}
|
|
3824
|
+
var CLIP_LATE = "so its page was closed and the run went on";
|
|
3459
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) {
|
|
3460
3853
|
const dir = path2.join(options.workDir, script.id);
|
|
3461
3854
|
const frames = [];
|
|
3462
3855
|
const checked = /* @__PURE__ */ new Set();
|
|
3463
3856
|
const fail = (because) => ({ ok: false, status: "failed", because });
|
|
3464
3857
|
const refuse = (because) => ({ ok: false, status: "refused", because });
|
|
3465
|
-
const page = await options.browser.page({ variant: "clip" }).catch(() => void 0);
|
|
3466
|
-
if (!page) return fail("the browser would not open a page to record in");
|
|
3467
3858
|
try {
|
|
3468
3859
|
await rm(dir, { recursive: true, force: true });
|
|
3469
3860
|
await mkdir(dir, { recursive: true });
|
|
@@ -3476,20 +3867,22 @@ async function recordClip(options, script, touched) {
|
|
|
3476
3867
|
let last = new Uint8Array();
|
|
3477
3868
|
const judge = async (where) => {
|
|
3478
3869
|
const route = where.split("?")[0];
|
|
3479
|
-
const
|
|
3870
|
+
const text2 = await page.text();
|
|
3871
|
+
if (showsErrorPage(text2)) return fail(`${route} answered with its error page, not the screen`);
|
|
3872
|
+
const verdict = gateFrame(text2, {
|
|
3480
3873
|
route,
|
|
3481
3874
|
...options.brands ? { brands: options.brands } : {},
|
|
3482
3875
|
...options.clearance?.expectedBrands?.length ? { expected: options.clearance.expectedBrands } : {}
|
|
3483
3876
|
});
|
|
3484
|
-
if (!verdict.safe) return verdict.reason ?? `the gate refused ${route}
|
|
3877
|
+
if (!verdict.safe) return refuse(verdict.reason ?? `the gate refused ${route}`);
|
|
3485
3878
|
if (!options.clearance) return null;
|
|
3486
3879
|
const clearance = options.clearance;
|
|
3487
3880
|
const reading = await readScreen(page, clearance);
|
|
3488
|
-
if (!reading) return "this browser cannot read a screen against a clearance";
|
|
3489
|
-
if (reading.inventory.unaccounted.length) return `${reading.inventory.unaccounted.length} value(s) on ${route} are outside every region the clearance names
|
|
3881
|
+
if (!reading) return refuse("this browser cannot read a screen against a clearance");
|
|
3882
|
+
if (reading.inventory.unaccounted.length) return refuse(`${reading.inventory.unaccounted.length} value(s) on ${route} are outside every region the clearance names`);
|
|
3490
3883
|
const approved = new Set(clearance.approved);
|
|
3491
3884
|
const loose = reading.surfaces.filter((surface) => !surface.read && !clearance.covers.includes(surface.where) && !approved.has(surface.where));
|
|
3492
|
-
if (loose.length && !clearance.coverUnnamed) return `${loose.length} surface(s) on ${route} are neither approved nor covered
|
|
3885
|
+
if (loose.length && !clearance.coverUnnamed) return refuse(`${loose.length} surface(s) on ${route} are neither approved nor covered`);
|
|
3493
3886
|
cover = reading.surfaces.filter((surface) => !surface.read && !approved.has(surface.where)).map((surface) => surface.where);
|
|
3494
3887
|
return null;
|
|
3495
3888
|
};
|
|
@@ -3501,13 +3894,14 @@ async function recordClip(options, script, touched) {
|
|
|
3501
3894
|
at += ms;
|
|
3502
3895
|
return file;
|
|
3503
3896
|
};
|
|
3504
|
-
const
|
|
3897
|
+
const picture = (covered) => page.frame ? page.frame(covered) : page.screenshot(covered);
|
|
3898
|
+
const shoot = async (ms) => keep(await picture(cover), ms);
|
|
3505
3899
|
const hold = (ms) => {
|
|
3506
3900
|
frames[frames.length - 1].ms += ms;
|
|
3507
3901
|
at += ms;
|
|
3508
3902
|
};
|
|
3509
3903
|
const film = async () => {
|
|
3510
|
-
const next = await
|
|
3904
|
+
const next = await picture(cover);
|
|
3511
3905
|
if (sameBytes(next, last)) {
|
|
3512
3906
|
hold(FRAME_MS);
|
|
3513
3907
|
return false;
|
|
@@ -3516,30 +3910,37 @@ async function recordClip(options, script, touched) {
|
|
|
3516
3910
|
return true;
|
|
3517
3911
|
};
|
|
3518
3912
|
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
3519
|
-
|
|
3520
|
-
|
|
3521
|
-
|
|
3522
|
-
|
|
3523
|
-
|
|
3524
|
-
|
|
3525
|
-
|
|
3526
|
-
|
|
3913
|
+
let redirected;
|
|
3914
|
+
const isOn = (route, where) => onScreen(route, where) || redirected?.route === route && where.split("?")[0] === redirected.path;
|
|
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
|
+
);
|
|
3527
3927
|
const steady = async () => {
|
|
3528
|
-
let previous = await
|
|
3928
|
+
let previous = await picture(cover);
|
|
3529
3929
|
for (let waited = 0; waited < SETTLE_MS; waited += FRAME_MS) {
|
|
3530
3930
|
await pause(FRAME_MS);
|
|
3531
|
-
const next = await
|
|
3931
|
+
const next = await picture(cover);
|
|
3532
3932
|
if (sameBytes(next, previous)) return;
|
|
3533
3933
|
previous = next;
|
|
3534
3934
|
}
|
|
3535
3935
|
};
|
|
3536
3936
|
let resting = START;
|
|
3937
|
+
let lastRegion;
|
|
3537
3938
|
const find = async (step, where, filming) => {
|
|
3538
3939
|
const raw = await layout(cover);
|
|
3539
|
-
const shown = resolveStep(manifestOf(raw, step, where), step);
|
|
3940
|
+
const shown = resolveStep(manifestOf(raw, step, where), step, { after: lastRegion });
|
|
3540
3941
|
if (shown.ok) return shown;
|
|
3541
|
-
const whole = resolveStep(manifestOf(wholePage(raw), step, where), step);
|
|
3542
|
-
if (!whole.ok) return shown;
|
|
3942
|
+
const whole = resolveStep(manifestOf(wholePage(raw), step, where), step, { after: lastRegion });
|
|
3943
|
+
if (!whole.ok) return whole.kind === "missing" ? shown : whole;
|
|
3543
3944
|
const box = whole.control.box;
|
|
3544
3945
|
const by = Math.round(box.y - WHOLE_SHIFT + box.height / 2 - CLIP_VIEWPORT.height * SCROLL_TO);
|
|
3545
3946
|
const wheel = { x: Math.round(Math.min(Math.max(box.x + box.width / 2, 1), CLIP_VIEWPORT.width - 1)), y: Math.round(CLIP_VIEWPORT.height / 2) };
|
|
@@ -3560,15 +3961,20 @@ async function recordClip(options, script, touched) {
|
|
|
3560
3961
|
} else {
|
|
3561
3962
|
await steady();
|
|
3562
3963
|
}
|
|
3563
|
-
return resolveStep(manifestOf(await layout(cover), step, where), step);
|
|
3964
|
+
return resolveStep(manifestOf(await layout(cover), step, where), step, { after: lastRegion });
|
|
3965
|
+
};
|
|
3966
|
+
const open = async (route, opens, where) => {
|
|
3967
|
+
if (!opens || isOn(route, where)) return where;
|
|
3968
|
+
await page.goto(new URL(opens, options.baseUrl).toString());
|
|
3969
|
+
return location2();
|
|
3564
3970
|
};
|
|
3565
|
-
const reach = async (route) => {
|
|
3566
|
-
let where = await location2();
|
|
3567
|
-
for (let waited = 0; !
|
|
3971
|
+
const reach = async (route, opens) => {
|
|
3972
|
+
let where = await open(route, opens, await location2());
|
|
3973
|
+
for (let waited = 0; !isOn(route, where) && waited < STEP_WAIT_MS; waited += FRAME_MS) {
|
|
3568
3974
|
await pause(FRAME_MS);
|
|
3569
3975
|
where = await location2();
|
|
3570
3976
|
}
|
|
3571
|
-
if (
|
|
3977
|
+
if (isOn(route, where)) {
|
|
3572
3978
|
await page.quiet?.(SETTLE_MS);
|
|
3573
3979
|
await steady();
|
|
3574
3980
|
}
|
|
@@ -3579,22 +3985,45 @@ async function recordClip(options, script, touched) {
|
|
|
3579
3985
|
await steady();
|
|
3580
3986
|
return location2();
|
|
3581
3987
|
};
|
|
3582
|
-
const
|
|
3988
|
+
const focus = (at2) => pointer({ kind: "focus", at: at2 }).then(
|
|
3989
|
+
() => null,
|
|
3990
|
+
(error) => error instanceof Error ? error.message : String(error)
|
|
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
|
+
};
|
|
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];
|
|
3583
4007
|
const lead = async (step) => {
|
|
3584
|
-
const
|
|
3585
|
-
|
|
3586
|
-
|
|
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);
|
|
3587
4012
|
for (let waited = 0; !resolution.ok && waited < STEP_WAIT_MS; waited += FRAME_MS) {
|
|
3588
4013
|
await pause(FRAME_MS);
|
|
3589
4014
|
resolution = await find(step, where, false);
|
|
3590
4015
|
}
|
|
3591
4016
|
if (!resolution.ok) return resolution.because;
|
|
4017
|
+
lastRegion = resolution.control.region;
|
|
3592
4018
|
const target = centreOf(resolution.control.box);
|
|
3593
4019
|
const action = step.action === "auto" ? actionForRole(resolution.control.role, step.value) : step.action;
|
|
4020
|
+
const typed = step.value ?? step.demo ?? demoValueForField(resolution.control);
|
|
3594
4021
|
if (action === "fill") {
|
|
3595
|
-
if (!
|
|
3596
|
-
|
|
3597
|
-
|
|
4022
|
+
if (!typed) return `it types into ${resolution.control.name}, and says nothing to type`;
|
|
4023
|
+
const unfocused = await focus(target);
|
|
4024
|
+
if (unfocused) return unfocused;
|
|
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`;
|
|
3598
4027
|
} else if (action === "select") {
|
|
3599
4028
|
await pointer({ kind: "select", at: target, value: step.value ?? "" });
|
|
3600
4029
|
} else {
|
|
@@ -3608,26 +4037,30 @@ async function recordClip(options, script, touched) {
|
|
|
3608
4037
|
if (await page.isSignIn()) return refuse("its first screen asked for a sign-in, so this session is not the demo tenant's");
|
|
3609
4038
|
const opened = await location2();
|
|
3610
4039
|
const performedFirst = script.before?.[0] ?? script.steps[0];
|
|
3611
|
-
if (!onScreen(performedFirst.route, opened))
|
|
4040
|
+
if (!onScreen(performedFirst.route, opened)) {
|
|
4041
|
+
if (!options.screens?.some((route) => onScreen(route, opened))) return fail(`it opened on ${opened}, not ${performedFirst.route}`);
|
|
4042
|
+
redirected = { route: performedFirst.route, path: opened.split("?")[0] };
|
|
4043
|
+
}
|
|
3612
4044
|
for (const [index, step] of (script.before ?? []).entries()) {
|
|
3613
4045
|
const problem = await lead(step);
|
|
3614
4046
|
if (problem) return fail(`before the recording, step ${index + 1}: ${problem}`);
|
|
3615
4047
|
}
|
|
3616
4048
|
const first = script.steps[0];
|
|
3617
|
-
const
|
|
3618
|
-
|
|
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}`);
|
|
3619
4052
|
const before = await judge(starting);
|
|
3620
|
-
if (before) return
|
|
4053
|
+
if (before) return before;
|
|
3621
4054
|
let poster;
|
|
3622
4055
|
const steps = [];
|
|
3623
|
-
const arrive = async (route, follows = false) => {
|
|
3624
|
-
let where = await location2();
|
|
3625
|
-
for (let waited = 0; !follows && !
|
|
4056
|
+
const arrive = async (route, follows = false, opens) => {
|
|
4057
|
+
let where = follows ? await location2() : await open(route, opens, await location2());
|
|
4058
|
+
for (let waited = 0; !follows && !isOn(route, where) && waited < STEP_WAIT_MS; waited += FRAME_MS) {
|
|
3626
4059
|
await cursor(resting);
|
|
3627
4060
|
await film();
|
|
3628
4061
|
where = await location2();
|
|
3629
4062
|
}
|
|
3630
|
-
if (!follows && !
|
|
4063
|
+
if (!follows && !isOn(route, where)) return where;
|
|
3631
4064
|
await page.quiet?.(SETTLE_MS);
|
|
3632
4065
|
await cursor(resting);
|
|
3633
4066
|
for (let waited = 0; waited < SETTLE_MS; waited += FRAME_MS) {
|
|
@@ -3638,21 +4071,24 @@ async function recordClip(options, script, touched) {
|
|
|
3638
4071
|
await cursor(resting);
|
|
3639
4072
|
checked.add(await shoot(OPENING_MS));
|
|
3640
4073
|
for (const [index, step] of script.steps.entries()) {
|
|
3641
|
-
const
|
|
3642
|
-
|
|
3643
|
-
|
|
3644
|
-
|
|
3645
|
-
|
|
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);
|
|
4078
|
+
if (arrived) return arrived;
|
|
4079
|
+
let resolution = shown?.resolution ?? await find(step, where, true);
|
|
3646
4080
|
for (let waited = 0; !resolution.ok && waited < STEP_WAIT_MS; waited += FRAME_MS) {
|
|
3647
4081
|
await cursor(resting);
|
|
3648
4082
|
await film();
|
|
3649
4083
|
resolution = await find(step, where, true);
|
|
3650
4084
|
}
|
|
3651
4085
|
if (!resolution.ok) return fail(`step ${index + 1}: ${resolution.because}`);
|
|
4086
|
+
lastRegion = resolution.control.region;
|
|
3652
4087
|
const target = centreOf(resolution.control.box);
|
|
3653
4088
|
const startedAt = at;
|
|
3654
4089
|
const action = step.action === "auto" ? actionForRole(resolution.control.role, step.value) : step.action;
|
|
3655
|
-
|
|
4090
|
+
const typed = step.value ?? step.demo ?? demoValueForField(resolution.control);
|
|
4091
|
+
if (action === "fill" && !typed) return fail(`step ${index + 1}: it types into ${resolution.control.name}, and says nothing to type`);
|
|
3656
4092
|
for (const point of cursorPath(resting, target, MOVE_FRAMES)) {
|
|
3657
4093
|
await cursor(point);
|
|
3658
4094
|
await shoot(FRAME_MS);
|
|
@@ -3663,13 +4099,15 @@ async function recordClip(options, script, touched) {
|
|
|
3663
4099
|
await cursor(target, true);
|
|
3664
4100
|
await shoot(PRESS_MS);
|
|
3665
4101
|
if (action === "fill") {
|
|
3666
|
-
const value =
|
|
3667
|
-
|
|
4102
|
+
const value = typed ?? "";
|
|
4103
|
+
const unfocused = await focus(target);
|
|
4104
|
+
if (unfocused) return fail(`step ${index + 1}: ${unfocused}`);
|
|
3668
4105
|
await cursor(target, false);
|
|
3669
|
-
for (const
|
|
3670
|
-
await type(
|
|
4106
|
+
for (const chunk2 of chunksOf(value, Math.max(CHARS_PER_FRAME, Math.ceil(value.length / TYPING_FRAMES)))) {
|
|
4107
|
+
await type(chunk2);
|
|
3671
4108
|
await shoot(FRAME_MS);
|
|
3672
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`);
|
|
3673
4111
|
} else if (action === "select") {
|
|
3674
4112
|
await pointer({ kind: "select", at: target, value: step.value ?? "" });
|
|
3675
4113
|
} else {
|
|
@@ -3685,7 +4123,7 @@ async function recordClip(options, script, touched) {
|
|
|
3685
4123
|
checked.add(frames[frames.length - 1].file);
|
|
3686
4124
|
const now = await location2();
|
|
3687
4125
|
const after = await judge(now);
|
|
3688
|
-
if (after) return
|
|
4126
|
+
if (after) return after;
|
|
3689
4127
|
steps.push({
|
|
3690
4128
|
route: performedOn(step, where),
|
|
3691
4129
|
role: resolution.control.role,
|
|
@@ -3697,10 +4135,10 @@ async function recordClip(options, script, touched) {
|
|
|
3697
4135
|
});
|
|
3698
4136
|
}
|
|
3699
4137
|
if (script.lands) {
|
|
3700
|
-
const
|
|
3701
|
-
if (!
|
|
3702
|
-
const seen = await judge(
|
|
3703
|
-
if (seen) return
|
|
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);
|
|
4141
|
+
if (seen) return seen;
|
|
3704
4142
|
}
|
|
3705
4143
|
hold(CLOSING_MS);
|
|
3706
4144
|
checked.add(frames[frames.length - 1].file);
|
|
@@ -3736,15 +4174,18 @@ async function recordClip(options, script, touched) {
|
|
|
3736
4174
|
} catch (error) {
|
|
3737
4175
|
return fail(error instanceof Error ? error.message : String(error));
|
|
3738
4176
|
} finally {
|
|
3739
|
-
await page.close().catch(() => void 0);
|
|
3740
4177
|
await rm(dir, { recursive: true, force: true }).catch(() => void 0);
|
|
3741
4178
|
}
|
|
3742
4179
|
}
|
|
4180
|
+
function leavesBrowserWaiting(because) {
|
|
4181
|
+
return because.includes(CLIP_LATE) || /execution context was destroyed|because of a navigation/i.test(because);
|
|
4182
|
+
}
|
|
3743
4183
|
var CLIP_BUDGET_MS = 20 * 6e4;
|
|
3744
4184
|
async function runClips(options) {
|
|
3745
4185
|
const started = Date.now();
|
|
3746
4186
|
const outcomes = [];
|
|
3747
4187
|
let spent;
|
|
4188
|
+
let browser = options.browser;
|
|
3748
4189
|
for (const [index, script] of options.scripts.entries()) {
|
|
3749
4190
|
const touched = clipRoutes(script).map((route) => ({
|
|
3750
4191
|
route,
|
|
@@ -3761,9 +4202,16 @@ async function runClips(options) {
|
|
|
3761
4202
|
} else if (!options.force && options.held.get(script.id) === fingerprint) {
|
|
3762
4203
|
outcome = { ...base, status: "unchanged", reason: "neither its steps nor a screen they touch has changed" };
|
|
3763
4204
|
} else {
|
|
3764
|
-
const result = await recordClip(options, script, touched);
|
|
4205
|
+
const result = await recordClip({ ...options, browser, screens: options.screens ?? [...options.frames.keys()] }, script, touched);
|
|
3765
4206
|
if (!result.ok) {
|
|
3766
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
|
+
}
|
|
3767
4215
|
} else {
|
|
3768
4216
|
const delivered = await options.send(result.clip);
|
|
3769
4217
|
if (delivered.ok) {
|
|
@@ -3785,14 +4233,15 @@ async function runClips(options) {
|
|
|
3785
4233
|
outcomes.push(outcome);
|
|
3786
4234
|
options.onProgress?.(outcome, index + 1, options.scripts.length);
|
|
3787
4235
|
}
|
|
4236
|
+
if (browser !== options.browser) await Promise.race([browser.close().catch(() => void 0), new Promise((resolve) => setTimeout(resolve, 5e3))]);
|
|
3788
4237
|
return outcomes;
|
|
3789
4238
|
}
|
|
3790
4239
|
function run(binary, args) {
|
|
3791
4240
|
return new Promise((resolve, reject) => {
|
|
3792
4241
|
const child = spawn(binary, args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
3793
4242
|
let stderr = "";
|
|
3794
|
-
child.stderr.on("data", (
|
|
3795
|
-
stderr = `${stderr}${
|
|
4243
|
+
child.stderr.on("data", (chunk2) => {
|
|
4244
|
+
stderr = `${stderr}${chunk2.toString()}`.slice(-2e3);
|
|
3796
4245
|
});
|
|
3797
4246
|
child.on(
|
|
3798
4247
|
"error",
|
|
@@ -3932,10 +4381,10 @@ function leadsToTheSamePlaces(children) {
|
|
|
3932
4381
|
}
|
|
3933
4382
|
function parameterName(segments, depth2) {
|
|
3934
4383
|
const parent = depth2 > 0 ? segments[depth2 - 1] : "";
|
|
3935
|
-
const base = parent && !parent.startsWith(":") ?
|
|
4384
|
+
const base = parent && !parent.startsWith(":") ? singular2(parent) : "id";
|
|
3936
4385
|
return base.replace(/[^a-z0-9]+/gi, "") || "id";
|
|
3937
4386
|
}
|
|
3938
|
-
function
|
|
4387
|
+
function singular2(word) {
|
|
3939
4388
|
const lower = word.toLowerCase();
|
|
3940
4389
|
if (/(analysis|status|address|series|species)$/.test(lower)) return lower;
|
|
3941
4390
|
if (lower.endsWith("ies")) return `${lower.slice(0, -3)}y`;
|
|
@@ -3995,7 +4444,7 @@ var MISS_STATUS = RECORDING_MISS_STATUS;
|
|
|
3995
4444
|
function bodyOf(message) {
|
|
3996
4445
|
return new Promise((resolve, reject) => {
|
|
3997
4446
|
const chunks = [];
|
|
3998
|
-
message.on("data", (
|
|
4447
|
+
message.on("data", (chunk2) => chunks.push(chunk2));
|
|
3999
4448
|
message.on("end", () => resolve(Buffer.concat(chunks)));
|
|
4000
4449
|
message.on("error", reject);
|
|
4001
4450
|
});
|
|
@@ -4169,18 +4618,12 @@ async function waitForApp(url, deadline, onLog, gaveUp) {
|
|
|
4169
4618
|
}
|
|
4170
4619
|
return false;
|
|
4171
4620
|
}
|
|
4172
|
-
async function
|
|
4173
|
-
const replay = await replayFrom({
|
|
4174
|
-
cassette: options.cassette,
|
|
4175
|
-
...options.replayPort ? { port: options.replayPort } : {},
|
|
4176
|
-
onMiss: (miss) => options.onLog?.(` the recording has no ${miss.method} ${miss.path}${miss.from ? ` (wanted by ${miss.from})` : ""}`)
|
|
4177
|
-
});
|
|
4178
|
-
const variable = options.appEnv ?? DEFAULT_APP_ENV;
|
|
4621
|
+
async function startProduct(options) {
|
|
4179
4622
|
let app;
|
|
4180
4623
|
let died;
|
|
4181
4624
|
const clock = options.appCommand ? await productClock(options.pinnedClock) : { env: {} };
|
|
4182
4625
|
if (options.appCommand) {
|
|
4183
|
-
options.onLog?.(
|
|
4626
|
+
options.onLog?.(options.starting);
|
|
4184
4627
|
app = spawn2(options.appCommand, {
|
|
4185
4628
|
shell: true,
|
|
4186
4629
|
// Its own process group, so it can be taken down as one.
|
|
@@ -4191,22 +4634,19 @@ async function stageAgainstRecording(options) {
|
|
|
4191
4634
|
// the address occupied by the last one — and on a build agent, the job
|
|
4192
4635
|
// never finishes.
|
|
4193
4636
|
detached: true,
|
|
4194
|
-
env: { ...process.env,
|
|
4637
|
+
env: { ...process.env, ...options.env, ...clock.env },
|
|
4195
4638
|
stdio: ["ignore", "pipe", "pipe"]
|
|
4196
4639
|
});
|
|
4197
|
-
app.stdout?.on("data", (
|
|
4198
|
-
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()}`));
|
|
4199
4642
|
app.on("exit", (code) => {
|
|
4200
4643
|
died = code ?? 1;
|
|
4201
4644
|
});
|
|
4202
|
-
} else {
|
|
4203
|
-
options.onLog?.(`The recording is at ${replay.url}. Start your product with ${variable} set to it.`);
|
|
4204
4645
|
}
|
|
4205
4646
|
const up = await waitForApp(options.appUrl, Date.now() + (options.waitMs ?? 6e4), options.onLog, () => died !== void 0);
|
|
4206
4647
|
if (app) await new Promise((resolve) => setTimeout(resolve, 250));
|
|
4207
4648
|
if (died === void 0 && app?.exitCode !== null && app?.exitCode !== void 0) died = app.exitCode;
|
|
4208
4649
|
if (died !== void 0) {
|
|
4209
|
-
await replay.close();
|
|
4210
4650
|
stop(app);
|
|
4211
4651
|
await clock.written?.remove();
|
|
4212
4652
|
throw new Error(
|
|
@@ -4216,25 +4656,66 @@ Something else is already answering on ${options.appUrl}. That is almost always
|
|
|
4216
4656
|
);
|
|
4217
4657
|
}
|
|
4218
4658
|
if (!up) {
|
|
4219
|
-
await replay.close();
|
|
4220
4659
|
stop(app);
|
|
4221
4660
|
await clock.written?.remove();
|
|
4222
4661
|
throw new Error(
|
|
4223
|
-
`${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)
|
|
4224
4663
|
);
|
|
4225
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
|
+
}
|
|
4226
4696
|
return {
|
|
4227
4697
|
appUrl: options.appUrl,
|
|
4228
4698
|
replayUrl: replay.url,
|
|
4229
4699
|
replay,
|
|
4230
4700
|
cassette: options.cassette,
|
|
4231
4701
|
close: async () => {
|
|
4232
|
-
|
|
4702
|
+
await running.close();
|
|
4233
4703
|
await replay.close();
|
|
4234
|
-
await clock.written?.remove();
|
|
4235
4704
|
}
|
|
4236
4705
|
};
|
|
4237
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
|
+
}
|
|
4238
4719
|
function clockNote(pinnedClock) {
|
|
4239
4720
|
return pinnedClock ? `, its clock pinned to ${pinnedClock}` : "";
|
|
4240
4721
|
}
|
|
@@ -4275,51 +4756,21 @@ Nothing has been photographed. A run that carried on from here would sign in to
|
|
|
4275
4756
|
const named = options.examplesOut ? await examplesFrom(options.examplesOut, onLog) : void 0;
|
|
4276
4757
|
const examples = named?.examples;
|
|
4277
4758
|
const clips = named?.clips ?? [];
|
|
4278
|
-
|
|
4279
|
-
|
|
4280
|
-
|
|
4281
|
-
|
|
4282
|
-
|
|
4283
|
-
|
|
4284
|
-
|
|
4285
|
-
|
|
4286
|
-
|
|
4287
|
-
stdio: ["ignore", "pipe", "pipe"]
|
|
4288
|
-
});
|
|
4289
|
-
app.stdout?.on("data", (chunk) => onLog?.(` [app] ${chunk.toString().trimEnd()}`));
|
|
4290
|
-
app.stderr?.on("data", (chunk) => onLog?.(` [app] ${chunk.toString().trimEnd()}`));
|
|
4291
|
-
app.on("exit", (code) => {
|
|
4292
|
-
died = code ?? 1;
|
|
4293
|
-
});
|
|
4294
|
-
}
|
|
4295
|
-
const up = await waitForApp(options.appUrl, Date.now() + (options.waitMs ?? 6e4), onLog, () => died !== void 0);
|
|
4296
|
-
if (app) await new Promise((resolve) => setTimeout(resolve, 250));
|
|
4297
|
-
if (died === void 0 && app?.exitCode !== null && app?.exitCode !== void 0) died = app.exitCode;
|
|
4298
|
-
if (died !== void 0) {
|
|
4299
|
-
stop(app);
|
|
4300
|
-
await clock.written?.remove();
|
|
4301
|
-
throw new Error(
|
|
4302
|
-
`The start command exited (${died}) before the product was ready. Its output is above.` + (up ? `
|
|
4303
|
-
|
|
4304
|
-
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.` : "")
|
|
4305
|
-
);
|
|
4306
|
-
}
|
|
4307
|
-
if (!up) {
|
|
4308
|
-
stop(app);
|
|
4309
|
-
await clock.written?.remove();
|
|
4310
|
-
throw new Error(
|
|
4311
|
-
`${options.appUrl} never answered. Check the start command above, and that your product listens on that address.`
|
|
4312
|
-
);
|
|
4313
|
-
}
|
|
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
|
+
});
|
|
4314
4768
|
return {
|
|
4315
4769
|
appUrl: options.appUrl,
|
|
4316
4770
|
...sessionPath ? { sessionPath } : {},
|
|
4317
4771
|
...examples ? { examples } : {},
|
|
4318
4772
|
...clips.length ? { clips } : {},
|
|
4319
|
-
close:
|
|
4320
|
-
stop(app);
|
|
4321
|
-
await clock.written?.remove();
|
|
4322
|
-
}
|
|
4773
|
+
close: () => running.close()
|
|
4323
4774
|
};
|
|
4324
4775
|
}
|
|
4325
4776
|
async function examplesFrom(path5, onLog) {
|
|
@@ -4363,8 +4814,8 @@ async function runToCompletion(command, timeoutMs, env, onLog) {
|
|
|
4363
4814
|
stop(child);
|
|
4364
4815
|
resolve({ ok: false, timedOut: true });
|
|
4365
4816
|
}, timeoutMs);
|
|
4366
|
-
child.stdout?.on("data", (
|
|
4367
|
-
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()}`));
|
|
4368
4819
|
child.on("error", (error) => {
|
|
4369
4820
|
clearTimeout(timer);
|
|
4370
4821
|
onLog?.(` [seed] ${error.message}`);
|
|
@@ -4602,6 +5053,34 @@ function depth(path5) {
|
|
|
4602
5053
|
return path5 === "/" ? 0 : path5.split("/").filter(Boolean).length;
|
|
4603
5054
|
}
|
|
4604
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
|
+
|
|
4605
5084
|
// core/discovery/article-plan.ts
|
|
4606
5085
|
var MONEY_OR_ACCESS = /* @__PURE__ */ new Set([
|
|
4607
5086
|
"billing",
|
|
@@ -4680,15 +5159,15 @@ function slugFor(path5) {
|
|
|
4680
5159
|
const segments = path5.split("/").filter(Boolean).filter((segment) => !segment.startsWith("["));
|
|
4681
5160
|
return segments.length ? segments.join("/") : "overview";
|
|
4682
5161
|
}
|
|
4683
|
-
function titleFor(route) {
|
|
4684
|
-
const segments = route.path
|
|
4685
|
-
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";
|
|
4686
5165
|
const words = segments[segments.length - 1].replace(/[-_]/g, " ");
|
|
4687
5166
|
const subject = words.charAt(0).toUpperCase() + words.slice(1);
|
|
4688
5167
|
if (!route.params.length) return subject;
|
|
4689
|
-
return `Working with ${article(
|
|
5168
|
+
return `Working with ${article(singular3(subject.toLowerCase()))}`;
|
|
4690
5169
|
}
|
|
4691
|
-
function
|
|
5170
|
+
function singular3(word) {
|
|
4692
5171
|
if (/(sis|ss|us|is)$/.test(word)) return word;
|
|
4693
5172
|
if (/(ches|shes|xes|zes)$/.test(word)) return word.slice(0, -2);
|
|
4694
5173
|
if (/[^aeiou]ies$/.test(word)) return `${word.slice(0, -3)}y`;
|
|
@@ -4734,6 +5213,7 @@ function buildArticlePlan(options) {
|
|
|
4734
5213
|
}
|
|
4735
5214
|
}
|
|
4736
5215
|
let covered = 0;
|
|
5216
|
+
const root = appRoot(options.routes);
|
|
4737
5217
|
const needs = [...merged.entries()].map(([slug, group], index) => {
|
|
4738
5218
|
const rank = index + 1;
|
|
4739
5219
|
const claimed = [group.entry.route.path, ...group.routes.slice(1) ?? []].map((path5) => options.documented?.[path5]).find(Boolean);
|
|
@@ -4741,7 +5221,7 @@ function buildArticlePlan(options) {
|
|
|
4741
5221
|
if (already) covered += 1;
|
|
4742
5222
|
return {
|
|
4743
5223
|
slug,
|
|
4744
|
-
title: group.title ?? titleFor(group.entry.route),
|
|
5224
|
+
title: group.title ?? titleFor(group.entry.route, root),
|
|
4745
5225
|
route: group.entry.route.path,
|
|
4746
5226
|
...group.routes.length > 1 ? { alsoCovers: group.routes.slice(1) } : {},
|
|
4747
5227
|
tier: tierFor(rank, merged.size),
|
|
@@ -4776,8 +5256,8 @@ function shotIdFor(route) {
|
|
|
4776
5256
|
const parts = route.split("/").filter(Boolean).map((segment) => segment.replace(/^\[+\.{0,3}|\]+$/g, ""));
|
|
4777
5257
|
return parts.length ? parts.join("-").toLowerCase() : "overview";
|
|
4778
5258
|
}
|
|
4779
|
-
function describe(route, isEntry) {
|
|
4780
|
-
const segments = route.path
|
|
5259
|
+
function describe(route, isEntry, root) {
|
|
5260
|
+
const segments = belowAppRoot(route.path, root);
|
|
4781
5261
|
const last = segments[segments.length - 1] ?? "";
|
|
4782
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";
|
|
4783
5263
|
return isEntry ? `${screen} as someone arrives on it` : `${screen}, reached from ${segments.slice(0, -1).join("/") || "the top level"}`;
|
|
@@ -4795,10 +5275,11 @@ function buildStoryboard(options) {
|
|
|
4795
5275
|
const own = [...covered].map((path5) => byPath.get(path5)).filter((route) => Boolean(route) && !route.skipped);
|
|
4796
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));
|
|
4797
5277
|
const limit = options.maxSteps ?? 5;
|
|
5278
|
+
const root = appRoot(options.routes);
|
|
4798
5279
|
const steps = ordered.slice(0, limit).map((route, index) => ({
|
|
4799
5280
|
route: route.path,
|
|
4800
5281
|
shotId: shotIdFor(route.path),
|
|
4801
|
-
depicts: describe(route, index === 0),
|
|
5282
|
+
depicts: describe(route, index === 0, root),
|
|
4802
5283
|
kind: kindFor(route, index === 0),
|
|
4803
5284
|
needsExampleFor: route.params
|
|
4804
5285
|
}));
|
|
@@ -4851,7 +5332,7 @@ function parseArgs(argv) {
|
|
|
4851
5332
|
if (args.out) {
|
|
4852
5333
|
args.key ??= "";
|
|
4853
5334
|
args.to ??= "";
|
|
4854
|
-
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.";
|
|
4855
5336
|
return args;
|
|
4856
5337
|
}
|
|
4857
5338
|
if (!args.key) return "Missing --key, or --out <dir> to keep the frames here instead.";
|
|
@@ -4952,11 +5433,21 @@ function articleClipsToRecord(raw, examples) {
|
|
|
4952
5433
|
if (!Array.isArray(raw)) return readClipScripts(raw, CLIP_MAX_ARTICLE_SCRIPTS);
|
|
4953
5434
|
const named = examples ?? { paths: /* @__PURE__ */ new Map(), refused: [] };
|
|
4954
5435
|
const unopened = [];
|
|
5436
|
+
const recordOf3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
|
|
5437
|
+
const withOpens = (steps, from) => Array.isArray(steps) ? steps.map((value, index) => {
|
|
5438
|
+
const step = recordOf3(value);
|
|
5439
|
+
const later = from + index > 0 && step["follows"] !== true;
|
|
5440
|
+
const resolved = later && typeof step["route"] === "string" ? resolveVisit(step["route"], named) : void 0;
|
|
5441
|
+
return resolved && "visit" in resolved && resolved.visit.includes("?") ? { ...step, opens: resolved.visit } : value;
|
|
5442
|
+
}) : steps;
|
|
4955
5443
|
const opened = raw.map((value) => {
|
|
4956
|
-
const script =
|
|
5444
|
+
const script = recordOf3(value);
|
|
4957
5445
|
const visit = typeof script["visit"] === "string" ? script["visit"] : "";
|
|
4958
5446
|
const resolved = resolveVisit(visit, named);
|
|
4959
|
-
|
|
5447
|
+
const lead = Array.isArray(script["before"]) ? script["before"].length : 0;
|
|
5448
|
+
if (!("needs" in resolved)) {
|
|
5449
|
+
return { ...script, visit: resolved.visit, ...lead ? { before: withOpens(script["before"], 0) } : {}, steps: withOpens(script["steps"], lead) };
|
|
5450
|
+
}
|
|
4960
5451
|
unopened.push({ id: typeof script["id"] === "string" ? script["id"] : "a clip", because: describeMissingExample(visit, resolved.needs) });
|
|
4961
5452
|
return void 0;
|
|
4962
5453
|
});
|
|
@@ -5020,6 +5511,7 @@ The seed command named ${args.named} clip(s)${asked ? `, and the help desk's how
|
|
|
5020
5511
|
const outcomes = await runClips({
|
|
5021
5512
|
baseUrl: args.baseUrl,
|
|
5022
5513
|
browser: args.browser,
|
|
5514
|
+
...args.relaunch ? { relaunch: args.relaunch } : {},
|
|
5023
5515
|
encoder: ffmpegEncoder(),
|
|
5024
5516
|
workDir: path4.join(tmpdir2(), `helpdesk-clips-${process.pid}`),
|
|
5025
5517
|
clearance: args.clearance,
|
|
@@ -5044,6 +5536,9 @@ The seed command named ${args.named} clip(s)${asked ? `, and the help desk's how
|
|
|
5044
5536
|
const kb = (bytes) => `${Math.round(bytes / 1024)} KB`;
|
|
5045
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 ?? "";
|
|
5046
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
|
+
}
|
|
5047
5542
|
}
|
|
5048
5543
|
});
|
|
5049
5544
|
if (args.local) {
|
|
@@ -5055,15 +5550,30 @@ The seed command named ${args.named} clip(s)${asked ? `, and the help desk's how
|
|
|
5055
5550
|
await writeFile3(path4.join(args.local, "clips", "manifest.json"), `${JSON.stringify({ schema: "olomon.clip-manifest/1", clips: kept }, null, 2)}
|
|
5056
5551
|
`, "utf8");
|
|
5057
5552
|
}
|
|
5058
|
-
const count = (status) => outcomes.filter((outcome) => outcome.status === status).length;
|
|
5059
5553
|
console.log(`
|
|
5060
|
-
Clips: ${
|
|
5554
|
+
Clips: ${clipCounts(countClips(outcomes))}.`);
|
|
5061
5555
|
const missed = outcomes.filter((outcome) => outcome.status === "refused" || outcome.status === "failed");
|
|
5062
5556
|
if (missed.length && process.env["GITHUB_ACTIONS"] === "true") {
|
|
5063
5557
|
console.log(`::warning title=${missed.length} clip(s) not recorded::${missed.map((outcome) => `${outcome.clipId}: ${outcome.reason}`).join("; ")}`);
|
|
5064
5558
|
}
|
|
5065
5559
|
return outcomes;
|
|
5066
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
|
+
}
|
|
5067
5577
|
function frameForm(frame) {
|
|
5068
5578
|
const form = new FormData();
|
|
5069
5579
|
form.set(
|
|
@@ -5158,7 +5668,13 @@ Approve one on the plan page, then run this again.`
|
|
|
5158
5668
|
return 1;
|
|
5159
5669
|
}
|
|
5160
5670
|
}
|
|
5161
|
-
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;
|
|
5162
5678
|
if (!baseUrl) {
|
|
5163
5679
|
console.error("No product address. Pass --base-url, or set one on the help desk's plan page.");
|
|
5164
5680
|
return 2;
|
|
@@ -5182,7 +5698,7 @@ Approve one on the plan page, then run this again.`
|
|
|
5182
5698
|
examplesOut: path4.join(process.cwd(), "helpdesk-examples.json"),
|
|
5183
5699
|
onLog: (line) => console.log(line)
|
|
5184
5700
|
});
|
|
5185
|
-
if (seeded.sessionPath) parsed.session = seeded.sessionPath;
|
|
5701
|
+
if (seeded.sessionPath && !signedOut) parsed.session = seeded.sessionPath;
|
|
5186
5702
|
else delete parsed.session;
|
|
5187
5703
|
console.log("");
|
|
5188
5704
|
} catch (error) {
|
|
@@ -5209,6 +5725,16 @@ Approve one on the plan page, then run this again.`
|
|
|
5209
5725
|
return 1;
|
|
5210
5726
|
}
|
|
5211
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
|
+
}
|
|
5212
5738
|
const browser = await playwrightBrowser({
|
|
5213
5739
|
...parsed.session ? { sessionPath: parsed.session } : {},
|
|
5214
5740
|
...parsed.liveClock ? { liveClock: true } : {}
|
|
@@ -5231,6 +5757,7 @@ Approve one on the plan page, then run this again.`
|
|
|
5231
5757
|
await browser.close();
|
|
5232
5758
|
await stage?.close();
|
|
5233
5759
|
await seeded?.close();
|
|
5760
|
+
await app?.close();
|
|
5234
5761
|
return 2;
|
|
5235
5762
|
}
|
|
5236
5763
|
if (stage) {
|
|
@@ -5356,6 +5883,7 @@ ${unclassified.join("\n")}`);
|
|
|
5356
5883
|
console.log(`
|
|
5357
5884
|
Written to ${local}: ${kept.length} frame(s) and manifest.json.`);
|
|
5358
5885
|
}
|
|
5886
|
+
let clips;
|
|
5359
5887
|
if (parsed.clips) {
|
|
5360
5888
|
const named = seeded?.clips ?? [];
|
|
5361
5889
|
const articles = seeded ? articleClipsToRecord(orders.clipScripts, seeded.examples) : { scripts: [], refused: [] };
|
|
@@ -5367,10 +5895,14 @@ The help desk's articles ask for ${asked} clip(s), and none is recorded: clips a
|
|
|
5367
5895
|
const unrecorded = seeded ? unrecordedLines(articles.refused, Array.isArray(orders.clipsSkipped) ? orders.clipsSkipped : []) : [];
|
|
5368
5896
|
if (unrecorded.length) console.log(`
|
|
5369
5897
|
${unrecorded.join("\n")}`);
|
|
5370
|
-
await recordSeededClips({
|
|
5898
|
+
const recorded = await recordSeededClips({
|
|
5371
5899
|
scripts: clipsToRecord(named, articles.scripts),
|
|
5372
5900
|
named: named.length,
|
|
5373
5901
|
browser,
|
|
5902
|
+
relaunch: () => playwrightBrowser({
|
|
5903
|
+
...parsed.session ? { sessionPath: parsed.session } : {},
|
|
5904
|
+
...parsed.liveClock ? { liveClock: true } : {}
|
|
5905
|
+
}),
|
|
5374
5906
|
baseUrl,
|
|
5375
5907
|
clearance,
|
|
5376
5908
|
brands: parsed.brands,
|
|
@@ -5381,6 +5913,7 @@ ${unrecorded.join("\n")}`);
|
|
|
5381
5913
|
held: new Map((orders.clips ?? []).map((clip) => [clip.id, clip.fingerprint])),
|
|
5382
5914
|
force: Boolean(parsed.rebaseline)
|
|
5383
5915
|
});
|
|
5916
|
+
if (recorded.length) clips = countClips(recorded);
|
|
5384
5917
|
}
|
|
5385
5918
|
if (report.survey.length) {
|
|
5386
5919
|
const survey = captureReport(report.survey);
|
|
@@ -5401,6 +5934,9 @@ whether they changed. Usually a spinner that never resolves or a looping animati
|
|
|
5401
5934
|
);
|
|
5402
5935
|
}
|
|
5403
5936
|
}
|
|
5937
|
+
const elsewhere = elsewhereLines(browser.thrownElsewhere?.() ?? []);
|
|
5938
|
+
if (elsewhere.length) console.log(`
|
|
5939
|
+
${elsewhere.join("\n")}`);
|
|
5404
5940
|
const gated = report.outcomes.filter((outcome) => outcome.needsAccount);
|
|
5405
5941
|
if (gated.length) {
|
|
5406
5942
|
console.log(
|
|
@@ -5438,7 +5974,8 @@ ${gapReport(gapsFrom([...stage.replay.misses])).join("\n")}`);
|
|
|
5438
5974
|
unchanged: report.unchanged,
|
|
5439
5975
|
skipped: report.skipped,
|
|
5440
5976
|
refused: report.refused,
|
|
5441
|
-
failed: report.failed
|
|
5977
|
+
failed: report.failed,
|
|
5978
|
+
...clips ? { clips } : {}
|
|
5442
5979
|
};
|
|
5443
5980
|
if (runId) {
|
|
5444
5981
|
await told;
|
|
@@ -5471,6 +6008,7 @@ ${verdict.sentence}`);
|
|
|
5471
6008
|
await browser.close();
|
|
5472
6009
|
await stage?.close();
|
|
5473
6010
|
await seeded?.close();
|
|
6011
|
+
await app?.close();
|
|
5474
6012
|
}
|
|
5475
6013
|
}
|
|
5476
6014
|
async function openRun(to, headers, total) {
|
|
@@ -5535,7 +6073,10 @@ if (runningAsCommand(import.meta.url)) {
|
|
|
5535
6073
|
export {
|
|
5536
6074
|
articleClipsToRecord,
|
|
5537
6075
|
clipForm,
|
|
6076
|
+
clipStepLines,
|
|
5538
6077
|
clipsToRecord,
|
|
6078
|
+
countClips,
|
|
6079
|
+
elsewhereLines,
|
|
5539
6080
|
frameForm,
|
|
5540
6081
|
framesOnDisk,
|
|
5541
6082
|
main,
|