@olomonhelpdesk2/helpdesk 0.1.11 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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(path3) {
8
- return path3.split("/").filter((part) => part !== "");
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, path3) {
24
+ function fillsPattern(pattern, path5) {
25
25
  const want = patternOf(pattern);
26
- const have = segmentsOf(path3);
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(path3) {
41
- if (path3.length > MAX_PATH) return "the path is longer than any screen's";
42
- if (!path3.startsWith("/") || path3.startsWith("//")) return "the path is not a path inside the product";
43
- if (/[?#\\]/.test(path3)) return "the path carries a query, a fragment or a backslash";
44
- if (/\s/.test(path3) || [...path3].some((character) => character.charCodeAt(0) < 32)) {
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(path3);
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(path3)) return "the path still holds a dynamic segment rather than a record";
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 path3 = mark < 0 ? named : named.slice(0, mark);
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 (path3 !== route) return { because: `${named} is not ${route} with a query` };
60
+ if (path5 !== route) return { because: `${named} is not ${route} with a query` };
61
61
  const unusable = unusablePath(route);
62
62
  if (unusable) return { because: unusable };
63
63
  if (named.length > MAX_PATH || !PLAIN_QUERY.test(query)) return { because: "the query is not a plain list of names and values" };
@@ -94,25 +94,25 @@ function readExampleRoutes(raw) {
94
94
  else paths.set(route, opened.visit);
95
95
  continue;
96
96
  }
97
- const path3 = value.trim();
98
- const unusable = unusablePath(path3);
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, path3)) {
104
- refused.push({ route, because: `${path3} is not ${route} with its dynamic segments filled in` });
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, path3);
107
+ paths.set(route, path5);
108
108
  }
109
109
  return { ok: true, examples: { paths, refused } };
110
110
  }
111
111
  function resolveVisit(route, examples) {
112
112
  const needs = dynamicSegments(route);
113
- const path3 = examples.paths.get(route);
114
- if (needs.length === 0) return { visit: path3 ?? route };
115
- return path3 ? { visit: path3 } : { needs };
113
+ const path5 = examples.paths.get(route);
114
+ if (needs.length === 0) return { visit: path5 ?? route };
115
+ return path5 ? { visit: path5 } : { needs };
116
116
  }
117
117
  function describeMissingExample(route, needs) {
118
118
  const named = needs.map((name) => `[${name}]`).join(" and ");
@@ -212,8 +212,8 @@ function actionFor(outcome) {
212
212
  return `${outcome.route} asked for ${outcome.missingEndpoint ?? "data"}, which is not in the recording. Visit this screen while recording, then run this again.`;
213
213
  }
214
214
  }
215
- function subjectOf(path3) {
216
- const segments = path3.split("/").filter(Boolean).filter((segment) => !/^(api|v\d+)$/.test(segment));
215
+ function subjectOf(path5) {
216
+ const segments = path5.split("/").filter(Boolean).filter((segment) => !/^(api|v\d+)$/.test(segment));
217
217
  while (segments.length > 1 && looksLikeAnId(segments[segments.length - 1])) segments.pop();
218
218
  const last = segments[segments.length - 1] ?? "record";
219
219
  return last.replace(/[-_]/g, " ").replace(/s$/, "");
@@ -324,11 +324,11 @@ function canonicalisation(asked, landed) {
324
324
  function samePlace(asked, landed) {
325
325
  return stripQuery(asked) === stripQuery(landed);
326
326
  }
327
- function stripQuery(path3) {
328
- return path3.split(/[?#]/)[0] ?? path3;
327
+ function stripQuery(path5) {
328
+ return path5.split(/[?#]/)[0] ?? path5;
329
329
  }
330
- function normalise(path3) {
331
- return stripQuery(path3).toLowerCase().replace(/\/index(\.html?)?$/, "/").replace(/\/+$/, "").replace(/^$/, "/");
330
+ function normalise(path5) {
331
+ return stripQuery(path5).toLowerCase().replace(/\/index(\.html?)?$/, "/").replace(/\/+$/, "").replace(/^$/, "/");
332
332
  }
333
333
  function firstSentence(text2) {
334
334
  const sentence = text2.trim().split(/(?<=[.!?])\s|\n/)[0] ?? text2;
@@ -422,7 +422,7 @@ function preflight(facts) {
422
422
  name: "posture",
423
423
  ok: true,
424
424
  blocking: false,
425
- detail: "read-only: this navigates and photographs, and activates no control"
425
+ detail: facts.clips ? "read-only for the screenshots; after them, the clips the seed command named press the controls their steps name, in the tenant it built" : "read-only: this navigates and photographs, and activates no control"
426
426
  });
427
427
  if (facts.rebaselineReason) {
428
428
  checks.push({
@@ -440,19 +440,19 @@ function preflight(facts) {
440
440
  ];
441
441
  return { ok: !blocked.length, checks, lines };
442
442
  }
443
- function attestation(run) {
444
- const took = run.unchanged ? `${run.captured} new frame(s) and ${run.unchanged} verified unchanged` : `${run.captured} frame(s)`;
443
+ function attestation(run2) {
444
+ const took = run2.unchanged ? `${run2.captured} new frame(s) and ${run2.unchanged} verified unchanged` : `${run2.captured} frame(s)`;
445
445
  return [
446
- `${took} from ${run.baseUrl}${run.browser ? ` with ${run.browser}` : ""}.`,
446
+ `${took} from ${run2.baseUrl}${run2.browser ? ` with ${run2.browser}` : ""}.`,
447
447
  "Every one was checked before the shutter and again on arrival: no frame in this set showed an email address,",
448
448
  "an account or card number, a national or tax id, a date of birth, a passport number or a named institution.",
449
- run.refused ? `${run.refused} screen(s) were refused for showing something that looked real, and no picture of them exists.` : "Nothing was refused.",
449
+ run2.refused ? `${run2.refused} screen(s) were refused for showing something that looked real, and no picture of them exists.` : "Nothing was refused.",
450
450
  // Two different claims, and the second is much stronger. "Nothing that
451
451
  // looked real got through" is a gate doing its job on unknown data; "there
452
452
  // was no real data behind any of it" is a fact about where the data came
453
453
  // from, and it is the one a compliance reader actually wants.
454
- run.replaying ? `Every screen was served from ${run.replaying}, so no customer data was reachable during this run at all.` : "",
455
- run.clockPinned && run.replaying ? "The clock was pinned and the data was fixed, so re-running this reproduces the same bytes." : run.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."
454
+ run2.replaying ? `Every screen was served from ${run2.replaying}, so no customer data was reachable during this run at all.` : "",
455
+ run2.clockPinned && run2.replaying ? "The clock was pinned and the data was fixed, so re-running this reproduces the same bytes." : run2.clockPinned ? "The clock was pinned. The backend was live, so any screen showing its data can still move between runs." : "The clock ran live, so these frames are not reproducible."
456
456
  ].filter(Boolean).join("\n");
457
457
  }
458
458
 
@@ -473,24 +473,24 @@ function runIdentityFromEnv(env) {
473
473
  }
474
474
 
475
475
  // core/capture/run-verdict.ts
476
- function runCounts(run) {
477
- return `${run.captured} photographed, ${run.unchanged} unchanged, ${run.skipped} skipped, ${run.refused} refused, ${run.failed} failed`;
478
- }
479
- function runVerdict(run) {
480
- const delivered = run.captured + run.unchanged;
481
- const missed = run.skipped + run.refused + run.failed;
482
- const counts = runCounts(run);
483
- if (run.screens > 0 && delivered === 0) {
476
+ function runCounts(run2) {
477
+ return `${run2.captured} photographed, ${run2.unchanged} unchanged, ${run2.skipped} skipped, ${run2.refused} refused, ${run2.failed} failed`;
478
+ }
479
+ function runVerdict(run2) {
480
+ const delivered = run2.captured + run2.unchanged;
481
+ const missed = run2.skipped + run2.refused + run2.failed;
482
+ const counts = runCounts(run2);
483
+ if (run2.screens > 0 && delivered === 0) {
484
484
  return {
485
485
  exit: 1,
486
- annotation: `::error title=No screenshots were delivered::None of the ${run.screens} screens was photographed (${counts}). The step log says why for each one.`,
486
+ annotation: `::error title=No screenshots were delivered::None of the ${run2.screens} screens was photographed (${counts}). The step log says why for each one.`,
487
487
  sentence: `Nothing was delivered, so this run fails rather than passing with no screenshots: ${counts}.`
488
488
  };
489
489
  }
490
490
  if (missed > 0) {
491
491
  return {
492
- exit: run.failed > 0 ? 1 : 0,
493
- annotation: `::warning title=${missed} of ${run.screens} screens were not photographed::${counts}. The step log says why for each one.`
492
+ exit: run2.failed > 0 ? 1 : 0,
493
+ annotation: `::warning title=${missed} of ${run2.screens} screens were not photographed::${counts}. The step log says why for each one.`
494
494
  };
495
495
  }
496
496
  return { exit: 0 };
@@ -777,7 +777,9 @@ function looksLikeSignIn(fields) {
777
777
  import { deflateSync, inflateSync } from "node:zlib";
778
778
  var MediaRefused = class extends Error {
779
779
  };
780
+ var VERIFICATION_SENTINEL = { r: 255, g: 0, b: 255 };
780
781
  var PUBLICATION_REDACTION = { r: 100, g: 116, b: 139 };
782
+ var cssColour = (colour) => `#${[colour.r, colour.g, colour.b].map((v) => v.toString(16).padStart(2, "0")).join("")}`;
781
783
  function decodePng(buffer) {
782
784
  if (buffer.length < 8 || buffer.readUInt32BE(0) !== 2303741511) {
783
785
  throw new MediaRefused("that is not a PNG");
@@ -856,6 +858,24 @@ function coveredFraction(image) {
856
858
  return covered / total;
857
859
  }
858
860
  var MOSTLY_COVERED = 0.5;
861
+ function readSentinel(image) {
862
+ let pixels = 0;
863
+ let at;
864
+ for (let y = 0; y < image.height; y += 1) {
865
+ for (let x = 0; x < image.width; x += 1) {
866
+ const index = (y * image.width + x) * 4;
867
+ if (image.pixels[index] === VERIFICATION_SENTINEL.r && image.pixels[index + 1] === VERIFICATION_SENTINEL.g && image.pixels[index + 2] === VERIFICATION_SENTINEL.b) {
868
+ pixels += 1;
869
+ at ??= { x, y };
870
+ }
871
+ }
872
+ }
873
+ return {
874
+ pixels,
875
+ ...at ? { at } : {},
876
+ because: pixels === 0 ? `no pixel in this image is ${cssColour(VERIFICATION_SENTINEL)}` : `${pixels} pixel(s) of this image are ${cssColour(VERIFICATION_SENTINEL)}, the first at (${at.x}, ${at.y})`
877
+ };
878
+ }
859
879
 
860
880
  // browser/page-side.ts
861
881
  function inPage(fn, arg) {
@@ -1029,6 +1049,665 @@ async function readPaintedMarkup(page, regions) {
1029
1049
  );
1030
1050
  }
1031
1051
 
1052
+ // browser/layout.ts
1053
+ async function readPageLayout(page, cover = []) {
1054
+ return page.evaluate(
1055
+ inPage(
1056
+ (spec) => {
1057
+ const CONTROLS = [
1058
+ "button",
1059
+ "link",
1060
+ "textbox",
1061
+ "searchbox",
1062
+ "checkbox",
1063
+ "radio",
1064
+ "combobox",
1065
+ "listbox",
1066
+ "switch",
1067
+ "tab",
1068
+ "menuitem",
1069
+ "menuitemcheckbox",
1070
+ "menuitemradio",
1071
+ "option",
1072
+ "slider",
1073
+ "spinbutton"
1074
+ ];
1075
+ const LANDMARKS = ["banner", "navigation", "main", "complementary", "contentinfo", "search", "form", "region", "dialog", "alertdialog"];
1076
+ const HEADINGS = "h1, h2, h3, h4, h5, h6, [role=heading]";
1077
+ const clean = (text2) => (text2 ?? "").replace(/\s+/g, " ").trim();
1078
+ const painted = (element) => {
1079
+ const style = window.getComputedStyle(element);
1080
+ if (style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse" || style.opacity === "0") return false;
1081
+ for (const box of Array.from(element.getClientRects())) {
1082
+ if (box.width > 0 && box.height > 0) return true;
1083
+ }
1084
+ return false;
1085
+ };
1086
+ const unseen = (element) => {
1087
+ if (element.closest('[aria-hidden="true"], [inert]')) return true;
1088
+ for (let node = element; node; node = node.parentElement) {
1089
+ const style = window.getComputedStyle(node);
1090
+ if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") return true;
1091
+ }
1092
+ return !painted(element);
1093
+ };
1094
+ const covered = (element) => spec.cover.some((selector) => {
1095
+ try {
1096
+ return element.closest(selector) !== null;
1097
+ } catch {
1098
+ return false;
1099
+ }
1100
+ });
1101
+ const textOfIds = (ids) => clean(
1102
+ ids.split(/\s+/).map((id) => {
1103
+ const target = document.getElementById(id);
1104
+ return target ? target.innerText ?? target.textContent ?? "" : "";
1105
+ }).join(" ")
1106
+ );
1107
+ const labelOf = (element) => {
1108
+ const by = element.getAttribute("aria-labelledby");
1109
+ const fromIds = by ? textOfIds(by) : "";
1110
+ return fromIds || clean(element.getAttribute("aria-label")) || clean(element.getAttribute("title"));
1111
+ };
1112
+ const scoped = (element) => Boolean(element.parentElement?.closest("article, aside, main, nav, section"));
1113
+ const roleOf = (element) => {
1114
+ const explicit = clean(element.getAttribute("role")).split(" ")[0];
1115
+ if (explicit) return explicit;
1116
+ const tag = element.tagName.toLowerCase();
1117
+ if (tag === "a") return element.hasAttribute("href") ? "link" : null;
1118
+ if (tag === "button" || tag === "summary") return "button";
1119
+ if (tag === "textarea") return "textbox";
1120
+ if (tag === "select") return element.multiple || element.size > 1 ? "listbox" : "combobox";
1121
+ if (tag === "input") {
1122
+ const type = clean(element.getAttribute("type")).toLowerCase() || "text";
1123
+ if (["button", "submit", "reset", "image"].includes(type)) return "button";
1124
+ if (type === "checkbox") return "checkbox";
1125
+ if (type === "radio") return "radio";
1126
+ if (type === "range") return "slider";
1127
+ if (type === "number") return "spinbutton";
1128
+ if (type === "search") return "searchbox";
1129
+ if (type === "hidden" || type === "file" || type === "color") return null;
1130
+ return "textbox";
1131
+ }
1132
+ if (element.getAttribute("contenteditable") === "true" || element.getAttribute("contenteditable") === "") return "textbox";
1133
+ if (tag === "nav") return "navigation";
1134
+ if (tag === "main") return "main";
1135
+ if (tag === "aside") return "complementary";
1136
+ if (tag === "search") return "search";
1137
+ if (tag === "header") return scoped(element) ? null : "banner";
1138
+ if (tag === "footer") return scoped(element) ? null : "contentinfo";
1139
+ if (tag === "form") return labelOf(element) ? "form" : null;
1140
+ if (tag === "section") return labelOf(element) ? "region" : null;
1141
+ if (tag === "dialog") return element.open ? "dialog" : null;
1142
+ if (/^h[1-6]$/.test(tag)) return "heading";
1143
+ return null;
1144
+ };
1145
+ const contentOf = (element) => {
1146
+ const text2 = clean(element.innerText);
1147
+ if (text2) return text2;
1148
+ const alternatives = Array.from(element.querySelectorAll("img[alt], [aria-label], svg title")).map(
1149
+ (node) => node.tagName.toLowerCase() === "img" ? node.getAttribute("alt") : node.tagName.toLowerCase() === "title" ? node.textContent : node.getAttribute("aria-label")
1150
+ );
1151
+ return clean(alternatives.filter(Boolean).join(" "));
1152
+ };
1153
+ const nameOf = (element) => {
1154
+ const labelled = labelOf(element);
1155
+ if (labelled && (element.getAttribute("aria-labelledby") || element.getAttribute("aria-label"))) return labelled;
1156
+ const tag = element.tagName.toLowerCase();
1157
+ if (tag === "input" || tag === "textarea" || tag === "select") {
1158
+ const field = element;
1159
+ const type = clean(field.getAttribute("type")).toLowerCase();
1160
+ if (type === "submit" || type === "reset" || type === "button") return clean(field.value) || (type === "reset" ? "Reset" : type === "submit" ? "Submit" : "");
1161
+ if (type === "image") return clean(field.getAttribute("alt")) || "Submit";
1162
+ const labels = Array.from(field.labels ?? []).map((label) => clean(label.innerText));
1163
+ return clean(labels.filter(Boolean).join(" ")) || clean(field.getAttribute("placeholder")) || clean(field.getAttribute("title"));
1164
+ }
1165
+ return contentOf(element) || clean(element.getAttribute("title"));
1166
+ };
1167
+ const stateOf = (element, role) => {
1168
+ const state = {};
1169
+ const aria = (name) => element.getAttribute(`aria-${name}`);
1170
+ if (element.disabled === true || aria("disabled") === "true") state.disabled = true;
1171
+ if (["checkbox", "radio", "switch", "menuitemcheckbox", "menuitemradio"].includes(role)) {
1172
+ state.checked = element.tagName.toLowerCase() === "input" ? element.checked : aria("checked") === "true";
1173
+ }
1174
+ if (aria("pressed") !== null) state.pressed = aria("pressed") === "true";
1175
+ if (aria("expanded") !== null) state.expanded = aria("expanded") === "true";
1176
+ if (aria("selected") === "true") state.selected = true;
1177
+ if (aria("current") !== null && aria("current") !== "false") state.current = true;
1178
+ return state;
1179
+ };
1180
+ const boxOf = (element) => {
1181
+ const rect = element.getBoundingClientRect();
1182
+ return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
1183
+ };
1184
+ const levelOf = (heading) => Number(heading.getAttribute("aria-level")) || Number(heading.tagName.slice(1)) || 2;
1185
+ const sectionOf = (heading) => {
1186
+ const level = levelOf(heading);
1187
+ const rival = (node) => node !== heading && node.matches(HEADINGS) && levelOf(node) <= level && !unseen(node);
1188
+ const holdsRival = (node) => rival(node) || Array.from(node.querySelectorAll(HEADINGS)).some(rival);
1189
+ let top = heading;
1190
+ let open = false;
1191
+ for (let node = heading.parentElement; node && node !== document.body; node = node.parentElement) {
1192
+ if (holdsRival(node)) {
1193
+ open = true;
1194
+ break;
1195
+ }
1196
+ top = node;
1197
+ if (LANDMARKS.includes(roleOf(node) ?? "")) break;
1198
+ }
1199
+ if (covered(top)) return null;
1200
+ const run2 = [top];
1201
+ for (let next = open ? top.nextElementSibling : null; next && !holdsRival(next); next = next.nextElementSibling) {
1202
+ if (!unseen(next) && !covered(next)) run2.push(next);
1203
+ }
1204
+ const boxes = run2.map(boxOf).filter((box) => box.width > 0 && box.height > 0);
1205
+ if (!boxes.length) return null;
1206
+ const x = Math.min(...boxes.map((box) => box.x));
1207
+ const y = Math.min(...boxes.map((box) => box.y));
1208
+ return { x, y, width: Math.max(...boxes.map((box) => box.x + box.width)) - x, height: Math.max(...boxes.map((box) => box.y + box.height)) - y };
1209
+ };
1210
+ const transparent = (colour) => colour === "transparent" || /rgba\([^)]*,\s*0\)$/.test(colour);
1211
+ const backdropOf = (element) => {
1212
+ for (let node = element; node; node = node.parentElement) {
1213
+ const colour = window.getComputedStyle(node).backgroundColor;
1214
+ if (!transparent(colour)) return colour;
1215
+ }
1216
+ return "rgb(255, 255, 255)";
1217
+ };
1218
+ const setApart = (element) => {
1219
+ const style = window.getComputedStyle(element);
1220
+ const bordered = ["Top", "Right", "Bottom", "Left"].filter(
1221
+ (side) => parseFloat(style.getPropertyValue(`border-${side.toLowerCase()}-width`)) > 0 && !transparent(style.getPropertyValue(`border-${side.toLowerCase()}-color`))
1222
+ ).length;
1223
+ if (bordered >= 3 || style.boxShadow && style.boxShadow !== "none") return true;
1224
+ return !transparent(style.backgroundColor) && style.backgroundColor !== backdropOf(element.parentElement);
1225
+ };
1226
+ const NOT_TITLE = "button, a, input, select, textarea, label, th, td, [role=button], [role=link], [role=tab], [role=columnheader], [role=rowheader], [role=cell], [role=gridcell]";
1227
+ const boxedIn = (node, panel, frame) => {
1228
+ for (let around = node; around && around !== panel; around = around.parentElement) {
1229
+ if (!setApart(around)) continue;
1230
+ const rect = around.getBoundingClientRect();
1231
+ return !(rect.top - frame.y <= 2 && rect.width >= frame.width * 0.9);
1232
+ }
1233
+ return false;
1234
+ };
1235
+ const wordsOf = (node) => {
1236
+ const size = window.getComputedStyle(node).fontSize;
1237
+ const words = Array.from(node.childNodes).map((child) => {
1238
+ if (child.nodeType === Node.TEXT_NODE) return child.textContent ?? "";
1239
+ if (!(child instanceof Element) || child.matches(NOT_TITLE) || child.getAttribute("aria-hidden") === "true" || !painted(child)) return "";
1240
+ const style = window.getComputedStyle(child);
1241
+ return style.display.startsWith("inline") && style.fontSize === size ? wordsOf(child) : "";
1242
+ });
1243
+ return clean(words.join(""));
1244
+ };
1245
+ const titleOf = (panel, frame) => {
1246
+ for (const node of Array.from(panel.querySelectorAll("*"))) {
1247
+ if (node.closest(NOT_TITLE)) continue;
1248
+ if (!Array.from(node.childNodes).some((child) => child.nodeType === Node.TEXT_NODE && clean(child.textContent))) continue;
1249
+ if (node.getBoundingClientRect().top - frame.y > 64) break;
1250
+ if (Number(window.getComputedStyle(node).fontWeight) < 600 || boxedIn(node, panel, frame)) continue;
1251
+ const words = wordsOf(node);
1252
+ if (new RegExp("\\p{L}", "u").test(words)) return words;
1253
+ }
1254
+ return "";
1255
+ };
1256
+ const regions = [];
1257
+ const controls = [];
1258
+ const viewportArea = window.innerWidth * window.innerHeight;
1259
+ const PANELS = ["DIV", "SECTION", "ARTICLE", "ASIDE", "LI", "FORM", "FIELDSET", "DETAILS"];
1260
+ for (const element of Array.from(document.body?.querySelectorAll("*") ?? [])) {
1261
+ const role = roleOf(element);
1262
+ if (!role) {
1263
+ if (!PANELS.includes(element.tagName)) continue;
1264
+ const box2 = boxOf(element);
1265
+ if (box2.width < 160 || box2.height < 48 || box2.width * box2.height > viewportArea * 0.6) continue;
1266
+ if (unseen(element) || covered(element) || !setApart(element)) continue;
1267
+ const title = titleOf(element, box2);
1268
+ if (title) regions.push({ kind: "section", role: "region", label: title, box: box2 });
1269
+ continue;
1270
+ }
1271
+ if (unseen(element) || covered(element)) continue;
1272
+ const box = boxOf(element);
1273
+ if (box.width < 2 || box.height < 2) continue;
1274
+ if (CONTROLS.includes(role)) {
1275
+ const href = role === "link" ? element.href || element.getAttribute("href") : null;
1276
+ controls.push({ role, name: nameOf(element), box, state: stateOf(element, role), ...href ? { href } : {} });
1277
+ } else if (LANDMARKS.includes(role)) {
1278
+ regions.push({ kind: role === "dialog" || role === "alertdialog" ? "dialog" : "landmark", role, label: labelOf(element), box });
1279
+ } else if (role === "heading") {
1280
+ const section = sectionOf(element);
1281
+ if (section) regions.push({ kind: "section", role: "region", label: clean(element.innerText) || labelOf(element), box: section });
1282
+ }
1283
+ }
1284
+ return { viewport: { width: window.innerWidth, height: window.innerHeight }, scale: window.devicePixelRatio, title: clean(document.title), regions, controls };
1285
+ },
1286
+ { cover: [...cover] }
1287
+ )
1288
+ );
1289
+ }
1290
+ async function phoneFit(page, width) {
1291
+ return page.evaluate(
1292
+ inPage(
1293
+ (phone) => {
1294
+ const root = document.documentElement;
1295
+ const laid = root.clientWidth;
1296
+ if (laid > phone.width + 1) return { fits: false, because: `it lays itself out ${laid}px wide, as a page with no viewport for a phone does` };
1297
+ const content = Math.max(root.scrollWidth, document.body?.scrollWidth ?? 0);
1298
+ if (content > laid + 1) return { fits: false, because: `it runs ${Math.round(content - laid)}px off the side` };
1299
+ const main2 = document.querySelector("main, [role=main]");
1300
+ const column = main2 ? main2.getBoundingClientRect().width : laid;
1301
+ return column >= laid * 0.75 ? { fits: true } : { fits: false, because: `its main column is ${Math.round(column)}px of ${laid}px, beside the rest of the layout` };
1302
+ },
1303
+ { width }
1304
+ )
1305
+ );
1306
+ }
1307
+
1308
+ // browser/clip-cursor.ts
1309
+ async function drawCursor(page, at, pressed = false) {
1310
+ await page.evaluate(
1311
+ inPage(
1312
+ (spec) => {
1313
+ const id = "helpdesk-clip-cursor";
1314
+ let cursor = document.getElementById(id);
1315
+ if (!cursor) {
1316
+ cursor = document.createElement("div");
1317
+ cursor.id = id;
1318
+ cursor.setAttribute("aria-hidden", "true");
1319
+ cursor.style.cssText = "position:fixed;left:0;top:0;width:28px;height:28px;pointer-events:none;z-index:2147483647;transition:none;animation:none;";
1320
+ cursor.innerHTML = '<svg width="28" height="28" viewBox="0 0 28 28" style="overflow:visible" xmlns="http://www.w3.org/2000/svg"><circle data-press cx="0" cy="0" r="14" fill="rgba(37,99,235,0.3)" stroke="rgb(37,99,235)" stroke-width="2" style="display:none"/><path d="M0 0 L0 19 L5 14.5 L8.5 22.5 L11.6 21.1 L8.2 13.4 L14.8 13.4 Z" fill="rgb(17,24,39)" stroke="rgb(255,255,255)" stroke-width="1.6" stroke-linejoin="round"/></svg>';
1321
+ document.documentElement.appendChild(cursor);
1322
+ }
1323
+ cursor.style.transform = `translate(${spec.x}px, ${spec.y}px)`;
1324
+ const ring = cursor.querySelector("[data-press]");
1325
+ if (ring) ring.style.display = spec.pressed ? "" : "none";
1326
+ },
1327
+ { x: at.x, y: at.y, pressed }
1328
+ )
1329
+ );
1330
+ }
1331
+
1332
+ // core/capture/clips.ts
1333
+ import { createHash } from "node:crypto";
1334
+
1335
+ // core/capture/layout-manifest.ts
1336
+ var LAYOUT_MANIFEST_SCHEMA = "helpdesk.layout-manifest/1";
1337
+ var PHONE_SUFFIX = "--phone";
1338
+ var PHONE_VIEWPORT = { width: 390, height: 844 };
1339
+ var CONTROL_ROLES = [
1340
+ "button",
1341
+ "link",
1342
+ "textbox",
1343
+ "searchbox",
1344
+ "checkbox",
1345
+ "radio",
1346
+ "combobox",
1347
+ "listbox",
1348
+ "switch",
1349
+ "tab",
1350
+ "menuitem",
1351
+ "menuitemcheckbox",
1352
+ "menuitemradio",
1353
+ "option",
1354
+ "slider",
1355
+ "spinbutton"
1356
+ ];
1357
+ var REGION_ROLES = ["banner", "navigation", "main", "complementary", "contentinfo", "search", "form", "region", "dialog", "alertdialog"];
1358
+ var MAX_CONTROLS = 600;
1359
+ var MAX_REGIONS = 200;
1360
+ var MAX_TEXT = 200;
1361
+ var MAX_ID_SLUG = 48;
1362
+ function isRole(roles, value) {
1363
+ return typeof value === "string" && roles.includes(value);
1364
+ }
1365
+ function slugOf(text2) {
1366
+ return text2.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, MAX_ID_SLUG).replace(/-+$/g, "");
1367
+ }
1368
+ function identityOf(name) {
1369
+ return slugOf(name.replace(/(?:\s+\d[\d\s/.,:%]*)+$/, "")) || slugOf(name);
1370
+ }
1371
+ function textOf(value) {
1372
+ return typeof value === "string" ? value.replace(/\s+/g, " ").trim().slice(0, MAX_TEXT) : "";
1373
+ }
1374
+ function recordOf(value) {
1375
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
1376
+ }
1377
+ function readState(value) {
1378
+ const given = recordOf(value);
1379
+ const state = {};
1380
+ if (given["disabled"] === true) state.disabled = true;
1381
+ if (given["selected"] === true) state.selected = true;
1382
+ if (given["current"] === true) state.current = true;
1383
+ if (typeof given["checked"] === "boolean") state.checked = given["checked"];
1384
+ if (typeof given["pressed"] === "boolean") state.pressed = given["pressed"];
1385
+ if (typeof given["expanded"] === "boolean") state.expanded = given["expanded"];
1386
+ return Object.keys(state).length ? state : void 0;
1387
+ }
1388
+ function placed(box, viewport, scale) {
1389
+ if (![box.x, box.y, box.width, box.height].every(Number.isFinite)) return null;
1390
+ const snap = (value) => Math.round(value * scale) / scale;
1391
+ const left = Math.max(0, box.x);
1392
+ const top = Math.max(0, box.y);
1393
+ const right = Math.min(viewport.width, box.x + box.width);
1394
+ const bottom = Math.min(viewport.height, box.y + box.height);
1395
+ if (right - left < 1 || bottom - top < 1) return null;
1396
+ const x = snap(left);
1397
+ const y = snap(top);
1398
+ return {
1399
+ box: { x, y, width: snap(right) - x, height: snap(bottom) - y },
1400
+ clipped: left !== box.x || top !== box.y || right !== box.x + box.width || bottom !== box.y + box.height
1401
+ };
1402
+ }
1403
+ function contains(outer, inner) {
1404
+ return inner.x >= outer.x && inner.y >= outer.y && inner.x + inner.width <= outer.x + outer.width && inner.y + inner.height <= outer.y + outer.height;
1405
+ }
1406
+ function sameBox(left, right) {
1407
+ return left.x === right.x && left.y === right.y && left.width === right.width && left.height === right.height;
1408
+ }
1409
+ function identify(items, base) {
1410
+ const taken = /* @__PURE__ */ new Set();
1411
+ return items.map((item) => {
1412
+ const root = base(item);
1413
+ let id = root;
1414
+ for (let ordinal = 2; taken.has(id); ordinal += 1) id = `${root}-${ordinal}`;
1415
+ taken.add(id);
1416
+ return id;
1417
+ });
1418
+ }
1419
+ function smallestAround(regions, box, except) {
1420
+ let best;
1421
+ for (const region of regions) {
1422
+ if (region.id === except || !contains(region.box, box)) continue;
1423
+ const size = region.box.width * region.box.height;
1424
+ if (!best || size < best.size) best = { id: region.id, size };
1425
+ }
1426
+ return best?.id;
1427
+ }
1428
+ function ownPath(href, origin) {
1429
+ if (typeof href !== "string" || !href) return void 0;
1430
+ try {
1431
+ const base = origin ?? "http://localhost";
1432
+ const url = new URL(href, base);
1433
+ if (url.origin !== new URL(base).origin) return void 0;
1434
+ return `${url.pathname}${url.search}`.slice(0, 2048);
1435
+ } catch {
1436
+ return void 0;
1437
+ }
1438
+ }
1439
+ function buildLayoutManifest(layout, frame) {
1440
+ const { viewport, scale } = layout;
1441
+ const regions = [];
1442
+ for (const raw of layout.regions.slice(0, MAX_REGIONS)) {
1443
+ if (!isRole(REGION_ROLES, raw.role)) continue;
1444
+ const where = placed(raw.box, viewport, scale);
1445
+ if (!where) continue;
1446
+ if (raw.kind === "section" && regions.some((region) => sameBox(region.box, where.box))) continue;
1447
+ regions.push({ kind: raw.kind, role: raw.role, label: textOf(raw.label), box: where.box, clipped: where.clipped });
1448
+ }
1449
+ const controls = [];
1450
+ for (const raw of layout.controls.slice(0, MAX_CONTROLS)) {
1451
+ if (!isRole(CONTROL_ROLES, raw.role)) continue;
1452
+ const where = placed(raw.box, viewport, scale);
1453
+ if (!where) continue;
1454
+ const href = raw.role === "link" ? ownPath(raw.href, frame.origin) : void 0;
1455
+ const state = readState(raw.state);
1456
+ controls.push({ role: raw.role, name: textOf(raw.name), box: where.box, clipped: where.clipped, ...state ? { state } : {}, ...href ? { href } : {} });
1457
+ }
1458
+ return {
1459
+ schema: LAYOUT_MANIFEST_SCHEMA,
1460
+ screen: {
1461
+ shotId: frame.shotId,
1462
+ route: frame.route,
1463
+ path: frame.path,
1464
+ title: textOf(layout.title),
1465
+ variant: frame.variant,
1466
+ viewport: { width: viewport.width, height: viewport.height },
1467
+ scale,
1468
+ image: { width: frame.image.width, height: frame.image.height }
1469
+ },
1470
+ ...assemble(regions, controls)
1471
+ };
1472
+ }
1473
+ function assemble(regions, controls) {
1474
+ const regionIds = identify(regions, (region) => region.label ? `${region.role}:${identityOf(region.label) || "unnamed"}` : region.role);
1475
+ const located = regions.map((region, index) => ({ id: regionIds[index], box: region.box }));
1476
+ const controlIds = identify(controls, (control) => `${control.role}:${identityOf(control.name) || "unnamed"}`);
1477
+ return {
1478
+ regions: regions.map((region, index) => {
1479
+ const id = regionIds[index];
1480
+ const parent = smallestAround(located, region.box, id);
1481
+ return {
1482
+ id,
1483
+ kind: region.kind,
1484
+ role: region.role,
1485
+ label: region.label,
1486
+ box: region.box,
1487
+ ...parent ? { parent } : {},
1488
+ ...region.clipped ? { clipped: true } : {}
1489
+ };
1490
+ }),
1491
+ controls: controls.map((control, index) => {
1492
+ const centre = { x: control.box.x + control.box.width / 2, y: control.box.y + control.box.height / 2, width: 0, height: 0 };
1493
+ const region = smallestAround(located, centre);
1494
+ return {
1495
+ id: controlIds[index],
1496
+ role: control.role,
1497
+ name: control.name,
1498
+ box: control.box,
1499
+ ...region ? { region } : {},
1500
+ ...control.state ? { state: control.state } : {},
1501
+ ...control.href ? { href: control.href } : {},
1502
+ ...control.clipped ? { clipped: true } : {}
1503
+ };
1504
+ })
1505
+ };
1506
+ }
1507
+ function layoutManifestText(manifest) {
1508
+ return [manifest.screen.title, ...manifest.regions.map((region) => region.label), ...manifest.controls.map((control) => control.name)].filter(Boolean).join("\n");
1509
+ }
1510
+
1511
+ // core/capture/clips.ts
1512
+ var CLIP_VIEWPORT = { width: 1280, height: 800 };
1513
+ var CLIP_FPS = 10;
1514
+ var CLIP_MAX_MS = 3e4;
1515
+ var CLIP_MAX_STEPS = 12;
1516
+ var CLIP_MAX_MP4_BYTES = 3 * 1024 * 1024;
1517
+ var CLIP_MAX_GIF_BYTES = 8 * 1024 * 1024;
1518
+ var CLIP_GIF_WIDTH = 960;
1519
+ var CLIP_ACTIONS = ["click", "fill", "select", "check"];
1520
+ var MAX_SCRIPTS = 50;
1521
+ var MAX_TEXT2 = 200;
1522
+ var MAX_TITLE = 120;
1523
+ var MAX_ID = 80;
1524
+ function cleanText(value) {
1525
+ return typeof value === "string" ? value.replace(/\s+/g, " ").trim() : "";
1526
+ }
1527
+ function recordOf2(value) {
1528
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
1529
+ }
1530
+ function isPathLike(value) {
1531
+ return value.startsWith("/") && !value.startsWith("//") && !value.includes("..") && !value.includes("#") && value.length <= 2048;
1532
+ }
1533
+ function readStep(value, at) {
1534
+ const step = recordOf2(value);
1535
+ const route = cleanText(step["route"]);
1536
+ const control = recordOf2(step["control"]);
1537
+ const role = control["role"];
1538
+ const name = cleanText(control["name"]);
1539
+ const action = step["action"];
1540
+ const says = cleanText(step["says"]);
1541
+ const given = step["value"];
1542
+ if (!route || !isPathLike(route)) return `step ${at} names no screen`;
1543
+ if (typeof role !== "string" || !CONTROL_ROLES.includes(role)) return `step ${at} names no control role a screen lists`;
1544
+ if (!name || name.length > MAX_TEXT2) return `step ${at} names no control`;
1545
+ if (typeof action !== "string" || !CLIP_ACTIONS.includes(action)) return `step ${at} says neither click, fill, select nor check`;
1546
+ if (!says || says.length > MAX_TEXT2) return `step ${at} has no sentence for a reader`;
1547
+ const typed = action === "fill" || action === "select";
1548
+ if (typed && (typeof given !== "string" || !given.trim() || given.length > MAX_TEXT2)) return `step ${at} has nothing to ${action}`;
1549
+ if (!typed && given !== void 0) return `step ${at} gives a value to ${action}, which takes none`;
1550
+ return { route, control: { role, name }, action, ...typed ? { value: given } : {}, says };
1551
+ }
1552
+ function readClipScripts(raw) {
1553
+ const scripts = [];
1554
+ const refused = [];
1555
+ if (raw === void 0) return { scripts, refused };
1556
+ if (!Array.isArray(raw)) return { scripts, refused: [{ id: "clips", because: "clips is not a list" }] };
1557
+ for (const value of raw.slice(0, MAX_SCRIPTS)) {
1558
+ const script = recordOf2(value);
1559
+ const id = typeof script["id"] === "string" ? script["id"] : "";
1560
+ const label = id || "a clip";
1561
+ const refuse = (because) => refused.push({ id: label, because });
1562
+ const title = cleanText(script["title"]);
1563
+ const visit = cleanText(script["visit"]);
1564
+ const lands = script["lands"] === void 0 ? void 0 : cleanText(script["lands"]);
1565
+ if (!/^[a-z0-9-]+$/.test(id) || id.length > MAX_ID) {
1566
+ refuse("its id is not lower-case letters, digits and hyphens");
1567
+ continue;
1568
+ }
1569
+ if (scripts.some((kept) => kept.id === id)) {
1570
+ refuse("another clip has the same id");
1571
+ continue;
1572
+ }
1573
+ if (!title || title.length > MAX_TITLE) {
1574
+ refuse("it has no title");
1575
+ continue;
1576
+ }
1577
+ const rawSteps = Array.isArray(script["steps"]) ? script["steps"] : [];
1578
+ if (!rawSteps.length || rawSteps.length > CLIP_MAX_STEPS) {
1579
+ refuse(`it has ${rawSteps.length} steps, and a clip has between 1 and ${CLIP_MAX_STEPS}`);
1580
+ continue;
1581
+ }
1582
+ const steps = [];
1583
+ let problem = "";
1584
+ for (const [index, rawStep] of rawSteps.entries()) {
1585
+ const step = readStep(rawStep, index + 1);
1586
+ if (typeof step === "string") {
1587
+ problem = step;
1588
+ break;
1589
+ }
1590
+ steps.push(step);
1591
+ }
1592
+ if (problem) {
1593
+ refuse(problem);
1594
+ continue;
1595
+ }
1596
+ const first = steps[0];
1597
+ if (!isPathLike(visit) || !fillsPattern(first.route, visit.split("?")[0])) {
1598
+ refuse(`it opens at ${visit || "nowhere"}, which is not ${first.route}`);
1599
+ continue;
1600
+ }
1601
+ if (lands !== void 0 && !isPathLike(lands)) {
1602
+ refuse("it names no screen to land on");
1603
+ continue;
1604
+ }
1605
+ scripts.push({ id, title, visit, steps, ...lands ? { lands } : {} });
1606
+ }
1607
+ return { scripts, refused };
1608
+ }
1609
+ function resolveStep(manifest, step) {
1610
+ const name = cleanText(step.control.name).toLowerCase();
1611
+ const where = manifest.screen.path;
1612
+ const matches = manifest.controls.filter((control) => control.role === step.control.role && control.name.toLowerCase() === name);
1613
+ if (matches.length === 0) return { ok: false, because: `no ${step.control.role} "${step.control.name}" on ${where}` };
1614
+ if (matches.length > 1) return { ok: false, because: `${matches.length} of ${step.control.role} "${step.control.name}" on ${where}, so the step does not say which` };
1615
+ if (matches[0].state?.disabled) return { ok: false, because: `${step.control.role} "${step.control.name}" on ${where} is disabled` };
1616
+ return { ok: true, control: matches[0] };
1617
+ }
1618
+ function onScreen(route, location2) {
1619
+ return fillsPattern(route, location2.split("?")[0].split("#")[0]);
1620
+ }
1621
+ function clipRoutes(script) {
1622
+ return [.../* @__PURE__ */ new Set([...script.steps.map((step) => step.route), ...script.lands ? [script.lands] : []])];
1623
+ }
1624
+ function clipFingerprint(script, touched) {
1625
+ const canonical = {
1626
+ version: 1,
1627
+ id: script.id,
1628
+ title: script.title,
1629
+ lands: script.lands ?? null,
1630
+ steps: script.steps.map((step) => ({
1631
+ route: step.route,
1632
+ role: step.control.role,
1633
+ name: step.control.name,
1634
+ action: step.action,
1635
+ value: step.value ?? null,
1636
+ says: step.says
1637
+ })),
1638
+ touched: [...touched].map((screen) => [screen.route, screen.shotId, screen.sha256]).sort((left, right) => String(left[0]).localeCompare(String(right[0])))
1639
+ };
1640
+ return createHash("sha256").update(JSON.stringify(canonical)).digest("hex");
1641
+ }
1642
+ function centreOf(box) {
1643
+ return { x: Math.round(box.x + box.width / 2), y: Math.round(box.y + box.height / 2) };
1644
+ }
1645
+ function cursorPath(from, to, frames) {
1646
+ return Array.from({ length: Math.max(1, frames) }, (_, index) => {
1647
+ const t = (index + 1) / Math.max(1, frames);
1648
+ const eased = t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2;
1649
+ return { x: Math.round(from.x + (to.x - from.x) * eased), y: Math.round(from.y + (to.y - from.y) * eased) };
1650
+ });
1651
+ }
1652
+ function framesLength(frames) {
1653
+ return frames.reduce((total, frame) => total + frame.ms, 0);
1654
+ }
1655
+ function concatList(frames) {
1656
+ const quote = (file) => `'${file.replace(/'/g, "'\\''")}'`;
1657
+ const lines = ["ffconcat version 1.0"];
1658
+ for (const frame of frames) lines.push(`file ${quote(frame.file)}`, `duration ${(frame.ms / 1e3).toFixed(3)}`);
1659
+ if (frames.length) lines.push(`file ${quote(frames[frames.length - 1].file)}`);
1660
+ return `${lines.join("\n")}
1661
+ `;
1662
+ }
1663
+ function mp4Arguments(list, out, quality = 28) {
1664
+ return [
1665
+ "-y",
1666
+ "-loglevel",
1667
+ "error",
1668
+ "-f",
1669
+ "concat",
1670
+ "-safe",
1671
+ "0",
1672
+ "-i",
1673
+ list,
1674
+ "-vf",
1675
+ `fps=${CLIP_FPS},format=yuv420p`,
1676
+ "-c:v",
1677
+ "libx264",
1678
+ "-preset",
1679
+ "slow",
1680
+ "-crf",
1681
+ String(quality),
1682
+ "-threads",
1683
+ "1",
1684
+ "-movflags",
1685
+ "+faststart",
1686
+ "-an",
1687
+ out
1688
+ ];
1689
+ }
1690
+ function gifArguments(list, out, options = {}) {
1691
+ const width = options.width ?? CLIP_GIF_WIDTH;
1692
+ const fps = options.fps ?? CLIP_FPS;
1693
+ return [
1694
+ "-y",
1695
+ "-loglevel",
1696
+ "error",
1697
+ "-f",
1698
+ "concat",
1699
+ "-safe",
1700
+ "0",
1701
+ "-i",
1702
+ list,
1703
+ "-vf",
1704
+ `fps=${fps},scale=${width}:-1:flags=lanczos,split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=4:diff_mode=rectangle`,
1705
+ "-threads",
1706
+ "1",
1707
+ out
1708
+ ];
1709
+ }
1710
+
1032
1711
  // core/coverage/normalize.ts
1033
1712
  var CURRENCY = /[$£€¥₹]\s?[\d.,]+(?:\s?[A-Z]{3})?/g;
1034
1713
  var DIGITS = /\d+(?:[.,]\d+)*/g;
@@ -1531,14 +2210,14 @@ function linkTarget(href, context) {
1531
2210
  }
1532
2211
  if (url.protocol !== "http:" && url.protocol !== "https:") return void 0;
1533
2212
  if (url.origin !== context.origin) return `${url.origin}${url.pathname}`;
1534
- const path3 = url.pathname;
1535
- const screen = context.patterns.find((pattern) => fillsPattern(pattern, path3));
2213
+ const path5 = url.pathname;
2214
+ const screen = context.patterns.find((pattern) => fillsPattern(pattern, path5));
1536
2215
  if (screen) return screen;
1537
- return path3.split("/").map((segment) => RECORD_ID.test(segment) ? "[id]" : segment).join("/");
2216
+ return path5.split("/").map((segment) => RECORD_ID.test(segment) ? "[id]" : segment).join("/");
1538
2217
  }
1539
2218
 
1540
2219
  // core/capture/determinism.ts
1541
- import { createHash } from "node:crypto";
2220
+ import { createHash as createHash2 } from "node:crypto";
1542
2221
  function freezeScript(pinnedClock, options = {}) {
1543
2222
  const fixed = Date.parse(pinnedClock);
1544
2223
  if (Number.isNaN(fixed)) {
@@ -1587,7 +2266,7 @@ function freezeScript(pinnedClock, options = {}) {
1587
2266
  })();`;
1588
2267
  }
1589
2268
  function frameHash(bytes) {
1590
- return createHash("sha256").update(bytes).digest("hex");
2269
+ return createHash2("sha256").update(bytes).digest("hex");
1591
2270
  }
1592
2271
  async function settle(options) {
1593
2272
  const wait = options.wait ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
@@ -1905,13 +2584,18 @@ async function runCapture(options) {
1905
2584
  const evidence = [];
1906
2585
  const build = await options.browser.build?.().catch(() => void 0);
1907
2586
  for (const [index, shot] of options.shots.entries()) {
1908
- const { outcome, observed, seen } = await captureOne(options, shot, build);
2587
+ const taken = await captureOne(options, shot, build);
2588
+ const outcome = shot.variant ? { ...taken.outcome, variant: shot.variant } : taken.outcome;
1909
2589
  outcomes.push(outcome);
1910
- if (observed) survey.push(observed);
1911
- if (seen) evidence.push(seen);
2590
+ if (taken.observed && !shot.variant) survey.push(taken.observed);
2591
+ if (taken.seen && !shot.variant) evidence.push(taken.seen);
1912
2592
  options.onProgress?.(outcome, index + 1, options.shots.length);
1913
2593
  }
1914
- const count = (status) => outcomes.filter((outcome) => outcome.status === status).length;
2594
+ const tally = (variant) => {
2595
+ const of = outcomes.filter((outcome) => Boolean(outcome.variant) === variant);
2596
+ const count = (status) => of.filter((outcome) => outcome.status === status).length;
2597
+ return { captured: count("captured"), unchanged: count("unchanged"), skipped: count("skipped"), refused: count("refused"), failed: count("failed") };
2598
+ };
1915
2599
  const readings = {};
1916
2600
  for (const outcome of outcomes) {
1917
2601
  if (outcome.facts && (outcome.status === "captured" || outcome.status === "unchanged")) readings[outcome.route] = outcome.facts;
@@ -1923,13 +2607,31 @@ async function runCapture(options) {
1923
2607
  survey,
1924
2608
  readings,
1925
2609
  ...build ? { browser: build } : {},
1926
- captured: count("captured"),
1927
- unchanged: count("unchanged"),
1928
- skipped: count("skipped"),
1929
- refused: count("refused"),
1930
- failed: count("failed")
2610
+ ...tally(false),
2611
+ ...outcomes.some((outcome) => outcome.variant) ? { phone: tally(true) } : {}
1931
2612
  };
1932
2613
  }
2614
+ async function layoutOf(page, shot, options, cover, size, landed) {
2615
+ if (!page.layout || !size) return void 0;
2616
+ try {
2617
+ const manifest = buildLayoutManifest(await page.layout(cover), {
2618
+ shotId: shot.id,
2619
+ route: shot.route,
2620
+ path: landed,
2621
+ variant: shot.variant ?? "desktop",
2622
+ image: size,
2623
+ origin: new URL(options.baseUrl).origin
2624
+ });
2625
+ const verdict = gateFrame(layoutManifestText(manifest), {
2626
+ route: shot.route,
2627
+ ...options.brands ? { brands: options.brands } : {},
2628
+ ...options.clearance?.expectedBrands?.length ? { expected: options.clearance.expectedBrands } : {}
2629
+ });
2630
+ return verdict.safe ? manifest : void 0;
2631
+ } catch {
2632
+ return void 0;
2633
+ }
2634
+ }
1933
2635
  async function captureOne(options, shot, build) {
1934
2636
  if (shot.unvisitable) {
1935
2637
  return { outcome: { shotId: shot.id, route: shot.route, status: "skipped", reason: shot.unvisitable } };
@@ -1937,7 +2639,7 @@ async function captureOne(options, shot, build) {
1937
2639
  const visit = shot.visit ?? shot.route;
1938
2640
  let page;
1939
2641
  try {
1940
- page = await options.browser.page();
2642
+ page = await options.browser.page(shot.variant ? { variant: shot.variant } : void 0);
1941
2643
  await page.goto(new URL(visit, options.baseUrl).toString());
1942
2644
  if (await page.isSignIn()) {
1943
2645
  return {
@@ -1990,7 +2692,20 @@ async function captureOne(options, shot, build) {
1990
2692
  observed
1991
2693
  };
1992
2694
  }
1993
- const evidence = await readEvidence(settledPage, shot.route, options, build, visit);
2695
+ const fit = shot.variant === "phone" ? await page.phoneFit?.() : void 0;
2696
+ if (fit && !fit.fits) {
2697
+ return {
2698
+ outcome: {
2699
+ shotId: shot.id,
2700
+ route: shot.route,
2701
+ status: "skipped",
2702
+ result: observed.result,
2703
+ reason: `not laid out for a phone at ${PHONE_VIEWPORT.width}px (${fit.because}), so there is no phone-width frame`
2704
+ },
2705
+ observed
2706
+ };
2707
+ }
2708
+ const evidence = shot.variant ? void 0 : await readEvidence(settledPage, shot.route, options, build, visit);
1994
2709
  const reading = options.clearance ? await readScreen(settledPage, options.clearance) : void 0;
1995
2710
  if (options.clearance && !reading) {
1996
2711
  return {
@@ -2042,7 +2757,7 @@ async function captureOne(options, shot, build) {
2042
2757
  };
2043
2758
  }
2044
2759
  }
2045
- const facts = await readDetails(settledPage, shot, options);
2760
+ const facts = shot.variant ? void 0 : await readDetails(settledPage, shot, options);
2046
2761
  const approved = new Set(options.clearance?.approved ?? []);
2047
2762
  const cover = (reading?.surfaces ?? []).filter((surface) => !surface.read && !approved.has(surface.where)).map((surface) => surface.where);
2048
2763
  const frame = await settle({
@@ -2054,14 +2769,15 @@ async function captureOne(options, shot, build) {
2054
2769
  });
2055
2770
  const image = frame.value;
2056
2771
  const size = pngSize(image);
2057
- if (size && size.width < CAPTURE_CONTRACT.minStillWidth) {
2772
+ const minimum = shot.variant === "phone" ? PHONE_VIEWPORT.width * CAPTURE_CONTRACT.stillScale : CAPTURE_CONTRACT.minStillWidth;
2773
+ if (size && size.width < minimum) {
2058
2774
  return {
2059
2775
  outcome: {
2060
2776
  shotId: shot.id,
2061
2777
  route: shot.route,
2062
2778
  status: "failed",
2063
2779
  result: observed.result,
2064
- reason: `that frame came back ${size.width}px wide; the contract needs at least ${CAPTURE_CONTRACT.minStillWidth}px`
2780
+ reason: `that frame came back ${size.width}px wide; the contract needs at least ${minimum}px`
2065
2781
  },
2066
2782
  observed,
2067
2783
  // The picture is unusable; the reading of the screen is not.
@@ -2082,8 +2798,9 @@ async function captureOne(options, shot, build) {
2082
2798
  ...evidence ? { seen: evidence } : {}
2083
2799
  };
2084
2800
  }
2801
+ const layout = await layoutOf(settledPage, shot, options, cover, size, seen.landedOn ?? visit);
2085
2802
  const sha256 = frameHash(image);
2086
- if (!options.force && shot.sha256 && shot.sha256 === sha256) {
2803
+ if (!options.force && shot.sha256 && shot.sha256 === sha256 && (shot.layout !== false || !layout)) {
2087
2804
  return {
2088
2805
  outcome: {
2089
2806
  shotId: shot.id,
@@ -2126,7 +2843,8 @@ async function captureOne(options, shot, build) {
2126
2843
  }
2127
2844
  } : {},
2128
2845
  ...build ? { browser: build } : {},
2129
- ...size ?? {}
2846
+ ...size ?? {},
2847
+ ...layout ? { layout } : {}
2130
2848
  });
2131
2849
  return {
2132
2850
  outcome: sent.ok ? {
@@ -2196,40 +2914,48 @@ async function readEvidence(page, route, options, build, visited = route) {
2196
2914
  channels: { html: Boolean(html), aria: Boolean(aria) }
2197
2915
  };
2198
2916
  }
2917
+ var BROWSER_ARGS = [
2918
+ "--no-sandbox",
2919
+ "--disable-dev-shm-usage",
2920
+ /**
2921
+ * Software rendering, deliberately, and it is slower.
2922
+ *
2923
+ * Anything drawn through the GPU — canvas, WebGL, most charting
2924
+ * libraries, some CSS filters — rasterises differently on different
2925
+ * drivers and driver versions. Two machines then produce different bytes
2926
+ * for identical input, so a run on a laptop and a run in CI disagree
2927
+ * about a screen that never changed. That failure is invisible until the
2928
+ * day somebody runs it somewhere new, which is the worst time to find it.
2929
+ */
2930
+ "--disable-gpu",
2931
+ "--disable-software-rasterizer",
2932
+ /**
2933
+ * Font rasterisation, pinned as far as a flag can pin it. Subpixel
2934
+ * antialiasing is tuned per display and hinting varies by platform;
2935
+ * both change every pixel of every glyph without changing a word.
2936
+ */
2937
+ "--font-render-hinting=none",
2938
+ "--disable-lcd-text",
2939
+ /**
2940
+ * Glyphs on whole pixels. With sub-pixel positioning on, a line of text can
2941
+ * come out at one of two sub-pixel phases from one page load to the next,
2942
+ * with the same font and the same layout, so an unchanged screen gets a
2943
+ * new picture on some runs and not others.
2944
+ */
2945
+ "--disable-font-subpixel-positioning",
2946
+ "--force-color-profile=srgb",
2947
+ // A scrollbar is an overlay on some platforms and consumes layout width
2948
+ // on others, so leaving it visible makes the page a different width
2949
+ // depending on who took the picture.
2950
+ "--hide-scrollbars"
2951
+ ];
2199
2952
  async function playwrightBrowser(options = {}) {
2200
2953
  const { chromium } = await loadPlaywright();
2201
2954
  const executablePath = process.env["CAPTURE_CHROME"] ?? void 0;
2202
2955
  const browser = await chromium.launch({
2203
2956
  headless: options.headless ?? true,
2204
2957
  ...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
- ]
2958
+ args: [...BROWSER_ARGS]
2233
2959
  });
2234
2960
  const contextOptions = {
2235
2961
  viewport: CAPTURE_CONTRACT.viewport,
@@ -2261,8 +2987,10 @@ async function playwrightBrowser(options = {}) {
2261
2987
  ...options.sessionPath ? { storageState: options.sessionPath } : { storageState: void 0 }
2262
2988
  };
2263
2989
  return {
2264
- async page() {
2265
- const context = await browser.newContext(contextOptions);
2990
+ async page(wanted) {
2991
+ const context = await browser.newContext(
2992
+ wanted?.variant === "phone" ? { ...contextOptions, viewport: { ...PHONE_VIEWPORT }, isMobile: true, hasTouch: true } : wanted?.variant === "clip" ? { ...contextOptions, viewport: { ...CLIP_VIEWPORT }, deviceScaleFactor: CAPTURE_CONTRACT.clipScale } : contextOptions
2993
+ );
2266
2994
  if (!options.liveClock) {
2267
2995
  await context.addInitScript(freezeScript(CAPTURE_CONTRACT.pinnedClock));
2268
2996
  }
@@ -2336,6 +3064,36 @@ async function playwrightBrowser(options = {}) {
2336
3064
  async painted(regions) {
2337
3065
  return readPaintedMarkup(page, regions).catch(() => "");
2338
3066
  },
3067
+ async layout(cover) {
3068
+ return readPageLayout(page, cover ?? []);
3069
+ },
3070
+ async phoneFit() {
3071
+ return phoneFit(page, PHONE_VIEWPORT.width).catch(() => ({ fits: false, because: "the page could not be measured at that width" }));
3072
+ },
3073
+ async location() {
3074
+ const url = new URL(page.url());
3075
+ return `${url.pathname}${url.search}`;
3076
+ },
3077
+ async pointer(action) {
3078
+ if (action.kind === "select") {
3079
+ const handle = await page.evaluateHandle(({ x, y }) => document.elementFromPoint(x, y), action.at);
3080
+ const element = handle.asElement();
3081
+ if (!element) throw new Error(`nothing is under (${action.at.x}, ${action.at.y}) to choose in`);
3082
+ await element.selectOption({ label: action.value ?? "" });
3083
+ return;
3084
+ }
3085
+ await page.mouse.click(action.at.x, action.at.y);
3086
+ if (action.kind === "focus") await page.keyboard.press("ControlOrMeta+A");
3087
+ },
3088
+ async type(text2) {
3089
+ await page.keyboard.type(text2);
3090
+ },
3091
+ async cursor(at, pressed) {
3092
+ await drawCursor(page, at, pressed ?? false).catch(() => void 0);
3093
+ },
3094
+ async quiet(timeoutMs) {
3095
+ await page.waitForLoadState("networkidle", { timeout: timeoutMs }).catch(() => void 0);
3096
+ },
2339
3097
  async screenshot(cover) {
2340
3098
  const masks = (cover ?? []).map((selector) => page.locator(selector));
2341
3099
  return page.screenshot({
@@ -2561,6 +3319,320 @@ async function loadPlaywright() {
2561
3319
  );
2562
3320
  }
2563
3321
 
3322
+ // adapters/capture-clips.ts
3323
+ import { spawn } from "node:child_process";
3324
+ import { createHash as createHash3 } from "node:crypto";
3325
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
3326
+ import path2 from "node:path";
3327
+ var FRAME_MS = 1e3 / CLIP_FPS;
3328
+ var OPENING_MS = 700;
3329
+ var MOVE_FRAMES = 6;
3330
+ var DWELL_MS = 500;
3331
+ var PRESS_MS = 200;
3332
+ var CHARS_PER_FRAME = 2;
3333
+ var SETTLE_MS = 3e3;
3334
+ var STEP_WAIT_MS = 4e3;
3335
+ var CLOSING_MS = 1200;
3336
+ var START = { x: Math.round(CLIP_VIEWPORT.width / 2), y: Math.round(CLIP_VIEWPORT.height * 0.66) };
3337
+ function chunksOf(text2, size) {
3338
+ return Array.from({ length: Math.ceil(text2.length / size) }, (_, index) => text2.slice(index * size, (index + 1) * size));
3339
+ }
3340
+ async function recordClip(options, script, touched) {
3341
+ const dir = path2.join(options.workDir, script.id);
3342
+ const frames = [];
3343
+ const checked = /* @__PURE__ */ new Set();
3344
+ const fail = (because) => ({ ok: false, status: "failed", because });
3345
+ const refuse = (because) => ({ ok: false, status: "refused", because });
3346
+ const page = await options.browser.page({ variant: "clip" }).catch(() => void 0);
3347
+ if (!page) return fail("the browser would not open a page to record in");
3348
+ try {
3349
+ await rm(dir, { recursive: true, force: true });
3350
+ await mkdir(dir, { recursive: true });
3351
+ if (!page.layout || !page.location || !page.pointer || !page.type || !page.cursor) {
3352
+ return fail("this capture runner cannot record clips: it cannot measure a page, act on it or draw a cursor");
3353
+ }
3354
+ const { layout, location: location2, pointer, type, cursor } = { layout: page.layout.bind(page), location: page.location.bind(page), pointer: page.pointer.bind(page), type: page.type.bind(page), cursor: page.cursor.bind(page) };
3355
+ let cover = [];
3356
+ let at = 0;
3357
+ let last = new Uint8Array();
3358
+ const judge = async (where) => {
3359
+ const route = where.split("?")[0];
3360
+ const verdict = gateFrame(await page.text(), {
3361
+ route,
3362
+ ...options.brands ? { brands: options.brands } : {},
3363
+ ...options.clearance?.expectedBrands?.length ? { expected: options.clearance.expectedBrands } : {}
3364
+ });
3365
+ if (!verdict.safe) return verdict.reason ?? `the gate refused ${route}`;
3366
+ if (!options.clearance) return null;
3367
+ const clearance = options.clearance;
3368
+ const reading = await readScreen(page, clearance);
3369
+ if (!reading) return "this browser cannot read a screen against a clearance";
3370
+ if (reading.inventory.unaccounted.length) return `${reading.inventory.unaccounted.length} value(s) on ${route} are outside every region the clearance names`;
3371
+ const approved = new Set(clearance.approved);
3372
+ const loose = reading.surfaces.filter((surface) => !surface.read && !clearance.covers.includes(surface.where) && !approved.has(surface.where));
3373
+ if (loose.length && !clearance.coverUnnamed) return `${loose.length} surface(s) on ${route} are neither approved nor covered`;
3374
+ cover = reading.surfaces.filter((surface) => !surface.read && !approved.has(surface.where)).map((surface) => surface.where);
3375
+ return null;
3376
+ };
3377
+ const keep = async (bytes, ms) => {
3378
+ const file = path2.join(dir, `frame-${String(frames.length + 1).padStart(4, "0")}.png`);
3379
+ await writeFile(file, bytes);
3380
+ frames.push({ file, ms });
3381
+ last = bytes;
3382
+ at += ms;
3383
+ return file;
3384
+ };
3385
+ const shoot = async (ms) => keep(await page.screenshot(cover), ms);
3386
+ const hold = (ms) => {
3387
+ frames[frames.length - 1].ms += ms;
3388
+ at += ms;
3389
+ };
3390
+ const film = async () => {
3391
+ const next = await page.screenshot(cover);
3392
+ if (sameBytes(next, last)) {
3393
+ hold(FRAME_MS);
3394
+ return false;
3395
+ }
3396
+ await keep(next, FRAME_MS);
3397
+ return true;
3398
+ };
3399
+ await page.goto(new URL(script.visit, options.baseUrl).toString());
3400
+ if (await page.isSignIn()) return refuse("its first screen asked for a sign-in, so this session is not the demo tenant's");
3401
+ const opened = await location2();
3402
+ if (!onScreen(script.steps[0].route, opened)) return fail(`it opened on ${opened}, not ${script.steps[0].route}`);
3403
+ const before = await judge(opened);
3404
+ if (before) return refuse(before);
3405
+ let resting = START;
3406
+ let poster;
3407
+ const steps = [];
3408
+ const arrive = async (route) => {
3409
+ let where = await location2();
3410
+ for (let waited = 0; !onScreen(route, where) && waited < STEP_WAIT_MS; waited += FRAME_MS) {
3411
+ await cursor(resting);
3412
+ await film();
3413
+ where = await location2();
3414
+ }
3415
+ if (!onScreen(route, where)) return where;
3416
+ await page.quiet?.(SETTLE_MS);
3417
+ await cursor(resting);
3418
+ for (let waited = 0; waited < SETTLE_MS; waited += FRAME_MS) {
3419
+ if (!await film()) break;
3420
+ }
3421
+ return where;
3422
+ };
3423
+ await cursor(resting);
3424
+ checked.add(await shoot(OPENING_MS));
3425
+ for (const [index, step] of script.steps.entries()) {
3426
+ const where = await arrive(step.route);
3427
+ if (!onScreen(step.route, where)) return fail(`step ${index + 1}: the browser is on ${where}, not ${step.route}`);
3428
+ const arrived = await judge(where);
3429
+ if (arrived) return refuse(arrived);
3430
+ const measure = async () => resolveStep(
3431
+ buildLayoutManifest(await layout(cover), {
3432
+ shotId: script.id,
3433
+ route: step.route,
3434
+ path: where.split("?")[0],
3435
+ variant: "desktop",
3436
+ image: { ...CLIP_VIEWPORT },
3437
+ origin: new URL(options.baseUrl).origin
3438
+ }),
3439
+ step
3440
+ );
3441
+ let resolution = await measure();
3442
+ for (let waited = 0; !resolution.ok && waited < STEP_WAIT_MS; waited += FRAME_MS) {
3443
+ await cursor(resting);
3444
+ await film();
3445
+ resolution = await measure();
3446
+ }
3447
+ if (!resolution.ok) return fail(`step ${index + 1}: ${resolution.because}`);
3448
+ const target = centreOf(resolution.control.box);
3449
+ const startedAt = at;
3450
+ for (const point of cursorPath(resting, target, MOVE_FRAMES)) {
3451
+ await cursor(point);
3452
+ await shoot(FRAME_MS);
3453
+ }
3454
+ resting = target;
3455
+ hold(DWELL_MS);
3456
+ poster ??= frames[frames.length - 1].file;
3457
+ await cursor(target, true);
3458
+ await shoot(PRESS_MS);
3459
+ if (step.action === "fill") {
3460
+ await pointer({ kind: "focus", at: target });
3461
+ await cursor(target, false);
3462
+ for (const chunk of chunksOf(step.value ?? "", CHARS_PER_FRAME)) {
3463
+ await type(chunk);
3464
+ await shoot(FRAME_MS);
3465
+ }
3466
+ } else if (step.action === "select") {
3467
+ await pointer({ kind: "select", at: target, value: step.value ?? "" });
3468
+ } else {
3469
+ await pointer({ kind: "click", at: target });
3470
+ }
3471
+ await cursor(target, false);
3472
+ await page.quiet?.(SETTLE_MS);
3473
+ await cursor(target, false);
3474
+ await shoot(FRAME_MS);
3475
+ for (let waited = 0; waited < SETTLE_MS; waited += FRAME_MS) {
3476
+ if (!await film()) break;
3477
+ }
3478
+ checked.add(frames[frames.length - 1].file);
3479
+ const now = await location2();
3480
+ const after = await judge(now);
3481
+ if (after) return refuse(after);
3482
+ steps.push({
3483
+ route: step.route,
3484
+ role: step.control.role,
3485
+ name: resolution.control.name,
3486
+ action: step.action,
3487
+ says: step.says,
3488
+ atMs: startedAt,
3489
+ box: resolution.control.box
3490
+ });
3491
+ }
3492
+ if (script.lands) {
3493
+ const landed = await arrive(script.lands);
3494
+ if (!onScreen(script.lands, landed)) return fail(`the last step left the browser on ${landed}, not ${script.lands}`);
3495
+ const seen = await judge(landed);
3496
+ if (seen) return refuse(seen);
3497
+ }
3498
+ hold(CLOSING_MS);
3499
+ checked.add(frames[frames.length - 1].file);
3500
+ const durationMs = framesLength(frames);
3501
+ if (durationMs > CLIP_MAX_MS) return fail(`it runs ${(durationMs / 1e3).toFixed(1)} s, over ${CLIP_MAX_MS / 1e3} s, so the steps want splitting`);
3502
+ for (const file of checked) {
3503
+ const pixels = readSentinel(decodePng(await readFile(file))).pixels;
3504
+ if (pixels > 0) return refuse(`a frame of it shows the verification colour (${pixels} pixel(s)), which is never published`);
3505
+ }
3506
+ const encoded = await options.encoder.encode(frames, dir);
3507
+ const mp4 = new Uint8Array(encoded.mp4);
3508
+ return {
3509
+ ok: true,
3510
+ clip: {
3511
+ recording: {
3512
+ clipId: script.id,
3513
+ title: script.title,
3514
+ route: script.steps[0].route,
3515
+ durationMs,
3516
+ width: CLIP_VIEWPORT.width,
3517
+ height: CLIP_VIEWPORT.height,
3518
+ fingerprint: clipFingerprint(script, touched),
3519
+ steps,
3520
+ touched: [...touched],
3521
+ browser: options.build ?? ""
3522
+ },
3523
+ mp4,
3524
+ gif: encoded.gif ? new Uint8Array(encoded.gif) : null,
3525
+ poster: new Uint8Array(await readFile(poster ?? frames[0].file)),
3526
+ sha256: createHash3("sha256").update(mp4).digest("hex")
3527
+ }
3528
+ };
3529
+ } catch (error) {
3530
+ return fail(error instanceof Error ? error.message : String(error));
3531
+ } finally {
3532
+ await page.close().catch(() => void 0);
3533
+ await rm(dir, { recursive: true, force: true }).catch(() => void 0);
3534
+ }
3535
+ }
3536
+ var CLIP_BUDGET_MS = 10 * 6e4;
3537
+ async function runClips(options) {
3538
+ const started = Date.now();
3539
+ const outcomes = [];
3540
+ let spent;
3541
+ for (const [index, script] of options.scripts.entries()) {
3542
+ const touched = clipRoutes(script).map((route) => ({
3543
+ route,
3544
+ shotId: options.frames.get(route)?.shotId ?? "",
3545
+ sha256: options.frames.get(route)?.sha256 ?? null
3546
+ }));
3547
+ const fingerprint = clipFingerprint(script, touched);
3548
+ const base = { clipId: script.id, title: script.title, fingerprint };
3549
+ let outcome;
3550
+ if (spent) {
3551
+ outcome = { ...base, status: "deferred", reason: spent };
3552
+ } else if (Date.now() - started > (options.budgetMs ?? CLIP_BUDGET_MS)) {
3553
+ outcome = { ...base, status: "deferred", reason: "the run's time for clips ran out, so it waits for the next run" };
3554
+ } else if (!options.force && options.held.get(script.id) === fingerprint) {
3555
+ outcome = { ...base, status: "unchanged", reason: "neither its steps nor a screen they touch has changed" };
3556
+ } else {
3557
+ const result = await recordClip(options, script, touched);
3558
+ if (!result.ok) {
3559
+ outcome = { ...base, status: result.status, reason: result.because };
3560
+ } else {
3561
+ const delivered = await options.send(result.clip);
3562
+ if (delivered.ok) {
3563
+ outcome = {
3564
+ ...base,
3565
+ status: "recorded",
3566
+ durationMs: result.clip.recording.durationMs,
3567
+ mp4Bytes: result.clip.mp4.length,
3568
+ gifBytes: result.clip.gif?.length ?? null
3569
+ };
3570
+ } else if (delivered.limit) {
3571
+ spent = delivered.error;
3572
+ outcome = { ...base, status: "deferred", reason: delivered.error };
3573
+ } else {
3574
+ outcome = { ...base, status: "failed", reason: delivered.error };
3575
+ }
3576
+ }
3577
+ }
3578
+ outcomes.push(outcome);
3579
+ options.onProgress?.(outcome, index + 1, options.scripts.length);
3580
+ }
3581
+ return outcomes;
3582
+ }
3583
+ function run(binary, args) {
3584
+ return new Promise((resolve, reject) => {
3585
+ const child = spawn(binary, args, { stdio: ["ignore", "ignore", "pipe"] });
3586
+ let stderr = "";
3587
+ child.stderr.on("data", (chunk) => {
3588
+ stderr = `${stderr}${chunk.toString()}`.slice(-2e3);
3589
+ });
3590
+ child.on(
3591
+ "error",
3592
+ (error) => reject(new Error(error.code === "ENOENT" ? `${binary} is not installed, and clips need it to encode` : error.message))
3593
+ );
3594
+ child.on("close", (code) => code === 0 ? resolve() : reject(new Error(`${binary} stopped with ${code}: ${stderr.trim() || "no output"}`)));
3595
+ });
3596
+ }
3597
+ async function ffmpegAvailable(binary = ffmpegBinary()) {
3598
+ return run(binary, ["-hide_banner", "-loglevel", "error", "-version"]).then(
3599
+ () => true,
3600
+ () => false
3601
+ );
3602
+ }
3603
+ function ffmpegBinary() {
3604
+ return process.env["CAPTURE_FFMPEG"]?.trim() || "ffmpeg";
3605
+ }
3606
+ function ffmpegEncoder(binary = ffmpegBinary()) {
3607
+ return {
3608
+ async encode(frames, dir) {
3609
+ const list = path2.join(dir, "frames.txt");
3610
+ const mp4Path = path2.join(dir, "clip.mp4");
3611
+ const gifPath = path2.join(dir, "clip.gif");
3612
+ await writeFile(list, concatList(frames), "utf8");
3613
+ await run(binary, mp4Arguments(list, mp4Path));
3614
+ let mp4 = await readFile(mp4Path);
3615
+ if (mp4.length > CLIP_MAX_MP4_BYTES) {
3616
+ await run(binary, mp4Arguments(list, mp4Path, 34));
3617
+ mp4 = await readFile(mp4Path);
3618
+ }
3619
+ if (mp4.length > CLIP_MAX_MP4_BYTES) {
3620
+ throw new Error(`its MP4 is ${(mp4.length / 1024 / 1024).toFixed(1)} MB even at a lower quality, over the ${CLIP_MAX_MP4_BYTES / 1024 / 1024} MB limit`);
3621
+ }
3622
+ await run(binary, gifArguments(list, gifPath));
3623
+ let gif = await readFile(gifPath);
3624
+ if (gif.length > CLIP_MAX_GIF_BYTES) {
3625
+ await run(binary, gifArguments(list, gifPath, { width: 800, fps: 6 }));
3626
+ gif = await readFile(gifPath);
3627
+ }
3628
+ return { mp4, gif: gif.length > CLIP_MAX_GIF_BYTES ? null : gif };
3629
+ }
3630
+ };
3631
+ }
3632
+
3633
+ // adapters/capture-bin.ts
3634
+ import { tmpdir as tmpdir2 } from "node:os";
3635
+
2564
3636
  // adapters/invoked-as-command.ts
2565
3637
  import { realpathSync } from "node:fs";
2566
3638
  import { pathToFileURL as pathToFileURL2 } from "node:url";
@@ -2575,7 +3647,7 @@ function runningAsCommand(moduleUrl) {
2575
3647
  }
2576
3648
 
2577
3649
  // adapters/capture-stage.ts
2578
- import { spawn } from "node:child_process";
3650
+ import { spawn as spawn2 } from "node:child_process";
2579
3651
 
2580
3652
  // adapters/seed-proxy.ts
2581
3653
  import { createServer, request as httpRequest } from "node:http";
@@ -2601,7 +3673,7 @@ function looksLikeIdentifier(segment) {
2601
3673
  return /\d/.test(segment) && segment.length >= 3;
2602
3674
  }
2603
3675
  function templatise(paths) {
2604
- const split = paths.map((path3) => path3.split("/").filter(Boolean));
3676
+ const split = paths.map((path5) => path5.split("/").filter(Boolean));
2605
3677
  const depth2 = Math.max(0, ...split.map((segments) => segments.length));
2606
3678
  const parameters = /* @__PURE__ */ new Set();
2607
3679
  const prefixOf = (segments, upTo) => {
@@ -2704,9 +3776,9 @@ var CassetteIndex = class {
2704
3776
  return void 0;
2705
3777
  }
2706
3778
  };
2707
- function matchesTemplate(template, path3) {
3779
+ function matchesTemplate(template, path5) {
2708
3780
  const wanted = template.split("/").filter(Boolean);
2709
- const actual = path3.split("/").filter(Boolean);
3781
+ const actual = path5.split("/").filter(Boolean);
2710
3782
  if (wanted.length !== actual.length) return false;
2711
3783
  return wanted.every((segment, index) => segment.startsWith(":") || segment === actual[index]);
2712
3784
  }
@@ -2737,10 +3809,10 @@ function shut(server) {
2737
3809
  });
2738
3810
  }
2739
3811
  function splitUrl(url) {
2740
- const [path3, search] = url.split("?");
3812
+ const [path5, search] = url.split("?");
2741
3813
  const query = {};
2742
3814
  for (const [name, value] of new URLSearchParams(search ?? "")) query[name] = value;
2743
- return { path: path3 ?? "/", query };
3815
+ return { path: path5 ?? "/", query };
2744
3816
  }
2745
3817
  async function replayFrom(options) {
2746
3818
  const index = new CassetteIndex(options.cassette);
@@ -2748,14 +3820,14 @@ async function replayFrom(options) {
2748
3820
  const served = /* @__PURE__ */ new Set();
2749
3821
  const server = createServer((incoming, outgoing) => {
2750
3822
  void (async () => {
2751
- const { path: path3, query } = splitUrl(incoming.url ?? "/");
3823
+ const { path: path5, query } = splitUrl(incoming.url ?? "/");
2752
3824
  await bodyOf(incoming);
2753
- const found = index.find({ method: incoming.method ?? "GET", path: path3, query });
3825
+ const found = index.find({ method: incoming.method ?? "GET", path: path5, query });
2754
3826
  if (!found) {
2755
3827
  const from = incoming.headers["referer"] ?? incoming.headers["x-seed-page"];
2756
3828
  const miss = {
2757
3829
  method: incoming.method ?? "GET",
2758
- path: path3,
3830
+ path: path5,
2759
3831
  ...Object.keys(query).length ? { query } : {},
2760
3832
  ...typeof from === "string" ? { from: pageOf(from) } : {}
2761
3833
  };
@@ -2795,12 +3867,84 @@ function pageOf(referer) {
2795
3867
  }
2796
3868
  }
2797
3869
 
3870
+ // adapters/capture-clock.ts
3871
+ import { mkdtemp, rm as rm2, writeFile as writeFile2 } from "node:fs/promises";
3872
+ import { tmpdir } from "node:os";
3873
+ import path3 from "node:path";
3874
+ var PINNED_CLOCK_ENV = "HELPDESK_PINNED_CLOCK";
3875
+ function clockPreload(setting) {
3876
+ const at = Date.parse(setting.at);
3877
+ if (Number.isNaN(at)) throw new Error(`clockPreload needs a parsable instant, got ${JSON.stringify(setting.at)}`);
3878
+ const from = setting.from === void 0 ? "null" : String(Math.trunc(setting.from));
3879
+ const pin = `(() => {
3880
+ const AT = ${at};
3881
+ const FROM = ${from};
3882
+ const RealDate = globalThis.Date;
3883
+ if (RealDate.helpdeskPinned) return;
3884
+ const now = FROM === null ? () => AT : () => AT + (RealDate.now() - FROM);
3885
+ globalThis.Date = new Proxy(RealDate, {
3886
+ construct: (target, args, newTarget) => Reflect.construct(target, args.length ? args : [now()], newTarget),
3887
+ apply: () => new RealDate(now()).toString(),
3888
+ get: (target, key, receiver) => (key === "now" ? now : key === "helpdeskPinned" ? true : Reflect.get(target, key, receiver)),
3889
+ });
3890
+ })();`;
3891
+ return `"use strict";
3892
+ ${pin}
3893
+ (() => {
3894
+ const vm = require("node:vm");
3895
+ const createContext = vm.createContext;
3896
+ vm.createContext = function (...args) {
3897
+ const context = createContext.apply(this, args);
3898
+ try {
3899
+ vm.runInContext(${JSON.stringify(pin)}, context);
3900
+ } catch {}
3901
+ return context;
3902
+ };
3903
+ })();
3904
+ `;
3905
+ }
3906
+ function withPreload(nodeOptions, file) {
3907
+ return [nodeOptions?.trim(), `--require ${JSON.stringify(file)}`].filter(Boolean).join(" ");
3908
+ }
3909
+ async function writeClockPreload(setting) {
3910
+ const dir = await mkdtemp(path3.join(tmpdir(), "helpdesk-clock-"));
3911
+ const file = path3.join(dir, "clock.cjs");
3912
+ await writeFile2(file, clockPreload(setting), "utf8");
3913
+ return { file, remove: () => rm2(dir, { recursive: true, force: true }) };
3914
+ }
3915
+ function pinnedEnv(at, file, nodeOptions) {
3916
+ return { [PINNED_CLOCK_ENV]: at, NODE_OPTIONS: withPreload(nodeOptions, file) };
3917
+ }
3918
+
2798
3919
  // core/ci-api.ts
3920
+ var CI_TOKEN_NAME = "HELPDESK_TOKEN";
2799
3921
  var CI_SESSION_OUT_ENV = "HELPDESK_SESSION_OUT";
2800
3922
  var CI_BASE_URL_ENV = "HELPDESK_BASE_URL";
3923
+ var CI_WORKFLOW_PATH = ".github/workflows/helpdesk.yml";
3924
+ var CAPTURE_WORKFLOW_PATH = ".github/workflows/helpdesk-screenshots.yml";
3925
+ function workflowNames(slug) {
3926
+ const tag = (slug ?? "").trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
3927
+ if (!tag) {
3928
+ return {
3929
+ secret: CI_TOKEN_NAME,
3930
+ change: { name: "Help desk", path: CI_WORKFLOW_PATH },
3931
+ capture: { name: "Help desk screenshots", path: CAPTURE_WORKFLOW_PATH }
3932
+ };
3933
+ }
3934
+ return {
3935
+ secret: `${CI_TOKEN_NAME}_${tag.toUpperCase().replace(/-/g, "_")}`,
3936
+ change: { name: `Help desk (${tag})`, path: `.github/workflows/helpdesk-${tag}-watch.yml` },
3937
+ capture: { name: `Help desk screenshots (${tag})`, path: `.github/workflows/helpdesk-${tag}-screenshots.yml` }
3938
+ };
3939
+ }
2801
3940
 
2802
3941
  // adapters/capture-stage.ts
2803
3942
  var DEFAULT_APP_ENV = "API_BASE_URL";
3943
+ async function productClock(pinnedClock) {
3944
+ if (!pinnedClock) return { env: {} };
3945
+ const written = await writeClockPreload({ at: pinnedClock });
3946
+ return { env: pinnedEnv(pinnedClock, written.file, process.env["NODE_OPTIONS"]), written };
3947
+ }
2804
3948
  async function waitForApp(url, deadline, onLog, gaveUp) {
2805
3949
  let announced = false;
2806
3950
  while (Date.now() < deadline) {
@@ -2827,9 +3971,10 @@ async function stageAgainstRecording(options) {
2827
3971
  const variable = options.appEnv ?? DEFAULT_APP_ENV;
2828
3972
  let app;
2829
3973
  let died;
3974
+ const clock = options.appCommand ? await productClock(options.pinnedClock) : { env: {} };
2830
3975
  if (options.appCommand) {
2831
- options.onLog?.(`Starting your product with ${variable}=${replay.url}`);
2832
- app = spawn(options.appCommand, {
3976
+ options.onLog?.(`Starting your product with ${variable}=${replay.url}${clockNote(options.pinnedClock)}`);
3977
+ app = spawn2(options.appCommand, {
2833
3978
  shell: true,
2834
3979
  // Its own process group, so it can be taken down as one.
2835
3980
  //
@@ -2839,7 +3984,7 @@ async function stageAgainstRecording(options) {
2839
3984
  // the address occupied by the last one — and on a build agent, the job
2840
3985
  // never finishes.
2841
3986
  detached: true,
2842
- env: { ...process.env, [variable]: replay.url },
3987
+ env: { ...process.env, [variable]: replay.url, ...clock.env },
2843
3988
  stdio: ["ignore", "pipe", "pipe"]
2844
3989
  });
2845
3990
  app.stdout?.on("data", (chunk) => options.onLog?.(` [app] ${chunk.toString().trimEnd()}`));
@@ -2856,6 +4001,7 @@ async function stageAgainstRecording(options) {
2856
4001
  if (died !== void 0) {
2857
4002
  await replay.close();
2858
4003
  stop(app);
4004
+ await clock.written?.remove();
2859
4005
  throw new Error(
2860
4006
  `The start command exited (${died}) before the product was ready. Its output is above.` + (up ? `
2861
4007
 
@@ -2865,6 +4011,7 @@ Something else is already answering on ${options.appUrl}. That is almost always
2865
4011
  if (!up) {
2866
4012
  await replay.close();
2867
4013
  stop(app);
4014
+ await clock.written?.remove();
2868
4015
  throw new Error(
2869
4016
  `${options.appUrl} never answered. ` + (options.appCommand ? "Check the start command above, and that your product listens on that address." : `Start your product with ${variable}=${replay.url}, then run this again.`)
2870
4017
  );
@@ -2877,9 +4024,13 @@ Something else is already answering on ${options.appUrl}. That is almost always
2877
4024
  close: async () => {
2878
4025
  stop(app);
2879
4026
  await replay.close();
4027
+ await clock.written?.remove();
2880
4028
  }
2881
4029
  };
2882
4030
  }
4031
+ function clockNote(pinnedClock) {
4032
+ return pinnedClock ? `, its clock pinned to ${pinnedClock}` : "";
4033
+ }
2883
4034
  var SESSION_OUT_ENV = CI_SESSION_OUT_ENV;
2884
4035
  var EXAMPLES_OUT_ENV = CI_EXAMPLES_OUT_ENV;
2885
4036
  async function stageSeeded(options) {
@@ -2894,7 +4045,8 @@ async function stageSeeded(options) {
2894
4045
  {
2895
4046
  [CI_SESSION_OUT_ENV]: options.sessionOut,
2896
4047
  [CI_BASE_URL_ENV]: options.appUrl,
2897
- ...options.examplesOut ? { [CI_EXAMPLES_OUT_ENV]: options.examplesOut } : {}
4048
+ ...options.examplesOut ? { [CI_EXAMPLES_OUT_ENV]: options.examplesOut } : {},
4049
+ ...options.pinnedClock ? { [PINNED_CLOCK_ENV]: options.pinnedClock } : {}
2898
4050
  },
2899
4051
  onLog
2900
4052
  );
@@ -2913,15 +4065,18 @@ Nothing has been photographed. A run that carried on from here would sign in to
2913
4065
  onLog?.(
2914
4066
  sessionPath ? `The seed command left a session at ${sessionPath}, so the screens behind your login can be photographed.` : `The seed command wrote no session to ${SESSION_OUT_ENV}. Only the screens a signed-out visitor can reach will be photographed.`
2915
4067
  );
2916
- const examples = options.examplesOut ? await examplesFrom(options.examplesOut, onLog) : void 0;
4068
+ const named = options.examplesOut ? await examplesFrom(options.examplesOut, onLog) : void 0;
4069
+ const examples = named?.examples;
4070
+ const clips = named?.clips ?? [];
2917
4071
  let app;
2918
4072
  let died;
4073
+ const clock = options.appCommand ? await productClock(options.pinnedClock) : { env: {} };
2919
4074
  if (options.appCommand) {
2920
- onLog?.(`Starting your product with ${options.appCommand}`);
2921
- app = spawn(options.appCommand, {
4075
+ onLog?.(`Starting your product with ${options.appCommand}${clockNote(options.pinnedClock)}`);
4076
+ app = spawn2(options.appCommand, {
2922
4077
  shell: true,
2923
4078
  detached: true,
2924
- env: { ...process.env },
4079
+ env: { ...process.env, ...clock.env },
2925
4080
  stdio: ["ignore", "pipe", "pipe"]
2926
4081
  });
2927
4082
  app.stdout?.on("data", (chunk) => onLog?.(` [app] ${chunk.toString().trimEnd()}`));
@@ -2935,6 +4090,7 @@ Nothing has been photographed. A run that carried on from here would sign in to
2935
4090
  if (died === void 0 && app?.exitCode !== null && app?.exitCode !== void 0) died = app.exitCode;
2936
4091
  if (died !== void 0) {
2937
4092
  stop(app);
4093
+ await clock.written?.remove();
2938
4094
  throw new Error(
2939
4095
  `The start command exited (${died}) before the product was ready. Its output is above.` + (up ? `
2940
4096
 
@@ -2943,6 +4099,7 @@ Something else is already answering on ${options.appUrl}, which is almost always
2943
4099
  }
2944
4100
  if (!up) {
2945
4101
  stop(app);
4102
+ await clock.written?.remove();
2946
4103
  throw new Error(
2947
4104
  `${options.appUrl} never answered. Check the start command above, and that your product listens on that address.`
2948
4105
  );
@@ -2951,16 +4108,18 @@ Something else is already answering on ${options.appUrl}, which is almost always
2951
4108
  appUrl: options.appUrl,
2952
4109
  ...sessionPath ? { sessionPath } : {},
2953
4110
  ...examples ? { examples } : {},
4111
+ ...clips.length ? { clips } : {},
2954
4112
  close: async () => {
2955
4113
  stop(app);
4114
+ await clock.written?.remove();
2956
4115
  }
2957
4116
  };
2958
4117
  }
2959
- async function examplesFrom(path3, onLog) {
2960
- const { readFile: readFile2 } = await import("node:fs/promises");
4118
+ async function examplesFrom(path5, onLog) {
4119
+ const { readFile: readFile3 } = await import("node:fs/promises");
2961
4120
  let text2;
2962
4121
  try {
2963
- text2 = await readFile2(path3, "utf8");
4122
+ text2 = await readFile3(path5, "utf8");
2964
4123
  } catch {
2965
4124
  return void 0;
2966
4125
  }
@@ -2971,20 +4130,23 @@ async function examplesFrom(path3, onLog) {
2971
4130
  onLog?.(`The seed command wrote ${EXAMPLES_OUT_ENV}, but it is not JSON, so no screen with an id in its address will be opened.`);
2972
4131
  return void 0;
2973
4132
  }
4133
+ const named = readClipScripts(parsed?.clips);
4134
+ if (named.scripts.length) onLog?.(`The seed command named ${named.scripts.length} clip(s) to record.`);
4135
+ for (const refused of named.refused) onLog?.(` not recording ${refused.id}: ${refused.because}`);
2974
4136
  const reading = readExampleRoutes(parsed);
2975
4137
  if (!reading.ok) {
2976
4138
  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 void 0;
4139
+ return { clips: named.scripts };
2978
4140
  }
2979
4141
  onLog?.(
2980
4142
  `The seed command named a record for ${reading.examples.paths.size} screen(s) with an id in the address.`
2981
4143
  );
2982
4144
  for (const refused of reading.examples.refused) onLog?.(` not using ${refused.route}: ${refused.because}`);
2983
- return reading.examples;
4145
+ return { examples: reading.examples, clips: named.scripts };
2984
4146
  }
2985
4147
  async function runToCompletion(command, timeoutMs, env, onLog) {
2986
4148
  return new Promise((resolve) => {
2987
- const child = spawn(command, {
4149
+ const child = spawn2(command, {
2988
4150
  shell: true,
2989
4151
  detached: true,
2990
4152
  env: { ...process.env, ...env },
@@ -3025,8 +4187,8 @@ function gapsFrom(misses, cassette) {
3025
4187
  const templates = templatise([...known, ...misses.map((miss) => miss.path)]);
3026
4188
  const grouped = /* @__PURE__ */ new Map();
3027
4189
  for (const miss of misses) {
3028
- const path3 = templates.get(miss.path) ?? miss.path;
3029
- const key = interactionKey({ method: miss.method, path: path3, ...miss.query ? { query: miss.query } : {} });
4190
+ const path5 = templates.get(miss.path) ?? miss.path;
4191
+ const key = interactionKey({ method: miss.method, path: path5, ...miss.query ? { query: miss.query } : {} });
3030
4192
  const existing = grouped.get(key);
3031
4193
  if (existing) {
3032
4194
  existing.asked += 1;
@@ -3035,7 +4197,7 @@ function gapsFrom(misses, cassette) {
3035
4197
  }
3036
4198
  grouped.set(key, {
3037
4199
  method: miss.method.toUpperCase(),
3038
- path: path3,
4200
+ path: path5,
3039
4201
  asked: 1,
3040
4202
  broke: miss.from ? [miss.from] : [],
3041
4203
  example: miss.path
@@ -3104,8 +4266,8 @@ function coverageReport(found, total) {
3104
4266
  }
3105
4267
 
3106
4268
  // adapters/capture-bin.ts
3107
- import { mkdir, readFile, writeFile } from "node:fs/promises";
3108
- import path2 from "node:path";
4269
+ import { mkdir as mkdir2, readFile as readFile2, writeFile as writeFile3 } from "node:fs/promises";
4270
+ import path4 from "node:path";
3109
4271
 
3110
4272
  // adapters/map-cli.ts
3111
4273
  import { execFileSync } from "node:child_process";
@@ -3118,14 +4280,14 @@ var ROUTE_GROUP = /^\((.+)\)$/;
3118
4280
  var DYNAMIC = /^\[{1,2}(\.{3})?(.+?)\]{1,2}$/;
3119
4281
  var PARALLEL = /^@/;
3120
4282
  var INTERCEPTING = /^\(\.+\)/;
3121
- function classify(path3) {
3122
- if (path3 === "/api" || path3.startsWith("/api/")) {
4283
+ function classify(path5) {
4284
+ if (path5 === "/api" || path5.startsWith("/api/")) {
3123
4285
  return { skipped: "not-html", note: "an API route, not a screen" };
3124
4286
  }
3125
- if (/(^|\/)(admin|internal|debug|_dev)(\/|$)/.test(path3)) {
4287
+ if (/(^|\/)(admin|internal|debug|_dev)(\/|$)/.test(path5)) {
3126
4288
  return { skipped: "internal-tooling", note: "internal tooling" };
3127
4289
  }
3128
- if (/(^|\/)(login|logout|signin|sign-in|signup|sign-up|auth|verify|reset-password)(\/|$)/.test(path3)) {
4290
+ if (/(^|\/)(login|logout|signin|sign-in|signup|sign-up|auth|verify|reset-password)(\/|$)/.test(path5)) {
3129
4291
  return { skipped: "unauthenticated-flow", note: "an authentication step" };
3130
4292
  }
3131
4293
  return void 0;
@@ -3229,8 +4391,8 @@ function enclosingLayouts(pageFile, layouts) {
3229
4391
  const dir = pageFile.slice(0, pageFile.lastIndexOf("/"));
3230
4392
  return layouts.filter((layout) => dir.startsWith(layout.slice(0, layout.lastIndexOf("/")))).sort((a, b) => a.length - b.length);
3231
4393
  }
3232
- function depth(path3) {
3233
- return path3 === "/" ? 0 : path3.split("/").filter(Boolean).length;
4394
+ function depth(path5) {
4395
+ return path5 === "/" ? 0 : path5.split("/").filter(Boolean).length;
3234
4396
  }
3235
4397
 
3236
4398
  // core/discovery/article-plan.ts
@@ -3267,8 +4429,8 @@ var ENTRY_POINT = /* @__PURE__ */ new Set([
3267
4429
  "integration",
3268
4430
  "integrations"
3269
4431
  ]);
3270
- function hasSegment(path3, vocabulary) {
3271
- return path3.split("/").filter(Boolean).some((segment) => vocabulary.has(segment.toLowerCase()));
4432
+ function hasSegment(path5, vocabulary) {
4433
+ return path5.split("/").filter(Boolean).some((segment) => vocabulary.has(segment.toLowerCase()));
3272
4434
  }
3273
4435
  function score(route, signals) {
3274
4436
  const because = [];
@@ -3307,8 +4469,8 @@ function tierFor(rank, count) {
3307
4469
  if (rank <= Math.max(16, Math.ceil(count * 0.6))) return "supporting";
3308
4470
  return "minor";
3309
4471
  }
3310
- function slugFor(path3) {
3311
- const segments = path3.split("/").filter(Boolean).filter((segment) => !segment.startsWith("["));
4472
+ function slugFor(path5) {
4473
+ const segments = path5.split("/").filter(Boolean).filter((segment) => !segment.startsWith("["));
3312
4474
  return segments.length ? segments.join("/") : "overview";
3313
4475
  }
3314
4476
  function titleFor(route) {
@@ -3367,7 +4529,7 @@ function buildArticlePlan(options) {
3367
4529
  let covered = 0;
3368
4530
  const needs = [...merged.entries()].map(([slug, group], index) => {
3369
4531
  const rank = index + 1;
3370
- const claimed = [group.entry.route.path, ...group.routes.slice(1) ?? []].map((path3) => options.documented?.[path3]).find(Boolean);
4532
+ const claimed = [group.entry.route.path, ...group.routes.slice(1) ?? []].map((path5) => options.documented?.[path5]).find(Boolean);
3371
4533
  const already = existing.has(slug) || Boolean(claimed);
3372
4534
  if (already) covered += 1;
3373
4535
  return {
@@ -3389,6 +4551,8 @@ function buildArticlePlan(options) {
3389
4551
  function repositoryFiles(cwd) {
3390
4552
  return execFileSync("git", ["ls-files"], { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }).split("\n").filter(Boolean);
3391
4553
  }
4554
+ var CAPTURE_WORKFLOW = workflowNames().capture;
4555
+ var CI_WORKFLOW = workflowNames().change;
3392
4556
  var WORKFLOW_ACTIONS = {
3393
4557
  checkout: "actions/checkout@v7",
3394
4558
  setupNode: "actions/setup-node@v7"
@@ -3408,8 +4572,8 @@ function shotIdFor(route) {
3408
4572
  function describe(route, isEntry) {
3409
4573
  const segments = route.path.split("/").filter(Boolean);
3410
4574
  const last = segments[segments.length - 1] ?? "";
3411
- const readable = route.title ?? (last.startsWith("[") ? `a single ${(segments[segments.length - 2] ?? "record").replace(/s$/, "")}` : last.replace(/[-_]/g, " ") || "the home screen");
3412
- return isEntry ? `The ${readable} screen as someone arrives on it` : `The ${readable} screen, reached from ${segments.slice(0, -1).join("/") || "the top level"}`;
4575
+ const screen = route.title ? `The ${route.title} screen` : last.startsWith("[") ? `The screen for a single ${(segments[segments.length - 2] ?? "record").replace(/s$/, "")}` : last ? `The ${last.replace(/[-_]/g, " ")} screen` : "The home screen";
4576
+ return isEntry ? `${screen} as someone arrives on it` : `${screen}, reached from ${segments.slice(0, -1).join("/") || "the top level"}`;
3413
4577
  }
3414
4578
  function kindFor(_route, _isEntry) {
3415
4579
  return "still";
@@ -3421,7 +4585,7 @@ function buildStoryboard(options) {
3421
4585
  const beneath = options.routes.filter(
3422
4586
  (route) => !covered.has(route.path) && !route.skipped && [...covered].some((base) => base !== "/" && route.path.startsWith(`${base}/`))
3423
4587
  );
3424
- const own = [...covered].map((path3) => byPath.get(path3)).filter((route) => Boolean(route) && !route.skipped);
4588
+ const own = [...covered].map((path5) => byPath.get(path5)).filter((route) => Boolean(route) && !route.skipped);
3425
4589
  const ordered = options.inOrder ? own : own.sort((a, b) => a.path.length - b.path.length).concat(beneath.sort((a, b) => a.path.length - b.path.length));
3426
4590
  const limit = options.maxSteps ?? 5;
3427
4591
  const steps = ordered.slice(0, limit).map((route, index) => ({
@@ -3448,7 +4612,9 @@ function parseArgs(argv) {
3448
4612
  brands: [],
3449
4613
  only: [],
3450
4614
  liveClock: false,
3451
- allowProduction: false
4615
+ allowProduction: false,
4616
+ phone: true,
4617
+ clips: false
3452
4618
  };
3453
4619
  for (let index = 0; index < argv.length; index += 1) {
3454
4620
  const flag = argv[index];
@@ -3469,6 +4635,8 @@ function parseArgs(argv) {
3469
4635
  else if (flag === "--repo" && value) args.repo = value, index += 1;
3470
4636
  else if (flag === "--live-clock") args.liveClock = true;
3471
4637
  else if (flag === "--allow-production") args.allowProduction = true;
4638
+ else if (flag === "--no-phone") args.phone = false;
4639
+ else if (flag === "--clips") args.clips = true;
3472
4640
  else if (flag === "--rebaseline" || flag === "--force") {
3473
4641
  return '--rebaseline needs a reason: `--rebaseline "new brand colors shipped"`. Replacing every stored frame moves every checksum at once, and a re-baseline nobody wrote down is indistinguishable from the product changing.';
3474
4642
  }
@@ -3483,17 +4651,37 @@ function parseArgs(argv) {
3483
4651
  if (!args.to) return "Missing --to. That is the address of your help desk, or use --out <dir>.";
3484
4652
  return args;
3485
4653
  }
4654
+ function seenBefore(frame) {
4655
+ if (!frame) return {};
4656
+ return { ...frame.sha256 ? { sha256: frame.sha256 } : {}, ...frame.layout !== void 0 ? { layout: frame.layout } : {} };
4657
+ }
3486
4658
  function shotsFromRepo(repo, previous) {
3487
4659
  const files = repositoryFiles(repo);
3488
4660
  const routes = readNextAppRouter(files, detectRouterRoot(files)).routes;
3489
- return buildArticlePlan({ routes }).needs.flatMap((need) => buildStoryboard({ need, routes }).steps).filter((step) => step.kind === "still").map((step) => ({
3490
- id: step.shotId,
3491
- route: step.route,
3492
- depicts: step.depicts,
3493
- // Carried so a second run can recognise a frame it already has, which is
3494
- // what makes "nothing changed" a sentence this can say offline too.
3495
- ...previous.has(step.shotId) ? { sha256: previous.get(step.shotId) } : {}
3496
- }));
4661
+ return onePerScreen(
4662
+ buildArticlePlan({ routes }).needs.flatMap((need) => buildStoryboard({ need, routes }).steps).filter((step) => step.kind === "still").map((step) => ({
4663
+ id: step.shotId,
4664
+ route: step.route,
4665
+ depicts: step.depicts,
4666
+ // Carried so a second run can recognise a frame it already has, which is
4667
+ // what makes "nothing changed" a sentence this can say offline too.
4668
+ ...seenBefore(previous.get(step.shotId))
4669
+ }))
4670
+ );
4671
+ }
4672
+ function withPhoneShots(shots, previous) {
4673
+ const planned = new Set(shots.map((shot) => shot.id));
4674
+ const phones = shots.filter((shot) => !shot.visit && !shot.unvisitable && !shot.variant && !planned.has(`${shot.id}${PHONE_SUFFIX}`)).map((shot) => {
4675
+ const id = `${shot.id}${PHONE_SUFFIX}`;
4676
+ return { id, route: shot.route, depicts: `${shot.depicts}, at phone width`, variant: "phone", ...seenBefore(previous.get(id)) };
4677
+ });
4678
+ return [...shots, ...phones];
4679
+ }
4680
+ function heldPhoneFrames(shots) {
4681
+ return new Map(shots.flatMap((shot) => shot.phone ? [[`${shot.id}${PHONE_SUFFIX}`, { sha256: shot.phone.sha256, layout: shot.phone.layout }]] : []));
4682
+ }
4683
+ function onePerScreen(shots) {
4684
+ return [...new Map(shots.map((shot) => [shot.id, shot])).values()];
3497
4685
  }
3498
4686
  function withExamples(shots, examples) {
3499
4687
  return shots.map((shot) => {
@@ -3502,10 +4690,178 @@ function withExamples(shots, examples) {
3502
4690
  return resolved.visit === shot.route ? shot : { ...shot, visit: resolved.visit };
3503
4691
  });
3504
4692
  }
4693
+ function seededLast(shots) {
4694
+ return [...shots.filter((shot) => !shot.visit), ...shots.filter((shot) => shot.visit)];
4695
+ }
4696
+ async function writeLocalFrame(dir, frame) {
4697
+ const file = `${frame.shot.id}.png`;
4698
+ const layout = frame.layout ? `${frame.shot.id}.json` : void 0;
4699
+ await writeFile3(path4.join(dir, file), frame.image);
4700
+ if (layout) await writeFile3(path4.join(dir, layout), `${JSON.stringify(frame.layout, null, 2)}
4701
+ `, "utf8");
4702
+ return {
4703
+ id: frame.shot.id,
4704
+ route: frame.shot.route,
4705
+ depicts: frame.shot.depicts,
4706
+ file,
4707
+ sha256: frame.sha256,
4708
+ settled: frame.settled,
4709
+ ...frame.browser ? { browser: frame.browser } : {},
4710
+ ...frame.width ? { width: frame.width } : {},
4711
+ ...frame.height ? { height: frame.height } : {},
4712
+ ...layout ? { layout } : {},
4713
+ ...frame.shot.variant ? { variant: frame.shot.variant } : {}
4714
+ };
4715
+ }
4716
+ async function writeLocalClip(dir, clip) {
4717
+ const id = clip.recording.clipId;
4718
+ const folder = path4.join(dir, "clips");
4719
+ await mkdir2(folder, { recursive: true });
4720
+ await writeFile3(path4.join(folder, `${id}.mp4`), clip.mp4);
4721
+ if (clip.gif) await writeFile3(path4.join(folder, `${id}.gif`), clip.gif);
4722
+ await writeFile3(path4.join(folder, `${id}.png`), clip.poster);
4723
+ await writeFile3(path4.join(folder, `${id}.json`), `${JSON.stringify(clip.recording, null, 2)}
4724
+ `, "utf8");
4725
+ return {
4726
+ id,
4727
+ title: clip.recording.title,
4728
+ route: clip.recording.route,
4729
+ mp4: `clips/${id}.mp4`,
4730
+ ...clip.gif ? { gif: `clips/${id}.gif` } : {},
4731
+ poster: `clips/${id}.png`,
4732
+ recording: `clips/${id}.json`,
4733
+ fingerprint: clip.recording.fingerprint,
4734
+ durationMs: clip.recording.durationMs
4735
+ };
4736
+ }
4737
+ function clipForm(clip) {
4738
+ const id = clip.recording.clipId;
4739
+ const form = new FormData();
4740
+ form.set("meta", JSON.stringify({ ...clip.recording, sha256: clip.sha256 }));
4741
+ form.set("mp4", new Blob([clip.mp4], { type: "video/mp4" }), `${id}.mp4`);
4742
+ if (clip.gif) form.set("gif", new Blob([clip.gif], { type: "image/gif" }), `${id}.gif`);
4743
+ form.set("poster", new Blob([clip.poster], { type: "image/png" }), `${id}.png`);
4744
+ return form;
4745
+ }
4746
+ async function clipsOnDisk(dir) {
4747
+ try {
4748
+ const manifest = JSON.parse(await readFile2(path4.join(dir, "clips", "manifest.json"), "utf8"));
4749
+ return new Map((manifest.clips ?? []).map((clip) => [clip.id, clip]));
4750
+ } catch {
4751
+ return /* @__PURE__ */ new Map();
4752
+ }
4753
+ }
4754
+ async function recordSeededClips(args) {
4755
+ if (!args.scripts.length) {
4756
+ console.log("\n--clips was given, but the seed command named no clips, so none were recorded.");
4757
+ return [];
4758
+ }
4759
+ if (!await ffmpegAvailable()) {
4760
+ console.log("\nClips were not recorded: this machine has no ffmpeg to encode them with. Install ffmpeg, or set CAPTURE_FFMPEG.");
4761
+ if (process.env["GITHUB_ACTIONS"] === "true") console.log("::warning title=Clips not recorded::This runner has no ffmpeg to encode clips with.");
4762
+ return [];
4763
+ }
4764
+ const frames = new Map(
4765
+ args.outcomes.filter((outcome) => !outcome.variant && outcome.sha256 && (outcome.status === "captured" || outcome.status === "unchanged")).map((outcome) => [outcome.route, { shotId: outcome.shotId, sha256: outcome.sha256 ?? null }])
4766
+ );
4767
+ const previous = args.local ? await clipsOnDisk(args.local) : /* @__PURE__ */ new Map();
4768
+ const written = [];
4769
+ console.log(`
4770
+ The seed command named ${args.scripts.length} clip(s). Each is recorded in the demo tenant it built when it is new or something it shows has changed.
4771
+ `);
4772
+ const outcomes = await runClips({
4773
+ baseUrl: args.baseUrl,
4774
+ browser: args.browser,
4775
+ encoder: ffmpegEncoder(),
4776
+ workDir: path4.join(tmpdir2(), `helpdesk-clips-${process.pid}`),
4777
+ clearance: args.clearance,
4778
+ brands: args.brands,
4779
+ build: await args.browser.build?.().catch(() => void 0),
4780
+ scripts: args.scripts,
4781
+ frames,
4782
+ held: args.local ? new Map([...previous].map(([id, clip]) => [id, clip.fingerprint])) : args.held,
4783
+ force: args.force,
4784
+ async send(clip) {
4785
+ if (args.local) {
4786
+ written.push(await writeLocalClip(args.local, clip));
4787
+ return { ok: true };
4788
+ }
4789
+ const response = await fetch(`${args.to}/api/helpdesk/clips`, { method: "POST", headers: args.headers, body: clipForm(clip) });
4790
+ if (response.ok) return { ok: true };
4791
+ const body = await response.json().catch(() => ({}));
4792
+ return { ok: false, error: body.error ?? `the help desk answered ${response.status}`, ...response.status === 402 || body.limit ? { limit: true } : {} };
4793
+ },
4794
+ onProgress(outcome, index, total) {
4795
+ const mark = { recorded: "\u2713", unchanged: "=", deferred: "\u2013", refused: "\u2717", failed: "!" }[outcome.status];
4796
+ const kb = (bytes) => `${Math.round(bytes / 1024)} KB`;
4797
+ const detail = outcome.status === "recorded" ? `${((outcome.durationMs ?? 0) / 1e3).toFixed(1)} s, ${kb(outcome.mp4Bytes ?? 0)} MP4, ${outcome.gifBytes ? `${kb(outcome.gifBytes)} GIF` : "no GIF"}` : outcome.reason ?? "";
4798
+ console.log(`${mark} clip ${index}/${total} ${outcome.title}: ${detail}`);
4799
+ }
4800
+ });
4801
+ if (args.local) {
4802
+ const kept = [
4803
+ ...written,
4804
+ ...outcomes.filter((outcome) => outcome.status === "unchanged").flatMap((outcome) => previous.has(outcome.clipId) ? [previous.get(outcome.clipId)] : [])
4805
+ ].sort((left, right) => left.id.localeCompare(right.id));
4806
+ await mkdir2(path4.join(args.local, "clips"), { recursive: true });
4807
+ await writeFile3(path4.join(args.local, "clips", "manifest.json"), `${JSON.stringify({ schema: "olomon.clip-manifest/1", clips: kept }, null, 2)}
4808
+ `, "utf8");
4809
+ }
4810
+ const count = (status) => outcomes.filter((outcome) => outcome.status === status).length;
4811
+ console.log(`
4812
+ Clips: ${count("recorded")} recorded, ${count("unchanged")} unchanged, ${count("deferred")} deferred, ${count("refused")} refused, ${count("failed")} failed.`);
4813
+ const missed = outcomes.filter((outcome) => outcome.status === "refused" || outcome.status === "failed");
4814
+ if (missed.length && process.env["GITHUB_ACTIONS"] === "true") {
4815
+ console.log(`::warning title=${missed.length} clip(s) not recorded::${missed.map((outcome) => `${outcome.clipId}: ${outcome.reason}`).join("; ")}`);
4816
+ }
4817
+ return outcomes;
4818
+ }
4819
+ function frameForm(frame) {
4820
+ const form = new FormData();
4821
+ form.set(
4822
+ "meta",
4823
+ JSON.stringify({
4824
+ shotId: frame.shot.id,
4825
+ route: frame.shot.route,
4826
+ depicts: frame.shot.depicts,
4827
+ pageText: frame.pageText,
4828
+ capturedFrom: frame.capturedFrom,
4829
+ settled: frame.settled,
4830
+ // What this run read off the screen. Absent is a refusal at the
4831
+ // other end rather than a default, which is the point of sending it.
4832
+ ...frame.report ? { report: frame.report } : {},
4833
+ ...frame.browser ? { browser: frame.browser } : {},
4834
+ ...frame.width ? { width: frame.width } : {},
4835
+ ...frame.height ? { height: frame.height } : {}
4836
+ })
4837
+ );
4838
+ form.set("image", new Blob([frame.image], { type: "image/png" }), `${frame.shot.id}.png`);
4839
+ if (frame.layout) form.set("layout", JSON.stringify(frame.layout));
4840
+ return form;
4841
+ }
4842
+ function framesOnDisk(written, planned, outcomes, previous) {
4843
+ const kept = new Map(written.map((frame) => [frame.id, frame]));
4844
+ for (const shot of planned) {
4845
+ if (kept.has(shot.id) || !shot.sha256) continue;
4846
+ const unchanged = outcomes.find((outcome) => outcome.shotId === shot.id);
4847
+ if (unchanged?.status !== "unchanged") continue;
4848
+ kept.set(shot.id, {
4849
+ id: shot.id,
4850
+ route: shot.route,
4851
+ depicts: shot.depicts,
4852
+ file: `${shot.id}.png`,
4853
+ sha256: shot.sha256,
4854
+ settled: unchanged.settled !== false,
4855
+ ...previous.get(shot.id)?.layout ? { layout: `${shot.id}.json` } : {},
4856
+ ...shot.variant ? { variant: shot.variant } : {}
4857
+ });
4858
+ }
4859
+ return [...kept.values()].sort((a, b) => a.id.localeCompare(b.id));
4860
+ }
3505
4861
  async function previousFrames(out) {
3506
4862
  try {
3507
- const manifest = JSON.parse(await readFile(path2.join(out, "manifest.json"), "utf8"));
3508
- return new Map((manifest.frames ?? []).map((frame) => [frame.id, frame.sha256]));
4863
+ const manifest = JSON.parse(await readFile2(path4.join(out, "manifest.json"), "utf8"));
4864
+ return new Map((manifest.frames ?? []).map((frame) => [frame.id, { sha256: frame.sha256, layout: Boolean(frame.layout) }]));
3509
4865
  } catch {
3510
4866
  return /* @__PURE__ */ new Map();
3511
4867
  }
@@ -3521,11 +4877,13 @@ async function main(argv) {
3521
4877
  const local = parsed.out;
3522
4878
  const frames = [];
3523
4879
  let orders;
4880
+ let previous = /* @__PURE__ */ new Map();
3524
4881
  if (local) {
3525
4882
  const repo = parsed.repo ?? process.cwd();
3526
- await mkdir(local, { recursive: true });
4883
+ await mkdir2(local, { recursive: true });
3527
4884
  try {
3528
- orders = { baseUrl: null, shots: shotsFromRepo(repo, await previousFrames(local)) };
4885
+ previous = await previousFrames(local);
4886
+ orders = { baseUrl: null, shots: shotsFromRepo(repo, previous) };
3529
4887
  } catch (error) {
3530
4888
  console.error(
3531
4889
  `Could not read a plan from ${repo}: ${error.message}
@@ -3542,6 +4900,7 @@ Run this from your repository, or pass --repo <dir>.`
3542
4900
  return 1;
3543
4901
  }
3544
4902
  orders = await listed.json();
4903
+ previous = heldPhoneFrames(orders.shots);
3545
4904
  if (!orders.capture) {
3546
4905
  console.error(
3547
4906
  `This workspace has approved no capture subject, so the help desk will refuse every frame.
@@ -3563,14 +4922,16 @@ Approve one on the plan page, then run this again.`
3563
4922
  }
3564
4923
  let stage;
3565
4924
  let seeded;
4925
+ const pinnedClock = parsed.liveClock ? {} : { pinnedClock: CAPTURE_CONTRACT.pinnedClock };
3566
4926
  if (parsed.seedCommand) {
3567
4927
  try {
3568
4928
  seeded = await stageSeeded({
3569
4929
  appUrl: baseUrl,
3570
4930
  seedCommand: parsed.seedCommand,
3571
4931
  ...parsed.appCommand ? { appCommand: parsed.appCommand } : {},
3572
- sessionOut: parsed.session ?? path2.join(process.cwd(), "helpdesk-demo-session.json"),
3573
- examplesOut: path2.join(process.cwd(), "helpdesk-examples.json"),
4932
+ ...pinnedClock,
4933
+ sessionOut: parsed.session ?? path4.join(process.cwd(), "helpdesk-demo-session.json"),
4934
+ examplesOut: path4.join(process.cwd(), "helpdesk-examples.json"),
3574
4935
  onLog: (line) => console.log(line)
3575
4936
  });
3576
4937
  if (seeded.sessionPath) parsed.session = seeded.sessionPath;
@@ -3581,13 +4942,15 @@ Approve one on the plan page, then run this again.`
3581
4942
  return 1;
3582
4943
  }
3583
4944
  }
3584
- const planned = seeded?.examples ? withExamples(shots, seeded.examples) : shots;
4945
+ const withRecords = seeded?.examples ? withExamples(shots, seeded.examples) : shots;
4946
+ const planned = seededLast(parsed.phone ? withPhoneShots(withRecords, previous) : withRecords);
3585
4947
  if (parsed.cassette) {
3586
4948
  try {
3587
4949
  stage = await stageAgainstRecording({
3588
- cassette: parseCassette(await readFile(parsed.cassette, "utf8")),
4950
+ cassette: parseCassette(await readFile2(parsed.cassette, "utf8")),
3589
4951
  appUrl: baseUrl,
3590
4952
  ...parsed.appCommand ? { appCommand: parsed.appCommand } : {},
4953
+ ...pinnedClock,
3591
4954
  ...parsed.appEnv ? { appEnv: parsed.appEnv } : {},
3592
4955
  ...parsed.replayPort ? { replayPort: parsed.replayPort } : {},
3593
4956
  onLog: (line) => console.log(line)
@@ -3611,7 +4974,8 @@ Approve one on the plan page, then run this again.`
3611
4974
  session: Boolean(parsed.session),
3612
4975
  ...stage ? { replaying: parsed.cassette } : {},
3613
4976
  ...parsed.allowProduction ? { productionGranted: true } : {},
3614
- ...parsed.rebaseline ? { rebaselineReason: parsed.rebaseline } : {}
4977
+ ...parsed.rebaseline ? { rebaselineReason: parsed.rebaseline } : {},
4978
+ ...parsed.clips ? { clips: true } : {}
3615
4979
  });
3616
4980
  console.log(checks.lines.join("\n"));
3617
4981
  console.log("");
@@ -3629,9 +4993,10 @@ Approve one on the plan page, then run this again.`
3629
4993
  console.log(`${coverageReport(found, shots.length).join("\n")}
3630
4994
  `);
3631
4995
  }
4996
+ const phones = parsed.phone ? `, each opened at its own address again at ${PHONE_VIEWPORT.width}px wide if it is laid out for a phone` : "";
3632
4997
  console.log(
3633
- stage ? `Photographing ${shots.length} screen(s) of ${baseUrl}, served from ${parsed.cassette}.
3634
- ` : `Photographing ${shots.length} screen(s) of ${baseUrl}.
4998
+ stage ? `Photographing ${shots.length} screen(s) of ${baseUrl}${phones}, served from ${parsed.cassette}.
4999
+ ` : `Photographing ${shots.length} screen(s) of ${baseUrl}${phones}.
3635
5000
  `
3636
5001
  );
3637
5002
  const runId = local ? void 0 : await openRun(parsed.to, headers, shots.length);
@@ -3639,26 +5004,25 @@ Approve one on the plan page, then run this again.`
3639
5004
  const tell = (body) => {
3640
5005
  told = told.then(() => tellHelpDesk(parsed.to, headers, body));
3641
5006
  };
5007
+ const clearance = orders.capture ? {
5008
+ subject: orders.capture.subject,
5009
+ routes: orders.capture.routes,
5010
+ regions: orders.capture.regions,
5011
+ // A cover is where to put a rectangle; an approval is where not
5012
+ // to. Both are read off the clearance the help desk holds, and
5013
+ // the second is checked again against that same record when the
5014
+ // frame arrives, so this run cannot approve anything by saying so.
5015
+ covers: (orders.capture.surfaces ?? []).filter((surface) => surface.treatment?.treat === "cover").map((surface) => surface.where),
5016
+ approved: (orders.capture.surfaces ?? []).filter((surface) => surface.treatment?.treat === "approve").map((surface) => surface.where),
5017
+ coverUnnamed: orders.capture.coverUnnamed === true,
5018
+ expectedBrands: orders.capture.expectedBrands ?? []
5019
+ } : void 0;
3642
5020
  try {
3643
5021
  const report = await runCapture({
3644
5022
  baseUrl,
3645
5023
  shots: planned,
3646
5024
  browser,
3647
- ...orders.capture ? {
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
- } : {},
5025
+ ...clearance ? { clearance } : {},
3662
5026
  brands: parsed.brands,
3663
5027
  force: Boolean(parsed.rebaseline),
3664
5028
  // The commit a workflow run checked out, so the screens' details say
@@ -3674,8 +5038,9 @@ Approve one on the plan page, then run this again.`
3674
5038
  role: parsed.session ? unknownRole() : noRole("this run was signed out"),
3675
5039
  onProgress: (outcome, index, total) => {
3676
5040
  const mark = { captured: "\u2713", unchanged: "=", skipped: "\u2013", refused: "\u2717", failed: "!" }[outcome.status];
3677
- console.log(`${mark} ${index}/${total} ${outcome.route}${outcome.reason ? `: ${outcome.reason}` : ""}`);
3678
- if (runId) {
5041
+ const screen = outcome.variant ? `${outcome.route} at phone width` : outcome.route;
5042
+ console.log(`${mark} ${index}/${total} ${screen}${outcome.reason ? `: ${outcome.reason}` : ""}`);
5043
+ if (runId && !outcome.variant) {
3679
5044
  tell({
3680
5045
  action: "progress",
3681
5046
  jobId: runId,
@@ -3692,44 +5057,13 @@ Approve one on the plan page, then run this again.`
3692
5057
  },
3693
5058
  async send(frame) {
3694
5059
  if (local) {
3695
- const file = `${frame.shot.id}.png`;
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
- });
5060
+ frames.push(await writeLocalFrame(local, frame));
3708
5061
  return { ok: true };
3709
5062
  }
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
5063
  const response = await fetch(`${parsed.to}/api/helpdesk/frames`, {
3730
5064
  method: "POST",
3731
5065
  headers,
3732
- body: form
5066
+ body: frameForm(frame)
3733
5067
  });
3734
5068
  if (response.ok) return { ok: true };
3735
5069
  const body = await response.json().catch(() => ({}));
@@ -3738,41 +5072,31 @@ Approve one on the plan page, then run this again.`
3738
5072
  });
3739
5073
  console.log(`
3740
5074
  ${runCounts(report)}.`);
5075
+ if (report.phone) console.log(`At phone width: ${runCounts(report.phone)}.`);
3741
5076
  const read = Object.keys(report.readings).length;
3742
5077
  if (read && !local) {
3743
5078
  console.log(`Read what ${read} screen${read === 1 ? "" : "s"} say, as the help desk's screen details: headings, buttons, fields and messages.`);
3744
5079
  }
3745
5080
  const unclassified = unclassifiedLines(
3746
5081
  groupUnclassified(
3747
- report.outcomes.flatMap((outcome) => outcome.unaccounted?.length ? [{ route: outcome.route, values: outcome.unaccounted }] : [])
5082
+ report.outcomes.flatMap(
5083
+ (outcome) => outcome.unaccounted?.length && !outcome.variant ? [{ route: outcome.route, values: outcome.unaccounted }] : []
5084
+ )
3748
5085
  )
3749
5086
  );
3750
5087
  if (unclassified.length) console.log(`
3751
5088
  ${unclassified.join("\n")}`);
3752
5089
  if (local) {
3753
- const kept = new Map(frames.map((frame) => [frame.id, frame]));
3754
- for (const shot of shots) {
3755
- if (kept.has(shot.id) || !shot.sha256) continue;
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"),
5090
+ const kept = framesOnDisk(frames, planned, report.outcomes, previous);
5091
+ await writeFile3(
5092
+ path4.join(local, "manifest.json"),
3769
5093
  `${JSON.stringify(
3770
5094
  {
3771
5095
  schema: "olomon.capture-manifest/1",
3772
5096
  capturedFrom: baseUrl,
3773
5097
  capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
3774
5098
  ...parsed.cassette ? { recording: parsed.cassette } : {},
3775
- frames: [...kept.values()].sort((a, b) => a.id.localeCompare(b.id)),
5099
+ frames: kept,
3776
5100
  survey: report.survey
3777
5101
  },
3778
5102
  null,
@@ -3782,7 +5106,22 @@ ${unclassified.join("\n")}`);
3782
5106
  "utf8"
3783
5107
  );
3784
5108
  console.log(`
3785
- Written to ${local}: ${kept.size} frame(s) and manifest.json.`);
5109
+ Written to ${local}: ${kept.length} frame(s) and manifest.json.`);
5110
+ }
5111
+ if (parsed.clips) {
5112
+ await recordSeededClips({
5113
+ scripts: seeded?.clips ?? [],
5114
+ browser,
5115
+ baseUrl,
5116
+ clearance,
5117
+ brands: parsed.brands,
5118
+ outcomes: report.outcomes,
5119
+ local,
5120
+ to: parsed.to,
5121
+ headers,
5122
+ held: new Map((orders.clips ?? []).map((clip) => [clip.id, clip.fingerprint])),
5123
+ force: Boolean(parsed.rebaseline)
5124
+ });
3786
5125
  }
3787
5126
  if (report.survey.length) {
3788
5127
  const survey = captureReport(report.survey);
@@ -3797,7 +5136,7 @@ ${surveyLine(report.survey)}
3797
5136
  if (unstable.length) {
3798
5137
  console.log(
3799
5138
  `
3800
- ${unstable.length} screen(s) never stopped moving: ${unstable.map((o) => o.route).join(", ")}.
5139
+ ${unstable.length} screen(s) never stopped moving: ${unstable.map((o) => o.variant ? `${o.route} at phone width` : o.route).join(", ")}.
3801
5140
  Their frames were kept, but they will hash differently every run, so nothing can tell you
3802
5141
  whether they changed. Usually a spinner that never resolves or a looping animation.`
3803
5142
  );
@@ -3820,8 +5159,9 @@ not somebody's data. The session file stays on this machine and is sent nowhere.
3820
5159
  console.log(
3821
5160
  `
3822
5161
  ${attestation({
3823
- captured: report.captured,
3824
- unchanged: report.unchanged,
5162
+ // Of frames, which the phone-width ones are as much as the screens' and were checked the same way.
5163
+ captured: report.captured + (report.phone?.captured ?? 0),
5164
+ unchanged: report.unchanged + (report.phone?.unchanged ?? 0),
3825
5165
  refused: report.refused,
3826
5166
  baseUrl,
3827
5167
  ...report.browser ? { browser: report.browser } : {},
@@ -3875,12 +5215,12 @@ ${verdict.sentence}`);
3875
5215
  }
3876
5216
  }
3877
5217
  async function openRun(to, headers, total) {
3878
- const run = runIdentityFromEnv(process.env);
5218
+ const run2 = runIdentityFromEnv(process.env);
3879
5219
  try {
3880
5220
  const response = await fetch(`${to}/api/helpdesk/captures`, {
3881
5221
  method: "POST",
3882
5222
  headers: { ...headers, "content-type": "application/json" },
3883
- body: JSON.stringify({ action: "start", total, ...run ? { run } : {} })
5223
+ body: JSON.stringify({ action: "start", total, ...run2 ? { run: run2 } : {} })
3884
5224
  });
3885
5225
  if (!response.ok) return void 0;
3886
5226
  const body = await response.json();
@@ -3934,6 +5274,15 @@ if (runningAsCommand(import.meta.url)) {
3934
5274
  );
3935
5275
  }
3936
5276
  export {
5277
+ clipForm,
5278
+ frameForm,
5279
+ framesOnDisk,
3937
5280
  main,
3938
- withExamples
5281
+ onePerScreen,
5282
+ seededLast,
5283
+ shotsFromRepo,
5284
+ withExamples,
5285
+ withPhoneShots,
5286
+ writeLocalClip,
5287
+ writeLocalFrame
3939
5288
  };