@olomonhelpdesk2/helpdesk 0.1.11 → 0.1.14
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 +1616 -234
- package/helpdesk.mjs +22 -0
- 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,30 @@ 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
|
+
const again = /* @__PURE__ */ new Set();
|
|
110
|
+
for (const route of Array.isArray(body["again"]) ? body["again"] : []) {
|
|
111
|
+
if (typeof route === "string" && paths.has(route)) again.add(route);
|
|
112
|
+
else refused.push({ route: String(route), because: "it is named as safe to open again, but not as a route with a record" });
|
|
113
|
+
}
|
|
114
|
+
return { ok: true, examples: { paths, refused, again } };
|
|
110
115
|
}
|
|
111
116
|
function resolveVisit(route, examples) {
|
|
112
117
|
const needs = dynamicSegments(route);
|
|
113
|
-
const
|
|
114
|
-
if (needs.length === 0) return { visit:
|
|
115
|
-
return
|
|
118
|
+
const path5 = examples.paths.get(route);
|
|
119
|
+
if (needs.length === 0) return { visit: path5 ?? route };
|
|
120
|
+
return path5 ? { visit: path5 } : { needs };
|
|
116
121
|
}
|
|
117
122
|
function describeMissingExample(route, needs) {
|
|
118
123
|
const named = needs.map((name) => `[${name}]`).join(" and ");
|
|
@@ -212,8 +217,8 @@ function actionFor(outcome) {
|
|
|
212
217
|
return `${outcome.route} asked for ${outcome.missingEndpoint ?? "data"}, which is not in the recording. Visit this screen while recording, then run this again.`;
|
|
213
218
|
}
|
|
214
219
|
}
|
|
215
|
-
function subjectOf(
|
|
216
|
-
const segments =
|
|
220
|
+
function subjectOf(path5) {
|
|
221
|
+
const segments = path5.split("/").filter(Boolean).filter((segment) => !/^(api|v\d+)$/.test(segment));
|
|
217
222
|
while (segments.length > 1 && looksLikeAnId(segments[segments.length - 1])) segments.pop();
|
|
218
223
|
const last = segments[segments.length - 1] ?? "record";
|
|
219
224
|
return last.replace(/[-_]/g, " ").replace(/s$/, "");
|
|
@@ -324,11 +329,11 @@ function canonicalisation(asked, landed) {
|
|
|
324
329
|
function samePlace(asked, landed) {
|
|
325
330
|
return stripQuery(asked) === stripQuery(landed);
|
|
326
331
|
}
|
|
327
|
-
function stripQuery(
|
|
328
|
-
return
|
|
332
|
+
function stripQuery(path5) {
|
|
333
|
+
return path5.split(/[?#]/)[0] ?? path5;
|
|
329
334
|
}
|
|
330
|
-
function normalise(
|
|
331
|
-
return stripQuery(
|
|
335
|
+
function normalise(path5) {
|
|
336
|
+
return stripQuery(path5).toLowerCase().replace(/\/index(\.html?)?$/, "/").replace(/\/+$/, "").replace(/^$/, "/");
|
|
332
337
|
}
|
|
333
338
|
function firstSentence(text2) {
|
|
334
339
|
const sentence = text2.trim().split(/(?<=[.!?])\s|\n/)[0] ?? text2;
|
|
@@ -422,7 +427,7 @@ function preflight(facts) {
|
|
|
422
427
|
name: "posture",
|
|
423
428
|
ok: true,
|
|
424
429
|
blocking: false,
|
|
425
|
-
detail: "read-only: this navigates and photographs, and activates no control"
|
|
430
|
+
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
431
|
});
|
|
427
432
|
if (facts.rebaselineReason) {
|
|
428
433
|
checks.push({
|
|
@@ -440,19 +445,19 @@ function preflight(facts) {
|
|
|
440
445
|
];
|
|
441
446
|
return { ok: !blocked.length, checks, lines };
|
|
442
447
|
}
|
|
443
|
-
function attestation(
|
|
444
|
-
const took =
|
|
448
|
+
function attestation(run2) {
|
|
449
|
+
const took = run2.unchanged ? `${run2.captured} new frame(s) and ${run2.unchanged} verified unchanged` : `${run2.captured} frame(s)`;
|
|
445
450
|
return [
|
|
446
|
-
`${took} from ${
|
|
451
|
+
`${took} from ${run2.baseUrl}${run2.browser ? ` with ${run2.browser}` : ""}.`,
|
|
447
452
|
"Every one was checked before the shutter and again on arrival: no frame in this set showed an email address,",
|
|
448
453
|
"an account or card number, a national or tax id, a date of birth, a passport number or a named institution.",
|
|
449
|
-
|
|
454
|
+
run2.refused ? `${run2.refused} screen(s) were refused for showing something that looked real, and no picture of them exists.` : "Nothing was refused.",
|
|
450
455
|
// Two different claims, and the second is much stronger. "Nothing that
|
|
451
456
|
// looked real got through" is a gate doing its job on unknown data; "there
|
|
452
457
|
// was no real data behind any of it" is a fact about where the data came
|
|
453
458
|
// from, and it is the one a compliance reader actually wants.
|
|
454
|
-
|
|
455
|
-
|
|
459
|
+
run2.replaying ? `Every screen was served from ${run2.replaying}, so no customer data was reachable during this run at all.` : "",
|
|
460
|
+
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
461
|
].filter(Boolean).join("\n");
|
|
457
462
|
}
|
|
458
463
|
|
|
@@ -473,24 +478,24 @@ function runIdentityFromEnv(env) {
|
|
|
473
478
|
}
|
|
474
479
|
|
|
475
480
|
// 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 (
|
|
481
|
+
function runCounts(run2) {
|
|
482
|
+
return `${run2.captured} photographed, ${run2.unchanged} unchanged, ${run2.skipped} skipped, ${run2.refused} refused, ${run2.failed} failed`;
|
|
483
|
+
}
|
|
484
|
+
function runVerdict(run2) {
|
|
485
|
+
const delivered = run2.captured + run2.unchanged;
|
|
486
|
+
const missed = run2.skipped + run2.refused + run2.failed;
|
|
487
|
+
const counts = runCounts(run2);
|
|
488
|
+
if (run2.screens > 0 && delivered === 0) {
|
|
484
489
|
return {
|
|
485
490
|
exit: 1,
|
|
486
|
-
annotation: `::error title=No screenshots were delivered::None of the ${
|
|
491
|
+
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
492
|
sentence: `Nothing was delivered, so this run fails rather than passing with no screenshots: ${counts}.`
|
|
488
493
|
};
|
|
489
494
|
}
|
|
490
495
|
if (missed > 0) {
|
|
491
496
|
return {
|
|
492
|
-
exit:
|
|
493
|
-
annotation: `::warning title=${missed} of ${
|
|
497
|
+
exit: run2.failed > 0 ? 1 : 0,
|
|
498
|
+
annotation: `::warning title=${missed} of ${run2.screens} screens were not photographed::${counts}. The step log says why for each one.`
|
|
494
499
|
};
|
|
495
500
|
}
|
|
496
501
|
return { exit: 0 };
|
|
@@ -777,7 +782,9 @@ function looksLikeSignIn(fields) {
|
|
|
777
782
|
import { deflateSync, inflateSync } from "node:zlib";
|
|
778
783
|
var MediaRefused = class extends Error {
|
|
779
784
|
};
|
|
785
|
+
var VERIFICATION_SENTINEL = { r: 255, g: 0, b: 255 };
|
|
780
786
|
var PUBLICATION_REDACTION = { r: 100, g: 116, b: 139 };
|
|
787
|
+
var cssColour = (colour) => `#${[colour.r, colour.g, colour.b].map((v) => v.toString(16).padStart(2, "0")).join("")}`;
|
|
781
788
|
function decodePng(buffer) {
|
|
782
789
|
if (buffer.length < 8 || buffer.readUInt32BE(0) !== 2303741511) {
|
|
783
790
|
throw new MediaRefused("that is not a PNG");
|
|
@@ -856,6 +863,24 @@ function coveredFraction(image) {
|
|
|
856
863
|
return covered / total;
|
|
857
864
|
}
|
|
858
865
|
var MOSTLY_COVERED = 0.5;
|
|
866
|
+
function readSentinel(image) {
|
|
867
|
+
let pixels = 0;
|
|
868
|
+
let at;
|
|
869
|
+
for (let y = 0; y < image.height; y += 1) {
|
|
870
|
+
for (let x = 0; x < image.width; x += 1) {
|
|
871
|
+
const index = (y * image.width + x) * 4;
|
|
872
|
+
if (image.pixels[index] === VERIFICATION_SENTINEL.r && image.pixels[index + 1] === VERIFICATION_SENTINEL.g && image.pixels[index + 2] === VERIFICATION_SENTINEL.b) {
|
|
873
|
+
pixels += 1;
|
|
874
|
+
at ??= { x, y };
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
return {
|
|
879
|
+
pixels,
|
|
880
|
+
...at ? { at } : {},
|
|
881
|
+
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})`
|
|
882
|
+
};
|
|
883
|
+
}
|
|
859
884
|
|
|
860
885
|
// browser/page-side.ts
|
|
861
886
|
function inPage(fn, arg) {
|
|
@@ -1029,6 +1054,671 @@ async function readPaintedMarkup(page, regions) {
|
|
|
1029
1054
|
);
|
|
1030
1055
|
}
|
|
1031
1056
|
|
|
1057
|
+
// browser/layout.ts
|
|
1058
|
+
async function readPageLayout(page, cover = []) {
|
|
1059
|
+
return page.evaluate(
|
|
1060
|
+
inPage(
|
|
1061
|
+
(spec) => {
|
|
1062
|
+
const CONTROLS = [
|
|
1063
|
+
"button",
|
|
1064
|
+
"link",
|
|
1065
|
+
"textbox",
|
|
1066
|
+
"searchbox",
|
|
1067
|
+
"checkbox",
|
|
1068
|
+
"radio",
|
|
1069
|
+
"combobox",
|
|
1070
|
+
"listbox",
|
|
1071
|
+
"switch",
|
|
1072
|
+
"tab",
|
|
1073
|
+
"menuitem",
|
|
1074
|
+
"menuitemcheckbox",
|
|
1075
|
+
"menuitemradio",
|
|
1076
|
+
"option",
|
|
1077
|
+
"slider",
|
|
1078
|
+
"spinbutton"
|
|
1079
|
+
];
|
|
1080
|
+
const LANDMARKS = ["banner", "navigation", "main", "complementary", "contentinfo", "search", "form", "region", "dialog", "alertdialog"];
|
|
1081
|
+
const HEADINGS = "h1, h2, h3, h4, h5, h6, [role=heading]";
|
|
1082
|
+
const clean = (text2) => (text2 ?? "").replace(/\s+/g, " ").trim();
|
|
1083
|
+
const painted = (element) => {
|
|
1084
|
+
const style = window.getComputedStyle(element);
|
|
1085
|
+
if (style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse" || style.opacity === "0") return false;
|
|
1086
|
+
for (const box of Array.from(element.getClientRects())) {
|
|
1087
|
+
if (box.width > 0 && box.height > 0) return true;
|
|
1088
|
+
}
|
|
1089
|
+
return false;
|
|
1090
|
+
};
|
|
1091
|
+
const unseen = (element) => {
|
|
1092
|
+
if (element.closest('[aria-hidden="true"], [inert]')) return true;
|
|
1093
|
+
for (let node = element; node; node = node.parentElement) {
|
|
1094
|
+
const style = window.getComputedStyle(node);
|
|
1095
|
+
if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") return true;
|
|
1096
|
+
}
|
|
1097
|
+
return !painted(element);
|
|
1098
|
+
};
|
|
1099
|
+
const covered = (element) => spec.cover.some((selector) => {
|
|
1100
|
+
try {
|
|
1101
|
+
return element.closest(selector) !== null;
|
|
1102
|
+
} catch {
|
|
1103
|
+
return false;
|
|
1104
|
+
}
|
|
1105
|
+
});
|
|
1106
|
+
const textOfIds = (ids) => clean(
|
|
1107
|
+
ids.split(/\s+/).map((id) => {
|
|
1108
|
+
const target = document.getElementById(id);
|
|
1109
|
+
return target ? target.innerText ?? target.textContent ?? "" : "";
|
|
1110
|
+
}).join(" ")
|
|
1111
|
+
);
|
|
1112
|
+
const labelOf = (element) => {
|
|
1113
|
+
const by = element.getAttribute("aria-labelledby");
|
|
1114
|
+
const fromIds = by ? textOfIds(by) : "";
|
|
1115
|
+
return fromIds || clean(element.getAttribute("aria-label")) || clean(element.getAttribute("title"));
|
|
1116
|
+
};
|
|
1117
|
+
const scoped = (element) => Boolean(element.parentElement?.closest("article, aside, main, nav, section"));
|
|
1118
|
+
const roleOf = (element) => {
|
|
1119
|
+
const explicit = clean(element.getAttribute("role")).split(" ")[0];
|
|
1120
|
+
if (explicit) return explicit;
|
|
1121
|
+
const tag = element.tagName.toLowerCase();
|
|
1122
|
+
if (tag === "a") return element.hasAttribute("href") ? "link" : null;
|
|
1123
|
+
if (tag === "button" || tag === "summary") return "button";
|
|
1124
|
+
if (tag === "textarea") return "textbox";
|
|
1125
|
+
if (tag === "select") return element.multiple || element.size > 1 ? "listbox" : "combobox";
|
|
1126
|
+
if (tag === "input") {
|
|
1127
|
+
const type = clean(element.getAttribute("type")).toLowerCase() || "text";
|
|
1128
|
+
if (["button", "submit", "reset", "image"].includes(type)) return "button";
|
|
1129
|
+
if (type === "checkbox") return "checkbox";
|
|
1130
|
+
if (type === "radio") return "radio";
|
|
1131
|
+
if (type === "range") return "slider";
|
|
1132
|
+
if (type === "number") return "spinbutton";
|
|
1133
|
+
if (type === "search") return "searchbox";
|
|
1134
|
+
if (type === "hidden" || type === "file" || type === "color") return null;
|
|
1135
|
+
return "textbox";
|
|
1136
|
+
}
|
|
1137
|
+
if (element.getAttribute("contenteditable") === "true" || element.getAttribute("contenteditable") === "") return "textbox";
|
|
1138
|
+
if (tag === "nav") return "navigation";
|
|
1139
|
+
if (tag === "main") return "main";
|
|
1140
|
+
if (tag === "aside") return "complementary";
|
|
1141
|
+
if (tag === "search") return "search";
|
|
1142
|
+
if (tag === "header") return scoped(element) ? null : "banner";
|
|
1143
|
+
if (tag === "footer") return scoped(element) ? null : "contentinfo";
|
|
1144
|
+
if (tag === "form") return labelOf(element) ? "form" : null;
|
|
1145
|
+
if (tag === "section") return labelOf(element) ? "region" : null;
|
|
1146
|
+
if (tag === "dialog") return element.open ? "dialog" : null;
|
|
1147
|
+
if (/^h[1-6]$/.test(tag)) return "heading";
|
|
1148
|
+
return null;
|
|
1149
|
+
};
|
|
1150
|
+
const contentOf = (element) => {
|
|
1151
|
+
const text2 = clean(element.innerText);
|
|
1152
|
+
if (text2) return text2;
|
|
1153
|
+
const alternatives = Array.from(element.querySelectorAll("img[alt], [aria-label], svg title")).map(
|
|
1154
|
+
(node) => node.tagName.toLowerCase() === "img" ? node.getAttribute("alt") : node.tagName.toLowerCase() === "title" ? node.textContent : node.getAttribute("aria-label")
|
|
1155
|
+
);
|
|
1156
|
+
return clean(alternatives.filter(Boolean).join(" "));
|
|
1157
|
+
};
|
|
1158
|
+
const nameOf = (element) => {
|
|
1159
|
+
const labelled = labelOf(element);
|
|
1160
|
+
if (labelled && (element.getAttribute("aria-labelledby") || element.getAttribute("aria-label"))) return labelled;
|
|
1161
|
+
const tag = element.tagName.toLowerCase();
|
|
1162
|
+
if (tag === "input" || tag === "textarea" || tag === "select") {
|
|
1163
|
+
const field = element;
|
|
1164
|
+
const type = clean(field.getAttribute("type")).toLowerCase();
|
|
1165
|
+
if (type === "submit" || type === "reset" || type === "button") return clean(field.value) || (type === "reset" ? "Reset" : type === "submit" ? "Submit" : "");
|
|
1166
|
+
if (type === "image") return clean(field.getAttribute("alt")) || "Submit";
|
|
1167
|
+
const labels = Array.from(field.labels ?? []).map((label) => clean(label.innerText));
|
|
1168
|
+
return clean(labels.filter(Boolean).join(" ")) || clean(field.getAttribute("placeholder")) || clean(field.getAttribute("title"));
|
|
1169
|
+
}
|
|
1170
|
+
return contentOf(element) || clean(element.getAttribute("title"));
|
|
1171
|
+
};
|
|
1172
|
+
const stateOf = (element, role) => {
|
|
1173
|
+
const state = {};
|
|
1174
|
+
const aria = (name) => element.getAttribute(`aria-${name}`);
|
|
1175
|
+
if (element.disabled === true || aria("disabled") === "true") state.disabled = true;
|
|
1176
|
+
if (["checkbox", "radio", "switch", "menuitemcheckbox", "menuitemradio"].includes(role)) {
|
|
1177
|
+
state.checked = element.tagName.toLowerCase() === "input" ? element.checked : aria("checked") === "true";
|
|
1178
|
+
}
|
|
1179
|
+
if (aria("pressed") !== null) state.pressed = aria("pressed") === "true";
|
|
1180
|
+
if (aria("expanded") !== null) state.expanded = aria("expanded") === "true";
|
|
1181
|
+
if (aria("selected") === "true") state.selected = true;
|
|
1182
|
+
if (aria("current") !== null && aria("current") !== "false") state.current = true;
|
|
1183
|
+
return state;
|
|
1184
|
+
};
|
|
1185
|
+
const boxOf = (element) => {
|
|
1186
|
+
const rect = element.getBoundingClientRect();
|
|
1187
|
+
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
|
1188
|
+
};
|
|
1189
|
+
const levelOf = (heading) => Number(heading.getAttribute("aria-level")) || Number(heading.tagName.slice(1)) || 2;
|
|
1190
|
+
const sectionOf = (heading) => {
|
|
1191
|
+
const level = levelOf(heading);
|
|
1192
|
+
const rival = (node) => node !== heading && node.matches(HEADINGS) && levelOf(node) <= level && !unseen(node);
|
|
1193
|
+
const holdsRival = (node) => rival(node) || Array.from(node.querySelectorAll(HEADINGS)).some(rival);
|
|
1194
|
+
let top = heading;
|
|
1195
|
+
let open = false;
|
|
1196
|
+
for (let node = heading.parentElement; node && node !== document.body; node = node.parentElement) {
|
|
1197
|
+
if (holdsRival(node)) {
|
|
1198
|
+
open = true;
|
|
1199
|
+
break;
|
|
1200
|
+
}
|
|
1201
|
+
top = node;
|
|
1202
|
+
if (LANDMARKS.includes(roleOf(node) ?? "")) break;
|
|
1203
|
+
}
|
|
1204
|
+
if (covered(top)) return null;
|
|
1205
|
+
const run2 = [top];
|
|
1206
|
+
for (let next = open ? top.nextElementSibling : null; next && !holdsRival(next); next = next.nextElementSibling) {
|
|
1207
|
+
if (!unseen(next) && !covered(next)) run2.push(next);
|
|
1208
|
+
}
|
|
1209
|
+
const boxes = run2.map(boxOf).filter((box) => box.width > 0 && box.height > 0);
|
|
1210
|
+
if (!boxes.length) return null;
|
|
1211
|
+
const x = Math.min(...boxes.map((box) => box.x));
|
|
1212
|
+
const y = Math.min(...boxes.map((box) => box.y));
|
|
1213
|
+
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 };
|
|
1214
|
+
};
|
|
1215
|
+
const transparent = (colour) => colour === "transparent" || /rgba\([^)]*,\s*0\)$/.test(colour);
|
|
1216
|
+
const backdropOf = (element) => {
|
|
1217
|
+
for (let node = element; node; node = node.parentElement) {
|
|
1218
|
+
const colour = window.getComputedStyle(node).backgroundColor;
|
|
1219
|
+
if (!transparent(colour)) return colour;
|
|
1220
|
+
}
|
|
1221
|
+
return "rgb(255, 255, 255)";
|
|
1222
|
+
};
|
|
1223
|
+
const setApart = (element) => {
|
|
1224
|
+
const style = window.getComputedStyle(element);
|
|
1225
|
+
const bordered = ["Top", "Right", "Bottom", "Left"].filter(
|
|
1226
|
+
(side) => parseFloat(style.getPropertyValue(`border-${side.toLowerCase()}-width`)) > 0 && !transparent(style.getPropertyValue(`border-${side.toLowerCase()}-color`))
|
|
1227
|
+
).length;
|
|
1228
|
+
if (bordered >= 3 || style.boxShadow && style.boxShadow !== "none") return true;
|
|
1229
|
+
return !transparent(style.backgroundColor) && style.backgroundColor !== backdropOf(element.parentElement);
|
|
1230
|
+
};
|
|
1231
|
+
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]";
|
|
1232
|
+
const boxedIn = (node, panel, frame) => {
|
|
1233
|
+
for (let around = node; around && around !== panel; around = around.parentElement) {
|
|
1234
|
+
if (!setApart(around)) continue;
|
|
1235
|
+
const rect = around.getBoundingClientRect();
|
|
1236
|
+
return !(rect.top - frame.y <= 2 && rect.width >= frame.width * 0.9);
|
|
1237
|
+
}
|
|
1238
|
+
return false;
|
|
1239
|
+
};
|
|
1240
|
+
const wordsOf = (node) => {
|
|
1241
|
+
const size = window.getComputedStyle(node).fontSize;
|
|
1242
|
+
const words = Array.from(node.childNodes).map((child) => {
|
|
1243
|
+
if (child.nodeType === Node.TEXT_NODE) return child.textContent ?? "";
|
|
1244
|
+
if (!(child instanceof Element) || child.matches(NOT_TITLE) || child.getAttribute("aria-hidden") === "true" || !painted(child)) return "";
|
|
1245
|
+
const style = window.getComputedStyle(child);
|
|
1246
|
+
return style.display.startsWith("inline") && style.fontSize === size ? wordsOf(child) : "";
|
|
1247
|
+
});
|
|
1248
|
+
return clean(words.join(""));
|
|
1249
|
+
};
|
|
1250
|
+
const titleOf = (panel, frame) => {
|
|
1251
|
+
for (const node of Array.from(panel.querySelectorAll("*"))) {
|
|
1252
|
+
if (node.closest(NOT_TITLE)) continue;
|
|
1253
|
+
if (!Array.from(node.childNodes).some((child) => child.nodeType === Node.TEXT_NODE && clean(child.textContent))) continue;
|
|
1254
|
+
if (node.getBoundingClientRect().top - frame.y > 64) break;
|
|
1255
|
+
if (Number(window.getComputedStyle(node).fontWeight) < 600 || boxedIn(node, panel, frame)) continue;
|
|
1256
|
+
const words = wordsOf(node);
|
|
1257
|
+
if (new RegExp("\\p{L}", "u").test(words)) return words;
|
|
1258
|
+
}
|
|
1259
|
+
return "";
|
|
1260
|
+
};
|
|
1261
|
+
const regions = [];
|
|
1262
|
+
const controls = [];
|
|
1263
|
+
const viewportArea = window.innerWidth * window.innerHeight;
|
|
1264
|
+
const PANELS = ["DIV", "SECTION", "ARTICLE", "ASIDE", "LI", "FORM", "FIELDSET", "DETAILS"];
|
|
1265
|
+
for (const element of Array.from(document.body?.querySelectorAll("*") ?? [])) {
|
|
1266
|
+
const role = roleOf(element);
|
|
1267
|
+
if (!role) {
|
|
1268
|
+
if (!PANELS.includes(element.tagName)) continue;
|
|
1269
|
+
const box2 = boxOf(element);
|
|
1270
|
+
if (box2.width < 160 || box2.height < 48 || box2.width * box2.height > viewportArea * 0.6) continue;
|
|
1271
|
+
if (unseen(element) || covered(element) || !setApart(element)) continue;
|
|
1272
|
+
const title = titleOf(element, box2);
|
|
1273
|
+
if (title) regions.push({ kind: "section", role: "region", label: title, box: box2 });
|
|
1274
|
+
continue;
|
|
1275
|
+
}
|
|
1276
|
+
if (unseen(element) || covered(element)) continue;
|
|
1277
|
+
const box = boxOf(element);
|
|
1278
|
+
if (box.width < 2 || box.height < 2) continue;
|
|
1279
|
+
if (CONTROLS.includes(role)) {
|
|
1280
|
+
const href = role === "link" ? element.href || element.getAttribute("href") : null;
|
|
1281
|
+
controls.push({ role, name: nameOf(element), box, state: stateOf(element, role), ...href ? { href } : {} });
|
|
1282
|
+
} else if (LANDMARKS.includes(role)) {
|
|
1283
|
+
regions.push({ kind: role === "dialog" || role === "alertdialog" ? "dialog" : "landmark", role, label: labelOf(element), box });
|
|
1284
|
+
} else if (role === "heading") {
|
|
1285
|
+
const section = sectionOf(element);
|
|
1286
|
+
if (section) regions.push({ kind: "section", role: "region", label: clean(element.innerText) || labelOf(element), box: section });
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
return { viewport: { width: window.innerWidth, height: window.innerHeight }, scale: window.devicePixelRatio, title: clean(document.title), regions, controls };
|
|
1290
|
+
},
|
|
1291
|
+
{ cover: [...cover] }
|
|
1292
|
+
)
|
|
1293
|
+
);
|
|
1294
|
+
}
|
|
1295
|
+
async function phoneFit(page, width) {
|
|
1296
|
+
return page.evaluate(
|
|
1297
|
+
inPage(
|
|
1298
|
+
(phone) => {
|
|
1299
|
+
const root = document.documentElement;
|
|
1300
|
+
const laid = root.clientWidth;
|
|
1301
|
+
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` };
|
|
1302
|
+
const content = Math.max(root.scrollWidth, document.body?.scrollWidth ?? 0);
|
|
1303
|
+
if (content > laid + 1) return { fits: false, because: `it runs ${Math.round(content - laid)}px off the side` };
|
|
1304
|
+
const main2 = document.querySelector("main, [role=main]");
|
|
1305
|
+
const column = main2 ? main2.getBoundingClientRect().width : laid;
|
|
1306
|
+
if (column < laid * 0.75) return { fits: false, because: `its main column is ${Math.round(column)}px of ${laid}px, beside the rest of the layout` };
|
|
1307
|
+
const overflow = main2 ? main2.scrollWidth - main2.clientWidth : 0;
|
|
1308
|
+
return overflow > 1 ? { fits: false, because: `its content runs ${Math.round(overflow)}px past its main column` } : { fits: true };
|
|
1309
|
+
},
|
|
1310
|
+
{ width }
|
|
1311
|
+
)
|
|
1312
|
+
);
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
// browser/clip-cursor.ts
|
|
1316
|
+
async function drawCursor(page, at, pressed = false) {
|
|
1317
|
+
await page.evaluate(
|
|
1318
|
+
inPage(
|
|
1319
|
+
(spec) => {
|
|
1320
|
+
const id = "helpdesk-clip-cursor";
|
|
1321
|
+
let cursor = document.getElementById(id);
|
|
1322
|
+
if (!cursor) {
|
|
1323
|
+
cursor = document.createElement("div");
|
|
1324
|
+
cursor.id = id;
|
|
1325
|
+
cursor.setAttribute("aria-hidden", "true");
|
|
1326
|
+
cursor.style.cssText = "position:fixed;left:0;top:0;width:28px;height:28px;pointer-events:none;z-index:2147483647;transition:none;animation:none;";
|
|
1327
|
+
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>';
|
|
1328
|
+
document.documentElement.appendChild(cursor);
|
|
1329
|
+
}
|
|
1330
|
+
cursor.style.transform = `translate(${spec.x}px, ${spec.y}px)`;
|
|
1331
|
+
const ring = cursor.querySelector("[data-press]");
|
|
1332
|
+
if (ring) ring.style.display = spec.pressed ? "" : "none";
|
|
1333
|
+
},
|
|
1334
|
+
{ x: at.x, y: at.y, pressed }
|
|
1335
|
+
)
|
|
1336
|
+
);
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
// core/capture/clips.ts
|
|
1340
|
+
import { createHash } from "node:crypto";
|
|
1341
|
+
|
|
1342
|
+
// core/capture/layout-manifest.ts
|
|
1343
|
+
var LAYOUT_MANIFEST_SCHEMA = "helpdesk.layout-manifest/1";
|
|
1344
|
+
var PHONE_SUFFIX = "--phone";
|
|
1345
|
+
var PHONE_VIEWPORT = { width: 390, height: 844 };
|
|
1346
|
+
var PHONE_VIEWPORTS = [PHONE_VIEWPORT, { width: 430, height: 932 }];
|
|
1347
|
+
function phoneViewport(width) {
|
|
1348
|
+
return PHONE_VIEWPORTS.find((viewport) => viewport.width === width) ?? PHONE_VIEWPORT;
|
|
1349
|
+
}
|
|
1350
|
+
var CONTROL_ROLES = [
|
|
1351
|
+
"button",
|
|
1352
|
+
"link",
|
|
1353
|
+
"textbox",
|
|
1354
|
+
"searchbox",
|
|
1355
|
+
"checkbox",
|
|
1356
|
+
"radio",
|
|
1357
|
+
"combobox",
|
|
1358
|
+
"listbox",
|
|
1359
|
+
"switch",
|
|
1360
|
+
"tab",
|
|
1361
|
+
"menuitem",
|
|
1362
|
+
"menuitemcheckbox",
|
|
1363
|
+
"menuitemradio",
|
|
1364
|
+
"option",
|
|
1365
|
+
"slider",
|
|
1366
|
+
"spinbutton"
|
|
1367
|
+
];
|
|
1368
|
+
var REGION_ROLES = ["banner", "navigation", "main", "complementary", "contentinfo", "search", "form", "region", "dialog", "alertdialog"];
|
|
1369
|
+
var MAX_CONTROLS = 600;
|
|
1370
|
+
var MAX_REGIONS = 200;
|
|
1371
|
+
var MAX_TEXT = 200;
|
|
1372
|
+
var MAX_ID_SLUG = 48;
|
|
1373
|
+
function isRole(roles, value) {
|
|
1374
|
+
return typeof value === "string" && roles.includes(value);
|
|
1375
|
+
}
|
|
1376
|
+
function slugOf(text2) {
|
|
1377
|
+
return text2.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, MAX_ID_SLUG).replace(/-+$/g, "");
|
|
1378
|
+
}
|
|
1379
|
+
function identityOf(name) {
|
|
1380
|
+
return slugOf(name.replace(/(?:\s+\d[\d\s/.,:%]*)+$/, "")) || slugOf(name);
|
|
1381
|
+
}
|
|
1382
|
+
function textOf(value) {
|
|
1383
|
+
return typeof value === "string" ? value.replace(/\s+/g, " ").trim().slice(0, MAX_TEXT) : "";
|
|
1384
|
+
}
|
|
1385
|
+
function recordOf(value) {
|
|
1386
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
|
|
1387
|
+
}
|
|
1388
|
+
function readState(value) {
|
|
1389
|
+
const given = recordOf(value);
|
|
1390
|
+
const state = {};
|
|
1391
|
+
if (given["disabled"] === true) state.disabled = true;
|
|
1392
|
+
if (given["selected"] === true) state.selected = true;
|
|
1393
|
+
if (given["current"] === true) state.current = true;
|
|
1394
|
+
if (typeof given["checked"] === "boolean") state.checked = given["checked"];
|
|
1395
|
+
if (typeof given["pressed"] === "boolean") state.pressed = given["pressed"];
|
|
1396
|
+
if (typeof given["expanded"] === "boolean") state.expanded = given["expanded"];
|
|
1397
|
+
return Object.keys(state).length ? state : void 0;
|
|
1398
|
+
}
|
|
1399
|
+
function placed(box, viewport, scale) {
|
|
1400
|
+
if (![box.x, box.y, box.width, box.height].every(Number.isFinite)) return null;
|
|
1401
|
+
const snap = (value) => Math.round(value * scale) / scale;
|
|
1402
|
+
const left = Math.max(0, box.x);
|
|
1403
|
+
const top = Math.max(0, box.y);
|
|
1404
|
+
const right = Math.min(viewport.width, box.x + box.width);
|
|
1405
|
+
const bottom = Math.min(viewport.height, box.y + box.height);
|
|
1406
|
+
if (right - left < 1 || bottom - top < 1) return null;
|
|
1407
|
+
const x = snap(left);
|
|
1408
|
+
const y = snap(top);
|
|
1409
|
+
return {
|
|
1410
|
+
box: { x, y, width: snap(right) - x, height: snap(bottom) - y },
|
|
1411
|
+
clipped: left !== box.x || top !== box.y || right !== box.x + box.width || bottom !== box.y + box.height
|
|
1412
|
+
};
|
|
1413
|
+
}
|
|
1414
|
+
function contains(outer, inner) {
|
|
1415
|
+
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;
|
|
1416
|
+
}
|
|
1417
|
+
function sameBox(left, right) {
|
|
1418
|
+
return left.x === right.x && left.y === right.y && left.width === right.width && left.height === right.height;
|
|
1419
|
+
}
|
|
1420
|
+
function identify(items, base) {
|
|
1421
|
+
const taken = /* @__PURE__ */ new Set();
|
|
1422
|
+
return items.map((item) => {
|
|
1423
|
+
const root = base(item);
|
|
1424
|
+
let id = root;
|
|
1425
|
+
for (let ordinal = 2; taken.has(id); ordinal += 1) id = `${root}-${ordinal}`;
|
|
1426
|
+
taken.add(id);
|
|
1427
|
+
return id;
|
|
1428
|
+
});
|
|
1429
|
+
}
|
|
1430
|
+
function smallestAround(regions, box, except) {
|
|
1431
|
+
let best;
|
|
1432
|
+
for (const region of regions) {
|
|
1433
|
+
if (region.id === except || !contains(region.box, box)) continue;
|
|
1434
|
+
const size = region.box.width * region.box.height;
|
|
1435
|
+
if (!best || size < best.size) best = { id: region.id, size };
|
|
1436
|
+
}
|
|
1437
|
+
return best?.id;
|
|
1438
|
+
}
|
|
1439
|
+
function ownPath(href, origin) {
|
|
1440
|
+
if (typeof href !== "string" || !href) return void 0;
|
|
1441
|
+
try {
|
|
1442
|
+
const base = origin ?? "http://localhost";
|
|
1443
|
+
const url = new URL(href, base);
|
|
1444
|
+
if (url.origin !== new URL(base).origin) return void 0;
|
|
1445
|
+
return `${url.pathname}${url.search}`.slice(0, 2048);
|
|
1446
|
+
} catch {
|
|
1447
|
+
return void 0;
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
function buildLayoutManifest(layout, frame) {
|
|
1451
|
+
const { viewport, scale } = layout;
|
|
1452
|
+
const regions = [];
|
|
1453
|
+
for (const raw of layout.regions.slice(0, MAX_REGIONS)) {
|
|
1454
|
+
if (!isRole(REGION_ROLES, raw.role)) continue;
|
|
1455
|
+
const where = placed(raw.box, viewport, scale);
|
|
1456
|
+
if (!where) continue;
|
|
1457
|
+
if (raw.kind === "section" && regions.some((region) => sameBox(region.box, where.box))) continue;
|
|
1458
|
+
regions.push({ kind: raw.kind, role: raw.role, label: textOf(raw.label), box: where.box, clipped: where.clipped });
|
|
1459
|
+
}
|
|
1460
|
+
const controls = [];
|
|
1461
|
+
for (const raw of layout.controls.slice(0, MAX_CONTROLS)) {
|
|
1462
|
+
if (!isRole(CONTROL_ROLES, raw.role)) continue;
|
|
1463
|
+
const where = placed(raw.box, viewport, scale);
|
|
1464
|
+
if (!where) continue;
|
|
1465
|
+
const href = raw.role === "link" ? ownPath(raw.href, frame.origin) : void 0;
|
|
1466
|
+
const state = readState(raw.state);
|
|
1467
|
+
controls.push({ role: raw.role, name: textOf(raw.name), box: where.box, clipped: where.clipped, ...state ? { state } : {}, ...href ? { href } : {} });
|
|
1468
|
+
}
|
|
1469
|
+
return {
|
|
1470
|
+
schema: LAYOUT_MANIFEST_SCHEMA,
|
|
1471
|
+
screen: {
|
|
1472
|
+
shotId: frame.shotId,
|
|
1473
|
+
route: frame.route,
|
|
1474
|
+
path: frame.path,
|
|
1475
|
+
title: textOf(layout.title),
|
|
1476
|
+
variant: frame.variant,
|
|
1477
|
+
viewport: { width: viewport.width, height: viewport.height },
|
|
1478
|
+
scale,
|
|
1479
|
+
image: { width: frame.image.width, height: frame.image.height }
|
|
1480
|
+
},
|
|
1481
|
+
...assemble(regions, controls)
|
|
1482
|
+
};
|
|
1483
|
+
}
|
|
1484
|
+
function assemble(regions, controls) {
|
|
1485
|
+
const regionIds = identify(regions, (region) => region.label ? `${region.role}:${identityOf(region.label) || "unnamed"}` : region.role);
|
|
1486
|
+
const located = regions.map((region, index) => ({ id: regionIds[index], box: region.box }));
|
|
1487
|
+
const controlIds = identify(controls, (control) => `${control.role}:${identityOf(control.name) || "unnamed"}`);
|
|
1488
|
+
return {
|
|
1489
|
+
regions: regions.map((region, index) => {
|
|
1490
|
+
const id = regionIds[index];
|
|
1491
|
+
const parent = smallestAround(located, region.box, id);
|
|
1492
|
+
return {
|
|
1493
|
+
id,
|
|
1494
|
+
kind: region.kind,
|
|
1495
|
+
role: region.role,
|
|
1496
|
+
label: region.label,
|
|
1497
|
+
box: region.box,
|
|
1498
|
+
...parent ? { parent } : {},
|
|
1499
|
+
...region.clipped ? { clipped: true } : {}
|
|
1500
|
+
};
|
|
1501
|
+
}),
|
|
1502
|
+
controls: controls.map((control, index) => {
|
|
1503
|
+
const centre = { x: control.box.x + control.box.width / 2, y: control.box.y + control.box.height / 2, width: 0, height: 0 };
|
|
1504
|
+
const region = smallestAround(located, centre);
|
|
1505
|
+
return {
|
|
1506
|
+
id: controlIds[index],
|
|
1507
|
+
role: control.role,
|
|
1508
|
+
name: control.name,
|
|
1509
|
+
box: control.box,
|
|
1510
|
+
...region ? { region } : {},
|
|
1511
|
+
...control.state ? { state: control.state } : {},
|
|
1512
|
+
...control.href ? { href: control.href } : {},
|
|
1513
|
+
...control.clipped ? { clipped: true } : {}
|
|
1514
|
+
};
|
|
1515
|
+
})
|
|
1516
|
+
};
|
|
1517
|
+
}
|
|
1518
|
+
function layoutManifestText(manifest) {
|
|
1519
|
+
return [manifest.screen.title, ...manifest.regions.map((region) => region.label), ...manifest.controls.map((control) => control.name)].filter(Boolean).join("\n");
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
// core/capture/clips.ts
|
|
1523
|
+
var CLIP_VIEWPORT = { width: 1280, height: 800 };
|
|
1524
|
+
var CLIP_FPS = 10;
|
|
1525
|
+
var CLIP_MAX_MS = 3e4;
|
|
1526
|
+
var CLIP_MAX_STEPS = 12;
|
|
1527
|
+
var CLIP_MAX_MP4_BYTES = 3 * 1024 * 1024;
|
|
1528
|
+
var CLIP_MAX_GIF_BYTES = 8 * 1024 * 1024;
|
|
1529
|
+
var CLIP_GIF_WIDTH = 960;
|
|
1530
|
+
var CLIP_ACTIONS = ["click", "fill", "select", "check"];
|
|
1531
|
+
var MAX_SCRIPTS = 50;
|
|
1532
|
+
var MAX_TEXT2 = 200;
|
|
1533
|
+
var MAX_TITLE = 120;
|
|
1534
|
+
var MAX_ID = 80;
|
|
1535
|
+
function cleanText(value) {
|
|
1536
|
+
return typeof value === "string" ? value.replace(/\s+/g, " ").trim() : "";
|
|
1537
|
+
}
|
|
1538
|
+
function recordOf2(value) {
|
|
1539
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
|
|
1540
|
+
}
|
|
1541
|
+
function isPathLike(value) {
|
|
1542
|
+
return value.startsWith("/") && !value.startsWith("//") && !value.includes("..") && !value.includes("#") && value.length <= 2048;
|
|
1543
|
+
}
|
|
1544
|
+
function readStep(value, at) {
|
|
1545
|
+
const step = recordOf2(value);
|
|
1546
|
+
const route = cleanText(step["route"]);
|
|
1547
|
+
const control = recordOf2(step["control"]);
|
|
1548
|
+
const role = control["role"];
|
|
1549
|
+
const name = cleanText(control["name"]);
|
|
1550
|
+
const action = step["action"];
|
|
1551
|
+
const says = cleanText(step["says"]);
|
|
1552
|
+
const given = step["value"];
|
|
1553
|
+
if (!route || !isPathLike(route)) return `step ${at} names no screen`;
|
|
1554
|
+
if (typeof role !== "string" || !CONTROL_ROLES.includes(role)) return `step ${at} names no control role a screen lists`;
|
|
1555
|
+
if (!name || name.length > MAX_TEXT2) return `step ${at} names no control`;
|
|
1556
|
+
if (typeof action !== "string" || !CLIP_ACTIONS.includes(action)) return `step ${at} says neither click, fill, select nor check`;
|
|
1557
|
+
if (!says || says.length > MAX_TEXT2) return `step ${at} has no sentence for a reader`;
|
|
1558
|
+
const typed = action === "fill" || action === "select";
|
|
1559
|
+
if (typed && (typeof given !== "string" || !given.trim() || given.length > MAX_TEXT2)) return `step ${at} has nothing to ${action}`;
|
|
1560
|
+
if (!typed && given !== void 0) return `step ${at} gives a value to ${action}, which takes none`;
|
|
1561
|
+
return { route, control: { role, name }, action, ...typed ? { value: given } : {}, says };
|
|
1562
|
+
}
|
|
1563
|
+
function readClipScripts(raw) {
|
|
1564
|
+
const scripts = [];
|
|
1565
|
+
const refused = [];
|
|
1566
|
+
if (raw === void 0) return { scripts, refused };
|
|
1567
|
+
if (!Array.isArray(raw)) return { scripts, refused: [{ id: "clips", because: "clips is not a list" }] };
|
|
1568
|
+
for (const value of raw.slice(0, MAX_SCRIPTS)) {
|
|
1569
|
+
const script = recordOf2(value);
|
|
1570
|
+
const id = typeof script["id"] === "string" ? script["id"] : "";
|
|
1571
|
+
const label = id || "a clip";
|
|
1572
|
+
const refuse = (because) => refused.push({ id: label, because });
|
|
1573
|
+
const title = cleanText(script["title"]);
|
|
1574
|
+
const visit = cleanText(script["visit"]);
|
|
1575
|
+
const lands = script["lands"] === void 0 ? void 0 : cleanText(script["lands"]);
|
|
1576
|
+
if (!/^[a-z0-9-]+$/.test(id) || id.length > MAX_ID) {
|
|
1577
|
+
refuse("its id is not lower-case letters, digits and hyphens");
|
|
1578
|
+
continue;
|
|
1579
|
+
}
|
|
1580
|
+
if (scripts.some((kept) => kept.id === id)) {
|
|
1581
|
+
refuse("another clip has the same id");
|
|
1582
|
+
continue;
|
|
1583
|
+
}
|
|
1584
|
+
if (!title || title.length > MAX_TITLE) {
|
|
1585
|
+
refuse("it has no title");
|
|
1586
|
+
continue;
|
|
1587
|
+
}
|
|
1588
|
+
const rawSteps = Array.isArray(script["steps"]) ? script["steps"] : [];
|
|
1589
|
+
if (!rawSteps.length || rawSteps.length > CLIP_MAX_STEPS) {
|
|
1590
|
+
refuse(`it has ${rawSteps.length} steps, and a clip has between 1 and ${CLIP_MAX_STEPS}`);
|
|
1591
|
+
continue;
|
|
1592
|
+
}
|
|
1593
|
+
const steps = [];
|
|
1594
|
+
let problem = "";
|
|
1595
|
+
for (const [index, rawStep] of rawSteps.entries()) {
|
|
1596
|
+
const step = readStep(rawStep, index + 1);
|
|
1597
|
+
if (typeof step === "string") {
|
|
1598
|
+
problem = step;
|
|
1599
|
+
break;
|
|
1600
|
+
}
|
|
1601
|
+
steps.push(step);
|
|
1602
|
+
}
|
|
1603
|
+
if (problem) {
|
|
1604
|
+
refuse(problem);
|
|
1605
|
+
continue;
|
|
1606
|
+
}
|
|
1607
|
+
const first = steps[0];
|
|
1608
|
+
if (!isPathLike(visit) || !fillsPattern(first.route, visit.split("?")[0])) {
|
|
1609
|
+
refuse(`it opens at ${visit || "nowhere"}, which is not ${first.route}`);
|
|
1610
|
+
continue;
|
|
1611
|
+
}
|
|
1612
|
+
if (lands !== void 0 && !isPathLike(lands)) {
|
|
1613
|
+
refuse("it names no screen to land on");
|
|
1614
|
+
continue;
|
|
1615
|
+
}
|
|
1616
|
+
scripts.push({ id, title, visit, steps, ...lands ? { lands } : {} });
|
|
1617
|
+
}
|
|
1618
|
+
return { scripts, refused };
|
|
1619
|
+
}
|
|
1620
|
+
function resolveStep(manifest, step) {
|
|
1621
|
+
const name = cleanText(step.control.name).toLowerCase();
|
|
1622
|
+
const where = manifest.screen.path;
|
|
1623
|
+
const matches = manifest.controls.filter((control) => control.role === step.control.role && control.name.toLowerCase() === name);
|
|
1624
|
+
if (matches.length === 0) return { ok: false, because: `no ${step.control.role} "${step.control.name}" on ${where}` };
|
|
1625
|
+
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` };
|
|
1626
|
+
if (matches[0].state?.disabled) return { ok: false, because: `${step.control.role} "${step.control.name}" on ${where} is disabled` };
|
|
1627
|
+
return { ok: true, control: matches[0] };
|
|
1628
|
+
}
|
|
1629
|
+
function onScreen(route, location2) {
|
|
1630
|
+
return fillsPattern(route, location2.split("?")[0].split("#")[0]);
|
|
1631
|
+
}
|
|
1632
|
+
function clipRoutes(script) {
|
|
1633
|
+
return [.../* @__PURE__ */ new Set([...script.steps.map((step) => step.route), ...script.lands ? [script.lands] : []])];
|
|
1634
|
+
}
|
|
1635
|
+
function clipFingerprint(script, touched) {
|
|
1636
|
+
const canonical = {
|
|
1637
|
+
version: 1,
|
|
1638
|
+
id: script.id,
|
|
1639
|
+
title: script.title,
|
|
1640
|
+
lands: script.lands ?? null,
|
|
1641
|
+
steps: script.steps.map((step) => ({
|
|
1642
|
+
route: step.route,
|
|
1643
|
+
role: step.control.role,
|
|
1644
|
+
name: step.control.name,
|
|
1645
|
+
action: step.action,
|
|
1646
|
+
value: step.value ?? null,
|
|
1647
|
+
says: step.says
|
|
1648
|
+
})),
|
|
1649
|
+
touched: [...touched].map((screen) => [screen.route, screen.shotId, screen.sha256]).sort((left, right) => String(left[0]).localeCompare(String(right[0])))
|
|
1650
|
+
};
|
|
1651
|
+
return createHash("sha256").update(JSON.stringify(canonical)).digest("hex");
|
|
1652
|
+
}
|
|
1653
|
+
function centreOf(box) {
|
|
1654
|
+
return { x: Math.round(box.x + box.width / 2), y: Math.round(box.y + box.height / 2) };
|
|
1655
|
+
}
|
|
1656
|
+
function cursorPath(from, to, frames) {
|
|
1657
|
+
return Array.from({ length: Math.max(1, frames) }, (_, index) => {
|
|
1658
|
+
const t = (index + 1) / Math.max(1, frames);
|
|
1659
|
+
const eased = t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2;
|
|
1660
|
+
return { x: Math.round(from.x + (to.x - from.x) * eased), y: Math.round(from.y + (to.y - from.y) * eased) };
|
|
1661
|
+
});
|
|
1662
|
+
}
|
|
1663
|
+
function framesLength(frames) {
|
|
1664
|
+
return frames.reduce((total, frame) => total + frame.ms, 0);
|
|
1665
|
+
}
|
|
1666
|
+
function concatList(frames) {
|
|
1667
|
+
const quote = (file) => `'${file.replace(/'/g, "'\\''")}'`;
|
|
1668
|
+
const lines = ["ffconcat version 1.0"];
|
|
1669
|
+
for (const frame of frames) lines.push(`file ${quote(frame.file)}`, `duration ${(frame.ms / 1e3).toFixed(3)}`);
|
|
1670
|
+
if (frames.length) lines.push(`file ${quote(frames[frames.length - 1].file)}`);
|
|
1671
|
+
return `${lines.join("\n")}
|
|
1672
|
+
`;
|
|
1673
|
+
}
|
|
1674
|
+
function mp4Arguments(list, out, quality = 28) {
|
|
1675
|
+
return [
|
|
1676
|
+
"-y",
|
|
1677
|
+
"-loglevel",
|
|
1678
|
+
"error",
|
|
1679
|
+
"-f",
|
|
1680
|
+
"concat",
|
|
1681
|
+
"-safe",
|
|
1682
|
+
"0",
|
|
1683
|
+
"-i",
|
|
1684
|
+
list,
|
|
1685
|
+
"-vf",
|
|
1686
|
+
`fps=${CLIP_FPS},format=yuv420p`,
|
|
1687
|
+
"-c:v",
|
|
1688
|
+
"libx264",
|
|
1689
|
+
"-preset",
|
|
1690
|
+
"slow",
|
|
1691
|
+
"-crf",
|
|
1692
|
+
String(quality),
|
|
1693
|
+
"-threads",
|
|
1694
|
+
"1",
|
|
1695
|
+
"-movflags",
|
|
1696
|
+
"+faststart",
|
|
1697
|
+
"-an",
|
|
1698
|
+
out
|
|
1699
|
+
];
|
|
1700
|
+
}
|
|
1701
|
+
function gifArguments(list, out, options = {}) {
|
|
1702
|
+
const width = options.width ?? CLIP_GIF_WIDTH;
|
|
1703
|
+
const fps = options.fps ?? CLIP_FPS;
|
|
1704
|
+
return [
|
|
1705
|
+
"-y",
|
|
1706
|
+
"-loglevel",
|
|
1707
|
+
"error",
|
|
1708
|
+
"-f",
|
|
1709
|
+
"concat",
|
|
1710
|
+
"-safe",
|
|
1711
|
+
"0",
|
|
1712
|
+
"-i",
|
|
1713
|
+
list,
|
|
1714
|
+
"-vf",
|
|
1715
|
+
`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`,
|
|
1716
|
+
"-threads",
|
|
1717
|
+
"1",
|
|
1718
|
+
out
|
|
1719
|
+
];
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1032
1722
|
// core/coverage/normalize.ts
|
|
1033
1723
|
var CURRENCY = /[$£€¥₹]\s?[\d.,]+(?:\s?[A-Z]{3})?/g;
|
|
1034
1724
|
var DIGITS = /\d+(?:[.,]\d+)*/g;
|
|
@@ -1531,14 +2221,14 @@ function linkTarget(href, context) {
|
|
|
1531
2221
|
}
|
|
1532
2222
|
if (url.protocol !== "http:" && url.protocol !== "https:") return void 0;
|
|
1533
2223
|
if (url.origin !== context.origin) return `${url.origin}${url.pathname}`;
|
|
1534
|
-
const
|
|
1535
|
-
const screen = context.patterns.find((pattern) => fillsPattern(pattern,
|
|
2224
|
+
const path5 = url.pathname;
|
|
2225
|
+
const screen = context.patterns.find((pattern) => fillsPattern(pattern, path5));
|
|
1536
2226
|
if (screen) return screen;
|
|
1537
|
-
return
|
|
2227
|
+
return path5.split("/").map((segment) => RECORD_ID.test(segment) ? "[id]" : segment).join("/");
|
|
1538
2228
|
}
|
|
1539
2229
|
|
|
1540
2230
|
// core/capture/determinism.ts
|
|
1541
|
-
import { createHash } from "node:crypto";
|
|
2231
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
1542
2232
|
function freezeScript(pinnedClock, options = {}) {
|
|
1543
2233
|
const fixed = Date.parse(pinnedClock);
|
|
1544
2234
|
if (Number.isNaN(fixed)) {
|
|
@@ -1587,7 +2277,7 @@ function freezeScript(pinnedClock, options = {}) {
|
|
|
1587
2277
|
})();`;
|
|
1588
2278
|
}
|
|
1589
2279
|
function frameHash(bytes) {
|
|
1590
|
-
return
|
|
2280
|
+
return createHash2("sha256").update(bytes).digest("hex");
|
|
1591
2281
|
}
|
|
1592
2282
|
async function settle(options) {
|
|
1593
2283
|
const wait = options.wait ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
@@ -1905,13 +2595,18 @@ async function runCapture(options) {
|
|
|
1905
2595
|
const evidence = [];
|
|
1906
2596
|
const build = await options.browser.build?.().catch(() => void 0);
|
|
1907
2597
|
for (const [index, shot] of options.shots.entries()) {
|
|
1908
|
-
const
|
|
2598
|
+
const taken = shot.variant === "phone" ? await captureAtPhoneWidths(options, shot, build) : await captureOne(options, shot, build);
|
|
2599
|
+
const outcome = shot.variant ? { ...taken.outcome, variant: shot.variant } : taken.outcome;
|
|
1909
2600
|
outcomes.push(outcome);
|
|
1910
|
-
if (observed) survey.push(observed);
|
|
1911
|
-
if (seen) evidence.push(seen);
|
|
2601
|
+
if (taken.observed && !shot.variant) survey.push(taken.observed);
|
|
2602
|
+
if (taken.seen && !shot.variant) evidence.push(taken.seen);
|
|
1912
2603
|
options.onProgress?.(outcome, index + 1, options.shots.length);
|
|
1913
2604
|
}
|
|
1914
|
-
const
|
|
2605
|
+
const tally = (variant) => {
|
|
2606
|
+
const of = outcomes.filter((outcome) => Boolean(outcome.variant) === variant);
|
|
2607
|
+
const count = (status) => of.filter((outcome) => outcome.status === status).length;
|
|
2608
|
+
return { captured: count("captured"), unchanged: count("unchanged"), skipped: count("skipped"), refused: count("refused"), failed: count("failed") };
|
|
2609
|
+
};
|
|
1915
2610
|
const readings = {};
|
|
1916
2611
|
for (const outcome of outcomes) {
|
|
1917
2612
|
if (outcome.facts && (outcome.status === "captured" || outcome.status === "unchanged")) readings[outcome.route] = outcome.facts;
|
|
@@ -1923,11 +2618,42 @@ async function runCapture(options) {
|
|
|
1923
2618
|
survey,
|
|
1924
2619
|
readings,
|
|
1925
2620
|
...build ? { browser: build } : {},
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
2621
|
+
...tally(false),
|
|
2622
|
+
...outcomes.some((outcome) => outcome.variant) ? { phone: tally(true) } : {}
|
|
2623
|
+
};
|
|
2624
|
+
}
|
|
2625
|
+
async function layoutOf(page, shot, options, cover, size, landed) {
|
|
2626
|
+
if (!page.layout || !size) return void 0;
|
|
2627
|
+
try {
|
|
2628
|
+
const manifest = buildLayoutManifest(await page.layout(cover), {
|
|
2629
|
+
shotId: shot.id,
|
|
2630
|
+
route: shot.route,
|
|
2631
|
+
path: landed,
|
|
2632
|
+
variant: shot.variant ?? "desktop",
|
|
2633
|
+
image: size,
|
|
2634
|
+
origin: new URL(options.baseUrl).origin
|
|
2635
|
+
});
|
|
2636
|
+
const verdict = gateFrame(layoutManifestText(manifest), {
|
|
2637
|
+
route: shot.route,
|
|
2638
|
+
...options.brands ? { brands: options.brands } : {},
|
|
2639
|
+
...options.clearance?.expectedBrands?.length ? { expected: options.clearance.expectedBrands } : {}
|
|
2640
|
+
});
|
|
2641
|
+
return verdict.safe ? manifest : void 0;
|
|
2642
|
+
} catch {
|
|
2643
|
+
return void 0;
|
|
2644
|
+
}
|
|
2645
|
+
}
|
|
2646
|
+
async function captureAtPhoneWidths(options, shot, build) {
|
|
2647
|
+
const tried = [];
|
|
2648
|
+
let taken;
|
|
2649
|
+
for (const { width } of PHONE_VIEWPORTS) {
|
|
2650
|
+
taken = await captureOne(options, { ...shot, phoneWidth: width }, build);
|
|
2651
|
+
if (taken.unfit === void 0) return taken;
|
|
2652
|
+
tried.push(`at ${width}px ${taken.unfit}`);
|
|
2653
|
+
}
|
|
2654
|
+
return {
|
|
2655
|
+
...taken,
|
|
2656
|
+
outcome: { ...taken.outcome, reason: `not laid out for a phone (${tried.join("; ")}), so there is no phone-width frame` }
|
|
1931
2657
|
};
|
|
1932
2658
|
}
|
|
1933
2659
|
async function captureOne(options, shot, build) {
|
|
@@ -1937,7 +2663,7 @@ async function captureOne(options, shot, build) {
|
|
|
1937
2663
|
const visit = shot.visit ?? shot.route;
|
|
1938
2664
|
let page;
|
|
1939
2665
|
try {
|
|
1940
|
-
page = await options.browser.page();
|
|
2666
|
+
page = await options.browser.page(shot.variant ? { variant: shot.variant, ...shot.phoneWidth ? { width: shot.phoneWidth } : {} } : void 0);
|
|
1941
2667
|
await page.goto(new URL(visit, options.baseUrl).toString());
|
|
1942
2668
|
if (await page.isSignIn()) {
|
|
1943
2669
|
return {
|
|
@@ -1990,7 +2716,21 @@ async function captureOne(options, shot, build) {
|
|
|
1990
2716
|
observed
|
|
1991
2717
|
};
|
|
1992
2718
|
}
|
|
1993
|
-
const
|
|
2719
|
+
const fit = shot.variant === "phone" ? await page.phoneFit?.() : void 0;
|
|
2720
|
+
if (fit && !fit.fits) {
|
|
2721
|
+
return {
|
|
2722
|
+
outcome: {
|
|
2723
|
+
shotId: shot.id,
|
|
2724
|
+
route: shot.route,
|
|
2725
|
+
status: "skipped",
|
|
2726
|
+
result: observed.result,
|
|
2727
|
+
reason: `not laid out for a phone at ${phoneViewport(shot.phoneWidth).width}px (${fit.because}), so there is no phone-width frame`
|
|
2728
|
+
},
|
|
2729
|
+
observed,
|
|
2730
|
+
unfit: fit.because
|
|
2731
|
+
};
|
|
2732
|
+
}
|
|
2733
|
+
const evidence = shot.variant ? void 0 : await readEvidence(settledPage, shot.route, options, build, visit);
|
|
1994
2734
|
const reading = options.clearance ? await readScreen(settledPage, options.clearance) : void 0;
|
|
1995
2735
|
if (options.clearance && !reading) {
|
|
1996
2736
|
return {
|
|
@@ -2042,7 +2782,7 @@ async function captureOne(options, shot, build) {
|
|
|
2042
2782
|
};
|
|
2043
2783
|
}
|
|
2044
2784
|
}
|
|
2045
|
-
const facts = await readDetails(settledPage, shot, options);
|
|
2785
|
+
const facts = shot.variant ? void 0 : await readDetails(settledPage, shot, options);
|
|
2046
2786
|
const approved = new Set(options.clearance?.approved ?? []);
|
|
2047
2787
|
const cover = (reading?.surfaces ?? []).filter((surface) => !surface.read && !approved.has(surface.where)).map((surface) => surface.where);
|
|
2048
2788
|
const frame = await settle({
|
|
@@ -2054,14 +2794,15 @@ async function captureOne(options, shot, build) {
|
|
|
2054
2794
|
});
|
|
2055
2795
|
const image = frame.value;
|
|
2056
2796
|
const size = pngSize(image);
|
|
2057
|
-
|
|
2797
|
+
const minimum = shot.variant === "phone" ? phoneViewport(shot.phoneWidth).width * CAPTURE_CONTRACT.stillScale : CAPTURE_CONTRACT.minStillWidth;
|
|
2798
|
+
if (size && size.width < minimum) {
|
|
2058
2799
|
return {
|
|
2059
2800
|
outcome: {
|
|
2060
2801
|
shotId: shot.id,
|
|
2061
2802
|
route: shot.route,
|
|
2062
2803
|
status: "failed",
|
|
2063
2804
|
result: observed.result,
|
|
2064
|
-
reason: `that frame came back ${size.width}px wide; the contract needs at least ${
|
|
2805
|
+
reason: `that frame came back ${size.width}px wide; the contract needs at least ${minimum}px`
|
|
2065
2806
|
},
|
|
2066
2807
|
observed,
|
|
2067
2808
|
// The picture is unusable; the reading of the screen is not.
|
|
@@ -2082,8 +2823,9 @@ async function captureOne(options, shot, build) {
|
|
|
2082
2823
|
...evidence ? { seen: evidence } : {}
|
|
2083
2824
|
};
|
|
2084
2825
|
}
|
|
2826
|
+
const layout = await layoutOf(settledPage, shot, options, cover, size, seen.landedOn ?? visit);
|
|
2085
2827
|
const sha256 = frameHash(image);
|
|
2086
|
-
if (!options.force && shot.sha256 && shot.sha256 === sha256) {
|
|
2828
|
+
if (!options.force && shot.sha256 && shot.sha256 === sha256 && (shot.layout !== false || !layout)) {
|
|
2087
2829
|
return {
|
|
2088
2830
|
outcome: {
|
|
2089
2831
|
shotId: shot.id,
|
|
@@ -2126,7 +2868,8 @@ async function captureOne(options, shot, build) {
|
|
|
2126
2868
|
}
|
|
2127
2869
|
} : {},
|
|
2128
2870
|
...build ? { browser: build } : {},
|
|
2129
|
-
...size ?? {}
|
|
2871
|
+
...size ?? {},
|
|
2872
|
+
...layout ? { layout } : {}
|
|
2130
2873
|
});
|
|
2131
2874
|
return {
|
|
2132
2875
|
outcome: sent.ok ? {
|
|
@@ -2196,40 +2939,48 @@ async function readEvidence(page, route, options, build, visited = route) {
|
|
|
2196
2939
|
channels: { html: Boolean(html), aria: Boolean(aria) }
|
|
2197
2940
|
};
|
|
2198
2941
|
}
|
|
2942
|
+
var BROWSER_ARGS = [
|
|
2943
|
+
"--no-sandbox",
|
|
2944
|
+
"--disable-dev-shm-usage",
|
|
2945
|
+
/**
|
|
2946
|
+
* Software rendering, deliberately, and it is slower.
|
|
2947
|
+
*
|
|
2948
|
+
* Anything drawn through the GPU — canvas, WebGL, most charting
|
|
2949
|
+
* libraries, some CSS filters — rasterises differently on different
|
|
2950
|
+
* drivers and driver versions. Two machines then produce different bytes
|
|
2951
|
+
* for identical input, so a run on a laptop and a run in CI disagree
|
|
2952
|
+
* about a screen that never changed. That failure is invisible until the
|
|
2953
|
+
* day somebody runs it somewhere new, which is the worst time to find it.
|
|
2954
|
+
*/
|
|
2955
|
+
"--disable-gpu",
|
|
2956
|
+
"--disable-software-rasterizer",
|
|
2957
|
+
/**
|
|
2958
|
+
* Font rasterisation, pinned as far as a flag can pin it. Subpixel
|
|
2959
|
+
* antialiasing is tuned per display and hinting varies by platform;
|
|
2960
|
+
* both change every pixel of every glyph without changing a word.
|
|
2961
|
+
*/
|
|
2962
|
+
"--font-render-hinting=none",
|
|
2963
|
+
"--disable-lcd-text",
|
|
2964
|
+
/**
|
|
2965
|
+
* Glyphs on whole pixels. With sub-pixel positioning on, a line of text can
|
|
2966
|
+
* come out at one of two sub-pixel phases from one page load to the next,
|
|
2967
|
+
* with the same font and the same layout, so an unchanged screen gets a
|
|
2968
|
+
* new picture on some runs and not others.
|
|
2969
|
+
*/
|
|
2970
|
+
"--disable-font-subpixel-positioning",
|
|
2971
|
+
"--force-color-profile=srgb",
|
|
2972
|
+
// A scrollbar is an overlay on some platforms and consumes layout width
|
|
2973
|
+
// on others, so leaving it visible makes the page a different width
|
|
2974
|
+
// depending on who took the picture.
|
|
2975
|
+
"--hide-scrollbars"
|
|
2976
|
+
];
|
|
2199
2977
|
async function playwrightBrowser(options = {}) {
|
|
2200
2978
|
const { chromium } = await loadPlaywright();
|
|
2201
2979
|
const executablePath = process.env["CAPTURE_CHROME"] ?? void 0;
|
|
2202
2980
|
const browser = await chromium.launch({
|
|
2203
2981
|
headless: options.headless ?? true,
|
|
2204
2982
|
...executablePath ? { executablePath } : {},
|
|
2205
|
-
args: [
|
|
2206
|
-
"--no-sandbox",
|
|
2207
|
-
"--disable-dev-shm-usage",
|
|
2208
|
-
/**
|
|
2209
|
-
* Software rendering, deliberately, and it is slower.
|
|
2210
|
-
*
|
|
2211
|
-
* Anything drawn through the GPU — canvas, WebGL, most charting
|
|
2212
|
-
* libraries, some CSS filters — rasterises differently on different
|
|
2213
|
-
* drivers and driver versions. Two machines then produce different bytes
|
|
2214
|
-
* for identical input, so a run on a laptop and a run in CI disagree
|
|
2215
|
-
* about a screen that never changed. That failure is invisible until the
|
|
2216
|
-
* day somebody runs it somewhere new, which is the worst time to find it.
|
|
2217
|
-
*/
|
|
2218
|
-
"--disable-gpu",
|
|
2219
|
-
"--disable-software-rasterizer",
|
|
2220
|
-
/**
|
|
2221
|
-
* Font rasterisation, pinned as far as a flag can pin it. Subpixel
|
|
2222
|
-
* antialiasing is tuned per display and hinting varies by platform;
|
|
2223
|
-
* both change every pixel of every glyph without changing a word.
|
|
2224
|
-
*/
|
|
2225
|
-
"--font-render-hinting=none",
|
|
2226
|
-
"--disable-lcd-text",
|
|
2227
|
-
"--force-color-profile=srgb",
|
|
2228
|
-
// A scrollbar is an overlay on some platforms and consumes layout width
|
|
2229
|
-
// on others, so leaving it visible makes the page a different width
|
|
2230
|
-
// depending on who took the picture.
|
|
2231
|
-
"--hide-scrollbars"
|
|
2232
|
-
]
|
|
2983
|
+
args: [...BROWSER_ARGS]
|
|
2233
2984
|
});
|
|
2234
2985
|
const contextOptions = {
|
|
2235
2986
|
viewport: CAPTURE_CONTRACT.viewport,
|
|
@@ -2261,8 +3012,11 @@ async function playwrightBrowser(options = {}) {
|
|
|
2261
3012
|
...options.sessionPath ? { storageState: options.sessionPath } : { storageState: void 0 }
|
|
2262
3013
|
};
|
|
2263
3014
|
return {
|
|
2264
|
-
async page() {
|
|
2265
|
-
const
|
|
3015
|
+
async page(wanted) {
|
|
3016
|
+
const phone = phoneViewport(wanted?.width);
|
|
3017
|
+
const context = await browser.newContext(
|
|
3018
|
+
wanted?.variant === "phone" ? { ...contextOptions, viewport: { ...phone }, isMobile: true, hasTouch: true } : wanted?.variant === "clip" ? { ...contextOptions, viewport: { ...CLIP_VIEWPORT }, deviceScaleFactor: CAPTURE_CONTRACT.clipScale } : contextOptions
|
|
3019
|
+
);
|
|
2266
3020
|
if (!options.liveClock) {
|
|
2267
3021
|
await context.addInitScript(freezeScript(CAPTURE_CONTRACT.pinnedClock));
|
|
2268
3022
|
}
|
|
@@ -2336,6 +3090,36 @@ async function playwrightBrowser(options = {}) {
|
|
|
2336
3090
|
async painted(regions) {
|
|
2337
3091
|
return readPaintedMarkup(page, regions).catch(() => "");
|
|
2338
3092
|
},
|
|
3093
|
+
async layout(cover) {
|
|
3094
|
+
return readPageLayout(page, cover ?? []);
|
|
3095
|
+
},
|
|
3096
|
+
async phoneFit() {
|
|
3097
|
+
return phoneFit(page, phone.width).catch(() => ({ fits: false, because: "the page could not be measured at that width" }));
|
|
3098
|
+
},
|
|
3099
|
+
async location() {
|
|
3100
|
+
const url = new URL(page.url());
|
|
3101
|
+
return `${url.pathname}${url.search}`;
|
|
3102
|
+
},
|
|
3103
|
+
async pointer(action) {
|
|
3104
|
+
if (action.kind === "select") {
|
|
3105
|
+
const handle = await page.evaluateHandle(({ x, y }) => document.elementFromPoint(x, y), action.at);
|
|
3106
|
+
const element = handle.asElement();
|
|
3107
|
+
if (!element) throw new Error(`nothing is under (${action.at.x}, ${action.at.y}) to choose in`);
|
|
3108
|
+
await element.selectOption({ label: action.value ?? "" });
|
|
3109
|
+
return;
|
|
3110
|
+
}
|
|
3111
|
+
await page.mouse.click(action.at.x, action.at.y);
|
|
3112
|
+
if (action.kind === "focus") await page.keyboard.press("ControlOrMeta+A");
|
|
3113
|
+
},
|
|
3114
|
+
async type(text2) {
|
|
3115
|
+
await page.keyboard.type(text2);
|
|
3116
|
+
},
|
|
3117
|
+
async cursor(at, pressed) {
|
|
3118
|
+
await drawCursor(page, at, pressed ?? false).catch(() => void 0);
|
|
3119
|
+
},
|
|
3120
|
+
async quiet(timeoutMs) {
|
|
3121
|
+
await page.waitForLoadState("networkidle", { timeout: timeoutMs }).catch(() => void 0);
|
|
3122
|
+
},
|
|
2339
3123
|
async screenshot(cover) {
|
|
2340
3124
|
const masks = (cover ?? []).map((selector) => page.locator(selector));
|
|
2341
3125
|
return page.screenshot({
|
|
@@ -2561,6 +3345,320 @@ async function loadPlaywright() {
|
|
|
2561
3345
|
);
|
|
2562
3346
|
}
|
|
2563
3347
|
|
|
3348
|
+
// adapters/capture-clips.ts
|
|
3349
|
+
import { spawn } from "node:child_process";
|
|
3350
|
+
import { createHash as createHash3 } from "node:crypto";
|
|
3351
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
3352
|
+
import path2 from "node:path";
|
|
3353
|
+
var FRAME_MS = 1e3 / CLIP_FPS;
|
|
3354
|
+
var OPENING_MS = 700;
|
|
3355
|
+
var MOVE_FRAMES = 6;
|
|
3356
|
+
var DWELL_MS = 500;
|
|
3357
|
+
var PRESS_MS = 200;
|
|
3358
|
+
var CHARS_PER_FRAME = 2;
|
|
3359
|
+
var SETTLE_MS = 3e3;
|
|
3360
|
+
var STEP_WAIT_MS = 4e3;
|
|
3361
|
+
var CLOSING_MS = 1200;
|
|
3362
|
+
var START = { x: Math.round(CLIP_VIEWPORT.width / 2), y: Math.round(CLIP_VIEWPORT.height * 0.66) };
|
|
3363
|
+
function chunksOf(text2, size) {
|
|
3364
|
+
return Array.from({ length: Math.ceil(text2.length / size) }, (_, index) => text2.slice(index * size, (index + 1) * size));
|
|
3365
|
+
}
|
|
3366
|
+
async function recordClip(options, script, touched) {
|
|
3367
|
+
const dir = path2.join(options.workDir, script.id);
|
|
3368
|
+
const frames = [];
|
|
3369
|
+
const checked = /* @__PURE__ */ new Set();
|
|
3370
|
+
const fail = (because) => ({ ok: false, status: "failed", because });
|
|
3371
|
+
const refuse = (because) => ({ ok: false, status: "refused", because });
|
|
3372
|
+
const page = await options.browser.page({ variant: "clip" }).catch(() => void 0);
|
|
3373
|
+
if (!page) return fail("the browser would not open a page to record in");
|
|
3374
|
+
try {
|
|
3375
|
+
await rm(dir, { recursive: true, force: true });
|
|
3376
|
+
await mkdir(dir, { recursive: true });
|
|
3377
|
+
if (!page.layout || !page.location || !page.pointer || !page.type || !page.cursor) {
|
|
3378
|
+
return fail("this capture runner cannot record clips: it cannot measure a page, act on it or draw a cursor");
|
|
3379
|
+
}
|
|
3380
|
+
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) };
|
|
3381
|
+
let cover = [];
|
|
3382
|
+
let at = 0;
|
|
3383
|
+
let last = new Uint8Array();
|
|
3384
|
+
const judge = async (where) => {
|
|
3385
|
+
const route = where.split("?")[0];
|
|
3386
|
+
const verdict = gateFrame(await page.text(), {
|
|
3387
|
+
route,
|
|
3388
|
+
...options.brands ? { brands: options.brands } : {},
|
|
3389
|
+
...options.clearance?.expectedBrands?.length ? { expected: options.clearance.expectedBrands } : {}
|
|
3390
|
+
});
|
|
3391
|
+
if (!verdict.safe) return verdict.reason ?? `the gate refused ${route}`;
|
|
3392
|
+
if (!options.clearance) return null;
|
|
3393
|
+
const clearance = options.clearance;
|
|
3394
|
+
const reading = await readScreen(page, clearance);
|
|
3395
|
+
if (!reading) return "this browser cannot read a screen against a clearance";
|
|
3396
|
+
if (reading.inventory.unaccounted.length) return `${reading.inventory.unaccounted.length} value(s) on ${route} are outside every region the clearance names`;
|
|
3397
|
+
const approved = new Set(clearance.approved);
|
|
3398
|
+
const loose = reading.surfaces.filter((surface) => !surface.read && !clearance.covers.includes(surface.where) && !approved.has(surface.where));
|
|
3399
|
+
if (loose.length && !clearance.coverUnnamed) return `${loose.length} surface(s) on ${route} are neither approved nor covered`;
|
|
3400
|
+
cover = reading.surfaces.filter((surface) => !surface.read && !approved.has(surface.where)).map((surface) => surface.where);
|
|
3401
|
+
return null;
|
|
3402
|
+
};
|
|
3403
|
+
const keep = async (bytes, ms) => {
|
|
3404
|
+
const file = path2.join(dir, `frame-${String(frames.length + 1).padStart(4, "0")}.png`);
|
|
3405
|
+
await writeFile(file, bytes);
|
|
3406
|
+
frames.push({ file, ms });
|
|
3407
|
+
last = bytes;
|
|
3408
|
+
at += ms;
|
|
3409
|
+
return file;
|
|
3410
|
+
};
|
|
3411
|
+
const shoot = async (ms) => keep(await page.screenshot(cover), ms);
|
|
3412
|
+
const hold = (ms) => {
|
|
3413
|
+
frames[frames.length - 1].ms += ms;
|
|
3414
|
+
at += ms;
|
|
3415
|
+
};
|
|
3416
|
+
const film = async () => {
|
|
3417
|
+
const next = await page.screenshot(cover);
|
|
3418
|
+
if (sameBytes(next, last)) {
|
|
3419
|
+
hold(FRAME_MS);
|
|
3420
|
+
return false;
|
|
3421
|
+
}
|
|
3422
|
+
await keep(next, FRAME_MS);
|
|
3423
|
+
return true;
|
|
3424
|
+
};
|
|
3425
|
+
await page.goto(new URL(script.visit, options.baseUrl).toString());
|
|
3426
|
+
if (await page.isSignIn()) return refuse("its first screen asked for a sign-in, so this session is not the demo tenant's");
|
|
3427
|
+
const opened = await location2();
|
|
3428
|
+
if (!onScreen(script.steps[0].route, opened)) return fail(`it opened on ${opened}, not ${script.steps[0].route}`);
|
|
3429
|
+
const before = await judge(opened);
|
|
3430
|
+
if (before) return refuse(before);
|
|
3431
|
+
let resting = START;
|
|
3432
|
+
let poster;
|
|
3433
|
+
const steps = [];
|
|
3434
|
+
const arrive = async (route) => {
|
|
3435
|
+
let where = await location2();
|
|
3436
|
+
for (let waited = 0; !onScreen(route, where) && waited < STEP_WAIT_MS; waited += FRAME_MS) {
|
|
3437
|
+
await cursor(resting);
|
|
3438
|
+
await film();
|
|
3439
|
+
where = await location2();
|
|
3440
|
+
}
|
|
3441
|
+
if (!onScreen(route, where)) return where;
|
|
3442
|
+
await page.quiet?.(SETTLE_MS);
|
|
3443
|
+
await cursor(resting);
|
|
3444
|
+
for (let waited = 0; waited < SETTLE_MS; waited += FRAME_MS) {
|
|
3445
|
+
if (!await film()) break;
|
|
3446
|
+
}
|
|
3447
|
+
return where;
|
|
3448
|
+
};
|
|
3449
|
+
await cursor(resting);
|
|
3450
|
+
checked.add(await shoot(OPENING_MS));
|
|
3451
|
+
for (const [index, step] of script.steps.entries()) {
|
|
3452
|
+
const where = await arrive(step.route);
|
|
3453
|
+
if (!onScreen(step.route, where)) return fail(`step ${index + 1}: the browser is on ${where}, not ${step.route}`);
|
|
3454
|
+
const arrived = await judge(where);
|
|
3455
|
+
if (arrived) return refuse(arrived);
|
|
3456
|
+
const measure = async () => resolveStep(
|
|
3457
|
+
buildLayoutManifest(await layout(cover), {
|
|
3458
|
+
shotId: script.id,
|
|
3459
|
+
route: step.route,
|
|
3460
|
+
path: where.split("?")[0],
|
|
3461
|
+
variant: "desktop",
|
|
3462
|
+
image: { ...CLIP_VIEWPORT },
|
|
3463
|
+
origin: new URL(options.baseUrl).origin
|
|
3464
|
+
}),
|
|
3465
|
+
step
|
|
3466
|
+
);
|
|
3467
|
+
let resolution = await measure();
|
|
3468
|
+
for (let waited = 0; !resolution.ok && waited < STEP_WAIT_MS; waited += FRAME_MS) {
|
|
3469
|
+
await cursor(resting);
|
|
3470
|
+
await film();
|
|
3471
|
+
resolution = await measure();
|
|
3472
|
+
}
|
|
3473
|
+
if (!resolution.ok) return fail(`step ${index + 1}: ${resolution.because}`);
|
|
3474
|
+
const target = centreOf(resolution.control.box);
|
|
3475
|
+
const startedAt = at;
|
|
3476
|
+
for (const point of cursorPath(resting, target, MOVE_FRAMES)) {
|
|
3477
|
+
await cursor(point);
|
|
3478
|
+
await shoot(FRAME_MS);
|
|
3479
|
+
}
|
|
3480
|
+
resting = target;
|
|
3481
|
+
hold(DWELL_MS);
|
|
3482
|
+
poster ??= frames[frames.length - 1].file;
|
|
3483
|
+
await cursor(target, true);
|
|
3484
|
+
await shoot(PRESS_MS);
|
|
3485
|
+
if (step.action === "fill") {
|
|
3486
|
+
await pointer({ kind: "focus", at: target });
|
|
3487
|
+
await cursor(target, false);
|
|
3488
|
+
for (const chunk of chunksOf(step.value ?? "", CHARS_PER_FRAME)) {
|
|
3489
|
+
await type(chunk);
|
|
3490
|
+
await shoot(FRAME_MS);
|
|
3491
|
+
}
|
|
3492
|
+
} else if (step.action === "select") {
|
|
3493
|
+
await pointer({ kind: "select", at: target, value: step.value ?? "" });
|
|
3494
|
+
} else {
|
|
3495
|
+
await pointer({ kind: "click", at: target });
|
|
3496
|
+
}
|
|
3497
|
+
await cursor(target, false);
|
|
3498
|
+
await page.quiet?.(SETTLE_MS);
|
|
3499
|
+
await cursor(target, false);
|
|
3500
|
+
await shoot(FRAME_MS);
|
|
3501
|
+
for (let waited = 0; waited < SETTLE_MS; waited += FRAME_MS) {
|
|
3502
|
+
if (!await film()) break;
|
|
3503
|
+
}
|
|
3504
|
+
checked.add(frames[frames.length - 1].file);
|
|
3505
|
+
const now = await location2();
|
|
3506
|
+
const after = await judge(now);
|
|
3507
|
+
if (after) return refuse(after);
|
|
3508
|
+
steps.push({
|
|
3509
|
+
route: step.route,
|
|
3510
|
+
role: step.control.role,
|
|
3511
|
+
name: resolution.control.name,
|
|
3512
|
+
action: step.action,
|
|
3513
|
+
says: step.says,
|
|
3514
|
+
atMs: startedAt,
|
|
3515
|
+
box: resolution.control.box
|
|
3516
|
+
});
|
|
3517
|
+
}
|
|
3518
|
+
if (script.lands) {
|
|
3519
|
+
const landed = await arrive(script.lands);
|
|
3520
|
+
if (!onScreen(script.lands, landed)) return fail(`the last step left the browser on ${landed}, not ${script.lands}`);
|
|
3521
|
+
const seen = await judge(landed);
|
|
3522
|
+
if (seen) return refuse(seen);
|
|
3523
|
+
}
|
|
3524
|
+
hold(CLOSING_MS);
|
|
3525
|
+
checked.add(frames[frames.length - 1].file);
|
|
3526
|
+
const durationMs = framesLength(frames);
|
|
3527
|
+
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`);
|
|
3528
|
+
for (const file of checked) {
|
|
3529
|
+
const pixels = readSentinel(decodePng(await readFile(file))).pixels;
|
|
3530
|
+
if (pixels > 0) return refuse(`a frame of it shows the verification colour (${pixels} pixel(s)), which is never published`);
|
|
3531
|
+
}
|
|
3532
|
+
const encoded = await options.encoder.encode(frames, dir);
|
|
3533
|
+
const mp4 = new Uint8Array(encoded.mp4);
|
|
3534
|
+
return {
|
|
3535
|
+
ok: true,
|
|
3536
|
+
clip: {
|
|
3537
|
+
recording: {
|
|
3538
|
+
clipId: script.id,
|
|
3539
|
+
title: script.title,
|
|
3540
|
+
route: script.steps[0].route,
|
|
3541
|
+
durationMs,
|
|
3542
|
+
width: CLIP_VIEWPORT.width,
|
|
3543
|
+
height: CLIP_VIEWPORT.height,
|
|
3544
|
+
fingerprint: clipFingerprint(script, touched),
|
|
3545
|
+
steps,
|
|
3546
|
+
touched: [...touched],
|
|
3547
|
+
browser: options.build ?? ""
|
|
3548
|
+
},
|
|
3549
|
+
mp4,
|
|
3550
|
+
gif: encoded.gif ? new Uint8Array(encoded.gif) : null,
|
|
3551
|
+
poster: new Uint8Array(await readFile(poster ?? frames[0].file)),
|
|
3552
|
+
sha256: createHash3("sha256").update(mp4).digest("hex")
|
|
3553
|
+
}
|
|
3554
|
+
};
|
|
3555
|
+
} catch (error) {
|
|
3556
|
+
return fail(error instanceof Error ? error.message : String(error));
|
|
3557
|
+
} finally {
|
|
3558
|
+
await page.close().catch(() => void 0);
|
|
3559
|
+
await rm(dir, { recursive: true, force: true }).catch(() => void 0);
|
|
3560
|
+
}
|
|
3561
|
+
}
|
|
3562
|
+
var CLIP_BUDGET_MS = 10 * 6e4;
|
|
3563
|
+
async function runClips(options) {
|
|
3564
|
+
const started = Date.now();
|
|
3565
|
+
const outcomes = [];
|
|
3566
|
+
let spent;
|
|
3567
|
+
for (const [index, script] of options.scripts.entries()) {
|
|
3568
|
+
const touched = clipRoutes(script).map((route) => ({
|
|
3569
|
+
route,
|
|
3570
|
+
shotId: options.frames.get(route)?.shotId ?? "",
|
|
3571
|
+
sha256: options.frames.get(route)?.sha256 ?? null
|
|
3572
|
+
}));
|
|
3573
|
+
const fingerprint = clipFingerprint(script, touched);
|
|
3574
|
+
const base = { clipId: script.id, title: script.title, fingerprint };
|
|
3575
|
+
let outcome;
|
|
3576
|
+
if (spent) {
|
|
3577
|
+
outcome = { ...base, status: "deferred", reason: spent };
|
|
3578
|
+
} else if (Date.now() - started > (options.budgetMs ?? CLIP_BUDGET_MS)) {
|
|
3579
|
+
outcome = { ...base, status: "deferred", reason: "the run's time for clips ran out, so it waits for the next run" };
|
|
3580
|
+
} else if (!options.force && options.held.get(script.id) === fingerprint) {
|
|
3581
|
+
outcome = { ...base, status: "unchanged", reason: "neither its steps nor a screen they touch has changed" };
|
|
3582
|
+
} else {
|
|
3583
|
+
const result = await recordClip(options, script, touched);
|
|
3584
|
+
if (!result.ok) {
|
|
3585
|
+
outcome = { ...base, status: result.status, reason: result.because };
|
|
3586
|
+
} else {
|
|
3587
|
+
const delivered = await options.send(result.clip);
|
|
3588
|
+
if (delivered.ok) {
|
|
3589
|
+
outcome = {
|
|
3590
|
+
...base,
|
|
3591
|
+
status: "recorded",
|
|
3592
|
+
durationMs: result.clip.recording.durationMs,
|
|
3593
|
+
mp4Bytes: result.clip.mp4.length,
|
|
3594
|
+
gifBytes: result.clip.gif?.length ?? null
|
|
3595
|
+
};
|
|
3596
|
+
} else if (delivered.limit) {
|
|
3597
|
+
spent = delivered.error;
|
|
3598
|
+
outcome = { ...base, status: "deferred", reason: delivered.error };
|
|
3599
|
+
} else {
|
|
3600
|
+
outcome = { ...base, status: "failed", reason: delivered.error };
|
|
3601
|
+
}
|
|
3602
|
+
}
|
|
3603
|
+
}
|
|
3604
|
+
outcomes.push(outcome);
|
|
3605
|
+
options.onProgress?.(outcome, index + 1, options.scripts.length);
|
|
3606
|
+
}
|
|
3607
|
+
return outcomes;
|
|
3608
|
+
}
|
|
3609
|
+
function run(binary, args) {
|
|
3610
|
+
return new Promise((resolve, reject) => {
|
|
3611
|
+
const child = spawn(binary, args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
3612
|
+
let stderr = "";
|
|
3613
|
+
child.stderr.on("data", (chunk) => {
|
|
3614
|
+
stderr = `${stderr}${chunk.toString()}`.slice(-2e3);
|
|
3615
|
+
});
|
|
3616
|
+
child.on(
|
|
3617
|
+
"error",
|
|
3618
|
+
(error) => reject(new Error(error.code === "ENOENT" ? `${binary} is not installed, and clips need it to encode` : error.message))
|
|
3619
|
+
);
|
|
3620
|
+
child.on("close", (code) => code === 0 ? resolve() : reject(new Error(`${binary} stopped with ${code}: ${stderr.trim() || "no output"}`)));
|
|
3621
|
+
});
|
|
3622
|
+
}
|
|
3623
|
+
async function ffmpegAvailable(binary = ffmpegBinary()) {
|
|
3624
|
+
return run(binary, ["-hide_banner", "-loglevel", "error", "-version"]).then(
|
|
3625
|
+
() => true,
|
|
3626
|
+
() => false
|
|
3627
|
+
);
|
|
3628
|
+
}
|
|
3629
|
+
function ffmpegBinary() {
|
|
3630
|
+
return process.env["CAPTURE_FFMPEG"]?.trim() || "ffmpeg";
|
|
3631
|
+
}
|
|
3632
|
+
function ffmpegEncoder(binary = ffmpegBinary()) {
|
|
3633
|
+
return {
|
|
3634
|
+
async encode(frames, dir) {
|
|
3635
|
+
const list = path2.join(dir, "frames.txt");
|
|
3636
|
+
const mp4Path = path2.join(dir, "clip.mp4");
|
|
3637
|
+
const gifPath = path2.join(dir, "clip.gif");
|
|
3638
|
+
await writeFile(list, concatList(frames), "utf8");
|
|
3639
|
+
await run(binary, mp4Arguments(list, mp4Path));
|
|
3640
|
+
let mp4 = await readFile(mp4Path);
|
|
3641
|
+
if (mp4.length > CLIP_MAX_MP4_BYTES) {
|
|
3642
|
+
await run(binary, mp4Arguments(list, mp4Path, 34));
|
|
3643
|
+
mp4 = await readFile(mp4Path);
|
|
3644
|
+
}
|
|
3645
|
+
if (mp4.length > CLIP_MAX_MP4_BYTES) {
|
|
3646
|
+
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`);
|
|
3647
|
+
}
|
|
3648
|
+
await run(binary, gifArguments(list, gifPath));
|
|
3649
|
+
let gif = await readFile(gifPath);
|
|
3650
|
+
if (gif.length > CLIP_MAX_GIF_BYTES) {
|
|
3651
|
+
await run(binary, gifArguments(list, gifPath, { width: 800, fps: 6 }));
|
|
3652
|
+
gif = await readFile(gifPath);
|
|
3653
|
+
}
|
|
3654
|
+
return { mp4, gif: gif.length > CLIP_MAX_GIF_BYTES ? null : gif };
|
|
3655
|
+
}
|
|
3656
|
+
};
|
|
3657
|
+
}
|
|
3658
|
+
|
|
3659
|
+
// adapters/capture-bin.ts
|
|
3660
|
+
import { tmpdir as tmpdir2 } from "node:os";
|
|
3661
|
+
|
|
2564
3662
|
// adapters/invoked-as-command.ts
|
|
2565
3663
|
import { realpathSync } from "node:fs";
|
|
2566
3664
|
import { pathToFileURL as pathToFileURL2 } from "node:url";
|
|
@@ -2575,7 +3673,7 @@ function runningAsCommand(moduleUrl) {
|
|
|
2575
3673
|
}
|
|
2576
3674
|
|
|
2577
3675
|
// adapters/capture-stage.ts
|
|
2578
|
-
import { spawn } from "node:child_process";
|
|
3676
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
2579
3677
|
|
|
2580
3678
|
// adapters/seed-proxy.ts
|
|
2581
3679
|
import { createServer, request as httpRequest } from "node:http";
|
|
@@ -2601,7 +3699,7 @@ function looksLikeIdentifier(segment) {
|
|
|
2601
3699
|
return /\d/.test(segment) && segment.length >= 3;
|
|
2602
3700
|
}
|
|
2603
3701
|
function templatise(paths) {
|
|
2604
|
-
const split = paths.map((
|
|
3702
|
+
const split = paths.map((path5) => path5.split("/").filter(Boolean));
|
|
2605
3703
|
const depth2 = Math.max(0, ...split.map((segments) => segments.length));
|
|
2606
3704
|
const parameters = /* @__PURE__ */ new Set();
|
|
2607
3705
|
const prefixOf = (segments, upTo) => {
|
|
@@ -2704,9 +3802,9 @@ var CassetteIndex = class {
|
|
|
2704
3802
|
return void 0;
|
|
2705
3803
|
}
|
|
2706
3804
|
};
|
|
2707
|
-
function matchesTemplate(template,
|
|
3805
|
+
function matchesTemplate(template, path5) {
|
|
2708
3806
|
const wanted = template.split("/").filter(Boolean);
|
|
2709
|
-
const actual =
|
|
3807
|
+
const actual = path5.split("/").filter(Boolean);
|
|
2710
3808
|
if (wanted.length !== actual.length) return false;
|
|
2711
3809
|
return wanted.every((segment, index) => segment.startsWith(":") || segment === actual[index]);
|
|
2712
3810
|
}
|
|
@@ -2737,10 +3835,10 @@ function shut(server) {
|
|
|
2737
3835
|
});
|
|
2738
3836
|
}
|
|
2739
3837
|
function splitUrl(url) {
|
|
2740
|
-
const [
|
|
3838
|
+
const [path5, search] = url.split("?");
|
|
2741
3839
|
const query = {};
|
|
2742
3840
|
for (const [name, value] of new URLSearchParams(search ?? "")) query[name] = value;
|
|
2743
|
-
return { path:
|
|
3841
|
+
return { path: path5 ?? "/", query };
|
|
2744
3842
|
}
|
|
2745
3843
|
async function replayFrom(options) {
|
|
2746
3844
|
const index = new CassetteIndex(options.cassette);
|
|
@@ -2748,14 +3846,14 @@ async function replayFrom(options) {
|
|
|
2748
3846
|
const served = /* @__PURE__ */ new Set();
|
|
2749
3847
|
const server = createServer((incoming, outgoing) => {
|
|
2750
3848
|
void (async () => {
|
|
2751
|
-
const { path:
|
|
3849
|
+
const { path: path5, query } = splitUrl(incoming.url ?? "/");
|
|
2752
3850
|
await bodyOf(incoming);
|
|
2753
|
-
const found = index.find({ method: incoming.method ?? "GET", path:
|
|
3851
|
+
const found = index.find({ method: incoming.method ?? "GET", path: path5, query });
|
|
2754
3852
|
if (!found) {
|
|
2755
3853
|
const from = incoming.headers["referer"] ?? incoming.headers["x-seed-page"];
|
|
2756
3854
|
const miss = {
|
|
2757
3855
|
method: incoming.method ?? "GET",
|
|
2758
|
-
path:
|
|
3856
|
+
path: path5,
|
|
2759
3857
|
...Object.keys(query).length ? { query } : {},
|
|
2760
3858
|
...typeof from === "string" ? { from: pageOf(from) } : {}
|
|
2761
3859
|
};
|
|
@@ -2795,12 +3893,84 @@ function pageOf(referer) {
|
|
|
2795
3893
|
}
|
|
2796
3894
|
}
|
|
2797
3895
|
|
|
3896
|
+
// adapters/capture-clock.ts
|
|
3897
|
+
import { mkdtemp, rm as rm2, writeFile as writeFile2 } from "node:fs/promises";
|
|
3898
|
+
import { tmpdir } from "node:os";
|
|
3899
|
+
import path3 from "node:path";
|
|
3900
|
+
var PINNED_CLOCK_ENV = "HELPDESK_PINNED_CLOCK";
|
|
3901
|
+
function clockPreload(setting) {
|
|
3902
|
+
const at = Date.parse(setting.at);
|
|
3903
|
+
if (Number.isNaN(at)) throw new Error(`clockPreload needs a parsable instant, got ${JSON.stringify(setting.at)}`);
|
|
3904
|
+
const from = setting.from === void 0 ? "null" : String(Math.trunc(setting.from));
|
|
3905
|
+
const pin = `(() => {
|
|
3906
|
+
const AT = ${at};
|
|
3907
|
+
const FROM = ${from};
|
|
3908
|
+
const RealDate = globalThis.Date;
|
|
3909
|
+
if (RealDate.helpdeskPinned) return;
|
|
3910
|
+
const now = FROM === null ? () => AT : () => AT + (RealDate.now() - FROM);
|
|
3911
|
+
globalThis.Date = new Proxy(RealDate, {
|
|
3912
|
+
construct: (target, args, newTarget) => Reflect.construct(target, args.length ? args : [now()], newTarget),
|
|
3913
|
+
apply: () => new RealDate(now()).toString(),
|
|
3914
|
+
get: (target, key, receiver) => (key === "now" ? now : key === "helpdeskPinned" ? true : Reflect.get(target, key, receiver)),
|
|
3915
|
+
});
|
|
3916
|
+
})();`;
|
|
3917
|
+
return `"use strict";
|
|
3918
|
+
${pin}
|
|
3919
|
+
(() => {
|
|
3920
|
+
const vm = require("node:vm");
|
|
3921
|
+
const createContext = vm.createContext;
|
|
3922
|
+
vm.createContext = function (...args) {
|
|
3923
|
+
const context = createContext.apply(this, args);
|
|
3924
|
+
try {
|
|
3925
|
+
vm.runInContext(${JSON.stringify(pin)}, context);
|
|
3926
|
+
} catch {}
|
|
3927
|
+
return context;
|
|
3928
|
+
};
|
|
3929
|
+
})();
|
|
3930
|
+
`;
|
|
3931
|
+
}
|
|
3932
|
+
function withPreload(nodeOptions, file) {
|
|
3933
|
+
return [nodeOptions?.trim(), `--require ${JSON.stringify(file)}`].filter(Boolean).join(" ");
|
|
3934
|
+
}
|
|
3935
|
+
async function writeClockPreload(setting) {
|
|
3936
|
+
const dir = await mkdtemp(path3.join(tmpdir(), "helpdesk-clock-"));
|
|
3937
|
+
const file = path3.join(dir, "clock.cjs");
|
|
3938
|
+
await writeFile2(file, clockPreload(setting), "utf8");
|
|
3939
|
+
return { file, remove: () => rm2(dir, { recursive: true, force: true }) };
|
|
3940
|
+
}
|
|
3941
|
+
function pinnedEnv(at, file, nodeOptions) {
|
|
3942
|
+
return { [PINNED_CLOCK_ENV]: at, NODE_OPTIONS: withPreload(nodeOptions, file) };
|
|
3943
|
+
}
|
|
3944
|
+
|
|
2798
3945
|
// core/ci-api.ts
|
|
3946
|
+
var CI_TOKEN_NAME = "HELPDESK_TOKEN";
|
|
2799
3947
|
var CI_SESSION_OUT_ENV = "HELPDESK_SESSION_OUT";
|
|
2800
3948
|
var CI_BASE_URL_ENV = "HELPDESK_BASE_URL";
|
|
3949
|
+
var CI_WORKFLOW_PATH = ".github/workflows/helpdesk.yml";
|
|
3950
|
+
var CAPTURE_WORKFLOW_PATH = ".github/workflows/helpdesk-screenshots.yml";
|
|
3951
|
+
function workflowNames(slug) {
|
|
3952
|
+
const tag = (slug ?? "").trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
3953
|
+
if (!tag) {
|
|
3954
|
+
return {
|
|
3955
|
+
secret: CI_TOKEN_NAME,
|
|
3956
|
+
change: { name: "Help desk", path: CI_WORKFLOW_PATH },
|
|
3957
|
+
capture: { name: "Help desk screenshots", path: CAPTURE_WORKFLOW_PATH }
|
|
3958
|
+
};
|
|
3959
|
+
}
|
|
3960
|
+
return {
|
|
3961
|
+
secret: `${CI_TOKEN_NAME}_${tag.toUpperCase().replace(/-/g, "_")}`,
|
|
3962
|
+
change: { name: `Help desk (${tag})`, path: `.github/workflows/helpdesk-${tag}-watch.yml` },
|
|
3963
|
+
capture: { name: `Help desk screenshots (${tag})`, path: `.github/workflows/helpdesk-${tag}-screenshots.yml` }
|
|
3964
|
+
};
|
|
3965
|
+
}
|
|
2801
3966
|
|
|
2802
3967
|
// adapters/capture-stage.ts
|
|
2803
3968
|
var DEFAULT_APP_ENV = "API_BASE_URL";
|
|
3969
|
+
async function productClock(pinnedClock) {
|
|
3970
|
+
if (!pinnedClock) return { env: {} };
|
|
3971
|
+
const written = await writeClockPreload({ at: pinnedClock });
|
|
3972
|
+
return { env: pinnedEnv(pinnedClock, written.file, process.env["NODE_OPTIONS"]), written };
|
|
3973
|
+
}
|
|
2804
3974
|
async function waitForApp(url, deadline, onLog, gaveUp) {
|
|
2805
3975
|
let announced = false;
|
|
2806
3976
|
while (Date.now() < deadline) {
|
|
@@ -2827,9 +3997,10 @@ async function stageAgainstRecording(options) {
|
|
|
2827
3997
|
const variable = options.appEnv ?? DEFAULT_APP_ENV;
|
|
2828
3998
|
let app;
|
|
2829
3999
|
let died;
|
|
4000
|
+
const clock = options.appCommand ? await productClock(options.pinnedClock) : { env: {} };
|
|
2830
4001
|
if (options.appCommand) {
|
|
2831
|
-
options.onLog?.(`Starting your product with ${variable}=${replay.url}`);
|
|
2832
|
-
app =
|
|
4002
|
+
options.onLog?.(`Starting your product with ${variable}=${replay.url}${clockNote(options.pinnedClock)}`);
|
|
4003
|
+
app = spawn2(options.appCommand, {
|
|
2833
4004
|
shell: true,
|
|
2834
4005
|
// Its own process group, so it can be taken down as one.
|
|
2835
4006
|
//
|
|
@@ -2839,7 +4010,7 @@ async function stageAgainstRecording(options) {
|
|
|
2839
4010
|
// the address occupied by the last one — and on a build agent, the job
|
|
2840
4011
|
// never finishes.
|
|
2841
4012
|
detached: true,
|
|
2842
|
-
env: { ...process.env, [variable]: replay.url },
|
|
4013
|
+
env: { ...process.env, [variable]: replay.url, ...clock.env },
|
|
2843
4014
|
stdio: ["ignore", "pipe", "pipe"]
|
|
2844
4015
|
});
|
|
2845
4016
|
app.stdout?.on("data", (chunk) => options.onLog?.(` [app] ${chunk.toString().trimEnd()}`));
|
|
@@ -2856,6 +4027,7 @@ async function stageAgainstRecording(options) {
|
|
|
2856
4027
|
if (died !== void 0) {
|
|
2857
4028
|
await replay.close();
|
|
2858
4029
|
stop(app);
|
|
4030
|
+
await clock.written?.remove();
|
|
2859
4031
|
throw new Error(
|
|
2860
4032
|
`The start command exited (${died}) before the product was ready. Its output is above.` + (up ? `
|
|
2861
4033
|
|
|
@@ -2865,6 +4037,7 @@ Something else is already answering on ${options.appUrl}. That is almost always
|
|
|
2865
4037
|
if (!up) {
|
|
2866
4038
|
await replay.close();
|
|
2867
4039
|
stop(app);
|
|
4040
|
+
await clock.written?.remove();
|
|
2868
4041
|
throw new Error(
|
|
2869
4042
|
`${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.`)
|
|
2870
4043
|
);
|
|
@@ -2877,9 +4050,13 @@ Something else is already answering on ${options.appUrl}. That is almost always
|
|
|
2877
4050
|
close: async () => {
|
|
2878
4051
|
stop(app);
|
|
2879
4052
|
await replay.close();
|
|
4053
|
+
await clock.written?.remove();
|
|
2880
4054
|
}
|
|
2881
4055
|
};
|
|
2882
4056
|
}
|
|
4057
|
+
function clockNote(pinnedClock) {
|
|
4058
|
+
return pinnedClock ? `, its clock pinned to ${pinnedClock}` : "";
|
|
4059
|
+
}
|
|
2883
4060
|
var SESSION_OUT_ENV = CI_SESSION_OUT_ENV;
|
|
2884
4061
|
var EXAMPLES_OUT_ENV = CI_EXAMPLES_OUT_ENV;
|
|
2885
4062
|
async function stageSeeded(options) {
|
|
@@ -2894,7 +4071,8 @@ async function stageSeeded(options) {
|
|
|
2894
4071
|
{
|
|
2895
4072
|
[CI_SESSION_OUT_ENV]: options.sessionOut,
|
|
2896
4073
|
[CI_BASE_URL_ENV]: options.appUrl,
|
|
2897
|
-
...options.examplesOut ? { [CI_EXAMPLES_OUT_ENV]: options.examplesOut } : {}
|
|
4074
|
+
...options.examplesOut ? { [CI_EXAMPLES_OUT_ENV]: options.examplesOut } : {},
|
|
4075
|
+
...options.pinnedClock ? { [PINNED_CLOCK_ENV]: options.pinnedClock } : {}
|
|
2898
4076
|
},
|
|
2899
4077
|
onLog
|
|
2900
4078
|
);
|
|
@@ -2913,15 +4091,18 @@ Nothing has been photographed. A run that carried on from here would sign in to
|
|
|
2913
4091
|
onLog?.(
|
|
2914
4092
|
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.`
|
|
2915
4093
|
);
|
|
2916
|
-
const
|
|
4094
|
+
const named = options.examplesOut ? await examplesFrom(options.examplesOut, onLog) : void 0;
|
|
4095
|
+
const examples = named?.examples;
|
|
4096
|
+
const clips = named?.clips ?? [];
|
|
2917
4097
|
let app;
|
|
2918
4098
|
let died;
|
|
4099
|
+
const clock = options.appCommand ? await productClock(options.pinnedClock) : { env: {} };
|
|
2919
4100
|
if (options.appCommand) {
|
|
2920
|
-
onLog?.(`Starting your product with ${options.appCommand}`);
|
|
2921
|
-
app =
|
|
4101
|
+
onLog?.(`Starting your product with ${options.appCommand}${clockNote(options.pinnedClock)}`);
|
|
4102
|
+
app = spawn2(options.appCommand, {
|
|
2922
4103
|
shell: true,
|
|
2923
4104
|
detached: true,
|
|
2924
|
-
env: { ...process.env },
|
|
4105
|
+
env: { ...process.env, ...clock.env },
|
|
2925
4106
|
stdio: ["ignore", "pipe", "pipe"]
|
|
2926
4107
|
});
|
|
2927
4108
|
app.stdout?.on("data", (chunk) => onLog?.(` [app] ${chunk.toString().trimEnd()}`));
|
|
@@ -2935,6 +4116,7 @@ Nothing has been photographed. A run that carried on from here would sign in to
|
|
|
2935
4116
|
if (died === void 0 && app?.exitCode !== null && app?.exitCode !== void 0) died = app.exitCode;
|
|
2936
4117
|
if (died !== void 0) {
|
|
2937
4118
|
stop(app);
|
|
4119
|
+
await clock.written?.remove();
|
|
2938
4120
|
throw new Error(
|
|
2939
4121
|
`The start command exited (${died}) before the product was ready. Its output is above.` + (up ? `
|
|
2940
4122
|
|
|
@@ -2943,6 +4125,7 @@ Something else is already answering on ${options.appUrl}, which is almost always
|
|
|
2943
4125
|
}
|
|
2944
4126
|
if (!up) {
|
|
2945
4127
|
stop(app);
|
|
4128
|
+
await clock.written?.remove();
|
|
2946
4129
|
throw new Error(
|
|
2947
4130
|
`${options.appUrl} never answered. Check the start command above, and that your product listens on that address.`
|
|
2948
4131
|
);
|
|
@@ -2951,16 +4134,18 @@ Something else is already answering on ${options.appUrl}, which is almost always
|
|
|
2951
4134
|
appUrl: options.appUrl,
|
|
2952
4135
|
...sessionPath ? { sessionPath } : {},
|
|
2953
4136
|
...examples ? { examples } : {},
|
|
4137
|
+
...clips.length ? { clips } : {},
|
|
2954
4138
|
close: async () => {
|
|
2955
4139
|
stop(app);
|
|
4140
|
+
await clock.written?.remove();
|
|
2956
4141
|
}
|
|
2957
4142
|
};
|
|
2958
4143
|
}
|
|
2959
|
-
async function examplesFrom(
|
|
2960
|
-
const { readFile:
|
|
4144
|
+
async function examplesFrom(path5, onLog) {
|
|
4145
|
+
const { readFile: readFile3 } = await import("node:fs/promises");
|
|
2961
4146
|
let text2;
|
|
2962
4147
|
try {
|
|
2963
|
-
text2 = await
|
|
4148
|
+
text2 = await readFile3(path5, "utf8");
|
|
2964
4149
|
} catch {
|
|
2965
4150
|
return void 0;
|
|
2966
4151
|
}
|
|
@@ -2971,20 +4156,23 @@ async function examplesFrom(path3, onLog) {
|
|
|
2971
4156
|
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.`);
|
|
2972
4157
|
return void 0;
|
|
2973
4158
|
}
|
|
4159
|
+
const named = readClipScripts(parsed?.clips);
|
|
4160
|
+
if (named.scripts.length) onLog?.(`The seed command named ${named.scripts.length} clip(s) to record.`);
|
|
4161
|
+
for (const refused of named.refused) onLog?.(` not recording ${refused.id}: ${refused.because}`);
|
|
2974
4162
|
const reading = readExampleRoutes(parsed);
|
|
2975
4163
|
if (!reading.ok) {
|
|
2976
4164
|
onLog?.(`The seed command wrote ${EXAMPLES_OUT_ENV}, but ${reading.because}, so no screen with an id in its address will be opened.`);
|
|
2977
|
-
return
|
|
4165
|
+
return { clips: named.scripts };
|
|
2978
4166
|
}
|
|
2979
4167
|
onLog?.(
|
|
2980
4168
|
`The seed command named a record for ${reading.examples.paths.size} screen(s) with an id in the address.`
|
|
2981
4169
|
);
|
|
2982
4170
|
for (const refused of reading.examples.refused) onLog?.(` not using ${refused.route}: ${refused.because}`);
|
|
2983
|
-
return reading.examples;
|
|
4171
|
+
return { examples: reading.examples, clips: named.scripts };
|
|
2984
4172
|
}
|
|
2985
4173
|
async function runToCompletion(command, timeoutMs, env, onLog) {
|
|
2986
4174
|
return new Promise((resolve) => {
|
|
2987
|
-
const child =
|
|
4175
|
+
const child = spawn2(command, {
|
|
2988
4176
|
shell: true,
|
|
2989
4177
|
detached: true,
|
|
2990
4178
|
env: { ...process.env, ...env },
|
|
@@ -3025,8 +4213,8 @@ function gapsFrom(misses, cassette) {
|
|
|
3025
4213
|
const templates = templatise([...known, ...misses.map((miss) => miss.path)]);
|
|
3026
4214
|
const grouped = /* @__PURE__ */ new Map();
|
|
3027
4215
|
for (const miss of misses) {
|
|
3028
|
-
const
|
|
3029
|
-
const key = interactionKey({ method: miss.method, path:
|
|
4216
|
+
const path5 = templates.get(miss.path) ?? miss.path;
|
|
4217
|
+
const key = interactionKey({ method: miss.method, path: path5, ...miss.query ? { query: miss.query } : {} });
|
|
3030
4218
|
const existing = grouped.get(key);
|
|
3031
4219
|
if (existing) {
|
|
3032
4220
|
existing.asked += 1;
|
|
@@ -3035,7 +4223,7 @@ function gapsFrom(misses, cassette) {
|
|
|
3035
4223
|
}
|
|
3036
4224
|
grouped.set(key, {
|
|
3037
4225
|
method: miss.method.toUpperCase(),
|
|
3038
|
-
path:
|
|
4226
|
+
path: path5,
|
|
3039
4227
|
asked: 1,
|
|
3040
4228
|
broke: miss.from ? [miss.from] : [],
|
|
3041
4229
|
example: miss.path
|
|
@@ -3104,8 +4292,8 @@ function coverageReport(found, total) {
|
|
|
3104
4292
|
}
|
|
3105
4293
|
|
|
3106
4294
|
// adapters/capture-bin.ts
|
|
3107
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3108
|
-
import
|
|
4295
|
+
import { mkdir as mkdir2, readFile as readFile2, writeFile as writeFile3 } from "node:fs/promises";
|
|
4296
|
+
import path4 from "node:path";
|
|
3109
4297
|
|
|
3110
4298
|
// adapters/map-cli.ts
|
|
3111
4299
|
import { execFileSync } from "node:child_process";
|
|
@@ -3118,14 +4306,14 @@ var ROUTE_GROUP = /^\((.+)\)$/;
|
|
|
3118
4306
|
var DYNAMIC = /^\[{1,2}(\.{3})?(.+?)\]{1,2}$/;
|
|
3119
4307
|
var PARALLEL = /^@/;
|
|
3120
4308
|
var INTERCEPTING = /^\(\.+\)/;
|
|
3121
|
-
function classify(
|
|
3122
|
-
if (
|
|
4309
|
+
function classify(path5) {
|
|
4310
|
+
if (path5 === "/api" || path5.startsWith("/api/")) {
|
|
3123
4311
|
return { skipped: "not-html", note: "an API route, not a screen" };
|
|
3124
4312
|
}
|
|
3125
|
-
if (/(^|\/)(admin|internal|debug|_dev)(\/|$)/.test(
|
|
4313
|
+
if (/(^|\/)(admin|internal|debug|_dev)(\/|$)/.test(path5)) {
|
|
3126
4314
|
return { skipped: "internal-tooling", note: "internal tooling" };
|
|
3127
4315
|
}
|
|
3128
|
-
if (/(^|\/)(login|logout|signin|sign-in|signup|sign-up|auth|verify|reset-password)(\/|$)/.test(
|
|
4316
|
+
if (/(^|\/)(login|logout|signin|sign-in|signup|sign-up|auth|verify|reset-password)(\/|$)/.test(path5)) {
|
|
3129
4317
|
return { skipped: "unauthenticated-flow", note: "an authentication step" };
|
|
3130
4318
|
}
|
|
3131
4319
|
return void 0;
|
|
@@ -3229,8 +4417,8 @@ function enclosingLayouts(pageFile, layouts) {
|
|
|
3229
4417
|
const dir = pageFile.slice(0, pageFile.lastIndexOf("/"));
|
|
3230
4418
|
return layouts.filter((layout) => dir.startsWith(layout.slice(0, layout.lastIndexOf("/")))).sort((a, b) => a.length - b.length);
|
|
3231
4419
|
}
|
|
3232
|
-
function depth(
|
|
3233
|
-
return
|
|
4420
|
+
function depth(path5) {
|
|
4421
|
+
return path5 === "/" ? 0 : path5.split("/").filter(Boolean).length;
|
|
3234
4422
|
}
|
|
3235
4423
|
|
|
3236
4424
|
// core/discovery/article-plan.ts
|
|
@@ -3267,8 +4455,8 @@ var ENTRY_POINT = /* @__PURE__ */ new Set([
|
|
|
3267
4455
|
"integration",
|
|
3268
4456
|
"integrations"
|
|
3269
4457
|
]);
|
|
3270
|
-
function hasSegment(
|
|
3271
|
-
return
|
|
4458
|
+
function hasSegment(path5, vocabulary) {
|
|
4459
|
+
return path5.split("/").filter(Boolean).some((segment) => vocabulary.has(segment.toLowerCase()));
|
|
3272
4460
|
}
|
|
3273
4461
|
function score(route, signals) {
|
|
3274
4462
|
const because = [];
|
|
@@ -3307,8 +4495,8 @@ function tierFor(rank, count) {
|
|
|
3307
4495
|
if (rank <= Math.max(16, Math.ceil(count * 0.6))) return "supporting";
|
|
3308
4496
|
return "minor";
|
|
3309
4497
|
}
|
|
3310
|
-
function slugFor(
|
|
3311
|
-
const segments =
|
|
4498
|
+
function slugFor(path5) {
|
|
4499
|
+
const segments = path5.split("/").filter(Boolean).filter((segment) => !segment.startsWith("["));
|
|
3312
4500
|
return segments.length ? segments.join("/") : "overview";
|
|
3313
4501
|
}
|
|
3314
4502
|
function titleFor(route) {
|
|
@@ -3367,7 +4555,7 @@ function buildArticlePlan(options) {
|
|
|
3367
4555
|
let covered = 0;
|
|
3368
4556
|
const needs = [...merged.entries()].map(([slug, group], index) => {
|
|
3369
4557
|
const rank = index + 1;
|
|
3370
|
-
const claimed = [group.entry.route.path, ...group.routes.slice(1) ?? []].map((
|
|
4558
|
+
const claimed = [group.entry.route.path, ...group.routes.slice(1) ?? []].map((path5) => options.documented?.[path5]).find(Boolean);
|
|
3371
4559
|
const already = existing.has(slug) || Boolean(claimed);
|
|
3372
4560
|
if (already) covered += 1;
|
|
3373
4561
|
return {
|
|
@@ -3389,6 +4577,8 @@ function buildArticlePlan(options) {
|
|
|
3389
4577
|
function repositoryFiles(cwd) {
|
|
3390
4578
|
return execFileSync("git", ["ls-files"], { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }).split("\n").filter(Boolean);
|
|
3391
4579
|
}
|
|
4580
|
+
var CAPTURE_WORKFLOW = workflowNames().capture;
|
|
4581
|
+
var CI_WORKFLOW = workflowNames().change;
|
|
3392
4582
|
var WORKFLOW_ACTIONS = {
|
|
3393
4583
|
checkout: "actions/checkout@v7",
|
|
3394
4584
|
setupNode: "actions/setup-node@v7"
|
|
@@ -3408,8 +4598,8 @@ function shotIdFor(route) {
|
|
|
3408
4598
|
function describe(route, isEntry) {
|
|
3409
4599
|
const segments = route.path.split("/").filter(Boolean);
|
|
3410
4600
|
const last = segments[segments.length - 1] ?? "";
|
|
3411
|
-
const
|
|
3412
|
-
return isEntry ?
|
|
4601
|
+
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";
|
|
4602
|
+
return isEntry ? `${screen} as someone arrives on it` : `${screen}, reached from ${segments.slice(0, -1).join("/") || "the top level"}`;
|
|
3413
4603
|
}
|
|
3414
4604
|
function kindFor(_route, _isEntry) {
|
|
3415
4605
|
return "still";
|
|
@@ -3421,7 +4611,7 @@ function buildStoryboard(options) {
|
|
|
3421
4611
|
const beneath = options.routes.filter(
|
|
3422
4612
|
(route) => !covered.has(route.path) && !route.skipped && [...covered].some((base) => base !== "/" && route.path.startsWith(`${base}/`))
|
|
3423
4613
|
);
|
|
3424
|
-
const own = [...covered].map((
|
|
4614
|
+
const own = [...covered].map((path5) => byPath.get(path5)).filter((route) => Boolean(route) && !route.skipped);
|
|
3425
4615
|
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));
|
|
3426
4616
|
const limit = options.maxSteps ?? 5;
|
|
3427
4617
|
const steps = ordered.slice(0, limit).map((route, index) => ({
|
|
@@ -3448,7 +4638,9 @@ function parseArgs(argv) {
|
|
|
3448
4638
|
brands: [],
|
|
3449
4639
|
only: [],
|
|
3450
4640
|
liveClock: false,
|
|
3451
|
-
allowProduction: false
|
|
4641
|
+
allowProduction: false,
|
|
4642
|
+
phone: true,
|
|
4643
|
+
clips: false
|
|
3452
4644
|
};
|
|
3453
4645
|
for (let index = 0; index < argv.length; index += 1) {
|
|
3454
4646
|
const flag = argv[index];
|
|
@@ -3469,6 +4661,8 @@ function parseArgs(argv) {
|
|
|
3469
4661
|
else if (flag === "--repo" && value) args.repo = value, index += 1;
|
|
3470
4662
|
else if (flag === "--live-clock") args.liveClock = true;
|
|
3471
4663
|
else if (flag === "--allow-production") args.allowProduction = true;
|
|
4664
|
+
else if (flag === "--no-phone") args.phone = false;
|
|
4665
|
+
else if (flag === "--clips") args.clips = true;
|
|
3472
4666
|
else if (flag === "--rebaseline" || flag === "--force") {
|
|
3473
4667
|
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.';
|
|
3474
4668
|
}
|
|
@@ -3483,17 +4677,44 @@ function parseArgs(argv) {
|
|
|
3483
4677
|
if (!args.to) return "Missing --to. That is the address of your help desk, or use --out <dir>.";
|
|
3484
4678
|
return args;
|
|
3485
4679
|
}
|
|
4680
|
+
function seenBefore(frame) {
|
|
4681
|
+
if (!frame) return {};
|
|
4682
|
+
return { ...frame.sha256 ? { sha256: frame.sha256 } : {}, ...frame.layout !== void 0 ? { layout: frame.layout } : {} };
|
|
4683
|
+
}
|
|
3486
4684
|
function shotsFromRepo(repo, previous) {
|
|
3487
4685
|
const files = repositoryFiles(repo);
|
|
3488
4686
|
const routes = readNextAppRouter(files, detectRouterRoot(files)).routes;
|
|
3489
|
-
return
|
|
3490
|
-
|
|
3491
|
-
|
|
3492
|
-
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
4687
|
+
return onePerScreen(
|
|
4688
|
+
buildArticlePlan({ routes }).needs.flatMap((need) => buildStoryboard({ need, routes }).steps).filter((step) => step.kind === "still").map((step) => ({
|
|
4689
|
+
id: step.shotId,
|
|
4690
|
+
route: step.route,
|
|
4691
|
+
depicts: step.depicts,
|
|
4692
|
+
// Carried so a second run can recognise a frame it already has, which is
|
|
4693
|
+
// what makes "nothing changed" a sentence this can say offline too.
|
|
4694
|
+
...seenBefore(previous.get(step.shotId))
|
|
4695
|
+
}))
|
|
4696
|
+
);
|
|
4697
|
+
}
|
|
4698
|
+
function withPhoneShots(shots, previous, again = /* @__PURE__ */ new Set()) {
|
|
4699
|
+
const planned = new Set(shots.map((shot) => shot.id));
|
|
4700
|
+
const phones = shots.filter((shot) => (!shot.visit || again.has(shot.route)) && !shot.unvisitable && !shot.variant && !planned.has(`${shot.id}${PHONE_SUFFIX}`)).map((shot) => {
|
|
4701
|
+
const id = `${shot.id}${PHONE_SUFFIX}`;
|
|
4702
|
+
return {
|
|
4703
|
+
id,
|
|
4704
|
+
route: shot.route,
|
|
4705
|
+
...shot.visit ? { visit: shot.visit } : {},
|
|
4706
|
+
depicts: `${shot.depicts}, at phone width`,
|
|
4707
|
+
variant: "phone",
|
|
4708
|
+
...seenBefore(previous.get(id))
|
|
4709
|
+
};
|
|
4710
|
+
});
|
|
4711
|
+
return [...shots, ...phones];
|
|
4712
|
+
}
|
|
4713
|
+
function heldPhoneFrames(shots) {
|
|
4714
|
+
return new Map(shots.flatMap((shot) => shot.phone ? [[`${shot.id}${PHONE_SUFFIX}`, { sha256: shot.phone.sha256, layout: shot.phone.layout }]] : []));
|
|
4715
|
+
}
|
|
4716
|
+
function onePerScreen(shots) {
|
|
4717
|
+
return [...new Map(shots.map((shot) => [shot.id, shot])).values()];
|
|
3497
4718
|
}
|
|
3498
4719
|
function withExamples(shots, examples) {
|
|
3499
4720
|
return shots.map((shot) => {
|
|
@@ -3502,10 +4723,178 @@ function withExamples(shots, examples) {
|
|
|
3502
4723
|
return resolved.visit === shot.route ? shot : { ...shot, visit: resolved.visit };
|
|
3503
4724
|
});
|
|
3504
4725
|
}
|
|
4726
|
+
function seededLast(shots) {
|
|
4727
|
+
return [...shots.filter((shot) => !shot.visit), ...shots.filter((shot) => shot.visit)];
|
|
4728
|
+
}
|
|
4729
|
+
async function writeLocalFrame(dir, frame) {
|
|
4730
|
+
const file = `${frame.shot.id}.png`;
|
|
4731
|
+
const layout = frame.layout ? `${frame.shot.id}.json` : void 0;
|
|
4732
|
+
await writeFile3(path4.join(dir, file), frame.image);
|
|
4733
|
+
if (layout) await writeFile3(path4.join(dir, layout), `${JSON.stringify(frame.layout, null, 2)}
|
|
4734
|
+
`, "utf8");
|
|
4735
|
+
return {
|
|
4736
|
+
id: frame.shot.id,
|
|
4737
|
+
route: frame.shot.route,
|
|
4738
|
+
depicts: frame.shot.depicts,
|
|
4739
|
+
file,
|
|
4740
|
+
sha256: frame.sha256,
|
|
4741
|
+
settled: frame.settled,
|
|
4742
|
+
...frame.browser ? { browser: frame.browser } : {},
|
|
4743
|
+
...frame.width ? { width: frame.width } : {},
|
|
4744
|
+
...frame.height ? { height: frame.height } : {},
|
|
4745
|
+
...layout ? { layout } : {},
|
|
4746
|
+
...frame.shot.variant ? { variant: frame.shot.variant } : {}
|
|
4747
|
+
};
|
|
4748
|
+
}
|
|
4749
|
+
async function writeLocalClip(dir, clip) {
|
|
4750
|
+
const id = clip.recording.clipId;
|
|
4751
|
+
const folder = path4.join(dir, "clips");
|
|
4752
|
+
await mkdir2(folder, { recursive: true });
|
|
4753
|
+
await writeFile3(path4.join(folder, `${id}.mp4`), clip.mp4);
|
|
4754
|
+
if (clip.gif) await writeFile3(path4.join(folder, `${id}.gif`), clip.gif);
|
|
4755
|
+
await writeFile3(path4.join(folder, `${id}.png`), clip.poster);
|
|
4756
|
+
await writeFile3(path4.join(folder, `${id}.json`), `${JSON.stringify(clip.recording, null, 2)}
|
|
4757
|
+
`, "utf8");
|
|
4758
|
+
return {
|
|
4759
|
+
id,
|
|
4760
|
+
title: clip.recording.title,
|
|
4761
|
+
route: clip.recording.route,
|
|
4762
|
+
mp4: `clips/${id}.mp4`,
|
|
4763
|
+
...clip.gif ? { gif: `clips/${id}.gif` } : {},
|
|
4764
|
+
poster: `clips/${id}.png`,
|
|
4765
|
+
recording: `clips/${id}.json`,
|
|
4766
|
+
fingerprint: clip.recording.fingerprint,
|
|
4767
|
+
durationMs: clip.recording.durationMs
|
|
4768
|
+
};
|
|
4769
|
+
}
|
|
4770
|
+
function clipForm(clip) {
|
|
4771
|
+
const id = clip.recording.clipId;
|
|
4772
|
+
const form = new FormData();
|
|
4773
|
+
form.set("meta", JSON.stringify({ ...clip.recording, sha256: clip.sha256 }));
|
|
4774
|
+
form.set("mp4", new Blob([clip.mp4], { type: "video/mp4" }), `${id}.mp4`);
|
|
4775
|
+
if (clip.gif) form.set("gif", new Blob([clip.gif], { type: "image/gif" }), `${id}.gif`);
|
|
4776
|
+
form.set("poster", new Blob([clip.poster], { type: "image/png" }), `${id}.png`);
|
|
4777
|
+
return form;
|
|
4778
|
+
}
|
|
4779
|
+
async function clipsOnDisk(dir) {
|
|
4780
|
+
try {
|
|
4781
|
+
const manifest = JSON.parse(await readFile2(path4.join(dir, "clips", "manifest.json"), "utf8"));
|
|
4782
|
+
return new Map((manifest.clips ?? []).map((clip) => [clip.id, clip]));
|
|
4783
|
+
} catch {
|
|
4784
|
+
return /* @__PURE__ */ new Map();
|
|
4785
|
+
}
|
|
4786
|
+
}
|
|
4787
|
+
async function recordSeededClips(args) {
|
|
4788
|
+
if (!args.scripts.length) {
|
|
4789
|
+
console.log("\n--clips was given, but the seed command named no clips, so none were recorded.");
|
|
4790
|
+
return [];
|
|
4791
|
+
}
|
|
4792
|
+
if (!await ffmpegAvailable()) {
|
|
4793
|
+
console.log("\nClips were not recorded: this machine has no ffmpeg to encode them with. Install ffmpeg, or set CAPTURE_FFMPEG.");
|
|
4794
|
+
if (process.env["GITHUB_ACTIONS"] === "true") console.log("::warning title=Clips not recorded::This runner has no ffmpeg to encode clips with.");
|
|
4795
|
+
return [];
|
|
4796
|
+
}
|
|
4797
|
+
const frames = new Map(
|
|
4798
|
+
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 }])
|
|
4799
|
+
);
|
|
4800
|
+
const previous = args.local ? await clipsOnDisk(args.local) : /* @__PURE__ */ new Map();
|
|
4801
|
+
const written = [];
|
|
4802
|
+
console.log(`
|
|
4803
|
+
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.
|
|
4804
|
+
`);
|
|
4805
|
+
const outcomes = await runClips({
|
|
4806
|
+
baseUrl: args.baseUrl,
|
|
4807
|
+
browser: args.browser,
|
|
4808
|
+
encoder: ffmpegEncoder(),
|
|
4809
|
+
workDir: path4.join(tmpdir2(), `helpdesk-clips-${process.pid}`),
|
|
4810
|
+
clearance: args.clearance,
|
|
4811
|
+
brands: args.brands,
|
|
4812
|
+
build: await args.browser.build?.().catch(() => void 0),
|
|
4813
|
+
scripts: args.scripts,
|
|
4814
|
+
frames,
|
|
4815
|
+
held: args.local ? new Map([...previous].map(([id, clip]) => [id, clip.fingerprint])) : args.held,
|
|
4816
|
+
force: args.force,
|
|
4817
|
+
async send(clip) {
|
|
4818
|
+
if (args.local) {
|
|
4819
|
+
written.push(await writeLocalClip(args.local, clip));
|
|
4820
|
+
return { ok: true };
|
|
4821
|
+
}
|
|
4822
|
+
const response = await fetch(`${args.to}/api/helpdesk/clips`, { method: "POST", headers: args.headers, body: clipForm(clip) });
|
|
4823
|
+
if (response.ok) return { ok: true };
|
|
4824
|
+
const body = await response.json().catch(() => ({}));
|
|
4825
|
+
return { ok: false, error: body.error ?? `the help desk answered ${response.status}`, ...response.status === 402 || body.limit ? { limit: true } : {} };
|
|
4826
|
+
},
|
|
4827
|
+
onProgress(outcome, index, total) {
|
|
4828
|
+
const mark = { recorded: "\u2713", unchanged: "=", deferred: "\u2013", refused: "\u2717", failed: "!" }[outcome.status];
|
|
4829
|
+
const kb = (bytes) => `${Math.round(bytes / 1024)} KB`;
|
|
4830
|
+
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 ?? "";
|
|
4831
|
+
console.log(`${mark} clip ${index}/${total} ${outcome.title}: ${detail}`);
|
|
4832
|
+
}
|
|
4833
|
+
});
|
|
4834
|
+
if (args.local) {
|
|
4835
|
+
const kept = [
|
|
4836
|
+
...written,
|
|
4837
|
+
...outcomes.filter((outcome) => outcome.status === "unchanged").flatMap((outcome) => previous.has(outcome.clipId) ? [previous.get(outcome.clipId)] : [])
|
|
4838
|
+
].sort((left, right) => left.id.localeCompare(right.id));
|
|
4839
|
+
await mkdir2(path4.join(args.local, "clips"), { recursive: true });
|
|
4840
|
+
await writeFile3(path4.join(args.local, "clips", "manifest.json"), `${JSON.stringify({ schema: "olomon.clip-manifest/1", clips: kept }, null, 2)}
|
|
4841
|
+
`, "utf8");
|
|
4842
|
+
}
|
|
4843
|
+
const count = (status) => outcomes.filter((outcome) => outcome.status === status).length;
|
|
4844
|
+
console.log(`
|
|
4845
|
+
Clips: ${count("recorded")} recorded, ${count("unchanged")} unchanged, ${count("deferred")} deferred, ${count("refused")} refused, ${count("failed")} failed.`);
|
|
4846
|
+
const missed = outcomes.filter((outcome) => outcome.status === "refused" || outcome.status === "failed");
|
|
4847
|
+
if (missed.length && process.env["GITHUB_ACTIONS"] === "true") {
|
|
4848
|
+
console.log(`::warning title=${missed.length} clip(s) not recorded::${missed.map((outcome) => `${outcome.clipId}: ${outcome.reason}`).join("; ")}`);
|
|
4849
|
+
}
|
|
4850
|
+
return outcomes;
|
|
4851
|
+
}
|
|
4852
|
+
function frameForm(frame) {
|
|
4853
|
+
const form = new FormData();
|
|
4854
|
+
form.set(
|
|
4855
|
+
"meta",
|
|
4856
|
+
JSON.stringify({
|
|
4857
|
+
shotId: frame.shot.id,
|
|
4858
|
+
route: frame.shot.route,
|
|
4859
|
+
depicts: frame.shot.depicts,
|
|
4860
|
+
pageText: frame.pageText,
|
|
4861
|
+
capturedFrom: frame.capturedFrom,
|
|
4862
|
+
settled: frame.settled,
|
|
4863
|
+
// What this run read off the screen. Absent is a refusal at the
|
|
4864
|
+
// other end rather than a default, which is the point of sending it.
|
|
4865
|
+
...frame.report ? { report: frame.report } : {},
|
|
4866
|
+
...frame.browser ? { browser: frame.browser } : {},
|
|
4867
|
+
...frame.width ? { width: frame.width } : {},
|
|
4868
|
+
...frame.height ? { height: frame.height } : {}
|
|
4869
|
+
})
|
|
4870
|
+
);
|
|
4871
|
+
form.set("image", new Blob([frame.image], { type: "image/png" }), `${frame.shot.id}.png`);
|
|
4872
|
+
if (frame.layout) form.set("layout", JSON.stringify(frame.layout));
|
|
4873
|
+
return form;
|
|
4874
|
+
}
|
|
4875
|
+
function framesOnDisk(written, planned, outcomes, previous) {
|
|
4876
|
+
const kept = new Map(written.map((frame) => [frame.id, frame]));
|
|
4877
|
+
for (const shot of planned) {
|
|
4878
|
+
if (kept.has(shot.id) || !shot.sha256) continue;
|
|
4879
|
+
const unchanged = outcomes.find((outcome) => outcome.shotId === shot.id);
|
|
4880
|
+
if (unchanged?.status !== "unchanged") continue;
|
|
4881
|
+
kept.set(shot.id, {
|
|
4882
|
+
id: shot.id,
|
|
4883
|
+
route: shot.route,
|
|
4884
|
+
depicts: shot.depicts,
|
|
4885
|
+
file: `${shot.id}.png`,
|
|
4886
|
+
sha256: shot.sha256,
|
|
4887
|
+
settled: unchanged.settled !== false,
|
|
4888
|
+
...previous.get(shot.id)?.layout ? { layout: `${shot.id}.json` } : {},
|
|
4889
|
+
...shot.variant ? { variant: shot.variant } : {}
|
|
4890
|
+
});
|
|
4891
|
+
}
|
|
4892
|
+
return [...kept.values()].sort((a, b) => a.id.localeCompare(b.id));
|
|
4893
|
+
}
|
|
3505
4894
|
async function previousFrames(out) {
|
|
3506
4895
|
try {
|
|
3507
|
-
const manifest = JSON.parse(await
|
|
3508
|
-
return new Map((manifest.frames ?? []).map((frame) => [frame.id, frame.sha256]));
|
|
4896
|
+
const manifest = JSON.parse(await readFile2(path4.join(out, "manifest.json"), "utf8"));
|
|
4897
|
+
return new Map((manifest.frames ?? []).map((frame) => [frame.id, { sha256: frame.sha256, layout: Boolean(frame.layout) }]));
|
|
3509
4898
|
} catch {
|
|
3510
4899
|
return /* @__PURE__ */ new Map();
|
|
3511
4900
|
}
|
|
@@ -3521,11 +4910,13 @@ async function main(argv) {
|
|
|
3521
4910
|
const local = parsed.out;
|
|
3522
4911
|
const frames = [];
|
|
3523
4912
|
let orders;
|
|
4913
|
+
let previous = /* @__PURE__ */ new Map();
|
|
3524
4914
|
if (local) {
|
|
3525
4915
|
const repo = parsed.repo ?? process.cwd();
|
|
3526
|
-
await
|
|
4916
|
+
await mkdir2(local, { recursive: true });
|
|
3527
4917
|
try {
|
|
3528
|
-
|
|
4918
|
+
previous = await previousFrames(local);
|
|
4919
|
+
orders = { baseUrl: null, shots: shotsFromRepo(repo, previous) };
|
|
3529
4920
|
} catch (error) {
|
|
3530
4921
|
console.error(
|
|
3531
4922
|
`Could not read a plan from ${repo}: ${error.message}
|
|
@@ -3542,6 +4933,7 @@ Run this from your repository, or pass --repo <dir>.`
|
|
|
3542
4933
|
return 1;
|
|
3543
4934
|
}
|
|
3544
4935
|
orders = await listed.json();
|
|
4936
|
+
previous = heldPhoneFrames(orders.shots);
|
|
3545
4937
|
if (!orders.capture) {
|
|
3546
4938
|
console.error(
|
|
3547
4939
|
`This workspace has approved no capture subject, so the help desk will refuse every frame.
|
|
@@ -3563,14 +4955,16 @@ Approve one on the plan page, then run this again.`
|
|
|
3563
4955
|
}
|
|
3564
4956
|
let stage;
|
|
3565
4957
|
let seeded;
|
|
4958
|
+
const pinnedClock = parsed.liveClock ? {} : { pinnedClock: CAPTURE_CONTRACT.pinnedClock };
|
|
3566
4959
|
if (parsed.seedCommand) {
|
|
3567
4960
|
try {
|
|
3568
4961
|
seeded = await stageSeeded({
|
|
3569
4962
|
appUrl: baseUrl,
|
|
3570
4963
|
seedCommand: parsed.seedCommand,
|
|
3571
4964
|
...parsed.appCommand ? { appCommand: parsed.appCommand } : {},
|
|
3572
|
-
|
|
3573
|
-
|
|
4965
|
+
...pinnedClock,
|
|
4966
|
+
sessionOut: parsed.session ?? path4.join(process.cwd(), "helpdesk-demo-session.json"),
|
|
4967
|
+
examplesOut: path4.join(process.cwd(), "helpdesk-examples.json"),
|
|
3574
4968
|
onLog: (line) => console.log(line)
|
|
3575
4969
|
});
|
|
3576
4970
|
if (seeded.sessionPath) parsed.session = seeded.sessionPath;
|
|
@@ -3581,13 +4975,15 @@ Approve one on the plan page, then run this again.`
|
|
|
3581
4975
|
return 1;
|
|
3582
4976
|
}
|
|
3583
4977
|
}
|
|
3584
|
-
const
|
|
4978
|
+
const withRecords = seeded?.examples ? withExamples(shots, seeded.examples) : shots;
|
|
4979
|
+
const planned = seededLast(parsed.phone ? withPhoneShots(withRecords, previous, seeded?.examples?.again) : withRecords);
|
|
3585
4980
|
if (parsed.cassette) {
|
|
3586
4981
|
try {
|
|
3587
4982
|
stage = await stageAgainstRecording({
|
|
3588
|
-
cassette: parseCassette(await
|
|
4983
|
+
cassette: parseCassette(await readFile2(parsed.cassette, "utf8")),
|
|
3589
4984
|
appUrl: baseUrl,
|
|
3590
4985
|
...parsed.appCommand ? { appCommand: parsed.appCommand } : {},
|
|
4986
|
+
...pinnedClock,
|
|
3591
4987
|
...parsed.appEnv ? { appEnv: parsed.appEnv } : {},
|
|
3592
4988
|
...parsed.replayPort ? { replayPort: parsed.replayPort } : {},
|
|
3593
4989
|
onLog: (line) => console.log(line)
|
|
@@ -3611,7 +5007,8 @@ Approve one on the plan page, then run this again.`
|
|
|
3611
5007
|
session: Boolean(parsed.session),
|
|
3612
5008
|
...stage ? { replaying: parsed.cassette } : {},
|
|
3613
5009
|
...parsed.allowProduction ? { productionGranted: true } : {},
|
|
3614
|
-
...parsed.rebaseline ? { rebaselineReason: parsed.rebaseline } : {}
|
|
5010
|
+
...parsed.rebaseline ? { rebaselineReason: parsed.rebaseline } : {},
|
|
5011
|
+
...parsed.clips ? { clips: true } : {}
|
|
3615
5012
|
});
|
|
3616
5013
|
console.log(checks.lines.join("\n"));
|
|
3617
5014
|
console.log("");
|
|
@@ -3629,9 +5026,10 @@ Approve one on the plan page, then run this again.`
|
|
|
3629
5026
|
console.log(`${coverageReport(found, shots.length).join("\n")}
|
|
3630
5027
|
`);
|
|
3631
5028
|
}
|
|
5029
|
+
const phones = parsed.phone ? `, each opened at its own address again at phone width (${PHONE_VIEWPORTS.map((viewport) => `${viewport.width}px`).join(", then ")}) if it is laid out for a phone` : "";
|
|
3632
5030
|
console.log(
|
|
3633
|
-
stage ? `Photographing ${shots.length} screen(s) of ${baseUrl}, served from ${parsed.cassette}.
|
|
3634
|
-
` : `Photographing ${shots.length} screen(s) of ${baseUrl}.
|
|
5031
|
+
stage ? `Photographing ${shots.length} screen(s) of ${baseUrl}${phones}, served from ${parsed.cassette}.
|
|
5032
|
+
` : `Photographing ${shots.length} screen(s) of ${baseUrl}${phones}.
|
|
3635
5033
|
`
|
|
3636
5034
|
);
|
|
3637
5035
|
const runId = local ? void 0 : await openRun(parsed.to, headers, shots.length);
|
|
@@ -3639,26 +5037,25 @@ Approve one on the plan page, then run this again.`
|
|
|
3639
5037
|
const tell = (body) => {
|
|
3640
5038
|
told = told.then(() => tellHelpDesk(parsed.to, headers, body));
|
|
3641
5039
|
};
|
|
5040
|
+
const clearance = orders.capture ? {
|
|
5041
|
+
subject: orders.capture.subject,
|
|
5042
|
+
routes: orders.capture.routes,
|
|
5043
|
+
regions: orders.capture.regions,
|
|
5044
|
+
// A cover is where to put a rectangle; an approval is where not
|
|
5045
|
+
// to. Both are read off the clearance the help desk holds, and
|
|
5046
|
+
// the second is checked again against that same record when the
|
|
5047
|
+
// frame arrives, so this run cannot approve anything by saying so.
|
|
5048
|
+
covers: (orders.capture.surfaces ?? []).filter((surface) => surface.treatment?.treat === "cover").map((surface) => surface.where),
|
|
5049
|
+
approved: (orders.capture.surfaces ?? []).filter((surface) => surface.treatment?.treat === "approve").map((surface) => surface.where),
|
|
5050
|
+
coverUnnamed: orders.capture.coverUnnamed === true,
|
|
5051
|
+
expectedBrands: orders.capture.expectedBrands ?? []
|
|
5052
|
+
} : void 0;
|
|
3642
5053
|
try {
|
|
3643
5054
|
const report = await runCapture({
|
|
3644
5055
|
baseUrl,
|
|
3645
5056
|
shots: planned,
|
|
3646
5057
|
browser,
|
|
3647
|
-
...
|
|
3648
|
-
clearance: {
|
|
3649
|
-
subject: orders.capture.subject,
|
|
3650
|
-
routes: orders.capture.routes,
|
|
3651
|
-
regions: orders.capture.regions,
|
|
3652
|
-
// A cover is where to put a rectangle; an approval is where not
|
|
3653
|
-
// to. Both are read off the clearance the help desk holds, and
|
|
3654
|
-
// the second is checked again against that same record when the
|
|
3655
|
-
// frame arrives, so this run cannot approve anything by saying so.
|
|
3656
|
-
covers: (orders.capture.surfaces ?? []).filter((surface) => surface.treatment?.treat === "cover").map((surface) => surface.where),
|
|
3657
|
-
approved: (orders.capture.surfaces ?? []).filter((surface) => surface.treatment?.treat === "approve").map((surface) => surface.where),
|
|
3658
|
-
coverUnnamed: orders.capture.coverUnnamed === true,
|
|
3659
|
-
expectedBrands: orders.capture.expectedBrands ?? []
|
|
3660
|
-
}
|
|
3661
|
-
} : {},
|
|
5058
|
+
...clearance ? { clearance } : {},
|
|
3662
5059
|
brands: parsed.brands,
|
|
3663
5060
|
force: Boolean(parsed.rebaseline),
|
|
3664
5061
|
// The commit a workflow run checked out, so the screens' details say
|
|
@@ -3674,8 +5071,9 @@ Approve one on the plan page, then run this again.`
|
|
|
3674
5071
|
role: parsed.session ? unknownRole() : noRole("this run was signed out"),
|
|
3675
5072
|
onProgress: (outcome, index, total) => {
|
|
3676
5073
|
const mark = { captured: "\u2713", unchanged: "=", skipped: "\u2013", refused: "\u2717", failed: "!" }[outcome.status];
|
|
3677
|
-
|
|
3678
|
-
|
|
5074
|
+
const screen = outcome.variant ? `${outcome.route} at phone width` : outcome.route;
|
|
5075
|
+
console.log(`${mark} ${index}/${total} ${screen}${outcome.reason ? `: ${outcome.reason}` : ""}`);
|
|
5076
|
+
if (runId && !outcome.variant) {
|
|
3679
5077
|
tell({
|
|
3680
5078
|
action: "progress",
|
|
3681
5079
|
jobId: runId,
|
|
@@ -3692,44 +5090,13 @@ Approve one on the plan page, then run this again.`
|
|
|
3692
5090
|
},
|
|
3693
5091
|
async send(frame) {
|
|
3694
5092
|
if (local) {
|
|
3695
|
-
|
|
3696
|
-
await writeFile(path2.join(local, file), frame.image);
|
|
3697
|
-
frames.push({
|
|
3698
|
-
id: frame.shot.id,
|
|
3699
|
-
route: frame.shot.route,
|
|
3700
|
-
depicts: frame.shot.depicts,
|
|
3701
|
-
file,
|
|
3702
|
-
sha256: frame.sha256,
|
|
3703
|
-
settled: frame.settled,
|
|
3704
|
-
...frame.browser ? { browser: frame.browser } : {},
|
|
3705
|
-
...frame.width ? { width: frame.width } : {},
|
|
3706
|
-
...frame.height ? { height: frame.height } : {}
|
|
3707
|
-
});
|
|
5093
|
+
frames.push(await writeLocalFrame(local, frame));
|
|
3708
5094
|
return { ok: true };
|
|
3709
5095
|
}
|
|
3710
|
-
const form = new FormData();
|
|
3711
|
-
form.set(
|
|
3712
|
-
"meta",
|
|
3713
|
-
JSON.stringify({
|
|
3714
|
-
shotId: frame.shot.id,
|
|
3715
|
-
route: frame.shot.route,
|
|
3716
|
-
depicts: frame.shot.depicts,
|
|
3717
|
-
pageText: frame.pageText,
|
|
3718
|
-
capturedFrom: frame.capturedFrom,
|
|
3719
|
-
settled: frame.settled,
|
|
3720
|
-
// What this run read off the screen. Absent is a refusal at the
|
|
3721
|
-
// other end rather than a default, which is the point of sending it.
|
|
3722
|
-
...frame.report ? { report: frame.report } : {},
|
|
3723
|
-
...frame.browser ? { browser: frame.browser } : {},
|
|
3724
|
-
...frame.width ? { width: frame.width } : {},
|
|
3725
|
-
...frame.height ? { height: frame.height } : {}
|
|
3726
|
-
})
|
|
3727
|
-
);
|
|
3728
|
-
form.set("image", new Blob([frame.image], { type: "image/png" }), `${frame.shot.id}.png`);
|
|
3729
5096
|
const response = await fetch(`${parsed.to}/api/helpdesk/frames`, {
|
|
3730
5097
|
method: "POST",
|
|
3731
5098
|
headers,
|
|
3732
|
-
body:
|
|
5099
|
+
body: frameForm(frame)
|
|
3733
5100
|
});
|
|
3734
5101
|
if (response.ok) return { ok: true };
|
|
3735
5102
|
const body = await response.json().catch(() => ({}));
|
|
@@ -3738,41 +5105,31 @@ Approve one on the plan page, then run this again.`
|
|
|
3738
5105
|
});
|
|
3739
5106
|
console.log(`
|
|
3740
5107
|
${runCounts(report)}.`);
|
|
5108
|
+
if (report.phone) console.log(`At phone width: ${runCounts(report.phone)}.`);
|
|
3741
5109
|
const read = Object.keys(report.readings).length;
|
|
3742
5110
|
if (read && !local) {
|
|
3743
5111
|
console.log(`Read what ${read} screen${read === 1 ? "" : "s"} say, as the help desk's screen details: headings, buttons, fields and messages.`);
|
|
3744
5112
|
}
|
|
3745
5113
|
const unclassified = unclassifiedLines(
|
|
3746
5114
|
groupUnclassified(
|
|
3747
|
-
report.outcomes.flatMap(
|
|
5115
|
+
report.outcomes.flatMap(
|
|
5116
|
+
(outcome) => outcome.unaccounted?.length && !outcome.variant ? [{ route: outcome.route, values: outcome.unaccounted }] : []
|
|
5117
|
+
)
|
|
3748
5118
|
)
|
|
3749
5119
|
);
|
|
3750
5120
|
if (unclassified.length) console.log(`
|
|
3751
5121
|
${unclassified.join("\n")}`);
|
|
3752
5122
|
if (local) {
|
|
3753
|
-
const kept =
|
|
3754
|
-
|
|
3755
|
-
|
|
3756
|
-
const unchanged = report.outcomes.find((outcome) => outcome.shotId === shot.id);
|
|
3757
|
-
if (unchanged?.status !== "unchanged") continue;
|
|
3758
|
-
kept.set(shot.id, {
|
|
3759
|
-
id: shot.id,
|
|
3760
|
-
route: shot.route,
|
|
3761
|
-
depicts: shot.depicts,
|
|
3762
|
-
file: `${shot.id}.png`,
|
|
3763
|
-
sha256: shot.sha256,
|
|
3764
|
-
settled: unchanged.settled !== false
|
|
3765
|
-
});
|
|
3766
|
-
}
|
|
3767
|
-
await writeFile(
|
|
3768
|
-
path2.join(local, "manifest.json"),
|
|
5123
|
+
const kept = framesOnDisk(frames, planned, report.outcomes, previous);
|
|
5124
|
+
await writeFile3(
|
|
5125
|
+
path4.join(local, "manifest.json"),
|
|
3769
5126
|
`${JSON.stringify(
|
|
3770
5127
|
{
|
|
3771
5128
|
schema: "olomon.capture-manifest/1",
|
|
3772
5129
|
capturedFrom: baseUrl,
|
|
3773
5130
|
capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3774
5131
|
...parsed.cassette ? { recording: parsed.cassette } : {},
|
|
3775
|
-
frames:
|
|
5132
|
+
frames: kept,
|
|
3776
5133
|
survey: report.survey
|
|
3777
5134
|
},
|
|
3778
5135
|
null,
|
|
@@ -3782,7 +5139,22 @@ ${unclassified.join("\n")}`);
|
|
|
3782
5139
|
"utf8"
|
|
3783
5140
|
);
|
|
3784
5141
|
console.log(`
|
|
3785
|
-
Written to ${local}: ${kept.
|
|
5142
|
+
Written to ${local}: ${kept.length} frame(s) and manifest.json.`);
|
|
5143
|
+
}
|
|
5144
|
+
if (parsed.clips) {
|
|
5145
|
+
await recordSeededClips({
|
|
5146
|
+
scripts: seeded?.clips ?? [],
|
|
5147
|
+
browser,
|
|
5148
|
+
baseUrl,
|
|
5149
|
+
clearance,
|
|
5150
|
+
brands: parsed.brands,
|
|
5151
|
+
outcomes: report.outcomes,
|
|
5152
|
+
local,
|
|
5153
|
+
to: parsed.to,
|
|
5154
|
+
headers,
|
|
5155
|
+
held: new Map((orders.clips ?? []).map((clip) => [clip.id, clip.fingerprint])),
|
|
5156
|
+
force: Boolean(parsed.rebaseline)
|
|
5157
|
+
});
|
|
3786
5158
|
}
|
|
3787
5159
|
if (report.survey.length) {
|
|
3788
5160
|
const survey = captureReport(report.survey);
|
|
@@ -3797,7 +5169,7 @@ ${surveyLine(report.survey)}
|
|
|
3797
5169
|
if (unstable.length) {
|
|
3798
5170
|
console.log(
|
|
3799
5171
|
`
|
|
3800
|
-
${unstable.length} screen(s) never stopped moving: ${unstable.map((o) => o.route).join(", ")}.
|
|
5172
|
+
${unstable.length} screen(s) never stopped moving: ${unstable.map((o) => o.variant ? `${o.route} at phone width` : o.route).join(", ")}.
|
|
3801
5173
|
Their frames were kept, but they will hash differently every run, so nothing can tell you
|
|
3802
5174
|
whether they changed. Usually a spinner that never resolves or a looping animation.`
|
|
3803
5175
|
);
|
|
@@ -3820,8 +5192,9 @@ not somebody's data. The session file stays on this machine and is sent nowhere.
|
|
|
3820
5192
|
console.log(
|
|
3821
5193
|
`
|
|
3822
5194
|
${attestation({
|
|
3823
|
-
|
|
3824
|
-
|
|
5195
|
+
// Of frames, which the phone-width ones are as much as the screens' and were checked the same way.
|
|
5196
|
+
captured: report.captured + (report.phone?.captured ?? 0),
|
|
5197
|
+
unchanged: report.unchanged + (report.phone?.unchanged ?? 0),
|
|
3825
5198
|
refused: report.refused,
|
|
3826
5199
|
baseUrl,
|
|
3827
5200
|
...report.browser ? { browser: report.browser } : {},
|
|
@@ -3875,12 +5248,12 @@ ${verdict.sentence}`);
|
|
|
3875
5248
|
}
|
|
3876
5249
|
}
|
|
3877
5250
|
async function openRun(to, headers, total) {
|
|
3878
|
-
const
|
|
5251
|
+
const run2 = runIdentityFromEnv(process.env);
|
|
3879
5252
|
try {
|
|
3880
5253
|
const response = await fetch(`${to}/api/helpdesk/captures`, {
|
|
3881
5254
|
method: "POST",
|
|
3882
5255
|
headers: { ...headers, "content-type": "application/json" },
|
|
3883
|
-
body: JSON.stringify({ action: "start", total, ...
|
|
5256
|
+
body: JSON.stringify({ action: "start", total, ...run2 ? { run: run2 } : {} })
|
|
3884
5257
|
});
|
|
3885
5258
|
if (!response.ok) return void 0;
|
|
3886
5259
|
const body = await response.json();
|
|
@@ -3934,6 +5307,15 @@ if (runningAsCommand(import.meta.url)) {
|
|
|
3934
5307
|
);
|
|
3935
5308
|
}
|
|
3936
5309
|
export {
|
|
5310
|
+
clipForm,
|
|
5311
|
+
frameForm,
|
|
5312
|
+
framesOnDisk,
|
|
3937
5313
|
main,
|
|
3938
|
-
|
|
5314
|
+
onePerScreen,
|
|
5315
|
+
seededLast,
|
|
5316
|
+
shotsFromRepo,
|
|
5317
|
+
withExamples,
|
|
5318
|
+
withPhoneShots,
|
|
5319
|
+
writeLocalClip,
|
|
5320
|
+
writeLocalFrame
|
|
3939
5321
|
};
|