@olomonhelpdesk2/helpdesk 0.1.10 → 0.1.13
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 +2063 -492
- package/helpdesk.mjs +29 -2
- package/package.json +1 -1
package/helpdesk-capture.mjs
CHANGED
|
@@ -4,8 +4,8 @@ var CI_EXAMPLES_OUT_ENV = "HELPDESK_EXAMPLES_OUT";
|
|
|
4
4
|
var EXAMPLES_SCHEMA = "helpdesk.examples/1";
|
|
5
5
|
var MAX_PATH = 2048;
|
|
6
6
|
var MAX_ROUTES = 500;
|
|
7
|
-
function segmentsOf(
|
|
8
|
-
return
|
|
7
|
+
function segmentsOf(path5) {
|
|
8
|
+
return path5.split("/").filter((part) => part !== "");
|
|
9
9
|
}
|
|
10
10
|
function patternOf(route) {
|
|
11
11
|
return segmentsOf(route).map((part) => {
|
|
@@ -21,9 +21,9 @@ function patternOf(route) {
|
|
|
21
21
|
function dynamicSegments(route) {
|
|
22
22
|
return patternOf(route).filter((segment) => segment.kind !== "static").map((segment) => segment.name);
|
|
23
23
|
}
|
|
24
|
-
function fillsPattern(pattern,
|
|
24
|
+
function fillsPattern(pattern, path5) {
|
|
25
25
|
const want = patternOf(pattern);
|
|
26
|
-
const have = segmentsOf(
|
|
26
|
+
const have = segmentsOf(path5);
|
|
27
27
|
const match = (w, h) => {
|
|
28
28
|
if (w === want.length) return h === have.length;
|
|
29
29
|
const segment = want[w];
|
|
@@ -37,27 +37,27 @@ function fillsPattern(pattern, path3) {
|
|
|
37
37
|
};
|
|
38
38
|
return match(0, 0);
|
|
39
39
|
}
|
|
40
|
-
function unusablePath(
|
|
41
|
-
if (
|
|
42
|
-
if (!
|
|
43
|
-
if (/[?#\\]/.test(
|
|
44
|
-
if (/\s/.test(
|
|
40
|
+
function unusablePath(path5) {
|
|
41
|
+
if (path5.length > MAX_PATH) return "the path is longer than any screen's";
|
|
42
|
+
if (!path5.startsWith("/") || path5.startsWith("//")) return "the path is not a path inside the product";
|
|
43
|
+
if (/[?#\\]/.test(path5)) return "the path carries a query, a fragment or a backslash";
|
|
44
|
+
if (/\s/.test(path5) || [...path5].some((character) => character.charCodeAt(0) < 32)) {
|
|
45
45
|
return "the path carries whitespace or a control character";
|
|
46
46
|
}
|
|
47
|
-
const segments = segmentsOf(
|
|
47
|
+
const segments = segmentsOf(path5);
|
|
48
48
|
if (segments.some((segment) => segment === "." || segment === ".." || /%2e%2e|%2f|%5c/i.test(segment))) {
|
|
49
49
|
return "the path climbs out of its position";
|
|
50
50
|
}
|
|
51
|
-
if (/\[|\]/.test(
|
|
51
|
+
if (/\[|\]/.test(path5)) return "the path still holds a dynamic segment rather than a record";
|
|
52
52
|
return void 0;
|
|
53
53
|
}
|
|
54
54
|
var PLAIN_QUERY = /^[\w.~%:+,-]+=[\w.~%:+,-]*(?:&[\w.~%:+,-]+=[\w.~%:+,-]*)*$/;
|
|
55
55
|
function queryFor(route, named) {
|
|
56
56
|
const mark = named.indexOf("?");
|
|
57
|
-
const
|
|
57
|
+
const path5 = mark < 0 ? named : named.slice(0, mark);
|
|
58
58
|
const query = mark < 0 ? "" : named.slice(mark + 1);
|
|
59
59
|
if (!query) return { because: "it has no dynamic segment, so the only thing to name for it is the query it opens with" };
|
|
60
|
-
if (
|
|
60
|
+
if (path5 !== route) return { because: `${named} is not ${route} with a query` };
|
|
61
61
|
const unusable = unusablePath(route);
|
|
62
62
|
if (unusable) return { because: unusable };
|
|
63
63
|
if (named.length > MAX_PATH || !PLAIN_QUERY.test(query)) return { because: "the query is not a plain list of names and values" };
|
|
@@ -94,25 +94,25 @@ function readExampleRoutes(raw) {
|
|
|
94
94
|
else paths.set(route, opened.visit);
|
|
95
95
|
continue;
|
|
96
96
|
}
|
|
97
|
-
const
|
|
98
|
-
const unusable = unusablePath(
|
|
97
|
+
const path5 = value.trim();
|
|
98
|
+
const unusable = unusablePath(path5);
|
|
99
99
|
if (unusable) {
|
|
100
100
|
refused.push({ route, because: unusable });
|
|
101
101
|
continue;
|
|
102
102
|
}
|
|
103
|
-
if (!fillsPattern(route,
|
|
104
|
-
refused.push({ route, because: `${
|
|
103
|
+
if (!fillsPattern(route, path5)) {
|
|
104
|
+
refused.push({ route, because: `${path5} is not ${route} with its dynamic segments filled in` });
|
|
105
105
|
continue;
|
|
106
106
|
}
|
|
107
|
-
paths.set(route,
|
|
107
|
+
paths.set(route, path5);
|
|
108
108
|
}
|
|
109
109
|
return { ok: true, examples: { paths, refused } };
|
|
110
110
|
}
|
|
111
111
|
function resolveVisit(route, examples) {
|
|
112
112
|
const needs = dynamicSegments(route);
|
|
113
|
-
const
|
|
114
|
-
if (needs.length === 0) return { visit:
|
|
115
|
-
return
|
|
113
|
+
const path5 = examples.paths.get(route);
|
|
114
|
+
if (needs.length === 0) return { visit: path5 ?? route };
|
|
115
|
+
return path5 ? { visit: path5 } : { needs };
|
|
116
116
|
}
|
|
117
117
|
function describeMissingExample(route, needs) {
|
|
118
118
|
const named = needs.map((name) => `[${name}]`).join(" and ");
|
|
@@ -212,8 +212,8 @@ function actionFor(outcome) {
|
|
|
212
212
|
return `${outcome.route} asked for ${outcome.missingEndpoint ?? "data"}, which is not in the recording. Visit this screen while recording, then run this again.`;
|
|
213
213
|
}
|
|
214
214
|
}
|
|
215
|
-
function subjectOf(
|
|
216
|
-
const segments =
|
|
215
|
+
function subjectOf(path5) {
|
|
216
|
+
const segments = path5.split("/").filter(Boolean).filter((segment) => !/^(api|v\d+)$/.test(segment));
|
|
217
217
|
while (segments.length > 1 && looksLikeAnId(segments[segments.length - 1])) segments.pop();
|
|
218
218
|
const last = segments[segments.length - 1] ?? "record";
|
|
219
219
|
return last.replace(/[-_]/g, " ").replace(/s$/, "");
|
|
@@ -324,11 +324,11 @@ function canonicalisation(asked, landed) {
|
|
|
324
324
|
function samePlace(asked, landed) {
|
|
325
325
|
return stripQuery(asked) === stripQuery(landed);
|
|
326
326
|
}
|
|
327
|
-
function stripQuery(
|
|
328
|
-
return
|
|
327
|
+
function stripQuery(path5) {
|
|
328
|
+
return path5.split(/[?#]/)[0] ?? path5;
|
|
329
329
|
}
|
|
330
|
-
function normalise(
|
|
331
|
-
return stripQuery(
|
|
330
|
+
function normalise(path5) {
|
|
331
|
+
return stripQuery(path5).toLowerCase().replace(/\/index(\.html?)?$/, "/").replace(/\/+$/, "").replace(/^$/, "/");
|
|
332
332
|
}
|
|
333
333
|
function firstSentence(text2) {
|
|
334
334
|
const sentence = text2.trim().split(/(?<=[.!?])\s|\n/)[0] ?? text2;
|
|
@@ -422,7 +422,7 @@ function preflight(facts) {
|
|
|
422
422
|
name: "posture",
|
|
423
423
|
ok: true,
|
|
424
424
|
blocking: false,
|
|
425
|
-
detail: "read-only: this navigates and photographs, and activates no control"
|
|
425
|
+
detail: facts.clips ? "read-only for the screenshots; after them, the clips the seed command named press the controls their steps name, in the tenant it built" : "read-only: this navigates and photographs, and activates no control"
|
|
426
426
|
});
|
|
427
427
|
if (facts.rebaselineReason) {
|
|
428
428
|
checks.push({
|
|
@@ -440,19 +440,19 @@ function preflight(facts) {
|
|
|
440
440
|
];
|
|
441
441
|
return { ok: !blocked.length, checks, lines };
|
|
442
442
|
}
|
|
443
|
-
function attestation(
|
|
444
|
-
const took =
|
|
443
|
+
function attestation(run2) {
|
|
444
|
+
const took = run2.unchanged ? `${run2.captured} new frame(s) and ${run2.unchanged} verified unchanged` : `${run2.captured} frame(s)`;
|
|
445
445
|
return [
|
|
446
|
-
`${took} from ${
|
|
446
|
+
`${took} from ${run2.baseUrl}${run2.browser ? ` with ${run2.browser}` : ""}.`,
|
|
447
447
|
"Every one was checked before the shutter and again on arrival: no frame in this set showed an email address,",
|
|
448
448
|
"an account or card number, a national or tax id, a date of birth, a passport number or a named institution.",
|
|
449
|
-
|
|
449
|
+
run2.refused ? `${run2.refused} screen(s) were refused for showing something that looked real, and no picture of them exists.` : "Nothing was refused.",
|
|
450
450
|
// Two different claims, and the second is much stronger. "Nothing that
|
|
451
451
|
// looked real got through" is a gate doing its job on unknown data; "there
|
|
452
452
|
// was no real data behind any of it" is a fact about where the data came
|
|
453
453
|
// from, and it is the one a compliance reader actually wants.
|
|
454
|
-
|
|
455
|
-
|
|
454
|
+
run2.replaying ? `Every screen was served from ${run2.replaying}, so no customer data was reachable during this run at all.` : "",
|
|
455
|
+
run2.clockPinned && run2.replaying ? "The clock was pinned and the data was fixed, so re-running this reproduces the same bytes." : run2.clockPinned ? "The clock was pinned. The backend was live, so any screen showing its data can still move between runs." : "The clock ran live, so these frames are not reproducible."
|
|
456
456
|
].filter(Boolean).join("\n");
|
|
457
457
|
}
|
|
458
458
|
|
|
@@ -473,24 +473,24 @@ function runIdentityFromEnv(env) {
|
|
|
473
473
|
}
|
|
474
474
|
|
|
475
475
|
// core/capture/run-verdict.ts
|
|
476
|
-
function runCounts(
|
|
477
|
-
return `${
|
|
478
|
-
}
|
|
479
|
-
function runVerdict(
|
|
480
|
-
const delivered =
|
|
481
|
-
const missed =
|
|
482
|
-
const counts = runCounts(
|
|
483
|
-
if (
|
|
476
|
+
function runCounts(run2) {
|
|
477
|
+
return `${run2.captured} photographed, ${run2.unchanged} unchanged, ${run2.skipped} skipped, ${run2.refused} refused, ${run2.failed} failed`;
|
|
478
|
+
}
|
|
479
|
+
function runVerdict(run2) {
|
|
480
|
+
const delivered = run2.captured + run2.unchanged;
|
|
481
|
+
const missed = run2.skipped + run2.refused + run2.failed;
|
|
482
|
+
const counts = runCounts(run2);
|
|
483
|
+
if (run2.screens > 0 && delivered === 0) {
|
|
484
484
|
return {
|
|
485
485
|
exit: 1,
|
|
486
|
-
annotation: `::error title=No screenshots were delivered::None of the ${
|
|
486
|
+
annotation: `::error title=No screenshots were delivered::None of the ${run2.screens} screens was photographed (${counts}). The step log says why for each one.`,
|
|
487
487
|
sentence: `Nothing was delivered, so this run fails rather than passing with no screenshots: ${counts}.`
|
|
488
488
|
};
|
|
489
489
|
}
|
|
490
490
|
if (missed > 0) {
|
|
491
491
|
return {
|
|
492
|
-
exit:
|
|
493
|
-
annotation: `::warning title=${missed} of ${
|
|
492
|
+
exit: run2.failed > 0 ? 1 : 0,
|
|
493
|
+
annotation: `::warning title=${missed} of ${run2.screens} screens were not photographed::${counts}. The step log says why for each one.`
|
|
494
494
|
};
|
|
495
495
|
}
|
|
496
496
|
return { exit: 0 };
|
|
@@ -777,7 +777,9 @@ function looksLikeSignIn(fields) {
|
|
|
777
777
|
import { deflateSync, inflateSync } from "node:zlib";
|
|
778
778
|
var MediaRefused = class extends Error {
|
|
779
779
|
};
|
|
780
|
+
var VERIFICATION_SENTINEL = { r: 255, g: 0, b: 255 };
|
|
780
781
|
var PUBLICATION_REDACTION = { r: 100, g: 116, b: 139 };
|
|
782
|
+
var cssColour = (colour) => `#${[colour.r, colour.g, colour.b].map((v) => v.toString(16).padStart(2, "0")).join("")}`;
|
|
781
783
|
function decodePng(buffer) {
|
|
782
784
|
if (buffer.length < 8 || buffer.readUInt32BE(0) !== 2303741511) {
|
|
783
785
|
throw new MediaRefused("that is not a PNG");
|
|
@@ -856,6 +858,24 @@ function coveredFraction(image) {
|
|
|
856
858
|
return covered / total;
|
|
857
859
|
}
|
|
858
860
|
var MOSTLY_COVERED = 0.5;
|
|
861
|
+
function readSentinel(image) {
|
|
862
|
+
let pixels = 0;
|
|
863
|
+
let at;
|
|
864
|
+
for (let y = 0; y < image.height; y += 1) {
|
|
865
|
+
for (let x = 0; x < image.width; x += 1) {
|
|
866
|
+
const index = (y * image.width + x) * 4;
|
|
867
|
+
if (image.pixels[index] === VERIFICATION_SENTINEL.r && image.pixels[index + 1] === VERIFICATION_SENTINEL.g && image.pixels[index + 2] === VERIFICATION_SENTINEL.b) {
|
|
868
|
+
pixels += 1;
|
|
869
|
+
at ??= { x, y };
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
return {
|
|
874
|
+
pixels,
|
|
875
|
+
...at ? { at } : {},
|
|
876
|
+
because: pixels === 0 ? `no pixel in this image is ${cssColour(VERIFICATION_SENTINEL)}` : `${pixels} pixel(s) of this image are ${cssColour(VERIFICATION_SENTINEL)}, the first at (${at.x}, ${at.y})`
|
|
877
|
+
};
|
|
878
|
+
}
|
|
859
879
|
|
|
860
880
|
// browser/page-side.ts
|
|
861
881
|
function inPage(fn, arg) {
|
|
@@ -976,88 +996,716 @@ async function takeInventory(page, regions) {
|
|
|
976
996
|
};
|
|
977
997
|
}
|
|
978
998
|
|
|
979
|
-
//
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
999
|
+
// browser/painted.ts
|
|
1000
|
+
async function readPaintedMarkup(page, regions) {
|
|
1001
|
+
return page.evaluate(
|
|
1002
|
+
inPage(
|
|
1003
|
+
(spec) => {
|
|
1004
|
+
const painted = (element) => {
|
|
1005
|
+
const style = window.getComputedStyle(element);
|
|
1006
|
+
if (style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse") return false;
|
|
1007
|
+
if (style.display === "contents") return true;
|
|
1008
|
+
for (const box of Array.from(element.getClientRects())) {
|
|
1009
|
+
if (box.width > 0 && box.height > 0) return true;
|
|
1010
|
+
}
|
|
1011
|
+
return false;
|
|
1012
|
+
};
|
|
1013
|
+
const choice = (element) => element.tagName === "OPTION" || element.tagName === "OPTGROUP";
|
|
1014
|
+
const matched = [];
|
|
1015
|
+
for (const region of spec.regions) {
|
|
1016
|
+
let found = [];
|
|
1017
|
+
try {
|
|
1018
|
+
found = Array.from(document.querySelectorAll(region));
|
|
1019
|
+
} catch {
|
|
1020
|
+
continue;
|
|
1021
|
+
}
|
|
1022
|
+
for (const element of found) if (!matched.includes(element)) matched.push(element);
|
|
1023
|
+
}
|
|
1024
|
+
const roots = matched.filter((element) => !matched.some((other) => other !== element && other.contains(element)));
|
|
1025
|
+
const prune = (original, copy) => {
|
|
1026
|
+
const originals = Array.from(original.children);
|
|
1027
|
+
const copies = Array.from(copy.children);
|
|
1028
|
+
for (const [index, child] of originals.entries()) {
|
|
1029
|
+
const twin = copies[index];
|
|
1030
|
+
if (!twin) continue;
|
|
1031
|
+
if (!choice(child) && !painted(child)) {
|
|
1032
|
+
twin.remove();
|
|
1033
|
+
continue;
|
|
1034
|
+
}
|
|
1035
|
+
prune(child, twin);
|
|
1036
|
+
}
|
|
1037
|
+
};
|
|
1038
|
+
const parts = [];
|
|
1039
|
+
for (const root of roots) {
|
|
1040
|
+
if (!painted(root)) continue;
|
|
1041
|
+
const copy = root.cloneNode(true);
|
|
1042
|
+
prune(root, copy);
|
|
1043
|
+
parts.push(copy.outerHTML);
|
|
1044
|
+
}
|
|
1045
|
+
return parts.join("\n");
|
|
1046
|
+
},
|
|
1047
|
+
{ regions: [...regions] }
|
|
1048
|
+
)
|
|
1049
|
+
);
|
|
1050
|
+
}
|
|
1001
1051
|
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1052
|
+
// browser/layout.ts
|
|
1053
|
+
async function readPageLayout(page, cover = []) {
|
|
1054
|
+
return page.evaluate(
|
|
1055
|
+
inPage(
|
|
1056
|
+
(spec) => {
|
|
1057
|
+
const CONTROLS = [
|
|
1058
|
+
"button",
|
|
1059
|
+
"link",
|
|
1060
|
+
"textbox",
|
|
1061
|
+
"searchbox",
|
|
1062
|
+
"checkbox",
|
|
1063
|
+
"radio",
|
|
1064
|
+
"combobox",
|
|
1065
|
+
"listbox",
|
|
1066
|
+
"switch",
|
|
1067
|
+
"tab",
|
|
1068
|
+
"menuitem",
|
|
1069
|
+
"menuitemcheckbox",
|
|
1070
|
+
"menuitemradio",
|
|
1071
|
+
"option",
|
|
1072
|
+
"slider",
|
|
1073
|
+
"spinbutton"
|
|
1074
|
+
];
|
|
1075
|
+
const LANDMARKS = ["banner", "navigation", "main", "complementary", "contentinfo", "search", "form", "region", "dialog", "alertdialog"];
|
|
1076
|
+
const HEADINGS = "h1, h2, h3, h4, h5, h6, [role=heading]";
|
|
1077
|
+
const clean = (text2) => (text2 ?? "").replace(/\s+/g, " ").trim();
|
|
1078
|
+
const painted = (element) => {
|
|
1079
|
+
const style = window.getComputedStyle(element);
|
|
1080
|
+
if (style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse" || style.opacity === "0") return false;
|
|
1081
|
+
for (const box of Array.from(element.getClientRects())) {
|
|
1082
|
+
if (box.width > 0 && box.height > 0) return true;
|
|
1083
|
+
}
|
|
1084
|
+
return false;
|
|
1085
|
+
};
|
|
1086
|
+
const unseen = (element) => {
|
|
1087
|
+
if (element.closest('[aria-hidden="true"], [inert]')) return true;
|
|
1088
|
+
for (let node = element; node; node = node.parentElement) {
|
|
1089
|
+
const style = window.getComputedStyle(node);
|
|
1090
|
+
if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") return true;
|
|
1091
|
+
}
|
|
1092
|
+
return !painted(element);
|
|
1093
|
+
};
|
|
1094
|
+
const covered = (element) => spec.cover.some((selector) => {
|
|
1095
|
+
try {
|
|
1096
|
+
return element.closest(selector) !== null;
|
|
1097
|
+
} catch {
|
|
1098
|
+
return false;
|
|
1099
|
+
}
|
|
1100
|
+
});
|
|
1101
|
+
const textOfIds = (ids) => clean(
|
|
1102
|
+
ids.split(/\s+/).map((id) => {
|
|
1103
|
+
const target = document.getElementById(id);
|
|
1104
|
+
return target ? target.innerText ?? target.textContent ?? "" : "";
|
|
1105
|
+
}).join(" ")
|
|
1106
|
+
);
|
|
1107
|
+
const labelOf = (element) => {
|
|
1108
|
+
const by = element.getAttribute("aria-labelledby");
|
|
1109
|
+
const fromIds = by ? textOfIds(by) : "";
|
|
1110
|
+
return fromIds || clean(element.getAttribute("aria-label")) || clean(element.getAttribute("title"));
|
|
1111
|
+
};
|
|
1112
|
+
const scoped = (element) => Boolean(element.parentElement?.closest("article, aside, main, nav, section"));
|
|
1113
|
+
const roleOf = (element) => {
|
|
1114
|
+
const explicit = clean(element.getAttribute("role")).split(" ")[0];
|
|
1115
|
+
if (explicit) return explicit;
|
|
1116
|
+
const tag = element.tagName.toLowerCase();
|
|
1117
|
+
if (tag === "a") return element.hasAttribute("href") ? "link" : null;
|
|
1118
|
+
if (tag === "button" || tag === "summary") return "button";
|
|
1119
|
+
if (tag === "textarea") return "textbox";
|
|
1120
|
+
if (tag === "select") return element.multiple || element.size > 1 ? "listbox" : "combobox";
|
|
1121
|
+
if (tag === "input") {
|
|
1122
|
+
const type = clean(element.getAttribute("type")).toLowerCase() || "text";
|
|
1123
|
+
if (["button", "submit", "reset", "image"].includes(type)) return "button";
|
|
1124
|
+
if (type === "checkbox") return "checkbox";
|
|
1125
|
+
if (type === "radio") return "radio";
|
|
1126
|
+
if (type === "range") return "slider";
|
|
1127
|
+
if (type === "number") return "spinbutton";
|
|
1128
|
+
if (type === "search") return "searchbox";
|
|
1129
|
+
if (type === "hidden" || type === "file" || type === "color") return null;
|
|
1130
|
+
return "textbox";
|
|
1131
|
+
}
|
|
1132
|
+
if (element.getAttribute("contenteditable") === "true" || element.getAttribute("contenteditable") === "") return "textbox";
|
|
1133
|
+
if (tag === "nav") return "navigation";
|
|
1134
|
+
if (tag === "main") return "main";
|
|
1135
|
+
if (tag === "aside") return "complementary";
|
|
1136
|
+
if (tag === "search") return "search";
|
|
1137
|
+
if (tag === "header") return scoped(element) ? null : "banner";
|
|
1138
|
+
if (tag === "footer") return scoped(element) ? null : "contentinfo";
|
|
1139
|
+
if (tag === "form") return labelOf(element) ? "form" : null;
|
|
1140
|
+
if (tag === "section") return labelOf(element) ? "region" : null;
|
|
1141
|
+
if (tag === "dialog") return element.open ? "dialog" : null;
|
|
1142
|
+
if (/^h[1-6]$/.test(tag)) return "heading";
|
|
1143
|
+
return null;
|
|
1144
|
+
};
|
|
1145
|
+
const contentOf = (element) => {
|
|
1146
|
+
const text2 = clean(element.innerText);
|
|
1147
|
+
if (text2) return text2;
|
|
1148
|
+
const alternatives = Array.from(element.querySelectorAll("img[alt], [aria-label], svg title")).map(
|
|
1149
|
+
(node) => node.tagName.toLowerCase() === "img" ? node.getAttribute("alt") : node.tagName.toLowerCase() === "title" ? node.textContent : node.getAttribute("aria-label")
|
|
1150
|
+
);
|
|
1151
|
+
return clean(alternatives.filter(Boolean).join(" "));
|
|
1152
|
+
};
|
|
1153
|
+
const nameOf = (element) => {
|
|
1154
|
+
const labelled = labelOf(element);
|
|
1155
|
+
if (labelled && (element.getAttribute("aria-labelledby") || element.getAttribute("aria-label"))) return labelled;
|
|
1156
|
+
const tag = element.tagName.toLowerCase();
|
|
1157
|
+
if (tag === "input" || tag === "textarea" || tag === "select") {
|
|
1158
|
+
const field = element;
|
|
1159
|
+
const type = clean(field.getAttribute("type")).toLowerCase();
|
|
1160
|
+
if (type === "submit" || type === "reset" || type === "button") return clean(field.value) || (type === "reset" ? "Reset" : type === "submit" ? "Submit" : "");
|
|
1161
|
+
if (type === "image") return clean(field.getAttribute("alt")) || "Submit";
|
|
1162
|
+
const labels = Array.from(field.labels ?? []).map((label) => clean(label.innerText));
|
|
1163
|
+
return clean(labels.filter(Boolean).join(" ")) || clean(field.getAttribute("placeholder")) || clean(field.getAttribute("title"));
|
|
1164
|
+
}
|
|
1165
|
+
return contentOf(element) || clean(element.getAttribute("title"));
|
|
1166
|
+
};
|
|
1167
|
+
const stateOf = (element, role) => {
|
|
1168
|
+
const state = {};
|
|
1169
|
+
const aria = (name) => element.getAttribute(`aria-${name}`);
|
|
1170
|
+
if (element.disabled === true || aria("disabled") === "true") state.disabled = true;
|
|
1171
|
+
if (["checkbox", "radio", "switch", "menuitemcheckbox", "menuitemradio"].includes(role)) {
|
|
1172
|
+
state.checked = element.tagName.toLowerCase() === "input" ? element.checked : aria("checked") === "true";
|
|
1173
|
+
}
|
|
1174
|
+
if (aria("pressed") !== null) state.pressed = aria("pressed") === "true";
|
|
1175
|
+
if (aria("expanded") !== null) state.expanded = aria("expanded") === "true";
|
|
1176
|
+
if (aria("selected") === "true") state.selected = true;
|
|
1177
|
+
if (aria("current") !== null && aria("current") !== "false") state.current = true;
|
|
1178
|
+
return state;
|
|
1179
|
+
};
|
|
1180
|
+
const boxOf = (element) => {
|
|
1181
|
+
const rect = element.getBoundingClientRect();
|
|
1182
|
+
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
|
1183
|
+
};
|
|
1184
|
+
const levelOf = (heading) => Number(heading.getAttribute("aria-level")) || Number(heading.tagName.slice(1)) || 2;
|
|
1185
|
+
const sectionOf = (heading) => {
|
|
1186
|
+
const level = levelOf(heading);
|
|
1187
|
+
const rival = (node) => node !== heading && node.matches(HEADINGS) && levelOf(node) <= level && !unseen(node);
|
|
1188
|
+
const holdsRival = (node) => rival(node) || Array.from(node.querySelectorAll(HEADINGS)).some(rival);
|
|
1189
|
+
let top = heading;
|
|
1190
|
+
let open = false;
|
|
1191
|
+
for (let node = heading.parentElement; node && node !== document.body; node = node.parentElement) {
|
|
1192
|
+
if (holdsRival(node)) {
|
|
1193
|
+
open = true;
|
|
1194
|
+
break;
|
|
1195
|
+
}
|
|
1196
|
+
top = node;
|
|
1197
|
+
if (LANDMARKS.includes(roleOf(node) ?? "")) break;
|
|
1198
|
+
}
|
|
1199
|
+
if (covered(top)) return null;
|
|
1200
|
+
const run2 = [top];
|
|
1201
|
+
for (let next = open ? top.nextElementSibling : null; next && !holdsRival(next); next = next.nextElementSibling) {
|
|
1202
|
+
if (!unseen(next) && !covered(next)) run2.push(next);
|
|
1203
|
+
}
|
|
1204
|
+
const boxes = run2.map(boxOf).filter((box) => box.width > 0 && box.height > 0);
|
|
1205
|
+
if (!boxes.length) return null;
|
|
1206
|
+
const x = Math.min(...boxes.map((box) => box.x));
|
|
1207
|
+
const y = Math.min(...boxes.map((box) => box.y));
|
|
1208
|
+
return { x, y, width: Math.max(...boxes.map((box) => box.x + box.width)) - x, height: Math.max(...boxes.map((box) => box.y + box.height)) - y };
|
|
1209
|
+
};
|
|
1210
|
+
const transparent = (colour) => colour === "transparent" || /rgba\([^)]*,\s*0\)$/.test(colour);
|
|
1211
|
+
const backdropOf = (element) => {
|
|
1212
|
+
for (let node = element; node; node = node.parentElement) {
|
|
1213
|
+
const colour = window.getComputedStyle(node).backgroundColor;
|
|
1214
|
+
if (!transparent(colour)) return colour;
|
|
1215
|
+
}
|
|
1216
|
+
return "rgb(255, 255, 255)";
|
|
1217
|
+
};
|
|
1218
|
+
const setApart = (element) => {
|
|
1219
|
+
const style = window.getComputedStyle(element);
|
|
1220
|
+
const bordered = ["Top", "Right", "Bottom", "Left"].filter(
|
|
1221
|
+
(side) => parseFloat(style.getPropertyValue(`border-${side.toLowerCase()}-width`)) > 0 && !transparent(style.getPropertyValue(`border-${side.toLowerCase()}-color`))
|
|
1222
|
+
).length;
|
|
1223
|
+
if (bordered >= 3 || style.boxShadow && style.boxShadow !== "none") return true;
|
|
1224
|
+
return !transparent(style.backgroundColor) && style.backgroundColor !== backdropOf(element.parentElement);
|
|
1225
|
+
};
|
|
1226
|
+
const NOT_TITLE = "button, a, input, select, textarea, label, th, td, [role=button], [role=link], [role=tab], [role=columnheader], [role=rowheader], [role=cell], [role=gridcell]";
|
|
1227
|
+
const boxedIn = (node, panel, frame) => {
|
|
1228
|
+
for (let around = node; around && around !== panel; around = around.parentElement) {
|
|
1229
|
+
if (!setApart(around)) continue;
|
|
1230
|
+
const rect = around.getBoundingClientRect();
|
|
1231
|
+
return !(rect.top - frame.y <= 2 && rect.width >= frame.width * 0.9);
|
|
1232
|
+
}
|
|
1233
|
+
return false;
|
|
1234
|
+
};
|
|
1235
|
+
const wordsOf = (node) => {
|
|
1236
|
+
const size = window.getComputedStyle(node).fontSize;
|
|
1237
|
+
const words = Array.from(node.childNodes).map((child) => {
|
|
1238
|
+
if (child.nodeType === Node.TEXT_NODE) return child.textContent ?? "";
|
|
1239
|
+
if (!(child instanceof Element) || child.matches(NOT_TITLE) || child.getAttribute("aria-hidden") === "true" || !painted(child)) return "";
|
|
1240
|
+
const style = window.getComputedStyle(child);
|
|
1241
|
+
return style.display.startsWith("inline") && style.fontSize === size ? wordsOf(child) : "";
|
|
1242
|
+
});
|
|
1243
|
+
return clean(words.join(""));
|
|
1244
|
+
};
|
|
1245
|
+
const titleOf = (panel, frame) => {
|
|
1246
|
+
for (const node of Array.from(panel.querySelectorAll("*"))) {
|
|
1247
|
+
if (node.closest(NOT_TITLE)) continue;
|
|
1248
|
+
if (!Array.from(node.childNodes).some((child) => child.nodeType === Node.TEXT_NODE && clean(child.textContent))) continue;
|
|
1249
|
+
if (node.getBoundingClientRect().top - frame.y > 64) break;
|
|
1250
|
+
if (Number(window.getComputedStyle(node).fontWeight) < 600 || boxedIn(node, panel, frame)) continue;
|
|
1251
|
+
const words = wordsOf(node);
|
|
1252
|
+
if (new RegExp("\\p{L}", "u").test(words)) return words;
|
|
1253
|
+
}
|
|
1254
|
+
return "";
|
|
1255
|
+
};
|
|
1256
|
+
const regions = [];
|
|
1257
|
+
const controls = [];
|
|
1258
|
+
const viewportArea = window.innerWidth * window.innerHeight;
|
|
1259
|
+
const PANELS = ["DIV", "SECTION", "ARTICLE", "ASIDE", "LI", "FORM", "FIELDSET", "DETAILS"];
|
|
1260
|
+
for (const element of Array.from(document.body?.querySelectorAll("*") ?? [])) {
|
|
1261
|
+
const role = roleOf(element);
|
|
1262
|
+
if (!role) {
|
|
1263
|
+
if (!PANELS.includes(element.tagName)) continue;
|
|
1264
|
+
const box2 = boxOf(element);
|
|
1265
|
+
if (box2.width < 160 || box2.height < 48 || box2.width * box2.height > viewportArea * 0.6) continue;
|
|
1266
|
+
if (unseen(element) || covered(element) || !setApart(element)) continue;
|
|
1267
|
+
const title = titleOf(element, box2);
|
|
1268
|
+
if (title) regions.push({ kind: "section", role: "region", label: title, box: box2 });
|
|
1269
|
+
continue;
|
|
1270
|
+
}
|
|
1271
|
+
if (unseen(element) || covered(element)) continue;
|
|
1272
|
+
const box = boxOf(element);
|
|
1273
|
+
if (box.width < 2 || box.height < 2) continue;
|
|
1274
|
+
if (CONTROLS.includes(role)) {
|
|
1275
|
+
const href = role === "link" ? element.href || element.getAttribute("href") : null;
|
|
1276
|
+
controls.push({ role, name: nameOf(element), box, state: stateOf(element, role), ...href ? { href } : {} });
|
|
1277
|
+
} else if (LANDMARKS.includes(role)) {
|
|
1278
|
+
regions.push({ kind: role === "dialog" || role === "alertdialog" ? "dialog" : "landmark", role, label: labelOf(element), box });
|
|
1279
|
+
} else if (role === "heading") {
|
|
1280
|
+
const section = sectionOf(element);
|
|
1281
|
+
if (section) regions.push({ kind: "section", role: "region", label: clean(element.innerText) || labelOf(element), box: section });
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
return { viewport: { width: window.innerWidth, height: window.innerHeight }, scale: window.devicePixelRatio, title: clean(document.title), regions, controls };
|
|
1285
|
+
},
|
|
1286
|
+
{ cover: [...cover] }
|
|
1287
|
+
)
|
|
1288
|
+
);
|
|
1289
|
+
}
|
|
1290
|
+
async function phoneFit(page, width) {
|
|
1291
|
+
return page.evaluate(
|
|
1292
|
+
inPage(
|
|
1293
|
+
(phone) => {
|
|
1294
|
+
const root = document.documentElement;
|
|
1295
|
+
const laid = root.clientWidth;
|
|
1296
|
+
if (laid > phone.width + 1) return { fits: false, because: `it lays itself out ${laid}px wide, as a page with no viewport for a phone does` };
|
|
1297
|
+
const content = Math.max(root.scrollWidth, document.body?.scrollWidth ?? 0);
|
|
1298
|
+
if (content > laid + 1) return { fits: false, because: `it runs ${Math.round(content - laid)}px off the side` };
|
|
1299
|
+
const main2 = document.querySelector("main, [role=main]");
|
|
1300
|
+
const column = main2 ? main2.getBoundingClientRect().width : laid;
|
|
1301
|
+
return column >= laid * 0.75 ? { fits: true } : { fits: false, because: `its main column is ${Math.round(column)}px of ${laid}px, beside the rest of the layout` };
|
|
1302
|
+
},
|
|
1303
|
+
{ width }
|
|
1304
|
+
)
|
|
1305
|
+
);
|
|
1306
|
+
}
|
|
1006
1307
|
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1308
|
+
// browser/clip-cursor.ts
|
|
1309
|
+
async function drawCursor(page, at, pressed = false) {
|
|
1310
|
+
await page.evaluate(
|
|
1311
|
+
inPage(
|
|
1312
|
+
(spec) => {
|
|
1313
|
+
const id = "helpdesk-clip-cursor";
|
|
1314
|
+
let cursor = document.getElementById(id);
|
|
1315
|
+
if (!cursor) {
|
|
1316
|
+
cursor = document.createElement("div");
|
|
1317
|
+
cursor.id = id;
|
|
1318
|
+
cursor.setAttribute("aria-hidden", "true");
|
|
1319
|
+
cursor.style.cssText = "position:fixed;left:0;top:0;width:28px;height:28px;pointer-events:none;z-index:2147483647;transition:none;animation:none;";
|
|
1320
|
+
cursor.innerHTML = '<svg width="28" height="28" viewBox="0 0 28 28" style="overflow:visible" xmlns="http://www.w3.org/2000/svg"><circle data-press cx="0" cy="0" r="14" fill="rgba(37,99,235,0.3)" stroke="rgb(37,99,235)" stroke-width="2" style="display:none"/><path d="M0 0 L0 19 L5 14.5 L8.5 22.5 L11.6 21.1 L8.2 13.4 L14.8 13.4 Z" fill="rgb(17,24,39)" stroke="rgb(255,255,255)" stroke-width="1.6" stroke-linejoin="round"/></svg>';
|
|
1321
|
+
document.documentElement.appendChild(cursor);
|
|
1322
|
+
}
|
|
1323
|
+
cursor.style.transform = `translate(${spec.x}px, ${spec.y}px)`;
|
|
1324
|
+
const ring = cursor.querySelector("[data-press]");
|
|
1325
|
+
if (ring) ring.style.display = spec.pressed ? "" : "none";
|
|
1326
|
+
},
|
|
1327
|
+
{ x: at.x, y: at.y, pressed }
|
|
1328
|
+
)
|
|
1329
|
+
);
|
|
1330
|
+
}
|
|
1017
1331
|
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
// Monotonic and deterministic. Frozen would deadlock anything that waits
|
|
1021
|
-
// for time to pass.
|
|
1022
|
-
globalThis.performance.now = () => (ticks += 16);
|
|
1023
|
-
}
|
|
1332
|
+
// core/capture/clips.ts
|
|
1333
|
+
import { createHash } from "node:crypto";
|
|
1024
1334
|
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1335
|
+
// core/capture/layout-manifest.ts
|
|
1336
|
+
var LAYOUT_MANIFEST_SCHEMA = "helpdesk.layout-manifest/1";
|
|
1337
|
+
var PHONE_SUFFIX = "--phone";
|
|
1338
|
+
var PHONE_VIEWPORT = { width: 390, height: 844 };
|
|
1339
|
+
var CONTROL_ROLES = [
|
|
1340
|
+
"button",
|
|
1341
|
+
"link",
|
|
1342
|
+
"textbox",
|
|
1343
|
+
"searchbox",
|
|
1344
|
+
"checkbox",
|
|
1345
|
+
"radio",
|
|
1346
|
+
"combobox",
|
|
1347
|
+
"listbox",
|
|
1348
|
+
"switch",
|
|
1349
|
+
"tab",
|
|
1350
|
+
"menuitem",
|
|
1351
|
+
"menuitemcheckbox",
|
|
1352
|
+
"menuitemradio",
|
|
1353
|
+
"option",
|
|
1354
|
+
"slider",
|
|
1355
|
+
"spinbutton"
|
|
1356
|
+
];
|
|
1357
|
+
var REGION_ROLES = ["banner", "navigation", "main", "complementary", "contentinfo", "search", "form", "region", "dialog", "alertdialog"];
|
|
1358
|
+
var MAX_CONTROLS = 600;
|
|
1359
|
+
var MAX_REGIONS = 200;
|
|
1360
|
+
var MAX_TEXT = 200;
|
|
1361
|
+
var MAX_ID_SLUG = 48;
|
|
1362
|
+
function isRole(roles, value) {
|
|
1363
|
+
return typeof value === "string" && roles.includes(value);
|
|
1364
|
+
}
|
|
1365
|
+
function slugOf(text2) {
|
|
1366
|
+
return text2.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, MAX_ID_SLUG).replace(/-+$/g, "");
|
|
1367
|
+
}
|
|
1368
|
+
function identityOf(name) {
|
|
1369
|
+
return slugOf(name.replace(/(?:\s+\d[\d\s/.,:%]*)+$/, "")) || slugOf(name);
|
|
1370
|
+
}
|
|
1371
|
+
function textOf(value) {
|
|
1372
|
+
return typeof value === "string" ? value.replace(/\s+/g, " ").trim().slice(0, MAX_TEXT) : "";
|
|
1373
|
+
}
|
|
1374
|
+
function recordOf(value) {
|
|
1375
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
|
|
1376
|
+
}
|
|
1377
|
+
function readState(value) {
|
|
1378
|
+
const given = recordOf(value);
|
|
1379
|
+
const state = {};
|
|
1380
|
+
if (given["disabled"] === true) state.disabled = true;
|
|
1381
|
+
if (given["selected"] === true) state.selected = true;
|
|
1382
|
+
if (given["current"] === true) state.current = true;
|
|
1383
|
+
if (typeof given["checked"] === "boolean") state.checked = given["checked"];
|
|
1384
|
+
if (typeof given["pressed"] === "boolean") state.pressed = given["pressed"];
|
|
1385
|
+
if (typeof given["expanded"] === "boolean") state.expanded = given["expanded"];
|
|
1386
|
+
return Object.keys(state).length ? state : void 0;
|
|
1387
|
+
}
|
|
1388
|
+
function placed(box, viewport, scale) {
|
|
1389
|
+
if (![box.x, box.y, box.width, box.height].every(Number.isFinite)) return null;
|
|
1390
|
+
const snap = (value) => Math.round(value * scale) / scale;
|
|
1391
|
+
const left = Math.max(0, box.x);
|
|
1392
|
+
const top = Math.max(0, box.y);
|
|
1393
|
+
const right = Math.min(viewport.width, box.x + box.width);
|
|
1394
|
+
const bottom = Math.min(viewport.height, box.y + box.height);
|
|
1395
|
+
if (right - left < 1 || bottom - top < 1) return null;
|
|
1396
|
+
const x = snap(left);
|
|
1397
|
+
const y = snap(top);
|
|
1398
|
+
return {
|
|
1399
|
+
box: { x, y, width: snap(right) - x, height: snap(bottom) - y },
|
|
1400
|
+
clipped: left !== box.x || top !== box.y || right !== box.x + box.width || bottom !== box.y + box.height
|
|
1401
|
+
};
|
|
1402
|
+
}
|
|
1403
|
+
function contains(outer, inner) {
|
|
1404
|
+
return inner.x >= outer.x && inner.y >= outer.y && inner.x + inner.width <= outer.x + outer.width && inner.y + inner.height <= outer.y + outer.height;
|
|
1405
|
+
}
|
|
1406
|
+
function sameBox(left, right) {
|
|
1407
|
+
return left.x === right.x && left.y === right.y && left.width === right.width && left.height === right.height;
|
|
1408
|
+
}
|
|
1409
|
+
function identify(items, base) {
|
|
1410
|
+
const taken = /* @__PURE__ */ new Set();
|
|
1411
|
+
return items.map((item) => {
|
|
1412
|
+
const root = base(item);
|
|
1413
|
+
let id = root;
|
|
1414
|
+
for (let ordinal = 2; taken.has(id); ordinal += 1) id = `${root}-${ordinal}`;
|
|
1415
|
+
taken.add(id);
|
|
1416
|
+
return id;
|
|
1417
|
+
});
|
|
1418
|
+
}
|
|
1419
|
+
function smallestAround(regions, box, except) {
|
|
1420
|
+
let best;
|
|
1421
|
+
for (const region of regions) {
|
|
1422
|
+
if (region.id === except || !contains(region.box, box)) continue;
|
|
1423
|
+
const size = region.box.width * region.box.height;
|
|
1424
|
+
if (!best || size < best.size) best = { id: region.id, size };
|
|
1032
1425
|
}
|
|
1033
|
-
|
|
1426
|
+
return best?.id;
|
|
1034
1427
|
}
|
|
1035
|
-
function
|
|
1036
|
-
|
|
1428
|
+
function ownPath(href, origin) {
|
|
1429
|
+
if (typeof href !== "string" || !href) return void 0;
|
|
1430
|
+
try {
|
|
1431
|
+
const base = origin ?? "http://localhost";
|
|
1432
|
+
const url = new URL(href, base);
|
|
1433
|
+
if (url.origin !== new URL(base).origin) return void 0;
|
|
1434
|
+
return `${url.pathname}${url.search}`.slice(0, 2048);
|
|
1435
|
+
} catch {
|
|
1436
|
+
return void 0;
|
|
1437
|
+
}
|
|
1037
1438
|
}
|
|
1038
|
-
|
|
1039
|
-
const
|
|
1040
|
-
const
|
|
1041
|
-
const
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
await wait(options.intervalMs);
|
|
1048
|
-
const next = await options.sample();
|
|
1049
|
-
samples += 1;
|
|
1050
|
-
matches = options.same(value, next) ? matches + 1 : 1;
|
|
1051
|
-
value = next;
|
|
1439
|
+
function buildLayoutManifest(layout, frame) {
|
|
1440
|
+
const { viewport, scale } = layout;
|
|
1441
|
+
const regions = [];
|
|
1442
|
+
for (const raw of layout.regions.slice(0, MAX_REGIONS)) {
|
|
1443
|
+
if (!isRole(REGION_ROLES, raw.role)) continue;
|
|
1444
|
+
const where = placed(raw.box, viewport, scale);
|
|
1445
|
+
if (!where) continue;
|
|
1446
|
+
if (raw.kind === "section" && regions.some((region) => sameBox(region.box, where.box))) continue;
|
|
1447
|
+
regions.push({ kind: raw.kind, role: raw.role, label: textOf(raw.label), box: where.box, clipped: where.clipped });
|
|
1052
1448
|
}
|
|
1053
|
-
|
|
1449
|
+
const controls = [];
|
|
1450
|
+
for (const raw of layout.controls.slice(0, MAX_CONTROLS)) {
|
|
1451
|
+
if (!isRole(CONTROL_ROLES, raw.role)) continue;
|
|
1452
|
+
const where = placed(raw.box, viewport, scale);
|
|
1453
|
+
if (!where) continue;
|
|
1454
|
+
const href = raw.role === "link" ? ownPath(raw.href, frame.origin) : void 0;
|
|
1455
|
+
const state = readState(raw.state);
|
|
1456
|
+
controls.push({ role: raw.role, name: textOf(raw.name), box: where.box, clipped: where.clipped, ...state ? { state } : {}, ...href ? { href } : {} });
|
|
1457
|
+
}
|
|
1458
|
+
return {
|
|
1459
|
+
schema: LAYOUT_MANIFEST_SCHEMA,
|
|
1460
|
+
screen: {
|
|
1461
|
+
shotId: frame.shotId,
|
|
1462
|
+
route: frame.route,
|
|
1463
|
+
path: frame.path,
|
|
1464
|
+
title: textOf(layout.title),
|
|
1465
|
+
variant: frame.variant,
|
|
1466
|
+
viewport: { width: viewport.width, height: viewport.height },
|
|
1467
|
+
scale,
|
|
1468
|
+
image: { width: frame.image.width, height: frame.image.height }
|
|
1469
|
+
},
|
|
1470
|
+
...assemble(regions, controls)
|
|
1471
|
+
};
|
|
1054
1472
|
}
|
|
1055
|
-
function
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1473
|
+
function assemble(regions, controls) {
|
|
1474
|
+
const regionIds = identify(regions, (region) => region.label ? `${region.role}:${identityOf(region.label) || "unnamed"}` : region.role);
|
|
1475
|
+
const located = regions.map((region, index) => ({ id: regionIds[index], box: region.box }));
|
|
1476
|
+
const controlIds = identify(controls, (control) => `${control.role}:${identityOf(control.name) || "unnamed"}`);
|
|
1477
|
+
return {
|
|
1478
|
+
regions: regions.map((region, index) => {
|
|
1479
|
+
const id = regionIds[index];
|
|
1480
|
+
const parent = smallestAround(located, region.box, id);
|
|
1481
|
+
return {
|
|
1482
|
+
id,
|
|
1483
|
+
kind: region.kind,
|
|
1484
|
+
role: region.role,
|
|
1485
|
+
label: region.label,
|
|
1486
|
+
box: region.box,
|
|
1487
|
+
...parent ? { parent } : {},
|
|
1488
|
+
...region.clipped ? { clipped: true } : {}
|
|
1489
|
+
};
|
|
1490
|
+
}),
|
|
1491
|
+
controls: controls.map((control, index) => {
|
|
1492
|
+
const centre = { x: control.box.x + control.box.width / 2, y: control.box.y + control.box.height / 2, width: 0, height: 0 };
|
|
1493
|
+
const region = smallestAround(located, centre);
|
|
1494
|
+
return {
|
|
1495
|
+
id: controlIds[index],
|
|
1496
|
+
role: control.role,
|
|
1497
|
+
name: control.name,
|
|
1498
|
+
box: control.box,
|
|
1499
|
+
...region ? { region } : {},
|
|
1500
|
+
...control.state ? { state: control.state } : {},
|
|
1501
|
+
...control.href ? { href: control.href } : {},
|
|
1502
|
+
...control.clipped ? { clipped: true } : {}
|
|
1503
|
+
};
|
|
1504
|
+
})
|
|
1505
|
+
};
|
|
1506
|
+
}
|
|
1507
|
+
function layoutManifestText(manifest) {
|
|
1508
|
+
return [manifest.screen.title, ...manifest.regions.map((region) => region.label), ...manifest.controls.map((control) => control.name)].filter(Boolean).join("\n");
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
// core/capture/clips.ts
|
|
1512
|
+
var CLIP_VIEWPORT = { width: 1280, height: 800 };
|
|
1513
|
+
var CLIP_FPS = 10;
|
|
1514
|
+
var CLIP_MAX_MS = 3e4;
|
|
1515
|
+
var CLIP_MAX_STEPS = 12;
|
|
1516
|
+
var CLIP_MAX_MP4_BYTES = 3 * 1024 * 1024;
|
|
1517
|
+
var CLIP_MAX_GIF_BYTES = 8 * 1024 * 1024;
|
|
1518
|
+
var CLIP_GIF_WIDTH = 960;
|
|
1519
|
+
var CLIP_ACTIONS = ["click", "fill", "select", "check"];
|
|
1520
|
+
var MAX_SCRIPTS = 50;
|
|
1521
|
+
var MAX_TEXT2 = 200;
|
|
1522
|
+
var MAX_TITLE = 120;
|
|
1523
|
+
var MAX_ID = 80;
|
|
1524
|
+
function cleanText(value) {
|
|
1525
|
+
return typeof value === "string" ? value.replace(/\s+/g, " ").trim() : "";
|
|
1526
|
+
}
|
|
1527
|
+
function recordOf2(value) {
|
|
1528
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
|
|
1529
|
+
}
|
|
1530
|
+
function isPathLike(value) {
|
|
1531
|
+
return value.startsWith("/") && !value.startsWith("//") && !value.includes("..") && !value.includes("#") && value.length <= 2048;
|
|
1532
|
+
}
|
|
1533
|
+
function readStep(value, at) {
|
|
1534
|
+
const step = recordOf2(value);
|
|
1535
|
+
const route = cleanText(step["route"]);
|
|
1536
|
+
const control = recordOf2(step["control"]);
|
|
1537
|
+
const role = control["role"];
|
|
1538
|
+
const name = cleanText(control["name"]);
|
|
1539
|
+
const action = step["action"];
|
|
1540
|
+
const says = cleanText(step["says"]);
|
|
1541
|
+
const given = step["value"];
|
|
1542
|
+
if (!route || !isPathLike(route)) return `step ${at} names no screen`;
|
|
1543
|
+
if (typeof role !== "string" || !CONTROL_ROLES.includes(role)) return `step ${at} names no control role a screen lists`;
|
|
1544
|
+
if (!name || name.length > MAX_TEXT2) return `step ${at} names no control`;
|
|
1545
|
+
if (typeof action !== "string" || !CLIP_ACTIONS.includes(action)) return `step ${at} says neither click, fill, select nor check`;
|
|
1546
|
+
if (!says || says.length > MAX_TEXT2) return `step ${at} has no sentence for a reader`;
|
|
1547
|
+
const typed = action === "fill" || action === "select";
|
|
1548
|
+
if (typed && (typeof given !== "string" || !given.trim() || given.length > MAX_TEXT2)) return `step ${at} has nothing to ${action}`;
|
|
1549
|
+
if (!typed && given !== void 0) return `step ${at} gives a value to ${action}, which takes none`;
|
|
1550
|
+
return { route, control: { role, name }, action, ...typed ? { value: given } : {}, says };
|
|
1551
|
+
}
|
|
1552
|
+
function readClipScripts(raw) {
|
|
1553
|
+
const scripts = [];
|
|
1554
|
+
const refused = [];
|
|
1555
|
+
if (raw === void 0) return { scripts, refused };
|
|
1556
|
+
if (!Array.isArray(raw)) return { scripts, refused: [{ id: "clips", because: "clips is not a list" }] };
|
|
1557
|
+
for (const value of raw.slice(0, MAX_SCRIPTS)) {
|
|
1558
|
+
const script = recordOf2(value);
|
|
1559
|
+
const id = typeof script["id"] === "string" ? script["id"] : "";
|
|
1560
|
+
const label = id || "a clip";
|
|
1561
|
+
const refuse = (because) => refused.push({ id: label, because });
|
|
1562
|
+
const title = cleanText(script["title"]);
|
|
1563
|
+
const visit = cleanText(script["visit"]);
|
|
1564
|
+
const lands = script["lands"] === void 0 ? void 0 : cleanText(script["lands"]);
|
|
1565
|
+
if (!/^[a-z0-9-]+$/.test(id) || id.length > MAX_ID) {
|
|
1566
|
+
refuse("its id is not lower-case letters, digits and hyphens");
|
|
1567
|
+
continue;
|
|
1568
|
+
}
|
|
1569
|
+
if (scripts.some((kept) => kept.id === id)) {
|
|
1570
|
+
refuse("another clip has the same id");
|
|
1571
|
+
continue;
|
|
1572
|
+
}
|
|
1573
|
+
if (!title || title.length > MAX_TITLE) {
|
|
1574
|
+
refuse("it has no title");
|
|
1575
|
+
continue;
|
|
1576
|
+
}
|
|
1577
|
+
const rawSteps = Array.isArray(script["steps"]) ? script["steps"] : [];
|
|
1578
|
+
if (!rawSteps.length || rawSteps.length > CLIP_MAX_STEPS) {
|
|
1579
|
+
refuse(`it has ${rawSteps.length} steps, and a clip has between 1 and ${CLIP_MAX_STEPS}`);
|
|
1580
|
+
continue;
|
|
1581
|
+
}
|
|
1582
|
+
const steps = [];
|
|
1583
|
+
let problem = "";
|
|
1584
|
+
for (const [index, rawStep] of rawSteps.entries()) {
|
|
1585
|
+
const step = readStep(rawStep, index + 1);
|
|
1586
|
+
if (typeof step === "string") {
|
|
1587
|
+
problem = step;
|
|
1588
|
+
break;
|
|
1589
|
+
}
|
|
1590
|
+
steps.push(step);
|
|
1591
|
+
}
|
|
1592
|
+
if (problem) {
|
|
1593
|
+
refuse(problem);
|
|
1594
|
+
continue;
|
|
1595
|
+
}
|
|
1596
|
+
const first = steps[0];
|
|
1597
|
+
if (!isPathLike(visit) || !fillsPattern(first.route, visit.split("?")[0])) {
|
|
1598
|
+
refuse(`it opens at ${visit || "nowhere"}, which is not ${first.route}`);
|
|
1599
|
+
continue;
|
|
1600
|
+
}
|
|
1601
|
+
if (lands !== void 0 && !isPathLike(lands)) {
|
|
1602
|
+
refuse("it names no screen to land on");
|
|
1603
|
+
continue;
|
|
1604
|
+
}
|
|
1605
|
+
scripts.push({ id, title, visit, steps, ...lands ? { lands } : {} });
|
|
1059
1606
|
}
|
|
1060
|
-
return
|
|
1607
|
+
return { scripts, refused };
|
|
1608
|
+
}
|
|
1609
|
+
function resolveStep(manifest, step) {
|
|
1610
|
+
const name = cleanText(step.control.name).toLowerCase();
|
|
1611
|
+
const where = manifest.screen.path;
|
|
1612
|
+
const matches = manifest.controls.filter((control) => control.role === step.control.role && control.name.toLowerCase() === name);
|
|
1613
|
+
if (matches.length === 0) return { ok: false, because: `no ${step.control.role} "${step.control.name}" on ${where}` };
|
|
1614
|
+
if (matches.length > 1) return { ok: false, because: `${matches.length} of ${step.control.role} "${step.control.name}" on ${where}, so the step does not say which` };
|
|
1615
|
+
if (matches[0].state?.disabled) return { ok: false, because: `${step.control.role} "${step.control.name}" on ${where} is disabled` };
|
|
1616
|
+
return { ok: true, control: matches[0] };
|
|
1617
|
+
}
|
|
1618
|
+
function onScreen(route, location2) {
|
|
1619
|
+
return fillsPattern(route, location2.split("?")[0].split("#")[0]);
|
|
1620
|
+
}
|
|
1621
|
+
function clipRoutes(script) {
|
|
1622
|
+
return [.../* @__PURE__ */ new Set([...script.steps.map((step) => step.route), ...script.lands ? [script.lands] : []])];
|
|
1623
|
+
}
|
|
1624
|
+
function clipFingerprint(script, touched) {
|
|
1625
|
+
const canonical = {
|
|
1626
|
+
version: 1,
|
|
1627
|
+
id: script.id,
|
|
1628
|
+
title: script.title,
|
|
1629
|
+
lands: script.lands ?? null,
|
|
1630
|
+
steps: script.steps.map((step) => ({
|
|
1631
|
+
route: step.route,
|
|
1632
|
+
role: step.control.role,
|
|
1633
|
+
name: step.control.name,
|
|
1634
|
+
action: step.action,
|
|
1635
|
+
value: step.value ?? null,
|
|
1636
|
+
says: step.says
|
|
1637
|
+
})),
|
|
1638
|
+
touched: [...touched].map((screen) => [screen.route, screen.shotId, screen.sha256]).sort((left, right) => String(left[0]).localeCompare(String(right[0])))
|
|
1639
|
+
};
|
|
1640
|
+
return createHash("sha256").update(JSON.stringify(canonical)).digest("hex");
|
|
1641
|
+
}
|
|
1642
|
+
function centreOf(box) {
|
|
1643
|
+
return { x: Math.round(box.x + box.width / 2), y: Math.round(box.y + box.height / 2) };
|
|
1644
|
+
}
|
|
1645
|
+
function cursorPath(from, to, frames) {
|
|
1646
|
+
return Array.from({ length: Math.max(1, frames) }, (_, index) => {
|
|
1647
|
+
const t = (index + 1) / Math.max(1, frames);
|
|
1648
|
+
const eased = t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2;
|
|
1649
|
+
return { x: Math.round(from.x + (to.x - from.x) * eased), y: Math.round(from.y + (to.y - from.y) * eased) };
|
|
1650
|
+
});
|
|
1651
|
+
}
|
|
1652
|
+
function framesLength(frames) {
|
|
1653
|
+
return frames.reduce((total, frame) => total + frame.ms, 0);
|
|
1654
|
+
}
|
|
1655
|
+
function concatList(frames) {
|
|
1656
|
+
const quote = (file) => `'${file.replace(/'/g, "'\\''")}'`;
|
|
1657
|
+
const lines = ["ffconcat version 1.0"];
|
|
1658
|
+
for (const frame of frames) lines.push(`file ${quote(frame.file)}`, `duration ${(frame.ms / 1e3).toFixed(3)}`);
|
|
1659
|
+
if (frames.length) lines.push(`file ${quote(frames[frames.length - 1].file)}`);
|
|
1660
|
+
return `${lines.join("\n")}
|
|
1661
|
+
`;
|
|
1662
|
+
}
|
|
1663
|
+
function mp4Arguments(list, out, quality = 28) {
|
|
1664
|
+
return [
|
|
1665
|
+
"-y",
|
|
1666
|
+
"-loglevel",
|
|
1667
|
+
"error",
|
|
1668
|
+
"-f",
|
|
1669
|
+
"concat",
|
|
1670
|
+
"-safe",
|
|
1671
|
+
"0",
|
|
1672
|
+
"-i",
|
|
1673
|
+
list,
|
|
1674
|
+
"-vf",
|
|
1675
|
+
`fps=${CLIP_FPS},format=yuv420p`,
|
|
1676
|
+
"-c:v",
|
|
1677
|
+
"libx264",
|
|
1678
|
+
"-preset",
|
|
1679
|
+
"slow",
|
|
1680
|
+
"-crf",
|
|
1681
|
+
String(quality),
|
|
1682
|
+
"-threads",
|
|
1683
|
+
"1",
|
|
1684
|
+
"-movflags",
|
|
1685
|
+
"+faststart",
|
|
1686
|
+
"-an",
|
|
1687
|
+
out
|
|
1688
|
+
];
|
|
1689
|
+
}
|
|
1690
|
+
function gifArguments(list, out, options = {}) {
|
|
1691
|
+
const width = options.width ?? CLIP_GIF_WIDTH;
|
|
1692
|
+
const fps = options.fps ?? CLIP_FPS;
|
|
1693
|
+
return [
|
|
1694
|
+
"-y",
|
|
1695
|
+
"-loglevel",
|
|
1696
|
+
"error",
|
|
1697
|
+
"-f",
|
|
1698
|
+
"concat",
|
|
1699
|
+
"-safe",
|
|
1700
|
+
"0",
|
|
1701
|
+
"-i",
|
|
1702
|
+
list,
|
|
1703
|
+
"-vf",
|
|
1704
|
+
`fps=${fps},scale=${width}:-1:flags=lanczos,split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=4:diff_mode=rectangle`,
|
|
1705
|
+
"-threads",
|
|
1706
|
+
"1",
|
|
1707
|
+
out
|
|
1708
|
+
];
|
|
1061
1709
|
}
|
|
1062
1710
|
|
|
1063
1711
|
// core/coverage/normalize.ts
|
|
@@ -1111,152 +1759,52 @@ function isEmptyState(text2) {
|
|
|
1111
1759
|
if (sentence.split(" ").length < 2) return false;
|
|
1112
1760
|
return EMPTY_STATE.test(sentence);
|
|
1113
1761
|
}
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
var
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
complementary: "aside",
|
|
1122
|
-
dialog: "dialog",
|
|
1123
|
-
alertdialog: "dialog",
|
|
1124
|
-
form: "form",
|
|
1125
|
-
search: "form"
|
|
1126
|
-
};
|
|
1127
|
-
var ACTION = /* @__PURE__ */ new Set(["button", "menuitem", "menuitemcheckbox", "menuitemradio", "tab", "switch"]);
|
|
1128
|
-
var FIELD = {
|
|
1129
|
-
textbox: "text",
|
|
1130
|
-
searchbox: "search",
|
|
1131
|
-
combobox: "select",
|
|
1132
|
-
listbox: "select",
|
|
1133
|
-
checkbox: "checkbox",
|
|
1134
|
-
radio: "radio",
|
|
1135
|
-
slider: "range",
|
|
1136
|
-
spinbutton: "number"
|
|
1137
|
-
};
|
|
1138
|
-
var NOTICE = /* @__PURE__ */ new Set(["status", "alert"]);
|
|
1139
|
-
var STATE = /* @__PURE__ */ new Set(["disabled", "checked", "selected", "expanded", "readonly", "required"]);
|
|
1140
|
-
function readAriaFactRecords(snapshot, options = {}) {
|
|
1141
|
-
const from = options.url ?? "";
|
|
1142
|
-
const inSource = options.inSource ?? /* @__PURE__ */ new Set();
|
|
1143
|
-
const facts = [];
|
|
1144
|
-
const add = (kind, value, node, extra = {}) => {
|
|
1145
|
-
if (!value) return;
|
|
1146
|
-
const scope = scopeFromLandmark(node.landmark);
|
|
1147
|
-
facts.push(
|
|
1148
|
-
fact({
|
|
1149
|
-
kind,
|
|
1150
|
-
value: trim(value, LIMITS.label),
|
|
1151
|
-
channel: "p3",
|
|
1152
|
-
scope,
|
|
1153
|
-
dataClass: classifyData(value, scope, { inSource: inSource.has(value) }),
|
|
1154
|
-
from,
|
|
1155
|
-
landmark: node.landmark,
|
|
1156
|
-
...extra
|
|
1157
|
-
})
|
|
1158
|
-
);
|
|
1159
|
-
};
|
|
1160
|
-
for (const node of parse(snapshot)) {
|
|
1161
|
-
const state = controlState(node);
|
|
1162
|
-
if (node.role === "heading") {
|
|
1163
|
-
add("heading", node.name || node.text, node);
|
|
1164
|
-
if (isEmptyState(node.name || node.text)) add("empty", node.name || node.text, node);
|
|
1165
|
-
continue;
|
|
1166
|
-
}
|
|
1167
|
-
if (node.role === "paragraph" || node.role === "text") {
|
|
1168
|
-
const said = node.text || node.name;
|
|
1169
|
-
if (isEmptyState(said)) add("empty", trim(said, LIMITS.sentence), node);
|
|
1170
|
-
continue;
|
|
1171
|
-
}
|
|
1172
|
-
if (node.role === "link") {
|
|
1173
|
-
add("link", node.name || node.text || node.url || "", node, {
|
|
1174
|
-
...node.url ? { href: node.url } : {},
|
|
1175
|
-
...state
|
|
1176
|
-
});
|
|
1177
|
-
continue;
|
|
1178
|
-
}
|
|
1179
|
-
if (ACTION.has(node.role)) {
|
|
1180
|
-
add("action", node.name || node.text, node, state);
|
|
1181
|
-
continue;
|
|
1182
|
-
}
|
|
1183
|
-
const fieldKind = FIELD[node.role];
|
|
1184
|
-
if (fieldKind) {
|
|
1185
|
-
add("field", node.name || node.text, node, {
|
|
1186
|
-
fieldKind,
|
|
1187
|
-
...node.choices.length ? { choices: node.choices.slice(0, LIMITS.choices) } : {},
|
|
1188
|
-
...state
|
|
1189
|
-
});
|
|
1190
|
-
continue;
|
|
1191
|
-
}
|
|
1192
|
-
if (NOTICE.has(node.role)) {
|
|
1193
|
-
const said = trim(node.text || node.name, LIMITS.sentence);
|
|
1194
|
-
add("notice", said, node);
|
|
1195
|
-
if (isEmptyState(said)) add("empty", said, node);
|
|
1196
|
-
}
|
|
1197
|
-
}
|
|
1198
|
-
return facts;
|
|
1199
|
-
}
|
|
1200
|
-
var LINE = /^(\s*)-\s+(.*)$/;
|
|
1201
|
-
var ENTRY = /^([a-z]+)(?:\s+"((?:[^"\\]|\\.)*)")?((?:\s*\[[^\]]*\])*)\s*(?::\s*(.*))?$/;
|
|
1202
|
-
function parse(snapshot) {
|
|
1203
|
-
const out = [];
|
|
1204
|
-
const stack = [];
|
|
1205
|
-
for (const raw of snapshot.split("\n")) {
|
|
1206
|
-
const line = LINE.exec(raw);
|
|
1207
|
-
if (!line) continue;
|
|
1208
|
-
const depth2 = line[1].length;
|
|
1209
|
-
const body = line[2];
|
|
1210
|
-
while (stack.length && stack[stack.length - 1].depth >= depth2) stack.pop();
|
|
1211
|
-
const parent = stack[stack.length - 1]?.node;
|
|
1212
|
-
if (body.startsWith("/")) {
|
|
1213
|
-
const [name, ...rest] = body.split(":");
|
|
1214
|
-
if (parent && name?.trim() === "/url") parent.url = rest.join(":").trim();
|
|
1215
|
-
continue;
|
|
1216
|
-
}
|
|
1217
|
-
const entry = ENTRY.exec(body);
|
|
1218
|
-
if (!entry) continue;
|
|
1219
|
-
const role = entry[1];
|
|
1220
|
-
const node = {
|
|
1221
|
-
role,
|
|
1222
|
-
name: unescape(entry[2] ?? ""),
|
|
1223
|
-
text: (entry[4] ?? "").trim(),
|
|
1224
|
-
attributes: attributesOf(entry[3] ?? ""),
|
|
1225
|
-
landmark: LANDMARK[role] ?? parent?.landmark ?? "none",
|
|
1226
|
-
choices: []
|
|
1227
|
-
};
|
|
1228
|
-
if (role === "option" && parent) {
|
|
1229
|
-
if (node.name || node.text) parent.choices.push(node.name || node.text);
|
|
1230
|
-
continue;
|
|
1231
|
-
}
|
|
1232
|
-
out.push(node);
|
|
1233
|
-
stack.push({ depth: depth2, node });
|
|
1234
|
-
}
|
|
1235
|
-
return out;
|
|
1236
|
-
}
|
|
1237
|
-
function attributesOf(raw) {
|
|
1238
|
-
const attributes = {};
|
|
1239
|
-
for (const match of raw.matchAll(/\[([^\]]*)\]/g)) {
|
|
1240
|
-
const [name, ...rest] = match[1].split("=");
|
|
1241
|
-
const key = name?.trim();
|
|
1242
|
-
if (!key) continue;
|
|
1243
|
-
attributes[key] = rest.length ? rest.join("=").trim() : true;
|
|
1762
|
+
var IRREVERSIBLE = /\b(cannot be undone|can't be undone|permanently|permanent|irreversible|no way back|cannot be recovered|unrecoverable|this cannot be reversed)\b/i;
|
|
1763
|
+
var DESTRUCTIVE = /\b(delete|deletes|deleting|remove|removes|revoke|revokes|erase|wipe|discard|destroy|overwrite|overwrites|reset|disconnect|unpublish|cancel subscription)\b/i;
|
|
1764
|
+
var PERMISSION = /\b(only (?:an?\s+)?(?:owner|admin|administrator)s?\b|your role|not allowed|do(?:es)? not have permission|don't have permission|permission to|requires? (?:an? )?(?:owner|admin|editing|editor)|admins? only|read[- ]only)\b/i;
|
|
1765
|
+
function finishScreenFacts(parts) {
|
|
1766
|
+
const warnings = [];
|
|
1767
|
+
for (const sentence of parts.notices) {
|
|
1768
|
+
if (IRREVERSIBLE.test(sentence) || PERMISSION.test(sentence)) warnings.push(sentence);
|
|
1244
1769
|
}
|
|
1245
|
-
|
|
1246
|
-
}
|
|
1247
|
-
function controlState(node) {
|
|
1248
|
-
const state = {};
|
|
1249
|
-
for (const [name, value] of Object.entries(node.attributes)) {
|
|
1250
|
-
if (STATE.has(name)) state[name] = value;
|
|
1770
|
+
for (const label of parts.actions) {
|
|
1771
|
+
if (DESTRUCTIVE.test(label)) warnings.push(`"${label}" destroys or replaces something.`);
|
|
1251
1772
|
}
|
|
1252
|
-
return
|
|
1773
|
+
return {
|
|
1774
|
+
headings: capped(parts.headings, LIMITS.headings),
|
|
1775
|
+
actions: capped(parts.actions, LIMITS.actions),
|
|
1776
|
+
links: cappedBy(parts.links, (link) => `${link.label ?? ""}|${link.href}`, LIMITS.links),
|
|
1777
|
+
fields: cappedBy(parts.fields, (field) => `${field.label}|${field.kind}`, LIMITS.fields),
|
|
1778
|
+
notices: capped(parts.notices, LIMITS.notices),
|
|
1779
|
+
warnings: capped(warnings, LIMITS.warnings),
|
|
1780
|
+
readFrom: parts.readFrom,
|
|
1781
|
+
...states({ disabled: parts.disabled, empty: parts.empty, gatedBy: parts.gatedBy })
|
|
1782
|
+
};
|
|
1253
1783
|
}
|
|
1254
|
-
function
|
|
1255
|
-
|
|
1784
|
+
function states(parts) {
|
|
1785
|
+
const disabled = capped(parts.disabled ?? [], LIMITS.disabled);
|
|
1786
|
+
const empty = capped(parts.empty ?? [], LIMITS.empty);
|
|
1787
|
+
const gatedBy = capped(parts.gatedBy ?? [], LIMITS.gatedBy);
|
|
1788
|
+
return {
|
|
1789
|
+
...disabled.length ? { disabled } : {},
|
|
1790
|
+
...empty.length ? { empty } : {},
|
|
1791
|
+
...gatedBy.length ? { gatedBy } : {}
|
|
1792
|
+
};
|
|
1256
1793
|
}
|
|
1257
|
-
function
|
|
1258
|
-
|
|
1259
|
-
|
|
1794
|
+
function capped(values, limit) {
|
|
1795
|
+
return [...new Set(values)].slice(0, limit);
|
|
1796
|
+
}
|
|
1797
|
+
function cappedBy(values, key, limit) {
|
|
1798
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1799
|
+
const kept = [];
|
|
1800
|
+
for (const value of values) {
|
|
1801
|
+
const identity = key(value);
|
|
1802
|
+
if (seen.has(identity)) continue;
|
|
1803
|
+
seen.add(identity);
|
|
1804
|
+
kept.push(value);
|
|
1805
|
+
if (kept.length >= limit) break;
|
|
1806
|
+
}
|
|
1807
|
+
return kept;
|
|
1260
1808
|
}
|
|
1261
1809
|
|
|
1262
1810
|
// core/discovery/rendered-facts.ts
|
|
@@ -1316,7 +1864,7 @@ function readRenderedFactRecords(html, options = {}) {
|
|
|
1316
1864
|
continue;
|
|
1317
1865
|
}
|
|
1318
1866
|
if (tag === "button" || PRESSABLE.has(role) || submitLike(element)) {
|
|
1319
|
-
add("action", label, landmark,
|
|
1867
|
+
add("action", label, landmark, controlState(element));
|
|
1320
1868
|
continue;
|
|
1321
1869
|
}
|
|
1322
1870
|
if (tag === "a") {
|
|
@@ -1332,19 +1880,19 @@ function readRenderedFactRecords(html, options = {}) {
|
|
|
1332
1880
|
add("field", field.label, landmark, {
|
|
1333
1881
|
fieldKind: field.kind,
|
|
1334
1882
|
...field.choices ? { choices: field.choices } : {},
|
|
1335
|
-
...
|
|
1883
|
+
...controlState(element)
|
|
1336
1884
|
});
|
|
1337
1885
|
}
|
|
1338
1886
|
continue;
|
|
1339
1887
|
}
|
|
1340
1888
|
if (ANNOUNCEMENT.has(role) || attributes["aria-live"]) {
|
|
1341
|
-
const said =
|
|
1889
|
+
const said = trim(text(inner), LIMITS.sentence);
|
|
1342
1890
|
add("notice", said, landmark);
|
|
1343
1891
|
if (isEmptyState(said)) add("empty", said, landmark);
|
|
1344
1892
|
continue;
|
|
1345
1893
|
}
|
|
1346
1894
|
if (TEXT_HOLDER.has(tag) && !BLOCK_INSIDE.test(inner)) {
|
|
1347
|
-
const said =
|
|
1895
|
+
const said = trim(text(inner).replace(/\s+([.,;:!])/g, "$1"), LIMITS.sentence);
|
|
1348
1896
|
if (isEmptyState(said)) add("empty", said, landmark);
|
|
1349
1897
|
}
|
|
1350
1898
|
}
|
|
@@ -1352,6 +1900,58 @@ function readRenderedFactRecords(html, options = {}) {
|
|
|
1352
1900
|
}
|
|
1353
1901
|
var TEXT_HOLDER = /* @__PURE__ */ new Set(["p", "span", "small", "td", "li", "div"]);
|
|
1354
1902
|
var BLOCK_INSIDE = /<(?!\/?(?:strong|em|b|i|a|code|br|span|small)\b)[a-z]/i;
|
|
1903
|
+
function readRenderedFacts(html, options = {}) {
|
|
1904
|
+
return toScreenFacts(readRenderedFactRecords(html, options), options.url);
|
|
1905
|
+
}
|
|
1906
|
+
function toScreenFacts(facts, from) {
|
|
1907
|
+
const headings = [];
|
|
1908
|
+
const actions = [];
|
|
1909
|
+
const links = [];
|
|
1910
|
+
const fields = [];
|
|
1911
|
+
const notices = [];
|
|
1912
|
+
const disabled = [];
|
|
1913
|
+
const empty = [];
|
|
1914
|
+
for (const entry of facts) {
|
|
1915
|
+
if ((entry.kind === "action" || entry.kind === "field") && entry.controlState?.["disabled"]) {
|
|
1916
|
+
disabled.push(entry.value);
|
|
1917
|
+
}
|
|
1918
|
+
switch (entry.kind) {
|
|
1919
|
+
case "heading":
|
|
1920
|
+
headings.push(entry.value);
|
|
1921
|
+
break;
|
|
1922
|
+
case "action":
|
|
1923
|
+
actions.push(entry.value);
|
|
1924
|
+
break;
|
|
1925
|
+
case "link":
|
|
1926
|
+
links.push(entry.href ? { label: entry.value, href: entry.href } : { href: entry.value });
|
|
1927
|
+
break;
|
|
1928
|
+
case "field": {
|
|
1929
|
+
const field = { label: entry.value, kind: entry.fieldKind ?? "text" };
|
|
1930
|
+
if (entry.choices?.length) field.choices = [...entry.choices];
|
|
1931
|
+
fields.push(field);
|
|
1932
|
+
break;
|
|
1933
|
+
}
|
|
1934
|
+
case "notice":
|
|
1935
|
+
notices.push(entry.value);
|
|
1936
|
+
break;
|
|
1937
|
+
case "empty":
|
|
1938
|
+
empty.push(entry.value);
|
|
1939
|
+
break;
|
|
1940
|
+
case "warning":
|
|
1941
|
+
break;
|
|
1942
|
+
}
|
|
1943
|
+
}
|
|
1944
|
+
return finishScreenFacts({
|
|
1945
|
+
headings,
|
|
1946
|
+
actions,
|
|
1947
|
+
links,
|
|
1948
|
+
fields,
|
|
1949
|
+
notices,
|
|
1950
|
+
readFrom: from ? [from] : [],
|
|
1951
|
+
disabled,
|
|
1952
|
+
empty
|
|
1953
|
+
});
|
|
1954
|
+
}
|
|
1355
1955
|
var OVERLAY_ROLE = {
|
|
1356
1956
|
dialog: "dialog",
|
|
1357
1957
|
alertdialog: "dialog",
|
|
@@ -1459,7 +2059,7 @@ function elements(markup) {
|
|
|
1459
2059
|
stack.length = depth2;
|
|
1460
2060
|
continue;
|
|
1461
2061
|
}
|
|
1462
|
-
const attributes =
|
|
2062
|
+
const attributes = attributesOf(match[3] ?? "");
|
|
1463
2063
|
const own = LANDMARK_ROLE[attributes["role"]?.toLowerCase() ?? ""] ?? LANDMARK_TAG[tag];
|
|
1464
2064
|
const frame = {
|
|
1465
2065
|
tag,
|
|
@@ -1480,7 +2080,7 @@ function elements(markup) {
|
|
|
1480
2080
|
return out.sort((a, b) => a.at - b.at);
|
|
1481
2081
|
}
|
|
1482
2082
|
var ATTRIBUTE = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g;
|
|
1483
|
-
function
|
|
2083
|
+
function attributesOf(raw) {
|
|
1484
2084
|
const attributes = {};
|
|
1485
2085
|
ATTRIBUTE.lastIndex = 0;
|
|
1486
2086
|
let match;
|
|
@@ -1491,7 +2091,7 @@ function attributesOf2(raw) {
|
|
|
1491
2091
|
}
|
|
1492
2092
|
return attributes;
|
|
1493
2093
|
}
|
|
1494
|
-
function
|
|
2094
|
+
function controlState(element) {
|
|
1495
2095
|
const state = {};
|
|
1496
2096
|
const { attributes } = element;
|
|
1497
2097
|
const flag = (name) => name in attributes && attributes[name] !== "false";
|
|
@@ -1505,8 +2105,8 @@ function controlState2(element) {
|
|
|
1505
2105
|
}
|
|
1506
2106
|
function accessibleName(element) {
|
|
1507
2107
|
const explicit = element.attributes["aria-label"] ?? element.attributes["title"];
|
|
1508
|
-
if (explicit?.trim()) return
|
|
1509
|
-
return
|
|
2108
|
+
if (explicit?.trim()) return trim(explicit.replace(/\s+/g, " ").trim(), LIMITS.label);
|
|
2109
|
+
return trim(text(element.inner), LIMITS.label);
|
|
1510
2110
|
}
|
|
1511
2111
|
function submitLike(element) {
|
|
1512
2112
|
if (element.tag !== "input") return false;
|
|
@@ -1520,55 +2120,324 @@ function fieldFrom(element, body) {
|
|
|
1520
2120
|
const kind = tag === "input" ? type : tag;
|
|
1521
2121
|
const label = attributes["aria-label"]?.trim() || attributes["placeholder"]?.trim() || labelFor(body, attributes["id"]) || humanise(attributes["name"] ?? "");
|
|
1522
2122
|
if (!label) return void 0;
|
|
1523
|
-
const field = { label:
|
|
2123
|
+
const field = { label: trim(label.replace(/\s+/g, " "), LIMITS.label), kind };
|
|
1524
2124
|
if (tag === "select") {
|
|
1525
2125
|
const choices = [...element.inner.matchAll(/<option\b[^>]*>([\s\S]*?)<\/option>/gi)].map((match) => text(match[1] ?? "")).filter(Boolean).slice(0, LIMITS.choices);
|
|
1526
2126
|
if (choices.length) field.choices = choices;
|
|
1527
2127
|
}
|
|
1528
|
-
return field;
|
|
1529
|
-
}
|
|
1530
|
-
function labelFor(body, id) {
|
|
1531
|
-
if (!id) return "";
|
|
1532
|
-
const match = new RegExp(
|
|
1533
|
-
`<label\\b[^>]*\\bfor\\s*=\\s*["']?${escape(id)}["']?[^>]*>([\\s\\S]*?)</label>`,
|
|
1534
|
-
"i"
|
|
1535
|
-
).exec(body);
|
|
1536
|
-
return match ? text(match[1] ?? "") : "";
|
|
1537
|
-
}
|
|
1538
|
-
function escape(value) {
|
|
1539
|
-
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2128
|
+
return field;
|
|
2129
|
+
}
|
|
2130
|
+
function labelFor(body, id) {
|
|
2131
|
+
if (!id) return "";
|
|
2132
|
+
const match = new RegExp(
|
|
2133
|
+
`<label\\b[^>]*\\bfor\\s*=\\s*["']?${escape(id)}["']?[^>]*>([\\s\\S]*?)</label>`,
|
|
2134
|
+
"i"
|
|
2135
|
+
).exec(body);
|
|
2136
|
+
return match ? text(match[1] ?? "") : "";
|
|
2137
|
+
}
|
|
2138
|
+
function escape(value) {
|
|
2139
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2140
|
+
}
|
|
2141
|
+
function humanise(name) {
|
|
2142
|
+
const words = name.replace(/[_-]+/g, " ").replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/\s+/g, " ").trim();
|
|
2143
|
+
return words ? `${words.charAt(0).toUpperCase()}${words.slice(1).toLowerCase()}` : "";
|
|
2144
|
+
}
|
|
2145
|
+
function text(fragment) {
|
|
2146
|
+
return decode(fragment.replace(/<[^>]*>/g, " ")).replace(/\s+/g, " ").trim();
|
|
2147
|
+
}
|
|
2148
|
+
var ENTITY = {
|
|
2149
|
+
amp: "&",
|
|
2150
|
+
lt: "<",
|
|
2151
|
+
gt: ">",
|
|
2152
|
+
quot: '"',
|
|
2153
|
+
apos: "'",
|
|
2154
|
+
nbsp: " ",
|
|
2155
|
+
"#39": "'",
|
|
2156
|
+
"#x27": "'",
|
|
2157
|
+
"#34": '"',
|
|
2158
|
+
mdash: "\u2014",
|
|
2159
|
+
ndash: "\u2013",
|
|
2160
|
+
hellip: "\u2026"
|
|
2161
|
+
};
|
|
2162
|
+
function decode(value) {
|
|
2163
|
+
return value.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (whole, name) => {
|
|
2164
|
+
const known = ENTITY[name.toLowerCase()];
|
|
2165
|
+
if (known) return known;
|
|
2166
|
+
const numeric = /^#x/i.test(name) ? Number.parseInt(name.slice(2), 16) : /^#/.test(name) ? Number.parseInt(name.slice(1), 10) : NaN;
|
|
2167
|
+
return Number.isFinite(numeric) && numeric > 0 && numeric < 1114112 ? String.fromCodePoint(numeric) : whole;
|
|
2168
|
+
});
|
|
2169
|
+
}
|
|
2170
|
+
function trim(value, limit) {
|
|
2171
|
+
return value.length <= limit ? value : `${value.slice(0, limit - 1).trimEnd()}\u2026`;
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2174
|
+
// core/capture/screen-details.ts
|
|
2175
|
+
function screenDetails(markup, context) {
|
|
2176
|
+
const read = readRenderedFacts(markup, { url: context.route });
|
|
2177
|
+
const clean = (text2) => scrub(text2, { where: context.route, ...context.brands?.length ? { brands: context.brands } : {} }).findings.length === 0;
|
|
2178
|
+
const keep = (values) => values.filter(clean);
|
|
2179
|
+
const links = [];
|
|
2180
|
+
for (const link of read.links) {
|
|
2181
|
+
const href = linkTarget(link.href, context);
|
|
2182
|
+
if (!href || link.label && !clean(link.label)) continue;
|
|
2183
|
+
links.push(link.label ? { label: link.label, href } : { href });
|
|
2184
|
+
}
|
|
2185
|
+
const disabled = keep(read.disabled ?? []);
|
|
2186
|
+
const empty = keep(read.empty ?? []);
|
|
2187
|
+
return {
|
|
2188
|
+
headings: keep(read.headings),
|
|
2189
|
+
actions: keep(read.actions),
|
|
2190
|
+
links,
|
|
2191
|
+
fields: read.fields.filter((field) => clean(field.label)).map((field) => field.choices ? { ...field, choices: keep(field.choices) } : field),
|
|
2192
|
+
notices: keep(read.notices),
|
|
2193
|
+
warnings: keep(read.warnings),
|
|
2194
|
+
readFrom: [context.route],
|
|
2195
|
+
read: { how: "rendered", commit: commitOf(context.commit) },
|
|
2196
|
+
...disabled.length ? { disabled } : {},
|
|
2197
|
+
...empty.length ? { empty } : {}
|
|
2198
|
+
};
|
|
2199
|
+
}
|
|
2200
|
+
function commitOf(commit) {
|
|
2201
|
+
return commit && /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(commit) ? commit.toLowerCase() : null;
|
|
2202
|
+
}
|
|
2203
|
+
var RECORD_ID = /^(?:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{16,}|\d+|[a-z0-9]{24,})$/i;
|
|
2204
|
+
function linkTarget(href, context) {
|
|
2205
|
+
let url;
|
|
2206
|
+
try {
|
|
2207
|
+
url = new URL(href, context.origin);
|
|
2208
|
+
} catch {
|
|
2209
|
+
return void 0;
|
|
2210
|
+
}
|
|
2211
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return void 0;
|
|
2212
|
+
if (url.origin !== context.origin) return `${url.origin}${url.pathname}`;
|
|
2213
|
+
const path5 = url.pathname;
|
|
2214
|
+
const screen = context.patterns.find((pattern) => fillsPattern(pattern, path5));
|
|
2215
|
+
if (screen) return screen;
|
|
2216
|
+
return path5.split("/").map((segment) => RECORD_ID.test(segment) ? "[id]" : segment).join("/");
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
// core/capture/determinism.ts
|
|
2220
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
2221
|
+
function freezeScript(pinnedClock, options = {}) {
|
|
2222
|
+
const fixed = Date.parse(pinnedClock);
|
|
2223
|
+
if (Number.isNaN(fixed)) {
|
|
2224
|
+
throw new Error(`freezeScript needs a parsable instant, got ${JSON.stringify(pinnedClock)}`);
|
|
2225
|
+
}
|
|
2226
|
+
const seed = options.seed ?? 2654435769;
|
|
2227
|
+
return `(() => {
|
|
2228
|
+
const FIXED = ${fixed};
|
|
2229
|
+
const RealDate = Date;
|
|
2230
|
+
|
|
2231
|
+
class FrozenDate extends RealDate {
|
|
2232
|
+
constructor(...args) {
|
|
2233
|
+
// Only the no-argument form is the clock. \`new Date(x)\` is arithmetic
|
|
2234
|
+
// on a value the page already had, and rewriting it would corrupt real
|
|
2235
|
+
// data rather than stabilise a render.
|
|
2236
|
+
if (args.length === 0) super(FIXED);
|
|
2237
|
+
else super(...args);
|
|
2238
|
+
}
|
|
2239
|
+
static now() { return FIXED; }
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
// \`parse\` and \`UTC\` come along by static inheritance and must not be
|
|
2243
|
+
// touched: they are pure functions of their arguments already.
|
|
2244
|
+
FrozenDate.prototype.constructor = FrozenDate;
|
|
2245
|
+
globalThis.Date = FrozenDate;
|
|
2246
|
+
|
|
2247
|
+
let state = ${seed} >>> 0;
|
|
2248
|
+
// mulberry32: small, fast, and good enough that a seeded shuffle still looks
|
|
2249
|
+
// shuffled. Cryptographic quality is the opposite of what is wanted here.
|
|
2250
|
+
globalThis.Math.random = () => {
|
|
2251
|
+
state = (state + 0x6d2b79f5) >>> 0;
|
|
2252
|
+
let t = state;
|
|
2253
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
2254
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
2255
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
2256
|
+
};
|
|
2257
|
+
|
|
2258
|
+
if (globalThis.crypto && globalThis.crypto.randomUUID) {
|
|
2259
|
+
let issued = 0;
|
|
2260
|
+
globalThis.crypto.randomUUID = () => {
|
|
2261
|
+
issued += 1;
|
|
2262
|
+
const n = issued.toString(16).padStart(12, "0");
|
|
2263
|
+
return "00000000-0000-4000-8000-" + n;
|
|
2264
|
+
};
|
|
2265
|
+
}
|
|
2266
|
+
})();`;
|
|
2267
|
+
}
|
|
2268
|
+
function frameHash(bytes) {
|
|
2269
|
+
return createHash2("sha256").update(bytes).digest("hex");
|
|
2270
|
+
}
|
|
2271
|
+
async function settle(options) {
|
|
2272
|
+
const wait = options.wait ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
2273
|
+
const now = options.now ?? (() => Date.now());
|
|
2274
|
+
const deadline = now() + options.timeoutMs;
|
|
2275
|
+
let value = await options.sample();
|
|
2276
|
+
let samples = 1;
|
|
2277
|
+
let matches = 1;
|
|
2278
|
+
while (matches < options.identicalSamples) {
|
|
2279
|
+
if (now() >= deadline) return { value, samples, settled: false };
|
|
2280
|
+
await wait(options.intervalMs);
|
|
2281
|
+
const next = await options.sample();
|
|
2282
|
+
samples += 1;
|
|
2283
|
+
matches = options.same(value, next) ? matches + 1 : 1;
|
|
2284
|
+
value = next;
|
|
2285
|
+
}
|
|
2286
|
+
return { value, samples, settled: true };
|
|
2287
|
+
}
|
|
2288
|
+
function sameBytes(a, b) {
|
|
2289
|
+
if (a.length !== b.length) return false;
|
|
2290
|
+
for (let index = 0; index < a.length; index += 1) {
|
|
2291
|
+
if (a[index] !== b[index]) return false;
|
|
2292
|
+
}
|
|
2293
|
+
return true;
|
|
2294
|
+
}
|
|
2295
|
+
|
|
2296
|
+
// core/discovery/aria-facts.ts
|
|
2297
|
+
var LANDMARK = {
|
|
2298
|
+
main: "main",
|
|
2299
|
+
navigation: "nav",
|
|
2300
|
+
banner: "header",
|
|
2301
|
+
contentinfo: "footer",
|
|
2302
|
+
complementary: "aside",
|
|
2303
|
+
dialog: "dialog",
|
|
2304
|
+
alertdialog: "dialog",
|
|
2305
|
+
form: "form",
|
|
2306
|
+
search: "form"
|
|
2307
|
+
};
|
|
2308
|
+
var ACTION = /* @__PURE__ */ new Set(["button", "menuitem", "menuitemcheckbox", "menuitemradio", "tab", "switch"]);
|
|
2309
|
+
var FIELD = {
|
|
2310
|
+
textbox: "text",
|
|
2311
|
+
searchbox: "search",
|
|
2312
|
+
combobox: "select",
|
|
2313
|
+
listbox: "select",
|
|
2314
|
+
checkbox: "checkbox",
|
|
2315
|
+
radio: "radio",
|
|
2316
|
+
slider: "range",
|
|
2317
|
+
spinbutton: "number"
|
|
2318
|
+
};
|
|
2319
|
+
var NOTICE = /* @__PURE__ */ new Set(["status", "alert"]);
|
|
2320
|
+
var STATE = /* @__PURE__ */ new Set(["disabled", "checked", "selected", "expanded", "readonly", "required"]);
|
|
2321
|
+
function readAriaFactRecords(snapshot, options = {}) {
|
|
2322
|
+
const from = options.url ?? "";
|
|
2323
|
+
const inSource = options.inSource ?? /* @__PURE__ */ new Set();
|
|
2324
|
+
const facts = [];
|
|
2325
|
+
const add = (kind, value, node, extra = {}) => {
|
|
2326
|
+
if (!value) return;
|
|
2327
|
+
const scope = scopeFromLandmark(node.landmark);
|
|
2328
|
+
facts.push(
|
|
2329
|
+
fact({
|
|
2330
|
+
kind,
|
|
2331
|
+
value: trim2(value, LIMITS.label),
|
|
2332
|
+
channel: "p3",
|
|
2333
|
+
scope,
|
|
2334
|
+
dataClass: classifyData(value, scope, { inSource: inSource.has(value) }),
|
|
2335
|
+
from,
|
|
2336
|
+
landmark: node.landmark,
|
|
2337
|
+
...extra
|
|
2338
|
+
})
|
|
2339
|
+
);
|
|
2340
|
+
};
|
|
2341
|
+
for (const node of parse(snapshot)) {
|
|
2342
|
+
const state = controlState2(node);
|
|
2343
|
+
if (node.role === "heading") {
|
|
2344
|
+
add("heading", node.name || node.text, node);
|
|
2345
|
+
if (isEmptyState(node.name || node.text)) add("empty", node.name || node.text, node);
|
|
2346
|
+
continue;
|
|
2347
|
+
}
|
|
2348
|
+
if (node.role === "paragraph" || node.role === "text") {
|
|
2349
|
+
const said = node.text || node.name;
|
|
2350
|
+
if (isEmptyState(said)) add("empty", trim2(said, LIMITS.sentence), node);
|
|
2351
|
+
continue;
|
|
2352
|
+
}
|
|
2353
|
+
if (node.role === "link") {
|
|
2354
|
+
add("link", node.name || node.text || node.url || "", node, {
|
|
2355
|
+
...node.url ? { href: node.url } : {},
|
|
2356
|
+
...state
|
|
2357
|
+
});
|
|
2358
|
+
continue;
|
|
2359
|
+
}
|
|
2360
|
+
if (ACTION.has(node.role)) {
|
|
2361
|
+
add("action", node.name || node.text, node, state);
|
|
2362
|
+
continue;
|
|
2363
|
+
}
|
|
2364
|
+
const fieldKind = FIELD[node.role];
|
|
2365
|
+
if (fieldKind) {
|
|
2366
|
+
add("field", node.name || node.text, node, {
|
|
2367
|
+
fieldKind,
|
|
2368
|
+
...node.choices.length ? { choices: node.choices.slice(0, LIMITS.choices) } : {},
|
|
2369
|
+
...state
|
|
2370
|
+
});
|
|
2371
|
+
continue;
|
|
2372
|
+
}
|
|
2373
|
+
if (NOTICE.has(node.role)) {
|
|
2374
|
+
const said = trim2(node.text || node.name, LIMITS.sentence);
|
|
2375
|
+
add("notice", said, node);
|
|
2376
|
+
if (isEmptyState(said)) add("empty", said, node);
|
|
2377
|
+
}
|
|
2378
|
+
}
|
|
2379
|
+
return facts;
|
|
2380
|
+
}
|
|
2381
|
+
var LINE = /^(\s*)-\s+(.*)$/;
|
|
2382
|
+
var ENTRY = /^([a-z]+)(?:\s+"((?:[^"\\]|\\.)*)")?((?:\s*\[[^\]]*\])*)\s*(?::\s*(.*))?$/;
|
|
2383
|
+
function parse(snapshot) {
|
|
2384
|
+
const out = [];
|
|
2385
|
+
const stack = [];
|
|
2386
|
+
for (const raw of snapshot.split("\n")) {
|
|
2387
|
+
const line = LINE.exec(raw);
|
|
2388
|
+
if (!line) continue;
|
|
2389
|
+
const depth2 = line[1].length;
|
|
2390
|
+
const body = line[2];
|
|
2391
|
+
while (stack.length && stack[stack.length - 1].depth >= depth2) stack.pop();
|
|
2392
|
+
const parent = stack[stack.length - 1]?.node;
|
|
2393
|
+
if (body.startsWith("/")) {
|
|
2394
|
+
const [name, ...rest] = body.split(":");
|
|
2395
|
+
if (parent && name?.trim() === "/url") parent.url = rest.join(":").trim();
|
|
2396
|
+
continue;
|
|
2397
|
+
}
|
|
2398
|
+
const entry = ENTRY.exec(body);
|
|
2399
|
+
if (!entry) continue;
|
|
2400
|
+
const role = entry[1];
|
|
2401
|
+
const node = {
|
|
2402
|
+
role,
|
|
2403
|
+
name: unescape(entry[2] ?? ""),
|
|
2404
|
+
text: (entry[4] ?? "").trim(),
|
|
2405
|
+
attributes: attributesOf2(entry[3] ?? ""),
|
|
2406
|
+
landmark: LANDMARK[role] ?? parent?.landmark ?? "none",
|
|
2407
|
+
choices: []
|
|
2408
|
+
};
|
|
2409
|
+
if (role === "option" && parent) {
|
|
2410
|
+
if (node.name || node.text) parent.choices.push(node.name || node.text);
|
|
2411
|
+
continue;
|
|
2412
|
+
}
|
|
2413
|
+
out.push(node);
|
|
2414
|
+
stack.push({ depth: depth2, node });
|
|
2415
|
+
}
|
|
2416
|
+
return out;
|
|
1540
2417
|
}
|
|
1541
|
-
function
|
|
1542
|
-
const
|
|
1543
|
-
|
|
2418
|
+
function attributesOf2(raw) {
|
|
2419
|
+
const attributes = {};
|
|
2420
|
+
for (const match of raw.matchAll(/\[([^\]]*)\]/g)) {
|
|
2421
|
+
const [name, ...rest] = match[1].split("=");
|
|
2422
|
+
const key = name?.trim();
|
|
2423
|
+
if (!key) continue;
|
|
2424
|
+
attributes[key] = rest.length ? rest.join("=").trim() : true;
|
|
2425
|
+
}
|
|
2426
|
+
return attributes;
|
|
1544
2427
|
}
|
|
1545
|
-
function
|
|
1546
|
-
|
|
2428
|
+
function controlState2(node) {
|
|
2429
|
+
const state = {};
|
|
2430
|
+
for (const [name, value] of Object.entries(node.attributes)) {
|
|
2431
|
+
if (STATE.has(name)) state[name] = value;
|
|
2432
|
+
}
|
|
2433
|
+
return Object.keys(state).length ? { controlState: state } : {};
|
|
1547
2434
|
}
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
lt: "<",
|
|
1551
|
-
gt: ">",
|
|
1552
|
-
quot: '"',
|
|
1553
|
-
apos: "'",
|
|
1554
|
-
nbsp: " ",
|
|
1555
|
-
"#39": "'",
|
|
1556
|
-
"#x27": "'",
|
|
1557
|
-
"#34": '"',
|
|
1558
|
-
mdash: "\u2014",
|
|
1559
|
-
ndash: "\u2013",
|
|
1560
|
-
hellip: "\u2026"
|
|
1561
|
-
};
|
|
1562
|
-
function decode(value) {
|
|
1563
|
-
return value.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (whole, name) => {
|
|
1564
|
-
const known = ENTITY[name.toLowerCase()];
|
|
1565
|
-
if (known) return known;
|
|
1566
|
-
const numeric = /^#x/i.test(name) ? Number.parseInt(name.slice(2), 16) : /^#/.test(name) ? Number.parseInt(name.slice(1), 10) : NaN;
|
|
1567
|
-
return Number.isFinite(numeric) && numeric > 0 && numeric < 1114112 ? String.fromCodePoint(numeric) : whole;
|
|
1568
|
-
});
|
|
2435
|
+
function unescape(value) {
|
|
2436
|
+
return value.replace(/\\(.)/g, "$1");
|
|
1569
2437
|
}
|
|
1570
2438
|
function trim2(value, limit) {
|
|
1571
|
-
|
|
2439
|
+
const collapsed = value.replace(/\s+/g, " ").trim();
|
|
2440
|
+
return collapsed.length <= limit ? collapsed : `${collapsed.slice(0, limit - 1).trimEnd()}\u2026`;
|
|
1572
2441
|
}
|
|
1573
2442
|
|
|
1574
2443
|
// core/coverage/stamp.ts
|
|
@@ -1715,26 +2584,54 @@ async function runCapture(options) {
|
|
|
1715
2584
|
const evidence = [];
|
|
1716
2585
|
const build = await options.browser.build?.().catch(() => void 0);
|
|
1717
2586
|
for (const [index, shot] of options.shots.entries()) {
|
|
1718
|
-
const
|
|
2587
|
+
const taken = await captureOne(options, shot, build);
|
|
2588
|
+
const outcome = shot.variant ? { ...taken.outcome, variant: shot.variant } : taken.outcome;
|
|
1719
2589
|
outcomes.push(outcome);
|
|
1720
|
-
if (observed) survey.push(observed);
|
|
1721
|
-
if (seen) evidence.push(seen);
|
|
2590
|
+
if (taken.observed && !shot.variant) survey.push(taken.observed);
|
|
2591
|
+
if (taken.seen && !shot.variant) evidence.push(taken.seen);
|
|
1722
2592
|
options.onProgress?.(outcome, index + 1, options.shots.length);
|
|
1723
2593
|
}
|
|
1724
|
-
const
|
|
2594
|
+
const tally = (variant) => {
|
|
2595
|
+
const of = outcomes.filter((outcome) => Boolean(outcome.variant) === variant);
|
|
2596
|
+
const count = (status) => of.filter((outcome) => outcome.status === status).length;
|
|
2597
|
+
return { captured: count("captured"), unchanged: count("unchanged"), skipped: count("skipped"), refused: count("refused"), failed: count("failed") };
|
|
2598
|
+
};
|
|
2599
|
+
const readings = {};
|
|
2600
|
+
for (const outcome of outcomes) {
|
|
2601
|
+
if (outcome.facts && (outcome.status === "captured" || outcome.status === "unchanged")) readings[outcome.route] = outcome.facts;
|
|
2602
|
+
}
|
|
1725
2603
|
return {
|
|
1726
2604
|
baseUrl: options.baseUrl,
|
|
1727
2605
|
outcomes,
|
|
1728
2606
|
evidence,
|
|
1729
2607
|
survey,
|
|
2608
|
+
readings,
|
|
1730
2609
|
...build ? { browser: build } : {},
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
skipped: count("skipped"),
|
|
1734
|
-
refused: count("refused"),
|
|
1735
|
-
failed: count("failed")
|
|
2610
|
+
...tally(false),
|
|
2611
|
+
...outcomes.some((outcome) => outcome.variant) ? { phone: tally(true) } : {}
|
|
1736
2612
|
};
|
|
1737
2613
|
}
|
|
2614
|
+
async function layoutOf(page, shot, options, cover, size, landed) {
|
|
2615
|
+
if (!page.layout || !size) return void 0;
|
|
2616
|
+
try {
|
|
2617
|
+
const manifest = buildLayoutManifest(await page.layout(cover), {
|
|
2618
|
+
shotId: shot.id,
|
|
2619
|
+
route: shot.route,
|
|
2620
|
+
path: landed,
|
|
2621
|
+
variant: shot.variant ?? "desktop",
|
|
2622
|
+
image: size,
|
|
2623
|
+
origin: new URL(options.baseUrl).origin
|
|
2624
|
+
});
|
|
2625
|
+
const verdict = gateFrame(layoutManifestText(manifest), {
|
|
2626
|
+
route: shot.route,
|
|
2627
|
+
...options.brands ? { brands: options.brands } : {},
|
|
2628
|
+
...options.clearance?.expectedBrands?.length ? { expected: options.clearance.expectedBrands } : {}
|
|
2629
|
+
});
|
|
2630
|
+
return verdict.safe ? manifest : void 0;
|
|
2631
|
+
} catch {
|
|
2632
|
+
return void 0;
|
|
2633
|
+
}
|
|
2634
|
+
}
|
|
1738
2635
|
async function captureOne(options, shot, build) {
|
|
1739
2636
|
if (shot.unvisitable) {
|
|
1740
2637
|
return { outcome: { shotId: shot.id, route: shot.route, status: "skipped", reason: shot.unvisitable } };
|
|
@@ -1742,7 +2639,7 @@ async function captureOne(options, shot, build) {
|
|
|
1742
2639
|
const visit = shot.visit ?? shot.route;
|
|
1743
2640
|
let page;
|
|
1744
2641
|
try {
|
|
1745
|
-
page = await options.browser.page();
|
|
2642
|
+
page = await options.browser.page(shot.variant ? { variant: shot.variant } : void 0);
|
|
1746
2643
|
await page.goto(new URL(visit, options.baseUrl).toString());
|
|
1747
2644
|
if (await page.isSignIn()) {
|
|
1748
2645
|
return {
|
|
@@ -1795,7 +2692,20 @@ async function captureOne(options, shot, build) {
|
|
|
1795
2692
|
observed
|
|
1796
2693
|
};
|
|
1797
2694
|
}
|
|
1798
|
-
const
|
|
2695
|
+
const fit = shot.variant === "phone" ? await page.phoneFit?.() : void 0;
|
|
2696
|
+
if (fit && !fit.fits) {
|
|
2697
|
+
return {
|
|
2698
|
+
outcome: {
|
|
2699
|
+
shotId: shot.id,
|
|
2700
|
+
route: shot.route,
|
|
2701
|
+
status: "skipped",
|
|
2702
|
+
result: observed.result,
|
|
2703
|
+
reason: `not laid out for a phone at ${PHONE_VIEWPORT.width}px (${fit.because}), so there is no phone-width frame`
|
|
2704
|
+
},
|
|
2705
|
+
observed
|
|
2706
|
+
};
|
|
2707
|
+
}
|
|
2708
|
+
const evidence = shot.variant ? void 0 : await readEvidence(settledPage, shot.route, options, build, visit);
|
|
1799
2709
|
const reading = options.clearance ? await readScreen(settledPage, options.clearance) : void 0;
|
|
1800
2710
|
if (options.clearance && !reading) {
|
|
1801
2711
|
return {
|
|
@@ -1847,6 +2757,7 @@ async function captureOne(options, shot, build) {
|
|
|
1847
2757
|
};
|
|
1848
2758
|
}
|
|
1849
2759
|
}
|
|
2760
|
+
const facts = shot.variant ? void 0 : await readDetails(settledPage, shot, options);
|
|
1850
2761
|
const approved = new Set(options.clearance?.approved ?? []);
|
|
1851
2762
|
const cover = (reading?.surfaces ?? []).filter((surface) => !surface.read && !approved.has(surface.where)).map((surface) => surface.where);
|
|
1852
2763
|
const frame = await settle({
|
|
@@ -1858,14 +2769,15 @@ async function captureOne(options, shot, build) {
|
|
|
1858
2769
|
});
|
|
1859
2770
|
const image = frame.value;
|
|
1860
2771
|
const size = pngSize(image);
|
|
1861
|
-
|
|
2772
|
+
const minimum = shot.variant === "phone" ? PHONE_VIEWPORT.width * CAPTURE_CONTRACT.stillScale : CAPTURE_CONTRACT.minStillWidth;
|
|
2773
|
+
if (size && size.width < minimum) {
|
|
1862
2774
|
return {
|
|
1863
2775
|
outcome: {
|
|
1864
2776
|
shotId: shot.id,
|
|
1865
2777
|
route: shot.route,
|
|
1866
2778
|
status: "failed",
|
|
1867
2779
|
result: observed.result,
|
|
1868
|
-
reason: `that frame came back ${size.width}px wide; the contract needs at least ${
|
|
2780
|
+
reason: `that frame came back ${size.width}px wide; the contract needs at least ${minimum}px`
|
|
1869
2781
|
},
|
|
1870
2782
|
observed,
|
|
1871
2783
|
// The picture is unusable; the reading of the screen is not.
|
|
@@ -1886,8 +2798,9 @@ async function captureOne(options, shot, build) {
|
|
|
1886
2798
|
...evidence ? { seen: evidence } : {}
|
|
1887
2799
|
};
|
|
1888
2800
|
}
|
|
2801
|
+
const layout = await layoutOf(settledPage, shot, options, cover, size, seen.landedOn ?? visit);
|
|
1889
2802
|
const sha256 = frameHash(image);
|
|
1890
|
-
if (!options.force && shot.sha256 && shot.sha256 === sha256) {
|
|
2803
|
+
if (!options.force && shot.sha256 && shot.sha256 === sha256 && (shot.layout !== false || !layout)) {
|
|
1891
2804
|
return {
|
|
1892
2805
|
outcome: {
|
|
1893
2806
|
shotId: shot.id,
|
|
@@ -1896,7 +2809,8 @@ async function captureOne(options, shot, build) {
|
|
|
1896
2809
|
result: observed.result,
|
|
1897
2810
|
sha256,
|
|
1898
2811
|
settled: frame.settled,
|
|
1899
|
-
reason: "byte-identical to the last run"
|
|
2812
|
+
reason: "byte-identical to the last run",
|
|
2813
|
+
...facts ? { facts } : {}
|
|
1900
2814
|
},
|
|
1901
2815
|
observed,
|
|
1902
2816
|
...evidence ? { seen: evidence } : {}
|
|
@@ -1929,7 +2843,8 @@ async function captureOne(options, shot, build) {
|
|
|
1929
2843
|
}
|
|
1930
2844
|
} : {},
|
|
1931
2845
|
...build ? { browser: build } : {},
|
|
1932
|
-
...size ?? {}
|
|
2846
|
+
...size ?? {},
|
|
2847
|
+
...layout ? { layout } : {}
|
|
1933
2848
|
});
|
|
1934
2849
|
return {
|
|
1935
2850
|
outcome: sent.ok ? {
|
|
@@ -1939,6 +2854,7 @@ async function captureOne(options, shot, build) {
|
|
|
1939
2854
|
result: observed.result,
|
|
1940
2855
|
sha256,
|
|
1941
2856
|
settled: frame.settled,
|
|
2857
|
+
...facts ? { facts } : {},
|
|
1942
2858
|
...frame.settled ? observed.landed ? { reason: `followed its redirect to ${observed.landed}` } : {} : {
|
|
1943
2859
|
reason: "photographed, but this screen never stopped moving. Its hash will differ every run, so drift on it means nothing"
|
|
1944
2860
|
}
|
|
@@ -1998,40 +2914,48 @@ async function readEvidence(page, route, options, build, visited = route) {
|
|
|
1998
2914
|
channels: { html: Boolean(html), aria: Boolean(aria) }
|
|
1999
2915
|
};
|
|
2000
2916
|
}
|
|
2917
|
+
var BROWSER_ARGS = [
|
|
2918
|
+
"--no-sandbox",
|
|
2919
|
+
"--disable-dev-shm-usage",
|
|
2920
|
+
/**
|
|
2921
|
+
* Software rendering, deliberately, and it is slower.
|
|
2922
|
+
*
|
|
2923
|
+
* Anything drawn through the GPU — canvas, WebGL, most charting
|
|
2924
|
+
* libraries, some CSS filters — rasterises differently on different
|
|
2925
|
+
* drivers and driver versions. Two machines then produce different bytes
|
|
2926
|
+
* for identical input, so a run on a laptop and a run in CI disagree
|
|
2927
|
+
* about a screen that never changed. That failure is invisible until the
|
|
2928
|
+
* day somebody runs it somewhere new, which is the worst time to find it.
|
|
2929
|
+
*/
|
|
2930
|
+
"--disable-gpu",
|
|
2931
|
+
"--disable-software-rasterizer",
|
|
2932
|
+
/**
|
|
2933
|
+
* Font rasterisation, pinned as far as a flag can pin it. Subpixel
|
|
2934
|
+
* antialiasing is tuned per display and hinting varies by platform;
|
|
2935
|
+
* both change every pixel of every glyph without changing a word.
|
|
2936
|
+
*/
|
|
2937
|
+
"--font-render-hinting=none",
|
|
2938
|
+
"--disable-lcd-text",
|
|
2939
|
+
/**
|
|
2940
|
+
* Glyphs on whole pixels. With sub-pixel positioning on, a line of text can
|
|
2941
|
+
* come out at one of two sub-pixel phases from one page load to the next,
|
|
2942
|
+
* with the same font and the same layout, so an unchanged screen gets a
|
|
2943
|
+
* new picture on some runs and not others.
|
|
2944
|
+
*/
|
|
2945
|
+
"--disable-font-subpixel-positioning",
|
|
2946
|
+
"--force-color-profile=srgb",
|
|
2947
|
+
// A scrollbar is an overlay on some platforms and consumes layout width
|
|
2948
|
+
// on others, so leaving it visible makes the page a different width
|
|
2949
|
+
// depending on who took the picture.
|
|
2950
|
+
"--hide-scrollbars"
|
|
2951
|
+
];
|
|
2001
2952
|
async function playwrightBrowser(options = {}) {
|
|
2002
2953
|
const { chromium } = await loadPlaywright();
|
|
2003
2954
|
const executablePath = process.env["CAPTURE_CHROME"] ?? void 0;
|
|
2004
2955
|
const browser = await chromium.launch({
|
|
2005
2956
|
headless: options.headless ?? true,
|
|
2006
2957
|
...executablePath ? { executablePath } : {},
|
|
2007
|
-
args: [
|
|
2008
|
-
"--no-sandbox",
|
|
2009
|
-
"--disable-dev-shm-usage",
|
|
2010
|
-
/**
|
|
2011
|
-
* Software rendering, deliberately, and it is slower.
|
|
2012
|
-
*
|
|
2013
|
-
* Anything drawn through the GPU — canvas, WebGL, most charting
|
|
2014
|
-
* libraries, some CSS filters — rasterises differently on different
|
|
2015
|
-
* drivers and driver versions. Two machines then produce different bytes
|
|
2016
|
-
* for identical input, so a run on a laptop and a run in CI disagree
|
|
2017
|
-
* about a screen that never changed. That failure is invisible until the
|
|
2018
|
-
* day somebody runs it somewhere new, which is the worst time to find it.
|
|
2019
|
-
*/
|
|
2020
|
-
"--disable-gpu",
|
|
2021
|
-
"--disable-software-rasterizer",
|
|
2022
|
-
/**
|
|
2023
|
-
* Font rasterisation, pinned as far as a flag can pin it. Subpixel
|
|
2024
|
-
* antialiasing is tuned per display and hinting varies by platform;
|
|
2025
|
-
* both change every pixel of every glyph without changing a word.
|
|
2026
|
-
*/
|
|
2027
|
-
"--font-render-hinting=none",
|
|
2028
|
-
"--disable-lcd-text",
|
|
2029
|
-
"--force-color-profile=srgb",
|
|
2030
|
-
// A scrollbar is an overlay on some platforms and consumes layout width
|
|
2031
|
-
// on others, so leaving it visible makes the page a different width
|
|
2032
|
-
// depending on who took the picture.
|
|
2033
|
-
"--hide-scrollbars"
|
|
2034
|
-
]
|
|
2958
|
+
args: [...BROWSER_ARGS]
|
|
2035
2959
|
});
|
|
2036
2960
|
const contextOptions = {
|
|
2037
2961
|
viewport: CAPTURE_CONTRACT.viewport,
|
|
@@ -2063,8 +2987,10 @@ async function playwrightBrowser(options = {}) {
|
|
|
2063
2987
|
...options.sessionPath ? { storageState: options.sessionPath } : { storageState: void 0 }
|
|
2064
2988
|
};
|
|
2065
2989
|
return {
|
|
2066
|
-
async page() {
|
|
2067
|
-
const context = await browser.newContext(
|
|
2990
|
+
async page(wanted) {
|
|
2991
|
+
const context = await browser.newContext(
|
|
2992
|
+
wanted?.variant === "phone" ? { ...contextOptions, viewport: { ...PHONE_VIEWPORT }, isMobile: true, hasTouch: true } : wanted?.variant === "clip" ? { ...contextOptions, viewport: { ...CLIP_VIEWPORT }, deviceScaleFactor: CAPTURE_CONTRACT.clipScale } : contextOptions
|
|
2993
|
+
);
|
|
2068
2994
|
if (!options.liveClock) {
|
|
2069
2995
|
await context.addInitScript(freezeScript(CAPTURE_CONTRACT.pinnedClock));
|
|
2070
2996
|
}
|
|
@@ -2135,6 +3061,39 @@ async function playwrightBrowser(options = {}) {
|
|
|
2135
3061
|
}).catch(() => false);
|
|
2136
3062
|
return { inventory, surfaces, styled };
|
|
2137
3063
|
},
|
|
3064
|
+
async painted(regions) {
|
|
3065
|
+
return readPaintedMarkup(page, regions).catch(() => "");
|
|
3066
|
+
},
|
|
3067
|
+
async layout(cover) {
|
|
3068
|
+
return readPageLayout(page, cover ?? []);
|
|
3069
|
+
},
|
|
3070
|
+
async phoneFit() {
|
|
3071
|
+
return phoneFit(page, PHONE_VIEWPORT.width).catch(() => ({ fits: false, because: "the page could not be measured at that width" }));
|
|
3072
|
+
},
|
|
3073
|
+
async location() {
|
|
3074
|
+
const url = new URL(page.url());
|
|
3075
|
+
return `${url.pathname}${url.search}`;
|
|
3076
|
+
},
|
|
3077
|
+
async pointer(action) {
|
|
3078
|
+
if (action.kind === "select") {
|
|
3079
|
+
const handle = await page.evaluateHandle(({ x, y }) => document.elementFromPoint(x, y), action.at);
|
|
3080
|
+
const element = handle.asElement();
|
|
3081
|
+
if (!element) throw new Error(`nothing is under (${action.at.x}, ${action.at.y}) to choose in`);
|
|
3082
|
+
await element.selectOption({ label: action.value ?? "" });
|
|
3083
|
+
return;
|
|
3084
|
+
}
|
|
3085
|
+
await page.mouse.click(action.at.x, action.at.y);
|
|
3086
|
+
if (action.kind === "focus") await page.keyboard.press("ControlOrMeta+A");
|
|
3087
|
+
},
|
|
3088
|
+
async type(text2) {
|
|
3089
|
+
await page.keyboard.type(text2);
|
|
3090
|
+
},
|
|
3091
|
+
async cursor(at, pressed) {
|
|
3092
|
+
await drawCursor(page, at, pressed ?? false).catch(() => void 0);
|
|
3093
|
+
},
|
|
3094
|
+
async quiet(timeoutMs) {
|
|
3095
|
+
await page.waitForLoadState("networkidle", { timeout: timeoutMs }).catch(() => void 0);
|
|
3096
|
+
},
|
|
2138
3097
|
async screenshot(cover) {
|
|
2139
3098
|
const masks = (cover ?? []).map((selector) => page.locator(selector));
|
|
2140
3099
|
return page.screenshot({
|
|
@@ -2269,6 +3228,18 @@ async function readScreen(page, clearance) {
|
|
|
2269
3228
|
if (!page.inspect) return void 0;
|
|
2270
3229
|
return page.inspect(clearance.regions);
|
|
2271
3230
|
}
|
|
3231
|
+
async function readDetails(page, shot, options) {
|
|
3232
|
+
if (!options.clearance || !page.painted) return void 0;
|
|
3233
|
+
const markup = await page.painted(options.clearance.regions).catch(() => "");
|
|
3234
|
+
if (!markup) return void 0;
|
|
3235
|
+
return screenDetails(markup, {
|
|
3236
|
+
route: shot.route,
|
|
3237
|
+
commit: options.commit,
|
|
3238
|
+
origin: new URL(options.baseUrl).origin,
|
|
3239
|
+
patterns: options.shots.map((each) => each.route),
|
|
3240
|
+
brands: options.brands
|
|
3241
|
+
});
|
|
3242
|
+
}
|
|
2272
3243
|
function pathOf(url) {
|
|
2273
3244
|
try {
|
|
2274
3245
|
const parsed = new URL(url);
|
|
@@ -2348,6 +3319,320 @@ async function loadPlaywright() {
|
|
|
2348
3319
|
);
|
|
2349
3320
|
}
|
|
2350
3321
|
|
|
3322
|
+
// adapters/capture-clips.ts
|
|
3323
|
+
import { spawn } from "node:child_process";
|
|
3324
|
+
import { createHash as createHash3 } from "node:crypto";
|
|
3325
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
3326
|
+
import path2 from "node:path";
|
|
3327
|
+
var FRAME_MS = 1e3 / CLIP_FPS;
|
|
3328
|
+
var OPENING_MS = 700;
|
|
3329
|
+
var MOVE_FRAMES = 6;
|
|
3330
|
+
var DWELL_MS = 500;
|
|
3331
|
+
var PRESS_MS = 200;
|
|
3332
|
+
var CHARS_PER_FRAME = 2;
|
|
3333
|
+
var SETTLE_MS = 3e3;
|
|
3334
|
+
var STEP_WAIT_MS = 4e3;
|
|
3335
|
+
var CLOSING_MS = 1200;
|
|
3336
|
+
var START = { x: Math.round(CLIP_VIEWPORT.width / 2), y: Math.round(CLIP_VIEWPORT.height * 0.66) };
|
|
3337
|
+
function chunksOf(text2, size) {
|
|
3338
|
+
return Array.from({ length: Math.ceil(text2.length / size) }, (_, index) => text2.slice(index * size, (index + 1) * size));
|
|
3339
|
+
}
|
|
3340
|
+
async function recordClip(options, script, touched) {
|
|
3341
|
+
const dir = path2.join(options.workDir, script.id);
|
|
3342
|
+
const frames = [];
|
|
3343
|
+
const checked = /* @__PURE__ */ new Set();
|
|
3344
|
+
const fail = (because) => ({ ok: false, status: "failed", because });
|
|
3345
|
+
const refuse = (because) => ({ ok: false, status: "refused", because });
|
|
3346
|
+
const page = await options.browser.page({ variant: "clip" }).catch(() => void 0);
|
|
3347
|
+
if (!page) return fail("the browser would not open a page to record in");
|
|
3348
|
+
try {
|
|
3349
|
+
await rm(dir, { recursive: true, force: true });
|
|
3350
|
+
await mkdir(dir, { recursive: true });
|
|
3351
|
+
if (!page.layout || !page.location || !page.pointer || !page.type || !page.cursor) {
|
|
3352
|
+
return fail("this capture runner cannot record clips: it cannot measure a page, act on it or draw a cursor");
|
|
3353
|
+
}
|
|
3354
|
+
const { layout, location: location2, pointer, type, cursor } = { layout: page.layout.bind(page), location: page.location.bind(page), pointer: page.pointer.bind(page), type: page.type.bind(page), cursor: page.cursor.bind(page) };
|
|
3355
|
+
let cover = [];
|
|
3356
|
+
let at = 0;
|
|
3357
|
+
let last = new Uint8Array();
|
|
3358
|
+
const judge = async (where) => {
|
|
3359
|
+
const route = where.split("?")[0];
|
|
3360
|
+
const verdict = gateFrame(await page.text(), {
|
|
3361
|
+
route,
|
|
3362
|
+
...options.brands ? { brands: options.brands } : {},
|
|
3363
|
+
...options.clearance?.expectedBrands?.length ? { expected: options.clearance.expectedBrands } : {}
|
|
3364
|
+
});
|
|
3365
|
+
if (!verdict.safe) return verdict.reason ?? `the gate refused ${route}`;
|
|
3366
|
+
if (!options.clearance) return null;
|
|
3367
|
+
const clearance = options.clearance;
|
|
3368
|
+
const reading = await readScreen(page, clearance);
|
|
3369
|
+
if (!reading) return "this browser cannot read a screen against a clearance";
|
|
3370
|
+
if (reading.inventory.unaccounted.length) return `${reading.inventory.unaccounted.length} value(s) on ${route} are outside every region the clearance names`;
|
|
3371
|
+
const approved = new Set(clearance.approved);
|
|
3372
|
+
const loose = reading.surfaces.filter((surface) => !surface.read && !clearance.covers.includes(surface.where) && !approved.has(surface.where));
|
|
3373
|
+
if (loose.length && !clearance.coverUnnamed) return `${loose.length} surface(s) on ${route} are neither approved nor covered`;
|
|
3374
|
+
cover = reading.surfaces.filter((surface) => !surface.read && !approved.has(surface.where)).map((surface) => surface.where);
|
|
3375
|
+
return null;
|
|
3376
|
+
};
|
|
3377
|
+
const keep = async (bytes, ms) => {
|
|
3378
|
+
const file = path2.join(dir, `frame-${String(frames.length + 1).padStart(4, "0")}.png`);
|
|
3379
|
+
await writeFile(file, bytes);
|
|
3380
|
+
frames.push({ file, ms });
|
|
3381
|
+
last = bytes;
|
|
3382
|
+
at += ms;
|
|
3383
|
+
return file;
|
|
3384
|
+
};
|
|
3385
|
+
const shoot = async (ms) => keep(await page.screenshot(cover), ms);
|
|
3386
|
+
const hold = (ms) => {
|
|
3387
|
+
frames[frames.length - 1].ms += ms;
|
|
3388
|
+
at += ms;
|
|
3389
|
+
};
|
|
3390
|
+
const film = async () => {
|
|
3391
|
+
const next = await page.screenshot(cover);
|
|
3392
|
+
if (sameBytes(next, last)) {
|
|
3393
|
+
hold(FRAME_MS);
|
|
3394
|
+
return false;
|
|
3395
|
+
}
|
|
3396
|
+
await keep(next, FRAME_MS);
|
|
3397
|
+
return true;
|
|
3398
|
+
};
|
|
3399
|
+
await page.goto(new URL(script.visit, options.baseUrl).toString());
|
|
3400
|
+
if (await page.isSignIn()) return refuse("its first screen asked for a sign-in, so this session is not the demo tenant's");
|
|
3401
|
+
const opened = await location2();
|
|
3402
|
+
if (!onScreen(script.steps[0].route, opened)) return fail(`it opened on ${opened}, not ${script.steps[0].route}`);
|
|
3403
|
+
const before = await judge(opened);
|
|
3404
|
+
if (before) return refuse(before);
|
|
3405
|
+
let resting = START;
|
|
3406
|
+
let poster;
|
|
3407
|
+
const steps = [];
|
|
3408
|
+
const arrive = async (route) => {
|
|
3409
|
+
let where = await location2();
|
|
3410
|
+
for (let waited = 0; !onScreen(route, where) && waited < STEP_WAIT_MS; waited += FRAME_MS) {
|
|
3411
|
+
await cursor(resting);
|
|
3412
|
+
await film();
|
|
3413
|
+
where = await location2();
|
|
3414
|
+
}
|
|
3415
|
+
if (!onScreen(route, where)) return where;
|
|
3416
|
+
await page.quiet?.(SETTLE_MS);
|
|
3417
|
+
await cursor(resting);
|
|
3418
|
+
for (let waited = 0; waited < SETTLE_MS; waited += FRAME_MS) {
|
|
3419
|
+
if (!await film()) break;
|
|
3420
|
+
}
|
|
3421
|
+
return where;
|
|
3422
|
+
};
|
|
3423
|
+
await cursor(resting);
|
|
3424
|
+
checked.add(await shoot(OPENING_MS));
|
|
3425
|
+
for (const [index, step] of script.steps.entries()) {
|
|
3426
|
+
const where = await arrive(step.route);
|
|
3427
|
+
if (!onScreen(step.route, where)) return fail(`step ${index + 1}: the browser is on ${where}, not ${step.route}`);
|
|
3428
|
+
const arrived = await judge(where);
|
|
3429
|
+
if (arrived) return refuse(arrived);
|
|
3430
|
+
const measure = async () => resolveStep(
|
|
3431
|
+
buildLayoutManifest(await layout(cover), {
|
|
3432
|
+
shotId: script.id,
|
|
3433
|
+
route: step.route,
|
|
3434
|
+
path: where.split("?")[0],
|
|
3435
|
+
variant: "desktop",
|
|
3436
|
+
image: { ...CLIP_VIEWPORT },
|
|
3437
|
+
origin: new URL(options.baseUrl).origin
|
|
3438
|
+
}),
|
|
3439
|
+
step
|
|
3440
|
+
);
|
|
3441
|
+
let resolution = await measure();
|
|
3442
|
+
for (let waited = 0; !resolution.ok && waited < STEP_WAIT_MS; waited += FRAME_MS) {
|
|
3443
|
+
await cursor(resting);
|
|
3444
|
+
await film();
|
|
3445
|
+
resolution = await measure();
|
|
3446
|
+
}
|
|
3447
|
+
if (!resolution.ok) return fail(`step ${index + 1}: ${resolution.because}`);
|
|
3448
|
+
const target = centreOf(resolution.control.box);
|
|
3449
|
+
const startedAt = at;
|
|
3450
|
+
for (const point of cursorPath(resting, target, MOVE_FRAMES)) {
|
|
3451
|
+
await cursor(point);
|
|
3452
|
+
await shoot(FRAME_MS);
|
|
3453
|
+
}
|
|
3454
|
+
resting = target;
|
|
3455
|
+
hold(DWELL_MS);
|
|
3456
|
+
poster ??= frames[frames.length - 1].file;
|
|
3457
|
+
await cursor(target, true);
|
|
3458
|
+
await shoot(PRESS_MS);
|
|
3459
|
+
if (step.action === "fill") {
|
|
3460
|
+
await pointer({ kind: "focus", at: target });
|
|
3461
|
+
await cursor(target, false);
|
|
3462
|
+
for (const chunk of chunksOf(step.value ?? "", CHARS_PER_FRAME)) {
|
|
3463
|
+
await type(chunk);
|
|
3464
|
+
await shoot(FRAME_MS);
|
|
3465
|
+
}
|
|
3466
|
+
} else if (step.action === "select") {
|
|
3467
|
+
await pointer({ kind: "select", at: target, value: step.value ?? "" });
|
|
3468
|
+
} else {
|
|
3469
|
+
await pointer({ kind: "click", at: target });
|
|
3470
|
+
}
|
|
3471
|
+
await cursor(target, false);
|
|
3472
|
+
await page.quiet?.(SETTLE_MS);
|
|
3473
|
+
await cursor(target, false);
|
|
3474
|
+
await shoot(FRAME_MS);
|
|
3475
|
+
for (let waited = 0; waited < SETTLE_MS; waited += FRAME_MS) {
|
|
3476
|
+
if (!await film()) break;
|
|
3477
|
+
}
|
|
3478
|
+
checked.add(frames[frames.length - 1].file);
|
|
3479
|
+
const now = await location2();
|
|
3480
|
+
const after = await judge(now);
|
|
3481
|
+
if (after) return refuse(after);
|
|
3482
|
+
steps.push({
|
|
3483
|
+
route: step.route,
|
|
3484
|
+
role: step.control.role,
|
|
3485
|
+
name: resolution.control.name,
|
|
3486
|
+
action: step.action,
|
|
3487
|
+
says: step.says,
|
|
3488
|
+
atMs: startedAt,
|
|
3489
|
+
box: resolution.control.box
|
|
3490
|
+
});
|
|
3491
|
+
}
|
|
3492
|
+
if (script.lands) {
|
|
3493
|
+
const landed = await arrive(script.lands);
|
|
3494
|
+
if (!onScreen(script.lands, landed)) return fail(`the last step left the browser on ${landed}, not ${script.lands}`);
|
|
3495
|
+
const seen = await judge(landed);
|
|
3496
|
+
if (seen) return refuse(seen);
|
|
3497
|
+
}
|
|
3498
|
+
hold(CLOSING_MS);
|
|
3499
|
+
checked.add(frames[frames.length - 1].file);
|
|
3500
|
+
const durationMs = framesLength(frames);
|
|
3501
|
+
if (durationMs > CLIP_MAX_MS) return fail(`it runs ${(durationMs / 1e3).toFixed(1)} s, over ${CLIP_MAX_MS / 1e3} s, so the steps want splitting`);
|
|
3502
|
+
for (const file of checked) {
|
|
3503
|
+
const pixels = readSentinel(decodePng(await readFile(file))).pixels;
|
|
3504
|
+
if (pixels > 0) return refuse(`a frame of it shows the verification colour (${pixels} pixel(s)), which is never published`);
|
|
3505
|
+
}
|
|
3506
|
+
const encoded = await options.encoder.encode(frames, dir);
|
|
3507
|
+
const mp4 = new Uint8Array(encoded.mp4);
|
|
3508
|
+
return {
|
|
3509
|
+
ok: true,
|
|
3510
|
+
clip: {
|
|
3511
|
+
recording: {
|
|
3512
|
+
clipId: script.id,
|
|
3513
|
+
title: script.title,
|
|
3514
|
+
route: script.steps[0].route,
|
|
3515
|
+
durationMs,
|
|
3516
|
+
width: CLIP_VIEWPORT.width,
|
|
3517
|
+
height: CLIP_VIEWPORT.height,
|
|
3518
|
+
fingerprint: clipFingerprint(script, touched),
|
|
3519
|
+
steps,
|
|
3520
|
+
touched: [...touched],
|
|
3521
|
+
browser: options.build ?? ""
|
|
3522
|
+
},
|
|
3523
|
+
mp4,
|
|
3524
|
+
gif: encoded.gif ? new Uint8Array(encoded.gif) : null,
|
|
3525
|
+
poster: new Uint8Array(await readFile(poster ?? frames[0].file)),
|
|
3526
|
+
sha256: createHash3("sha256").update(mp4).digest("hex")
|
|
3527
|
+
}
|
|
3528
|
+
};
|
|
3529
|
+
} catch (error) {
|
|
3530
|
+
return fail(error instanceof Error ? error.message : String(error));
|
|
3531
|
+
} finally {
|
|
3532
|
+
await page.close().catch(() => void 0);
|
|
3533
|
+
await rm(dir, { recursive: true, force: true }).catch(() => void 0);
|
|
3534
|
+
}
|
|
3535
|
+
}
|
|
3536
|
+
var CLIP_BUDGET_MS = 10 * 6e4;
|
|
3537
|
+
async function runClips(options) {
|
|
3538
|
+
const started = Date.now();
|
|
3539
|
+
const outcomes = [];
|
|
3540
|
+
let spent;
|
|
3541
|
+
for (const [index, script] of options.scripts.entries()) {
|
|
3542
|
+
const touched = clipRoutes(script).map((route) => ({
|
|
3543
|
+
route,
|
|
3544
|
+
shotId: options.frames.get(route)?.shotId ?? "",
|
|
3545
|
+
sha256: options.frames.get(route)?.sha256 ?? null
|
|
3546
|
+
}));
|
|
3547
|
+
const fingerprint = clipFingerprint(script, touched);
|
|
3548
|
+
const base = { clipId: script.id, title: script.title, fingerprint };
|
|
3549
|
+
let outcome;
|
|
3550
|
+
if (spent) {
|
|
3551
|
+
outcome = { ...base, status: "deferred", reason: spent };
|
|
3552
|
+
} else if (Date.now() - started > (options.budgetMs ?? CLIP_BUDGET_MS)) {
|
|
3553
|
+
outcome = { ...base, status: "deferred", reason: "the run's time for clips ran out, so it waits for the next run" };
|
|
3554
|
+
} else if (!options.force && options.held.get(script.id) === fingerprint) {
|
|
3555
|
+
outcome = { ...base, status: "unchanged", reason: "neither its steps nor a screen they touch has changed" };
|
|
3556
|
+
} else {
|
|
3557
|
+
const result = await recordClip(options, script, touched);
|
|
3558
|
+
if (!result.ok) {
|
|
3559
|
+
outcome = { ...base, status: result.status, reason: result.because };
|
|
3560
|
+
} else {
|
|
3561
|
+
const delivered = await options.send(result.clip);
|
|
3562
|
+
if (delivered.ok) {
|
|
3563
|
+
outcome = {
|
|
3564
|
+
...base,
|
|
3565
|
+
status: "recorded",
|
|
3566
|
+
durationMs: result.clip.recording.durationMs,
|
|
3567
|
+
mp4Bytes: result.clip.mp4.length,
|
|
3568
|
+
gifBytes: result.clip.gif?.length ?? null
|
|
3569
|
+
};
|
|
3570
|
+
} else if (delivered.limit) {
|
|
3571
|
+
spent = delivered.error;
|
|
3572
|
+
outcome = { ...base, status: "deferred", reason: delivered.error };
|
|
3573
|
+
} else {
|
|
3574
|
+
outcome = { ...base, status: "failed", reason: delivered.error };
|
|
3575
|
+
}
|
|
3576
|
+
}
|
|
3577
|
+
}
|
|
3578
|
+
outcomes.push(outcome);
|
|
3579
|
+
options.onProgress?.(outcome, index + 1, options.scripts.length);
|
|
3580
|
+
}
|
|
3581
|
+
return outcomes;
|
|
3582
|
+
}
|
|
3583
|
+
function run(binary, args) {
|
|
3584
|
+
return new Promise((resolve, reject) => {
|
|
3585
|
+
const child = spawn(binary, args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
3586
|
+
let stderr = "";
|
|
3587
|
+
child.stderr.on("data", (chunk) => {
|
|
3588
|
+
stderr = `${stderr}${chunk.toString()}`.slice(-2e3);
|
|
3589
|
+
});
|
|
3590
|
+
child.on(
|
|
3591
|
+
"error",
|
|
3592
|
+
(error) => reject(new Error(error.code === "ENOENT" ? `${binary} is not installed, and clips need it to encode` : error.message))
|
|
3593
|
+
);
|
|
3594
|
+
child.on("close", (code) => code === 0 ? resolve() : reject(new Error(`${binary} stopped with ${code}: ${stderr.trim() || "no output"}`)));
|
|
3595
|
+
});
|
|
3596
|
+
}
|
|
3597
|
+
async function ffmpegAvailable(binary = ffmpegBinary()) {
|
|
3598
|
+
return run(binary, ["-hide_banner", "-loglevel", "error", "-version"]).then(
|
|
3599
|
+
() => true,
|
|
3600
|
+
() => false
|
|
3601
|
+
);
|
|
3602
|
+
}
|
|
3603
|
+
function ffmpegBinary() {
|
|
3604
|
+
return process.env["CAPTURE_FFMPEG"]?.trim() || "ffmpeg";
|
|
3605
|
+
}
|
|
3606
|
+
function ffmpegEncoder(binary = ffmpegBinary()) {
|
|
3607
|
+
return {
|
|
3608
|
+
async encode(frames, dir) {
|
|
3609
|
+
const list = path2.join(dir, "frames.txt");
|
|
3610
|
+
const mp4Path = path2.join(dir, "clip.mp4");
|
|
3611
|
+
const gifPath = path2.join(dir, "clip.gif");
|
|
3612
|
+
await writeFile(list, concatList(frames), "utf8");
|
|
3613
|
+
await run(binary, mp4Arguments(list, mp4Path));
|
|
3614
|
+
let mp4 = await readFile(mp4Path);
|
|
3615
|
+
if (mp4.length > CLIP_MAX_MP4_BYTES) {
|
|
3616
|
+
await run(binary, mp4Arguments(list, mp4Path, 34));
|
|
3617
|
+
mp4 = await readFile(mp4Path);
|
|
3618
|
+
}
|
|
3619
|
+
if (mp4.length > CLIP_MAX_MP4_BYTES) {
|
|
3620
|
+
throw new Error(`its MP4 is ${(mp4.length / 1024 / 1024).toFixed(1)} MB even at a lower quality, over the ${CLIP_MAX_MP4_BYTES / 1024 / 1024} MB limit`);
|
|
3621
|
+
}
|
|
3622
|
+
await run(binary, gifArguments(list, gifPath));
|
|
3623
|
+
let gif = await readFile(gifPath);
|
|
3624
|
+
if (gif.length > CLIP_MAX_GIF_BYTES) {
|
|
3625
|
+
await run(binary, gifArguments(list, gifPath, { width: 800, fps: 6 }));
|
|
3626
|
+
gif = await readFile(gifPath);
|
|
3627
|
+
}
|
|
3628
|
+
return { mp4, gif: gif.length > CLIP_MAX_GIF_BYTES ? null : gif };
|
|
3629
|
+
}
|
|
3630
|
+
};
|
|
3631
|
+
}
|
|
3632
|
+
|
|
3633
|
+
// adapters/capture-bin.ts
|
|
3634
|
+
import { tmpdir as tmpdir2 } from "node:os";
|
|
3635
|
+
|
|
2351
3636
|
// adapters/invoked-as-command.ts
|
|
2352
3637
|
import { realpathSync } from "node:fs";
|
|
2353
3638
|
import { pathToFileURL as pathToFileURL2 } from "node:url";
|
|
@@ -2362,7 +3647,7 @@ function runningAsCommand(moduleUrl) {
|
|
|
2362
3647
|
}
|
|
2363
3648
|
|
|
2364
3649
|
// adapters/capture-stage.ts
|
|
2365
|
-
import { spawn } from "node:child_process";
|
|
3650
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
2366
3651
|
|
|
2367
3652
|
// adapters/seed-proxy.ts
|
|
2368
3653
|
import { createServer, request as httpRequest } from "node:http";
|
|
@@ -2388,7 +3673,7 @@ function looksLikeIdentifier(segment) {
|
|
|
2388
3673
|
return /\d/.test(segment) && segment.length >= 3;
|
|
2389
3674
|
}
|
|
2390
3675
|
function templatise(paths) {
|
|
2391
|
-
const split = paths.map((
|
|
3676
|
+
const split = paths.map((path5) => path5.split("/").filter(Boolean));
|
|
2392
3677
|
const depth2 = Math.max(0, ...split.map((segments) => segments.length));
|
|
2393
3678
|
const parameters = /* @__PURE__ */ new Set();
|
|
2394
3679
|
const prefixOf = (segments, upTo) => {
|
|
@@ -2491,9 +3776,9 @@ var CassetteIndex = class {
|
|
|
2491
3776
|
return void 0;
|
|
2492
3777
|
}
|
|
2493
3778
|
};
|
|
2494
|
-
function matchesTemplate(template,
|
|
3779
|
+
function matchesTemplate(template, path5) {
|
|
2495
3780
|
const wanted = template.split("/").filter(Boolean);
|
|
2496
|
-
const actual =
|
|
3781
|
+
const actual = path5.split("/").filter(Boolean);
|
|
2497
3782
|
if (wanted.length !== actual.length) return false;
|
|
2498
3783
|
return wanted.every((segment, index) => segment.startsWith(":") || segment === actual[index]);
|
|
2499
3784
|
}
|
|
@@ -2524,10 +3809,10 @@ function shut(server) {
|
|
|
2524
3809
|
});
|
|
2525
3810
|
}
|
|
2526
3811
|
function splitUrl(url) {
|
|
2527
|
-
const [
|
|
3812
|
+
const [path5, search] = url.split("?");
|
|
2528
3813
|
const query = {};
|
|
2529
3814
|
for (const [name, value] of new URLSearchParams(search ?? "")) query[name] = value;
|
|
2530
|
-
return { path:
|
|
3815
|
+
return { path: path5 ?? "/", query };
|
|
2531
3816
|
}
|
|
2532
3817
|
async function replayFrom(options) {
|
|
2533
3818
|
const index = new CassetteIndex(options.cassette);
|
|
@@ -2535,14 +3820,14 @@ async function replayFrom(options) {
|
|
|
2535
3820
|
const served = /* @__PURE__ */ new Set();
|
|
2536
3821
|
const server = createServer((incoming, outgoing) => {
|
|
2537
3822
|
void (async () => {
|
|
2538
|
-
const { path:
|
|
3823
|
+
const { path: path5, query } = splitUrl(incoming.url ?? "/");
|
|
2539
3824
|
await bodyOf(incoming);
|
|
2540
|
-
const found = index.find({ method: incoming.method ?? "GET", path:
|
|
3825
|
+
const found = index.find({ method: incoming.method ?? "GET", path: path5, query });
|
|
2541
3826
|
if (!found) {
|
|
2542
3827
|
const from = incoming.headers["referer"] ?? incoming.headers["x-seed-page"];
|
|
2543
3828
|
const miss = {
|
|
2544
3829
|
method: incoming.method ?? "GET",
|
|
2545
|
-
path:
|
|
3830
|
+
path: path5,
|
|
2546
3831
|
...Object.keys(query).length ? { query } : {},
|
|
2547
3832
|
...typeof from === "string" ? { from: pageOf(from) } : {}
|
|
2548
3833
|
};
|
|
@@ -2582,12 +3867,84 @@ function pageOf(referer) {
|
|
|
2582
3867
|
}
|
|
2583
3868
|
}
|
|
2584
3869
|
|
|
3870
|
+
// adapters/capture-clock.ts
|
|
3871
|
+
import { mkdtemp, rm as rm2, writeFile as writeFile2 } from "node:fs/promises";
|
|
3872
|
+
import { tmpdir } from "node:os";
|
|
3873
|
+
import path3 from "node:path";
|
|
3874
|
+
var PINNED_CLOCK_ENV = "HELPDESK_PINNED_CLOCK";
|
|
3875
|
+
function clockPreload(setting) {
|
|
3876
|
+
const at = Date.parse(setting.at);
|
|
3877
|
+
if (Number.isNaN(at)) throw new Error(`clockPreload needs a parsable instant, got ${JSON.stringify(setting.at)}`);
|
|
3878
|
+
const from = setting.from === void 0 ? "null" : String(Math.trunc(setting.from));
|
|
3879
|
+
const pin = `(() => {
|
|
3880
|
+
const AT = ${at};
|
|
3881
|
+
const FROM = ${from};
|
|
3882
|
+
const RealDate = globalThis.Date;
|
|
3883
|
+
if (RealDate.helpdeskPinned) return;
|
|
3884
|
+
const now = FROM === null ? () => AT : () => AT + (RealDate.now() - FROM);
|
|
3885
|
+
globalThis.Date = new Proxy(RealDate, {
|
|
3886
|
+
construct: (target, args, newTarget) => Reflect.construct(target, args.length ? args : [now()], newTarget),
|
|
3887
|
+
apply: () => new RealDate(now()).toString(),
|
|
3888
|
+
get: (target, key, receiver) => (key === "now" ? now : key === "helpdeskPinned" ? true : Reflect.get(target, key, receiver)),
|
|
3889
|
+
});
|
|
3890
|
+
})();`;
|
|
3891
|
+
return `"use strict";
|
|
3892
|
+
${pin}
|
|
3893
|
+
(() => {
|
|
3894
|
+
const vm = require("node:vm");
|
|
3895
|
+
const createContext = vm.createContext;
|
|
3896
|
+
vm.createContext = function (...args) {
|
|
3897
|
+
const context = createContext.apply(this, args);
|
|
3898
|
+
try {
|
|
3899
|
+
vm.runInContext(${JSON.stringify(pin)}, context);
|
|
3900
|
+
} catch {}
|
|
3901
|
+
return context;
|
|
3902
|
+
};
|
|
3903
|
+
})();
|
|
3904
|
+
`;
|
|
3905
|
+
}
|
|
3906
|
+
function withPreload(nodeOptions, file) {
|
|
3907
|
+
return [nodeOptions?.trim(), `--require ${JSON.stringify(file)}`].filter(Boolean).join(" ");
|
|
3908
|
+
}
|
|
3909
|
+
async function writeClockPreload(setting) {
|
|
3910
|
+
const dir = await mkdtemp(path3.join(tmpdir(), "helpdesk-clock-"));
|
|
3911
|
+
const file = path3.join(dir, "clock.cjs");
|
|
3912
|
+
await writeFile2(file, clockPreload(setting), "utf8");
|
|
3913
|
+
return { file, remove: () => rm2(dir, { recursive: true, force: true }) };
|
|
3914
|
+
}
|
|
3915
|
+
function pinnedEnv(at, file, nodeOptions) {
|
|
3916
|
+
return { [PINNED_CLOCK_ENV]: at, NODE_OPTIONS: withPreload(nodeOptions, file) };
|
|
3917
|
+
}
|
|
3918
|
+
|
|
2585
3919
|
// core/ci-api.ts
|
|
3920
|
+
var CI_TOKEN_NAME = "HELPDESK_TOKEN";
|
|
2586
3921
|
var CI_SESSION_OUT_ENV = "HELPDESK_SESSION_OUT";
|
|
2587
3922
|
var CI_BASE_URL_ENV = "HELPDESK_BASE_URL";
|
|
3923
|
+
var CI_WORKFLOW_PATH = ".github/workflows/helpdesk.yml";
|
|
3924
|
+
var CAPTURE_WORKFLOW_PATH = ".github/workflows/helpdesk-screenshots.yml";
|
|
3925
|
+
function workflowNames(slug) {
|
|
3926
|
+
const tag = (slug ?? "").trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
3927
|
+
if (!tag) {
|
|
3928
|
+
return {
|
|
3929
|
+
secret: CI_TOKEN_NAME,
|
|
3930
|
+
change: { name: "Help desk", path: CI_WORKFLOW_PATH },
|
|
3931
|
+
capture: { name: "Help desk screenshots", path: CAPTURE_WORKFLOW_PATH }
|
|
3932
|
+
};
|
|
3933
|
+
}
|
|
3934
|
+
return {
|
|
3935
|
+
secret: `${CI_TOKEN_NAME}_${tag.toUpperCase().replace(/-/g, "_")}`,
|
|
3936
|
+
change: { name: `Help desk (${tag})`, path: `.github/workflows/helpdesk-${tag}-watch.yml` },
|
|
3937
|
+
capture: { name: `Help desk screenshots (${tag})`, path: `.github/workflows/helpdesk-${tag}-screenshots.yml` }
|
|
3938
|
+
};
|
|
3939
|
+
}
|
|
2588
3940
|
|
|
2589
3941
|
// adapters/capture-stage.ts
|
|
2590
3942
|
var DEFAULT_APP_ENV = "API_BASE_URL";
|
|
3943
|
+
async function productClock(pinnedClock) {
|
|
3944
|
+
if (!pinnedClock) return { env: {} };
|
|
3945
|
+
const written = await writeClockPreload({ at: pinnedClock });
|
|
3946
|
+
return { env: pinnedEnv(pinnedClock, written.file, process.env["NODE_OPTIONS"]), written };
|
|
3947
|
+
}
|
|
2591
3948
|
async function waitForApp(url, deadline, onLog, gaveUp) {
|
|
2592
3949
|
let announced = false;
|
|
2593
3950
|
while (Date.now() < deadline) {
|
|
@@ -2614,9 +3971,10 @@ async function stageAgainstRecording(options) {
|
|
|
2614
3971
|
const variable = options.appEnv ?? DEFAULT_APP_ENV;
|
|
2615
3972
|
let app;
|
|
2616
3973
|
let died;
|
|
3974
|
+
const clock = options.appCommand ? await productClock(options.pinnedClock) : { env: {} };
|
|
2617
3975
|
if (options.appCommand) {
|
|
2618
|
-
options.onLog?.(`Starting your product with ${variable}=${replay.url}`);
|
|
2619
|
-
app =
|
|
3976
|
+
options.onLog?.(`Starting your product with ${variable}=${replay.url}${clockNote(options.pinnedClock)}`);
|
|
3977
|
+
app = spawn2(options.appCommand, {
|
|
2620
3978
|
shell: true,
|
|
2621
3979
|
// Its own process group, so it can be taken down as one.
|
|
2622
3980
|
//
|
|
@@ -2626,7 +3984,7 @@ async function stageAgainstRecording(options) {
|
|
|
2626
3984
|
// the address occupied by the last one — and on a build agent, the job
|
|
2627
3985
|
// never finishes.
|
|
2628
3986
|
detached: true,
|
|
2629
|
-
env: { ...process.env, [variable]: replay.url },
|
|
3987
|
+
env: { ...process.env, [variable]: replay.url, ...clock.env },
|
|
2630
3988
|
stdio: ["ignore", "pipe", "pipe"]
|
|
2631
3989
|
});
|
|
2632
3990
|
app.stdout?.on("data", (chunk) => options.onLog?.(` [app] ${chunk.toString().trimEnd()}`));
|
|
@@ -2643,6 +4001,7 @@ async function stageAgainstRecording(options) {
|
|
|
2643
4001
|
if (died !== void 0) {
|
|
2644
4002
|
await replay.close();
|
|
2645
4003
|
stop(app);
|
|
4004
|
+
await clock.written?.remove();
|
|
2646
4005
|
throw new Error(
|
|
2647
4006
|
`The start command exited (${died}) before the product was ready. Its output is above.` + (up ? `
|
|
2648
4007
|
|
|
@@ -2652,6 +4011,7 @@ Something else is already answering on ${options.appUrl}. That is almost always
|
|
|
2652
4011
|
if (!up) {
|
|
2653
4012
|
await replay.close();
|
|
2654
4013
|
stop(app);
|
|
4014
|
+
await clock.written?.remove();
|
|
2655
4015
|
throw new Error(
|
|
2656
4016
|
`${options.appUrl} never answered. ` + (options.appCommand ? "Check the start command above, and that your product listens on that address." : `Start your product with ${variable}=${replay.url}, then run this again.`)
|
|
2657
4017
|
);
|
|
@@ -2664,9 +4024,13 @@ Something else is already answering on ${options.appUrl}. That is almost always
|
|
|
2664
4024
|
close: async () => {
|
|
2665
4025
|
stop(app);
|
|
2666
4026
|
await replay.close();
|
|
4027
|
+
await clock.written?.remove();
|
|
2667
4028
|
}
|
|
2668
4029
|
};
|
|
2669
4030
|
}
|
|
4031
|
+
function clockNote(pinnedClock) {
|
|
4032
|
+
return pinnedClock ? `, its clock pinned to ${pinnedClock}` : "";
|
|
4033
|
+
}
|
|
2670
4034
|
var SESSION_OUT_ENV = CI_SESSION_OUT_ENV;
|
|
2671
4035
|
var EXAMPLES_OUT_ENV = CI_EXAMPLES_OUT_ENV;
|
|
2672
4036
|
async function stageSeeded(options) {
|
|
@@ -2681,7 +4045,8 @@ async function stageSeeded(options) {
|
|
|
2681
4045
|
{
|
|
2682
4046
|
[CI_SESSION_OUT_ENV]: options.sessionOut,
|
|
2683
4047
|
[CI_BASE_URL_ENV]: options.appUrl,
|
|
2684
|
-
...options.examplesOut ? { [CI_EXAMPLES_OUT_ENV]: options.examplesOut } : {}
|
|
4048
|
+
...options.examplesOut ? { [CI_EXAMPLES_OUT_ENV]: options.examplesOut } : {},
|
|
4049
|
+
...options.pinnedClock ? { [PINNED_CLOCK_ENV]: options.pinnedClock } : {}
|
|
2685
4050
|
},
|
|
2686
4051
|
onLog
|
|
2687
4052
|
);
|
|
@@ -2700,15 +4065,18 @@ Nothing has been photographed. A run that carried on from here would sign in to
|
|
|
2700
4065
|
onLog?.(
|
|
2701
4066
|
sessionPath ? `The seed command left a session at ${sessionPath}, so the screens behind your login can be photographed.` : `The seed command wrote no session to ${SESSION_OUT_ENV}. Only the screens a signed-out visitor can reach will be photographed.`
|
|
2702
4067
|
);
|
|
2703
|
-
const
|
|
4068
|
+
const named = options.examplesOut ? await examplesFrom(options.examplesOut, onLog) : void 0;
|
|
4069
|
+
const examples = named?.examples;
|
|
4070
|
+
const clips = named?.clips ?? [];
|
|
2704
4071
|
let app;
|
|
2705
4072
|
let died;
|
|
4073
|
+
const clock = options.appCommand ? await productClock(options.pinnedClock) : { env: {} };
|
|
2706
4074
|
if (options.appCommand) {
|
|
2707
|
-
onLog?.(`Starting your product with ${options.appCommand}`);
|
|
2708
|
-
app =
|
|
4075
|
+
onLog?.(`Starting your product with ${options.appCommand}${clockNote(options.pinnedClock)}`);
|
|
4076
|
+
app = spawn2(options.appCommand, {
|
|
2709
4077
|
shell: true,
|
|
2710
4078
|
detached: true,
|
|
2711
|
-
env: { ...process.env },
|
|
4079
|
+
env: { ...process.env, ...clock.env },
|
|
2712
4080
|
stdio: ["ignore", "pipe", "pipe"]
|
|
2713
4081
|
});
|
|
2714
4082
|
app.stdout?.on("data", (chunk) => onLog?.(` [app] ${chunk.toString().trimEnd()}`));
|
|
@@ -2722,6 +4090,7 @@ Nothing has been photographed. A run that carried on from here would sign in to
|
|
|
2722
4090
|
if (died === void 0 && app?.exitCode !== null && app?.exitCode !== void 0) died = app.exitCode;
|
|
2723
4091
|
if (died !== void 0) {
|
|
2724
4092
|
stop(app);
|
|
4093
|
+
await clock.written?.remove();
|
|
2725
4094
|
throw new Error(
|
|
2726
4095
|
`The start command exited (${died}) before the product was ready. Its output is above.` + (up ? `
|
|
2727
4096
|
|
|
@@ -2730,6 +4099,7 @@ Something else is already answering on ${options.appUrl}, which is almost always
|
|
|
2730
4099
|
}
|
|
2731
4100
|
if (!up) {
|
|
2732
4101
|
stop(app);
|
|
4102
|
+
await clock.written?.remove();
|
|
2733
4103
|
throw new Error(
|
|
2734
4104
|
`${options.appUrl} never answered. Check the start command above, and that your product listens on that address.`
|
|
2735
4105
|
);
|
|
@@ -2738,16 +4108,18 @@ Something else is already answering on ${options.appUrl}, which is almost always
|
|
|
2738
4108
|
appUrl: options.appUrl,
|
|
2739
4109
|
...sessionPath ? { sessionPath } : {},
|
|
2740
4110
|
...examples ? { examples } : {},
|
|
4111
|
+
...clips.length ? { clips } : {},
|
|
2741
4112
|
close: async () => {
|
|
2742
4113
|
stop(app);
|
|
4114
|
+
await clock.written?.remove();
|
|
2743
4115
|
}
|
|
2744
4116
|
};
|
|
2745
4117
|
}
|
|
2746
|
-
async function examplesFrom(
|
|
2747
|
-
const { readFile:
|
|
4118
|
+
async function examplesFrom(path5, onLog) {
|
|
4119
|
+
const { readFile: readFile3 } = await import("node:fs/promises");
|
|
2748
4120
|
let text2;
|
|
2749
4121
|
try {
|
|
2750
|
-
text2 = await
|
|
4122
|
+
text2 = await readFile3(path5, "utf8");
|
|
2751
4123
|
} catch {
|
|
2752
4124
|
return void 0;
|
|
2753
4125
|
}
|
|
@@ -2758,20 +4130,23 @@ async function examplesFrom(path3, onLog) {
|
|
|
2758
4130
|
onLog?.(`The seed command wrote ${EXAMPLES_OUT_ENV}, but it is not JSON, so no screen with an id in its address will be opened.`);
|
|
2759
4131
|
return void 0;
|
|
2760
4132
|
}
|
|
4133
|
+
const named = readClipScripts(parsed?.clips);
|
|
4134
|
+
if (named.scripts.length) onLog?.(`The seed command named ${named.scripts.length} clip(s) to record.`);
|
|
4135
|
+
for (const refused of named.refused) onLog?.(` not recording ${refused.id}: ${refused.because}`);
|
|
2761
4136
|
const reading = readExampleRoutes(parsed);
|
|
2762
4137
|
if (!reading.ok) {
|
|
2763
4138
|
onLog?.(`The seed command wrote ${EXAMPLES_OUT_ENV}, but ${reading.because}, so no screen with an id in its address will be opened.`);
|
|
2764
|
-
return
|
|
4139
|
+
return { clips: named.scripts };
|
|
2765
4140
|
}
|
|
2766
4141
|
onLog?.(
|
|
2767
4142
|
`The seed command named a record for ${reading.examples.paths.size} screen(s) with an id in the address.`
|
|
2768
4143
|
);
|
|
2769
4144
|
for (const refused of reading.examples.refused) onLog?.(` not using ${refused.route}: ${refused.because}`);
|
|
2770
|
-
return reading.examples;
|
|
4145
|
+
return { examples: reading.examples, clips: named.scripts };
|
|
2771
4146
|
}
|
|
2772
4147
|
async function runToCompletion(command, timeoutMs, env, onLog) {
|
|
2773
4148
|
return new Promise((resolve) => {
|
|
2774
|
-
const child =
|
|
4149
|
+
const child = spawn2(command, {
|
|
2775
4150
|
shell: true,
|
|
2776
4151
|
detached: true,
|
|
2777
4152
|
env: { ...process.env, ...env },
|
|
@@ -2812,8 +4187,8 @@ function gapsFrom(misses, cassette) {
|
|
|
2812
4187
|
const templates = templatise([...known, ...misses.map((miss) => miss.path)]);
|
|
2813
4188
|
const grouped = /* @__PURE__ */ new Map();
|
|
2814
4189
|
for (const miss of misses) {
|
|
2815
|
-
const
|
|
2816
|
-
const key = interactionKey({ method: miss.method, path:
|
|
4190
|
+
const path5 = templates.get(miss.path) ?? miss.path;
|
|
4191
|
+
const key = interactionKey({ method: miss.method, path: path5, ...miss.query ? { query: miss.query } : {} });
|
|
2817
4192
|
const existing = grouped.get(key);
|
|
2818
4193
|
if (existing) {
|
|
2819
4194
|
existing.asked += 1;
|
|
@@ -2822,7 +4197,7 @@ function gapsFrom(misses, cassette) {
|
|
|
2822
4197
|
}
|
|
2823
4198
|
grouped.set(key, {
|
|
2824
4199
|
method: miss.method.toUpperCase(),
|
|
2825
|
-
path:
|
|
4200
|
+
path: path5,
|
|
2826
4201
|
asked: 1,
|
|
2827
4202
|
broke: miss.from ? [miss.from] : [],
|
|
2828
4203
|
example: miss.path
|
|
@@ -2891,8 +4266,8 @@ function coverageReport(found, total) {
|
|
|
2891
4266
|
}
|
|
2892
4267
|
|
|
2893
4268
|
// adapters/capture-bin.ts
|
|
2894
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2895
|
-
import
|
|
4269
|
+
import { mkdir as mkdir2, readFile as readFile2, writeFile as writeFile3 } from "node:fs/promises";
|
|
4270
|
+
import path4 from "node:path";
|
|
2896
4271
|
|
|
2897
4272
|
// adapters/map-cli.ts
|
|
2898
4273
|
import { execFileSync } from "node:child_process";
|
|
@@ -2905,14 +4280,14 @@ var ROUTE_GROUP = /^\((.+)\)$/;
|
|
|
2905
4280
|
var DYNAMIC = /^\[{1,2}(\.{3})?(.+?)\]{1,2}$/;
|
|
2906
4281
|
var PARALLEL = /^@/;
|
|
2907
4282
|
var INTERCEPTING = /^\(\.+\)/;
|
|
2908
|
-
function classify(
|
|
2909
|
-
if (
|
|
4283
|
+
function classify(path5) {
|
|
4284
|
+
if (path5 === "/api" || path5.startsWith("/api/")) {
|
|
2910
4285
|
return { skipped: "not-html", note: "an API route, not a screen" };
|
|
2911
4286
|
}
|
|
2912
|
-
if (/(^|\/)(admin|internal|debug|_dev)(\/|$)/.test(
|
|
4287
|
+
if (/(^|\/)(admin|internal|debug|_dev)(\/|$)/.test(path5)) {
|
|
2913
4288
|
return { skipped: "internal-tooling", note: "internal tooling" };
|
|
2914
4289
|
}
|
|
2915
|
-
if (/(^|\/)(login|logout|signin|sign-in|signup|sign-up|auth|verify|reset-password)(\/|$)/.test(
|
|
4290
|
+
if (/(^|\/)(login|logout|signin|sign-in|signup|sign-up|auth|verify|reset-password)(\/|$)/.test(path5)) {
|
|
2916
4291
|
return { skipped: "unauthenticated-flow", note: "an authentication step" };
|
|
2917
4292
|
}
|
|
2918
4293
|
return void 0;
|
|
@@ -3016,8 +4391,8 @@ function enclosingLayouts(pageFile, layouts) {
|
|
|
3016
4391
|
const dir = pageFile.slice(0, pageFile.lastIndexOf("/"));
|
|
3017
4392
|
return layouts.filter((layout) => dir.startsWith(layout.slice(0, layout.lastIndexOf("/")))).sort((a, b) => a.length - b.length);
|
|
3018
4393
|
}
|
|
3019
|
-
function depth(
|
|
3020
|
-
return
|
|
4394
|
+
function depth(path5) {
|
|
4395
|
+
return path5 === "/" ? 0 : path5.split("/").filter(Boolean).length;
|
|
3021
4396
|
}
|
|
3022
4397
|
|
|
3023
4398
|
// core/discovery/article-plan.ts
|
|
@@ -3054,8 +4429,8 @@ var ENTRY_POINT = /* @__PURE__ */ new Set([
|
|
|
3054
4429
|
"integration",
|
|
3055
4430
|
"integrations"
|
|
3056
4431
|
]);
|
|
3057
|
-
function hasSegment(
|
|
3058
|
-
return
|
|
4432
|
+
function hasSegment(path5, vocabulary) {
|
|
4433
|
+
return path5.split("/").filter(Boolean).some((segment) => vocabulary.has(segment.toLowerCase()));
|
|
3059
4434
|
}
|
|
3060
4435
|
function score(route, signals) {
|
|
3061
4436
|
const because = [];
|
|
@@ -3094,8 +4469,8 @@ function tierFor(rank, count) {
|
|
|
3094
4469
|
if (rank <= Math.max(16, Math.ceil(count * 0.6))) return "supporting";
|
|
3095
4470
|
return "minor";
|
|
3096
4471
|
}
|
|
3097
|
-
function slugFor(
|
|
3098
|
-
const segments =
|
|
4472
|
+
function slugFor(path5) {
|
|
4473
|
+
const segments = path5.split("/").filter(Boolean).filter((segment) => !segment.startsWith("["));
|
|
3099
4474
|
return segments.length ? segments.join("/") : "overview";
|
|
3100
4475
|
}
|
|
3101
4476
|
function titleFor(route) {
|
|
@@ -3154,7 +4529,7 @@ function buildArticlePlan(options) {
|
|
|
3154
4529
|
let covered = 0;
|
|
3155
4530
|
const needs = [...merged.entries()].map(([slug, group], index) => {
|
|
3156
4531
|
const rank = index + 1;
|
|
3157
|
-
const claimed = [group.entry.route.path, ...group.routes.slice(1) ?? []].map((
|
|
4532
|
+
const claimed = [group.entry.route.path, ...group.routes.slice(1) ?? []].map((path5) => options.documented?.[path5]).find(Boolean);
|
|
3158
4533
|
const already = existing.has(slug) || Boolean(claimed);
|
|
3159
4534
|
if (already) covered += 1;
|
|
3160
4535
|
return {
|
|
@@ -3176,6 +4551,8 @@ function buildArticlePlan(options) {
|
|
|
3176
4551
|
function repositoryFiles(cwd) {
|
|
3177
4552
|
return execFileSync("git", ["ls-files"], { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }).split("\n").filter(Boolean);
|
|
3178
4553
|
}
|
|
4554
|
+
var CAPTURE_WORKFLOW = workflowNames().capture;
|
|
4555
|
+
var CI_WORKFLOW = workflowNames().change;
|
|
3179
4556
|
var WORKFLOW_ACTIONS = {
|
|
3180
4557
|
checkout: "actions/checkout@v7",
|
|
3181
4558
|
setupNode: "actions/setup-node@v7"
|
|
@@ -3195,8 +4572,8 @@ function shotIdFor(route) {
|
|
|
3195
4572
|
function describe(route, isEntry) {
|
|
3196
4573
|
const segments = route.path.split("/").filter(Boolean);
|
|
3197
4574
|
const last = segments[segments.length - 1] ?? "";
|
|
3198
|
-
const
|
|
3199
|
-
return isEntry ?
|
|
4575
|
+
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";
|
|
4576
|
+
return isEntry ? `${screen} as someone arrives on it` : `${screen}, reached from ${segments.slice(0, -1).join("/") || "the top level"}`;
|
|
3200
4577
|
}
|
|
3201
4578
|
function kindFor(_route, _isEntry) {
|
|
3202
4579
|
return "still";
|
|
@@ -3208,7 +4585,7 @@ function buildStoryboard(options) {
|
|
|
3208
4585
|
const beneath = options.routes.filter(
|
|
3209
4586
|
(route) => !covered.has(route.path) && !route.skipped && [...covered].some((base) => base !== "/" && route.path.startsWith(`${base}/`))
|
|
3210
4587
|
);
|
|
3211
|
-
const own = [...covered].map((
|
|
4588
|
+
const own = [...covered].map((path5) => byPath.get(path5)).filter((route) => Boolean(route) && !route.skipped);
|
|
3212
4589
|
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));
|
|
3213
4590
|
const limit = options.maxSteps ?? 5;
|
|
3214
4591
|
const steps = ordered.slice(0, limit).map((route, index) => ({
|
|
@@ -3235,7 +4612,9 @@ function parseArgs(argv) {
|
|
|
3235
4612
|
brands: [],
|
|
3236
4613
|
only: [],
|
|
3237
4614
|
liveClock: false,
|
|
3238
|
-
allowProduction: false
|
|
4615
|
+
allowProduction: false,
|
|
4616
|
+
phone: true,
|
|
4617
|
+
clips: false
|
|
3239
4618
|
};
|
|
3240
4619
|
for (let index = 0; index < argv.length; index += 1) {
|
|
3241
4620
|
const flag = argv[index];
|
|
@@ -3256,6 +4635,8 @@ function parseArgs(argv) {
|
|
|
3256
4635
|
else if (flag === "--repo" && value) args.repo = value, index += 1;
|
|
3257
4636
|
else if (flag === "--live-clock") args.liveClock = true;
|
|
3258
4637
|
else if (flag === "--allow-production") args.allowProduction = true;
|
|
4638
|
+
else if (flag === "--no-phone") args.phone = false;
|
|
4639
|
+
else if (flag === "--clips") args.clips = true;
|
|
3259
4640
|
else if (flag === "--rebaseline" || flag === "--force") {
|
|
3260
4641
|
return '--rebaseline needs a reason: `--rebaseline "new brand colors shipped"`. Replacing every stored frame moves every checksum at once, and a re-baseline nobody wrote down is indistinguishable from the product changing.';
|
|
3261
4642
|
}
|
|
@@ -3270,17 +4651,37 @@ function parseArgs(argv) {
|
|
|
3270
4651
|
if (!args.to) return "Missing --to. That is the address of your help desk, or use --out <dir>.";
|
|
3271
4652
|
return args;
|
|
3272
4653
|
}
|
|
4654
|
+
function seenBefore(frame) {
|
|
4655
|
+
if (!frame) return {};
|
|
4656
|
+
return { ...frame.sha256 ? { sha256: frame.sha256 } : {}, ...frame.layout !== void 0 ? { layout: frame.layout } : {} };
|
|
4657
|
+
}
|
|
3273
4658
|
function shotsFromRepo(repo, previous) {
|
|
3274
4659
|
const files = repositoryFiles(repo);
|
|
3275
4660
|
const routes = readNextAppRouter(files, detectRouterRoot(files)).routes;
|
|
3276
|
-
return
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
|
|
4661
|
+
return onePerScreen(
|
|
4662
|
+
buildArticlePlan({ routes }).needs.flatMap((need) => buildStoryboard({ need, routes }).steps).filter((step) => step.kind === "still").map((step) => ({
|
|
4663
|
+
id: step.shotId,
|
|
4664
|
+
route: step.route,
|
|
4665
|
+
depicts: step.depicts,
|
|
4666
|
+
// Carried so a second run can recognise a frame it already has, which is
|
|
4667
|
+
// what makes "nothing changed" a sentence this can say offline too.
|
|
4668
|
+
...seenBefore(previous.get(step.shotId))
|
|
4669
|
+
}))
|
|
4670
|
+
);
|
|
4671
|
+
}
|
|
4672
|
+
function withPhoneShots(shots, previous) {
|
|
4673
|
+
const planned = new Set(shots.map((shot) => shot.id));
|
|
4674
|
+
const phones = shots.filter((shot) => !shot.visit && !shot.unvisitable && !shot.variant && !planned.has(`${shot.id}${PHONE_SUFFIX}`)).map((shot) => {
|
|
4675
|
+
const id = `${shot.id}${PHONE_SUFFIX}`;
|
|
4676
|
+
return { id, route: shot.route, depicts: `${shot.depicts}, at phone width`, variant: "phone", ...seenBefore(previous.get(id)) };
|
|
4677
|
+
});
|
|
4678
|
+
return [...shots, ...phones];
|
|
4679
|
+
}
|
|
4680
|
+
function heldPhoneFrames(shots) {
|
|
4681
|
+
return new Map(shots.flatMap((shot) => shot.phone ? [[`${shot.id}${PHONE_SUFFIX}`, { sha256: shot.phone.sha256, layout: shot.phone.layout }]] : []));
|
|
4682
|
+
}
|
|
4683
|
+
function onePerScreen(shots) {
|
|
4684
|
+
return [...new Map(shots.map((shot) => [shot.id, shot])).values()];
|
|
3284
4685
|
}
|
|
3285
4686
|
function withExamples(shots, examples) {
|
|
3286
4687
|
return shots.map((shot) => {
|
|
@@ -3289,10 +4690,178 @@ function withExamples(shots, examples) {
|
|
|
3289
4690
|
return resolved.visit === shot.route ? shot : { ...shot, visit: resolved.visit };
|
|
3290
4691
|
});
|
|
3291
4692
|
}
|
|
4693
|
+
function seededLast(shots) {
|
|
4694
|
+
return [...shots.filter((shot) => !shot.visit), ...shots.filter((shot) => shot.visit)];
|
|
4695
|
+
}
|
|
4696
|
+
async function writeLocalFrame(dir, frame) {
|
|
4697
|
+
const file = `${frame.shot.id}.png`;
|
|
4698
|
+
const layout = frame.layout ? `${frame.shot.id}.json` : void 0;
|
|
4699
|
+
await writeFile3(path4.join(dir, file), frame.image);
|
|
4700
|
+
if (layout) await writeFile3(path4.join(dir, layout), `${JSON.stringify(frame.layout, null, 2)}
|
|
4701
|
+
`, "utf8");
|
|
4702
|
+
return {
|
|
4703
|
+
id: frame.shot.id,
|
|
4704
|
+
route: frame.shot.route,
|
|
4705
|
+
depicts: frame.shot.depicts,
|
|
4706
|
+
file,
|
|
4707
|
+
sha256: frame.sha256,
|
|
4708
|
+
settled: frame.settled,
|
|
4709
|
+
...frame.browser ? { browser: frame.browser } : {},
|
|
4710
|
+
...frame.width ? { width: frame.width } : {},
|
|
4711
|
+
...frame.height ? { height: frame.height } : {},
|
|
4712
|
+
...layout ? { layout } : {},
|
|
4713
|
+
...frame.shot.variant ? { variant: frame.shot.variant } : {}
|
|
4714
|
+
};
|
|
4715
|
+
}
|
|
4716
|
+
async function writeLocalClip(dir, clip) {
|
|
4717
|
+
const id = clip.recording.clipId;
|
|
4718
|
+
const folder = path4.join(dir, "clips");
|
|
4719
|
+
await mkdir2(folder, { recursive: true });
|
|
4720
|
+
await writeFile3(path4.join(folder, `${id}.mp4`), clip.mp4);
|
|
4721
|
+
if (clip.gif) await writeFile3(path4.join(folder, `${id}.gif`), clip.gif);
|
|
4722
|
+
await writeFile3(path4.join(folder, `${id}.png`), clip.poster);
|
|
4723
|
+
await writeFile3(path4.join(folder, `${id}.json`), `${JSON.stringify(clip.recording, null, 2)}
|
|
4724
|
+
`, "utf8");
|
|
4725
|
+
return {
|
|
4726
|
+
id,
|
|
4727
|
+
title: clip.recording.title,
|
|
4728
|
+
route: clip.recording.route,
|
|
4729
|
+
mp4: `clips/${id}.mp4`,
|
|
4730
|
+
...clip.gif ? { gif: `clips/${id}.gif` } : {},
|
|
4731
|
+
poster: `clips/${id}.png`,
|
|
4732
|
+
recording: `clips/${id}.json`,
|
|
4733
|
+
fingerprint: clip.recording.fingerprint,
|
|
4734
|
+
durationMs: clip.recording.durationMs
|
|
4735
|
+
};
|
|
4736
|
+
}
|
|
4737
|
+
function clipForm(clip) {
|
|
4738
|
+
const id = clip.recording.clipId;
|
|
4739
|
+
const form = new FormData();
|
|
4740
|
+
form.set("meta", JSON.stringify({ ...clip.recording, sha256: clip.sha256 }));
|
|
4741
|
+
form.set("mp4", new Blob([clip.mp4], { type: "video/mp4" }), `${id}.mp4`);
|
|
4742
|
+
if (clip.gif) form.set("gif", new Blob([clip.gif], { type: "image/gif" }), `${id}.gif`);
|
|
4743
|
+
form.set("poster", new Blob([clip.poster], { type: "image/png" }), `${id}.png`);
|
|
4744
|
+
return form;
|
|
4745
|
+
}
|
|
4746
|
+
async function clipsOnDisk(dir) {
|
|
4747
|
+
try {
|
|
4748
|
+
const manifest = JSON.parse(await readFile2(path4.join(dir, "clips", "manifest.json"), "utf8"));
|
|
4749
|
+
return new Map((manifest.clips ?? []).map((clip) => [clip.id, clip]));
|
|
4750
|
+
} catch {
|
|
4751
|
+
return /* @__PURE__ */ new Map();
|
|
4752
|
+
}
|
|
4753
|
+
}
|
|
4754
|
+
async function recordSeededClips(args) {
|
|
4755
|
+
if (!args.scripts.length) {
|
|
4756
|
+
console.log("\n--clips was given, but the seed command named no clips, so none were recorded.");
|
|
4757
|
+
return [];
|
|
4758
|
+
}
|
|
4759
|
+
if (!await ffmpegAvailable()) {
|
|
4760
|
+
console.log("\nClips were not recorded: this machine has no ffmpeg to encode them with. Install ffmpeg, or set CAPTURE_FFMPEG.");
|
|
4761
|
+
if (process.env["GITHUB_ACTIONS"] === "true") console.log("::warning title=Clips not recorded::This runner has no ffmpeg to encode clips with.");
|
|
4762
|
+
return [];
|
|
4763
|
+
}
|
|
4764
|
+
const frames = new Map(
|
|
4765
|
+
args.outcomes.filter((outcome) => !outcome.variant && outcome.sha256 && (outcome.status === "captured" || outcome.status === "unchanged")).map((outcome) => [outcome.route, { shotId: outcome.shotId, sha256: outcome.sha256 ?? null }])
|
|
4766
|
+
);
|
|
4767
|
+
const previous = args.local ? await clipsOnDisk(args.local) : /* @__PURE__ */ new Map();
|
|
4768
|
+
const written = [];
|
|
4769
|
+
console.log(`
|
|
4770
|
+
The seed command named ${args.scripts.length} clip(s). Each is recorded in the demo tenant it built when it is new or something it shows has changed.
|
|
4771
|
+
`);
|
|
4772
|
+
const outcomes = await runClips({
|
|
4773
|
+
baseUrl: args.baseUrl,
|
|
4774
|
+
browser: args.browser,
|
|
4775
|
+
encoder: ffmpegEncoder(),
|
|
4776
|
+
workDir: path4.join(tmpdir2(), `helpdesk-clips-${process.pid}`),
|
|
4777
|
+
clearance: args.clearance,
|
|
4778
|
+
brands: args.brands,
|
|
4779
|
+
build: await args.browser.build?.().catch(() => void 0),
|
|
4780
|
+
scripts: args.scripts,
|
|
4781
|
+
frames,
|
|
4782
|
+
held: args.local ? new Map([...previous].map(([id, clip]) => [id, clip.fingerprint])) : args.held,
|
|
4783
|
+
force: args.force,
|
|
4784
|
+
async send(clip) {
|
|
4785
|
+
if (args.local) {
|
|
4786
|
+
written.push(await writeLocalClip(args.local, clip));
|
|
4787
|
+
return { ok: true };
|
|
4788
|
+
}
|
|
4789
|
+
const response = await fetch(`${args.to}/api/helpdesk/clips`, { method: "POST", headers: args.headers, body: clipForm(clip) });
|
|
4790
|
+
if (response.ok) return { ok: true };
|
|
4791
|
+
const body = await response.json().catch(() => ({}));
|
|
4792
|
+
return { ok: false, error: body.error ?? `the help desk answered ${response.status}`, ...response.status === 402 || body.limit ? { limit: true } : {} };
|
|
4793
|
+
},
|
|
4794
|
+
onProgress(outcome, index, total) {
|
|
4795
|
+
const mark = { recorded: "\u2713", unchanged: "=", deferred: "\u2013", refused: "\u2717", failed: "!" }[outcome.status];
|
|
4796
|
+
const kb = (bytes) => `${Math.round(bytes / 1024)} KB`;
|
|
4797
|
+
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 ?? "";
|
|
4798
|
+
console.log(`${mark} clip ${index}/${total} ${outcome.title}: ${detail}`);
|
|
4799
|
+
}
|
|
4800
|
+
});
|
|
4801
|
+
if (args.local) {
|
|
4802
|
+
const kept = [
|
|
4803
|
+
...written,
|
|
4804
|
+
...outcomes.filter((outcome) => outcome.status === "unchanged").flatMap((outcome) => previous.has(outcome.clipId) ? [previous.get(outcome.clipId)] : [])
|
|
4805
|
+
].sort((left, right) => left.id.localeCompare(right.id));
|
|
4806
|
+
await mkdir2(path4.join(args.local, "clips"), { recursive: true });
|
|
4807
|
+
await writeFile3(path4.join(args.local, "clips", "manifest.json"), `${JSON.stringify({ schema: "olomon.clip-manifest/1", clips: kept }, null, 2)}
|
|
4808
|
+
`, "utf8");
|
|
4809
|
+
}
|
|
4810
|
+
const count = (status) => outcomes.filter((outcome) => outcome.status === status).length;
|
|
4811
|
+
console.log(`
|
|
4812
|
+
Clips: ${count("recorded")} recorded, ${count("unchanged")} unchanged, ${count("deferred")} deferred, ${count("refused")} refused, ${count("failed")} failed.`);
|
|
4813
|
+
const missed = outcomes.filter((outcome) => outcome.status === "refused" || outcome.status === "failed");
|
|
4814
|
+
if (missed.length && process.env["GITHUB_ACTIONS"] === "true") {
|
|
4815
|
+
console.log(`::warning title=${missed.length} clip(s) not recorded::${missed.map((outcome) => `${outcome.clipId}: ${outcome.reason}`).join("; ")}`);
|
|
4816
|
+
}
|
|
4817
|
+
return outcomes;
|
|
4818
|
+
}
|
|
4819
|
+
function frameForm(frame) {
|
|
4820
|
+
const form = new FormData();
|
|
4821
|
+
form.set(
|
|
4822
|
+
"meta",
|
|
4823
|
+
JSON.stringify({
|
|
4824
|
+
shotId: frame.shot.id,
|
|
4825
|
+
route: frame.shot.route,
|
|
4826
|
+
depicts: frame.shot.depicts,
|
|
4827
|
+
pageText: frame.pageText,
|
|
4828
|
+
capturedFrom: frame.capturedFrom,
|
|
4829
|
+
settled: frame.settled,
|
|
4830
|
+
// What this run read off the screen. Absent is a refusal at the
|
|
4831
|
+
// other end rather than a default, which is the point of sending it.
|
|
4832
|
+
...frame.report ? { report: frame.report } : {},
|
|
4833
|
+
...frame.browser ? { browser: frame.browser } : {},
|
|
4834
|
+
...frame.width ? { width: frame.width } : {},
|
|
4835
|
+
...frame.height ? { height: frame.height } : {}
|
|
4836
|
+
})
|
|
4837
|
+
);
|
|
4838
|
+
form.set("image", new Blob([frame.image], { type: "image/png" }), `${frame.shot.id}.png`);
|
|
4839
|
+
if (frame.layout) form.set("layout", JSON.stringify(frame.layout));
|
|
4840
|
+
return form;
|
|
4841
|
+
}
|
|
4842
|
+
function framesOnDisk(written, planned, outcomes, previous) {
|
|
4843
|
+
const kept = new Map(written.map((frame) => [frame.id, frame]));
|
|
4844
|
+
for (const shot of planned) {
|
|
4845
|
+
if (kept.has(shot.id) || !shot.sha256) continue;
|
|
4846
|
+
const unchanged = outcomes.find((outcome) => outcome.shotId === shot.id);
|
|
4847
|
+
if (unchanged?.status !== "unchanged") continue;
|
|
4848
|
+
kept.set(shot.id, {
|
|
4849
|
+
id: shot.id,
|
|
4850
|
+
route: shot.route,
|
|
4851
|
+
depicts: shot.depicts,
|
|
4852
|
+
file: `${shot.id}.png`,
|
|
4853
|
+
sha256: shot.sha256,
|
|
4854
|
+
settled: unchanged.settled !== false,
|
|
4855
|
+
...previous.get(shot.id)?.layout ? { layout: `${shot.id}.json` } : {},
|
|
4856
|
+
...shot.variant ? { variant: shot.variant } : {}
|
|
4857
|
+
});
|
|
4858
|
+
}
|
|
4859
|
+
return [...kept.values()].sort((a, b) => a.id.localeCompare(b.id));
|
|
4860
|
+
}
|
|
3292
4861
|
async function previousFrames(out) {
|
|
3293
4862
|
try {
|
|
3294
|
-
const manifest = JSON.parse(await
|
|
3295
|
-
return new Map((manifest.frames ?? []).map((frame) => [frame.id, frame.sha256]));
|
|
4863
|
+
const manifest = JSON.parse(await readFile2(path4.join(out, "manifest.json"), "utf8"));
|
|
4864
|
+
return new Map((manifest.frames ?? []).map((frame) => [frame.id, { sha256: frame.sha256, layout: Boolean(frame.layout) }]));
|
|
3296
4865
|
} catch {
|
|
3297
4866
|
return /* @__PURE__ */ new Map();
|
|
3298
4867
|
}
|
|
@@ -3308,11 +4877,13 @@ async function main(argv) {
|
|
|
3308
4877
|
const local = parsed.out;
|
|
3309
4878
|
const frames = [];
|
|
3310
4879
|
let orders;
|
|
4880
|
+
let previous = /* @__PURE__ */ new Map();
|
|
3311
4881
|
if (local) {
|
|
3312
4882
|
const repo = parsed.repo ?? process.cwd();
|
|
3313
|
-
await
|
|
4883
|
+
await mkdir2(local, { recursive: true });
|
|
3314
4884
|
try {
|
|
3315
|
-
|
|
4885
|
+
previous = await previousFrames(local);
|
|
4886
|
+
orders = { baseUrl: null, shots: shotsFromRepo(repo, previous) };
|
|
3316
4887
|
} catch (error) {
|
|
3317
4888
|
console.error(
|
|
3318
4889
|
`Could not read a plan from ${repo}: ${error.message}
|
|
@@ -3329,6 +4900,7 @@ Run this from your repository, or pass --repo <dir>.`
|
|
|
3329
4900
|
return 1;
|
|
3330
4901
|
}
|
|
3331
4902
|
orders = await listed.json();
|
|
4903
|
+
previous = heldPhoneFrames(orders.shots);
|
|
3332
4904
|
if (!orders.capture) {
|
|
3333
4905
|
console.error(
|
|
3334
4906
|
`This workspace has approved no capture subject, so the help desk will refuse every frame.
|
|
@@ -3350,14 +4922,16 @@ Approve one on the plan page, then run this again.`
|
|
|
3350
4922
|
}
|
|
3351
4923
|
let stage;
|
|
3352
4924
|
let seeded;
|
|
4925
|
+
const pinnedClock = parsed.liveClock ? {} : { pinnedClock: CAPTURE_CONTRACT.pinnedClock };
|
|
3353
4926
|
if (parsed.seedCommand) {
|
|
3354
4927
|
try {
|
|
3355
4928
|
seeded = await stageSeeded({
|
|
3356
4929
|
appUrl: baseUrl,
|
|
3357
4930
|
seedCommand: parsed.seedCommand,
|
|
3358
4931
|
...parsed.appCommand ? { appCommand: parsed.appCommand } : {},
|
|
3359
|
-
|
|
3360
|
-
|
|
4932
|
+
...pinnedClock,
|
|
4933
|
+
sessionOut: parsed.session ?? path4.join(process.cwd(), "helpdesk-demo-session.json"),
|
|
4934
|
+
examplesOut: path4.join(process.cwd(), "helpdesk-examples.json"),
|
|
3361
4935
|
onLog: (line) => console.log(line)
|
|
3362
4936
|
});
|
|
3363
4937
|
if (seeded.sessionPath) parsed.session = seeded.sessionPath;
|
|
@@ -3368,13 +4942,15 @@ Approve one on the plan page, then run this again.`
|
|
|
3368
4942
|
return 1;
|
|
3369
4943
|
}
|
|
3370
4944
|
}
|
|
3371
|
-
const
|
|
4945
|
+
const withRecords = seeded?.examples ? withExamples(shots, seeded.examples) : shots;
|
|
4946
|
+
const planned = seededLast(parsed.phone ? withPhoneShots(withRecords, previous) : withRecords);
|
|
3372
4947
|
if (parsed.cassette) {
|
|
3373
4948
|
try {
|
|
3374
4949
|
stage = await stageAgainstRecording({
|
|
3375
|
-
cassette: parseCassette(await
|
|
4950
|
+
cassette: parseCassette(await readFile2(parsed.cassette, "utf8")),
|
|
3376
4951
|
appUrl: baseUrl,
|
|
3377
4952
|
...parsed.appCommand ? { appCommand: parsed.appCommand } : {},
|
|
4953
|
+
...pinnedClock,
|
|
3378
4954
|
...parsed.appEnv ? { appEnv: parsed.appEnv } : {},
|
|
3379
4955
|
...parsed.replayPort ? { replayPort: parsed.replayPort } : {},
|
|
3380
4956
|
onLog: (line) => console.log(line)
|
|
@@ -3398,7 +4974,8 @@ Approve one on the plan page, then run this again.`
|
|
|
3398
4974
|
session: Boolean(parsed.session),
|
|
3399
4975
|
...stage ? { replaying: parsed.cassette } : {},
|
|
3400
4976
|
...parsed.allowProduction ? { productionGranted: true } : {},
|
|
3401
|
-
...parsed.rebaseline ? { rebaselineReason: parsed.rebaseline } : {}
|
|
4977
|
+
...parsed.rebaseline ? { rebaselineReason: parsed.rebaseline } : {},
|
|
4978
|
+
...parsed.clips ? { clips: true } : {}
|
|
3402
4979
|
});
|
|
3403
4980
|
console.log(checks.lines.join("\n"));
|
|
3404
4981
|
console.log("");
|
|
@@ -3416,9 +4993,10 @@ Approve one on the plan page, then run this again.`
|
|
|
3416
4993
|
console.log(`${coverageReport(found, shots.length).join("\n")}
|
|
3417
4994
|
`);
|
|
3418
4995
|
}
|
|
4996
|
+
const phones = parsed.phone ? `, each opened at its own address again at ${PHONE_VIEWPORT.width}px wide if it is laid out for a phone` : "";
|
|
3419
4997
|
console.log(
|
|
3420
|
-
stage ? `Photographing ${shots.length} screen(s) of ${baseUrl}, served from ${parsed.cassette}.
|
|
3421
|
-
` : `Photographing ${shots.length} screen(s) of ${baseUrl}.
|
|
4998
|
+
stage ? `Photographing ${shots.length} screen(s) of ${baseUrl}${phones}, served from ${parsed.cassette}.
|
|
4999
|
+
` : `Photographing ${shots.length} screen(s) of ${baseUrl}${phones}.
|
|
3422
5000
|
`
|
|
3423
5001
|
);
|
|
3424
5002
|
const runId = local ? void 0 : await openRun(parsed.to, headers, shots.length);
|
|
@@ -3426,28 +5004,30 @@ Approve one on the plan page, then run this again.`
|
|
|
3426
5004
|
const tell = (body) => {
|
|
3427
5005
|
told = told.then(() => tellHelpDesk(parsed.to, headers, body));
|
|
3428
5006
|
};
|
|
5007
|
+
const clearance = orders.capture ? {
|
|
5008
|
+
subject: orders.capture.subject,
|
|
5009
|
+
routes: orders.capture.routes,
|
|
5010
|
+
regions: orders.capture.regions,
|
|
5011
|
+
// A cover is where to put a rectangle; an approval is where not
|
|
5012
|
+
// to. Both are read off the clearance the help desk holds, and
|
|
5013
|
+
// the second is checked again against that same record when the
|
|
5014
|
+
// frame arrives, so this run cannot approve anything by saying so.
|
|
5015
|
+
covers: (orders.capture.surfaces ?? []).filter((surface) => surface.treatment?.treat === "cover").map((surface) => surface.where),
|
|
5016
|
+
approved: (orders.capture.surfaces ?? []).filter((surface) => surface.treatment?.treat === "approve").map((surface) => surface.where),
|
|
5017
|
+
coverUnnamed: orders.capture.coverUnnamed === true,
|
|
5018
|
+
expectedBrands: orders.capture.expectedBrands ?? []
|
|
5019
|
+
} : void 0;
|
|
3429
5020
|
try {
|
|
3430
5021
|
const report = await runCapture({
|
|
3431
5022
|
baseUrl,
|
|
3432
5023
|
shots: planned,
|
|
3433
5024
|
browser,
|
|
3434
|
-
...
|
|
3435
|
-
clearance: {
|
|
3436
|
-
subject: orders.capture.subject,
|
|
3437
|
-
routes: orders.capture.routes,
|
|
3438
|
-
regions: orders.capture.regions,
|
|
3439
|
-
// A cover is where to put a rectangle; an approval is where not
|
|
3440
|
-
// to. Both are read off the clearance the help desk holds, and
|
|
3441
|
-
// the second is checked again against that same record when the
|
|
3442
|
-
// frame arrives, so this run cannot approve anything by saying so.
|
|
3443
|
-
covers: (orders.capture.surfaces ?? []).filter((surface) => surface.treatment?.treat === "cover").map((surface) => surface.where),
|
|
3444
|
-
approved: (orders.capture.surfaces ?? []).filter((surface) => surface.treatment?.treat === "approve").map((surface) => surface.where),
|
|
3445
|
-
coverUnnamed: orders.capture.coverUnnamed === true,
|
|
3446
|
-
expectedBrands: orders.capture.expectedBrands ?? []
|
|
3447
|
-
}
|
|
3448
|
-
} : {},
|
|
5025
|
+
...clearance ? { clearance } : {},
|
|
3449
5026
|
brands: parsed.brands,
|
|
3450
5027
|
force: Boolean(parsed.rebaseline),
|
|
5028
|
+
// The commit a workflow run checked out, so the screens' details say
|
|
5029
|
+
// which code they describe. Absent anywhere else, and never guessed.
|
|
5030
|
+
.../^[0-9a-f]{40}$/i.test(process.env["GITHUB_SHA"] ?? "") ? { commit: process.env["GITHUB_SHA"] } : {},
|
|
3451
5031
|
/*
|
|
3452
5032
|
* Said rather than inferred. A run with no saved session is signed out,
|
|
3453
5033
|
* which is a fact about the evidence worth recording; a run with one is
|
|
@@ -3458,8 +5038,9 @@ Approve one on the plan page, then run this again.`
|
|
|
3458
5038
|
role: parsed.session ? unknownRole() : noRole("this run was signed out"),
|
|
3459
5039
|
onProgress: (outcome, index, total) => {
|
|
3460
5040
|
const mark = { captured: "\u2713", unchanged: "=", skipped: "\u2013", refused: "\u2717", failed: "!" }[outcome.status];
|
|
3461
|
-
|
|
3462
|
-
|
|
5041
|
+
const screen = outcome.variant ? `${outcome.route} at phone width` : outcome.route;
|
|
5042
|
+
console.log(`${mark} ${index}/${total} ${screen}${outcome.reason ? `: ${outcome.reason}` : ""}`);
|
|
5043
|
+
if (runId && !outcome.variant) {
|
|
3463
5044
|
tell({
|
|
3464
5045
|
action: "progress",
|
|
3465
5046
|
jobId: runId,
|
|
@@ -3476,44 +5057,13 @@ Approve one on the plan page, then run this again.`
|
|
|
3476
5057
|
},
|
|
3477
5058
|
async send(frame) {
|
|
3478
5059
|
if (local) {
|
|
3479
|
-
|
|
3480
|
-
await writeFile(path2.join(local, file), frame.image);
|
|
3481
|
-
frames.push({
|
|
3482
|
-
id: frame.shot.id,
|
|
3483
|
-
route: frame.shot.route,
|
|
3484
|
-
depicts: frame.shot.depicts,
|
|
3485
|
-
file,
|
|
3486
|
-
sha256: frame.sha256,
|
|
3487
|
-
settled: frame.settled,
|
|
3488
|
-
...frame.browser ? { browser: frame.browser } : {},
|
|
3489
|
-
...frame.width ? { width: frame.width } : {},
|
|
3490
|
-
...frame.height ? { height: frame.height } : {}
|
|
3491
|
-
});
|
|
5060
|
+
frames.push(await writeLocalFrame(local, frame));
|
|
3492
5061
|
return { ok: true };
|
|
3493
5062
|
}
|
|
3494
|
-
const form = new FormData();
|
|
3495
|
-
form.set(
|
|
3496
|
-
"meta",
|
|
3497
|
-
JSON.stringify({
|
|
3498
|
-
shotId: frame.shot.id,
|
|
3499
|
-
route: frame.shot.route,
|
|
3500
|
-
depicts: frame.shot.depicts,
|
|
3501
|
-
pageText: frame.pageText,
|
|
3502
|
-
capturedFrom: frame.capturedFrom,
|
|
3503
|
-
settled: frame.settled,
|
|
3504
|
-
// What this run read off the screen. Absent is a refusal at the
|
|
3505
|
-
// other end rather than a default, which is the point of sending it.
|
|
3506
|
-
...frame.report ? { report: frame.report } : {},
|
|
3507
|
-
...frame.browser ? { browser: frame.browser } : {},
|
|
3508
|
-
...frame.width ? { width: frame.width } : {},
|
|
3509
|
-
...frame.height ? { height: frame.height } : {}
|
|
3510
|
-
})
|
|
3511
|
-
);
|
|
3512
|
-
form.set("image", new Blob([frame.image], { type: "image/png" }), `${frame.shot.id}.png`);
|
|
3513
5063
|
const response = await fetch(`${parsed.to}/api/helpdesk/frames`, {
|
|
3514
5064
|
method: "POST",
|
|
3515
5065
|
headers,
|
|
3516
|
-
body:
|
|
5066
|
+
body: frameForm(frame)
|
|
3517
5067
|
});
|
|
3518
5068
|
if (response.ok) return { ok: true };
|
|
3519
5069
|
const body = await response.json().catch(() => ({}));
|
|
@@ -3522,37 +5072,31 @@ Approve one on the plan page, then run this again.`
|
|
|
3522
5072
|
});
|
|
3523
5073
|
console.log(`
|
|
3524
5074
|
${runCounts(report)}.`);
|
|
5075
|
+
if (report.phone) console.log(`At phone width: ${runCounts(report.phone)}.`);
|
|
5076
|
+
const read = Object.keys(report.readings).length;
|
|
5077
|
+
if (read && !local) {
|
|
5078
|
+
console.log(`Read what ${read} screen${read === 1 ? "" : "s"} say, as the help desk's screen details: headings, buttons, fields and messages.`);
|
|
5079
|
+
}
|
|
3525
5080
|
const unclassified = unclassifiedLines(
|
|
3526
5081
|
groupUnclassified(
|
|
3527
|
-
report.outcomes.flatMap(
|
|
5082
|
+
report.outcomes.flatMap(
|
|
5083
|
+
(outcome) => outcome.unaccounted?.length && !outcome.variant ? [{ route: outcome.route, values: outcome.unaccounted }] : []
|
|
5084
|
+
)
|
|
3528
5085
|
)
|
|
3529
5086
|
);
|
|
3530
5087
|
if (unclassified.length) console.log(`
|
|
3531
5088
|
${unclassified.join("\n")}`);
|
|
3532
5089
|
if (local) {
|
|
3533
|
-
const kept =
|
|
3534
|
-
|
|
3535
|
-
|
|
3536
|
-
const unchanged = report.outcomes.find((outcome) => outcome.shotId === shot.id);
|
|
3537
|
-
if (unchanged?.status !== "unchanged") continue;
|
|
3538
|
-
kept.set(shot.id, {
|
|
3539
|
-
id: shot.id,
|
|
3540
|
-
route: shot.route,
|
|
3541
|
-
depicts: shot.depicts,
|
|
3542
|
-
file: `${shot.id}.png`,
|
|
3543
|
-
sha256: shot.sha256,
|
|
3544
|
-
settled: unchanged.settled !== false
|
|
3545
|
-
});
|
|
3546
|
-
}
|
|
3547
|
-
await writeFile(
|
|
3548
|
-
path2.join(local, "manifest.json"),
|
|
5090
|
+
const kept = framesOnDisk(frames, planned, report.outcomes, previous);
|
|
5091
|
+
await writeFile3(
|
|
5092
|
+
path4.join(local, "manifest.json"),
|
|
3549
5093
|
`${JSON.stringify(
|
|
3550
5094
|
{
|
|
3551
5095
|
schema: "olomon.capture-manifest/1",
|
|
3552
5096
|
capturedFrom: baseUrl,
|
|
3553
5097
|
capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3554
5098
|
...parsed.cassette ? { recording: parsed.cassette } : {},
|
|
3555
|
-
frames:
|
|
5099
|
+
frames: kept,
|
|
3556
5100
|
survey: report.survey
|
|
3557
5101
|
},
|
|
3558
5102
|
null,
|
|
@@ -3562,7 +5106,22 @@ ${unclassified.join("\n")}`);
|
|
|
3562
5106
|
"utf8"
|
|
3563
5107
|
);
|
|
3564
5108
|
console.log(`
|
|
3565
|
-
Written to ${local}: ${kept.
|
|
5109
|
+
Written to ${local}: ${kept.length} frame(s) and manifest.json.`);
|
|
5110
|
+
}
|
|
5111
|
+
if (parsed.clips) {
|
|
5112
|
+
await recordSeededClips({
|
|
5113
|
+
scripts: seeded?.clips ?? [],
|
|
5114
|
+
browser,
|
|
5115
|
+
baseUrl,
|
|
5116
|
+
clearance,
|
|
5117
|
+
brands: parsed.brands,
|
|
5118
|
+
outcomes: report.outcomes,
|
|
5119
|
+
local,
|
|
5120
|
+
to: parsed.to,
|
|
5121
|
+
headers,
|
|
5122
|
+
held: new Map((orders.clips ?? []).map((clip) => [clip.id, clip.fingerprint])),
|
|
5123
|
+
force: Boolean(parsed.rebaseline)
|
|
5124
|
+
});
|
|
3566
5125
|
}
|
|
3567
5126
|
if (report.survey.length) {
|
|
3568
5127
|
const survey = captureReport(report.survey);
|
|
@@ -3577,7 +5136,7 @@ ${surveyLine(report.survey)}
|
|
|
3577
5136
|
if (unstable.length) {
|
|
3578
5137
|
console.log(
|
|
3579
5138
|
`
|
|
3580
|
-
${unstable.length} screen(s) never stopped moving: ${unstable.map((o) => o.route).join(", ")}.
|
|
5139
|
+
${unstable.length} screen(s) never stopped moving: ${unstable.map((o) => o.variant ? `${o.route} at phone width` : o.route).join(", ")}.
|
|
3581
5140
|
Their frames were kept, but they will hash differently every run, so nothing can tell you
|
|
3582
5141
|
whether they changed. Usually a spinner that never resolves or a looping animation.`
|
|
3583
5142
|
);
|
|
@@ -3600,8 +5159,9 @@ not somebody's data. The session file stays on this machine and is sent nowhere.
|
|
|
3600
5159
|
console.log(
|
|
3601
5160
|
`
|
|
3602
5161
|
${attestation({
|
|
3603
|
-
|
|
3604
|
-
|
|
5162
|
+
// Of frames, which the phone-width ones are as much as the screens' and were checked the same way.
|
|
5163
|
+
captured: report.captured + (report.phone?.captured ?? 0),
|
|
5164
|
+
unchanged: report.unchanged + (report.phone?.unchanged ?? 0),
|
|
3605
5165
|
refused: report.refused,
|
|
3606
5166
|
baseUrl,
|
|
3607
5167
|
...report.browser ? { browser: report.browser } : {},
|
|
@@ -3628,7 +5188,9 @@ ${gapReport(gapsFrom([...stage.replay.misses])).join("\n")}`);
|
|
|
3628
5188
|
jobId: runId,
|
|
3629
5189
|
// What the verdict below prints, so Setup can say what this log says.
|
|
3630
5190
|
counts,
|
|
3631
|
-
...report.survey.length ? { survey: report.survey } : {}
|
|
5191
|
+
...report.survey.length ? { survey: report.survey } : {},
|
|
5192
|
+
// What each photographed screen says, as the screens' details.
|
|
5193
|
+
...Object.keys(report.readings).length ? { readings: report.readings } : {}
|
|
3632
5194
|
});
|
|
3633
5195
|
}
|
|
3634
5196
|
const verdict = runVerdict(counts);
|
|
@@ -3653,12 +5215,12 @@ ${verdict.sentence}`);
|
|
|
3653
5215
|
}
|
|
3654
5216
|
}
|
|
3655
5217
|
async function openRun(to, headers, total) {
|
|
3656
|
-
const
|
|
5218
|
+
const run2 = runIdentityFromEnv(process.env);
|
|
3657
5219
|
try {
|
|
3658
5220
|
const response = await fetch(`${to}/api/helpdesk/captures`, {
|
|
3659
5221
|
method: "POST",
|
|
3660
5222
|
headers: { ...headers, "content-type": "application/json" },
|
|
3661
|
-
body: JSON.stringify({ action: "start", total, ...
|
|
5223
|
+
body: JSON.stringify({ action: "start", total, ...run2 ? { run: run2 } : {} })
|
|
3662
5224
|
});
|
|
3663
5225
|
if (!response.ok) return void 0;
|
|
3664
5226
|
const body = await response.json();
|
|
@@ -3712,6 +5274,15 @@ if (runningAsCommand(import.meta.url)) {
|
|
|
3712
5274
|
);
|
|
3713
5275
|
}
|
|
3714
5276
|
export {
|
|
5277
|
+
clipForm,
|
|
5278
|
+
frameForm,
|
|
5279
|
+
framesOnDisk,
|
|
3715
5280
|
main,
|
|
3716
|
-
|
|
5281
|
+
onePerScreen,
|
|
5282
|
+
seededLast,
|
|
5283
|
+
shotsFromRepo,
|
|
5284
|
+
withExamples,
|
|
5285
|
+
withPhoneShots,
|
|
5286
|
+
writeLocalClip,
|
|
5287
|
+
writeLocalFrame
|
|
3717
5288
|
};
|