@olomonhelpdesk2/helpdesk 0.1.17 → 0.1.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -158,8 +158,14 @@ var CAPTURE_CONTRACT = {
158
158
  timezone: "UTC",
159
159
  /**
160
160
  * Injected before every shot. Necessary, not sufficient — see above.
161
+ *
162
+ * Every CSS animation ends at once, on its last frame, rather than being
163
+ * removed. An entrance starts its element at opacity 0 and keeps the last
164
+ * frame it animates to, and `animation: none` left that element at opacity
165
+ * 0, so a site's hero was blank in every frame. One that loops ends after a
166
+ * single instant pass, back at its element's own style.
161
167
  */
162
- motionOff: "*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important}",
168
+ motionOff: "*,*::before,*::after{animation-duration:0s!important;animation-delay:0s!important;animation-iteration-count:1!important;transition:none!important;caret-color:transparent!important}",
163
169
  /**
164
170
  * What an app uses to leave its own floating widgets out of the pictures.
165
171
  *
@@ -221,7 +227,13 @@ function subjectOf(path5) {
221
227
  const segments = path5.split("/").filter(Boolean).filter((segment) => !/^(api|v\d+)$/.test(segment));
222
228
  while (segments.length > 1 && looksLikeAnId(segments[segments.length - 1])) segments.pop();
223
229
  const last = segments[segments.length - 1] ?? "record";
224
- return last.replace(/[-_]/g, " ").replace(/s$/, "");
230
+ return singular(last.replace(/[-_]/g, " "));
231
+ }
232
+ function singular(word) {
233
+ if (/ies$/.test(word)) return word.replace(/ies$/, "y");
234
+ if (/(ss|sh|ch|x|z|us)es$/.test(word)) return word.replace(/es$/, "");
235
+ if (/(ss|us)$/.test(word)) return word;
236
+ return word.replace(/s$/, "");
225
237
  }
226
238
  function looksLikeAnId(segment) {
227
239
  return /^\[{1,2}[^\]]+\]{1,2}$/.test(segment) || /^\d+$/.test(segment) || /^[0-9a-f]{8}-[0-9a-f]{4}-/i.test(segment) || // `inv_123`, `cus_9f2a`: a prefix, a separator, and something with a digit
@@ -248,6 +260,11 @@ function helpdeskCommand(command, args = []) {
248
260
 
249
261
  // core/capture/classify.ts
250
262
  var THIN_TEXT_CHARS = 25;
263
+ var ERROR_PAGE_PHRASES = [
264
+ /\bapplication error: a (server|client)-side exception has occurred\b/i,
265
+ /\bthis screen did not load\b/i,
266
+ /\bdigest\s*:?\s*[\w-]{6,}\s+copy reference\b/i
267
+ ];
251
268
  var BOUNDARY_PHRASES = [
252
269
  /\bsomething went wrong\b/i,
253
270
  /\bapplication error\b/i,
@@ -256,8 +273,12 @@ var BOUNDARY_PHRASES = [
256
273
  /\binternal server error\b/i,
257
274
  /\bthis page (could not|couldn'?t) be loaded\b/i,
258
275
  /\bwe('| a)re sorry[ —,-]+something\b/i,
259
- /\berror (id|digest)\b/i
276
+ /\berror (id|digest)\b/i,
277
+ ...ERROR_PAGE_PHRASES
260
278
  ];
279
+ function showsErrorPage(text2) {
280
+ return ERROR_PAGE_PHRASES.some((phrase) => phrase.test(text2));
281
+ }
261
282
  var NOT_FOUND_PHRASES = [
262
283
  /\b(page|it) (was )?not found\b/i,
263
284
  /\b404\b/,
@@ -339,6 +360,35 @@ function firstSentence(text2) {
339
360
  const sentence = text2.trim().split(/(?<=[.!?])\s|\n/)[0] ?? text2;
340
361
  return sentence.length > 160 ? `${sentence.slice(0, 157)}\u2026` : sentence;
341
362
  }
363
+ var OPAQUE_SCRIPT_ERROR = /^script error\.?$/i;
364
+ function frameUrls(stack) {
365
+ return stack.split("\n").filter((line) => /^\s*at\s/.test(line)).flatMap((line) => {
366
+ const url = /\bhttps?:\/\/[^\s()]+?(?=:\d+(?::\d+)?\)?$)/.exec(line.trim())?.[0];
367
+ return url ? [url] : [];
368
+ });
369
+ }
370
+ function siteOf(host) {
371
+ return /^[\d.]+$|^\[|^[^.]+$/.test(host) ? host : host.split(".").slice(-2).join(".");
372
+ }
373
+ function thrownElsewhere(error, pageUrl) {
374
+ let page;
375
+ try {
376
+ page = new URL(pageUrl);
377
+ } catch {
378
+ return void 0;
379
+ }
380
+ const urls = frameUrls(error.stack ?? "");
381
+ if (urls.length === 0) return OPAQUE_SCRIPT_ERROR.test(error.message.trim()) ? "a script from another site" : void 0;
382
+ const hosts = urls.flatMap((url) => {
383
+ try {
384
+ return [new URL(url).hostname];
385
+ } catch {
386
+ return [];
387
+ }
388
+ });
389
+ if (hosts.length === 0 || hosts.some((host) => siteOf(host) === siteOf(page.hostname))) return void 0;
390
+ return hosts[0];
391
+ }
342
392
  function surveyLine(outcomes) {
343
393
  const count = (result) => outcomes.filter((outcome) => outcome.result === result).length;
344
394
  const crashed = count("crashed");
@@ -478,6 +528,15 @@ function runIdentityFromEnv(env) {
478
528
  }
479
529
 
480
530
  // core/capture/run-verdict.ts
531
+ function clipSuccessRate(clips) {
532
+ const played = clips.recorded + clips.unchanged;
533
+ const tried = played + clips.refused + clips.failed;
534
+ return tried ? { played, tried, percent: Math.round(played / tried * 100) } : null;
535
+ }
536
+ function clipCounts(clips) {
537
+ const rate = clipSuccessRate(clips);
538
+ return `${clips.recorded} recorded, ${clips.unchanged} unchanged, ${clips.deferred} deferred, ${clips.refused} refused, ${clips.failed} failed${rate ? `: ${rate.played} of ${rate.tried} play (${rate.percent}%)` : ""}`;
539
+ }
481
540
  function runCounts(run2) {
482
541
  return `${run2.captured} photographed, ${run2.unchanged} unchanged, ${run2.skipped} skipped, ${run2.refused} refused, ${run2.failed} failed`;
483
542
  }
@@ -850,6 +909,70 @@ function paeth(a, b, c) {
850
909
  const pc = Math.abs(p - c);
851
910
  return pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
852
911
  }
912
+ function encodePng(image) {
913
+ const stride = image.width * 4;
914
+ const raw = Buffer.alloc(image.height * (stride + 1));
915
+ const previous = Buffer.alloc(stride);
916
+ const tries = [0, 1, 2, 3, 4].map(() => Buffer.alloc(stride));
917
+ for (let y = 0; y < image.height; y += 1) {
918
+ const line = image.pixels.subarray(y * stride, (y + 1) * stride);
919
+ let best = 0;
920
+ let bestCost = Number.POSITIVE_INFINITY;
921
+ for (let filter = 0; filter <= 4; filter += 1) {
922
+ const out = tries[filter];
923
+ let cost = 0;
924
+ for (let index = 0; index < stride; index += 1) {
925
+ const x = line[index];
926
+ const a = index >= 4 ? line[index - 4] : 0;
927
+ const b = previous[index];
928
+ const c = index >= 4 ? previous[index - 4] : 0;
929
+ const value = filter === 0 ? x : filter === 1 ? x - a & 255 : filter === 2 ? x - b & 255 : filter === 3 ? x - (a + b >> 1) & 255 : x - paeth(a, b, c) & 255;
930
+ out[index] = value;
931
+ cost += value < 128 ? value : 256 - value;
932
+ }
933
+ if (cost < bestCost) {
934
+ bestCost = cost;
935
+ best = filter;
936
+ }
937
+ }
938
+ raw[y * (stride + 1)] = best;
939
+ tries[best].copy(raw, y * (stride + 1) + 1);
940
+ line.copy(previous);
941
+ }
942
+ const header = Buffer.alloc(13);
943
+ header.writeUInt32BE(image.width, 0);
944
+ header.writeUInt32BE(image.height, 4);
945
+ header.writeUInt8(8, 8);
946
+ header.writeUInt8(6, 9);
947
+ return Buffer.concat([
948
+ Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
949
+ chunk("IHDR", header),
950
+ chunk("IDAT", deflateSync(raw, { level: 9 })),
951
+ chunk("IEND", Buffer.alloc(0))
952
+ ]);
953
+ }
954
+ function chunk(type, body) {
955
+ const head = Buffer.alloc(8);
956
+ head.writeUInt32BE(body.length, 0);
957
+ head.write(type, 4, "ascii");
958
+ const tail = Buffer.alloc(4);
959
+ tail.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), body])), 0);
960
+ return Buffer.concat([head, body, tail]);
961
+ }
962
+ var crcTable;
963
+ function crc32(bytes) {
964
+ if (!crcTable) {
965
+ crcTable = new Uint32Array(256);
966
+ for (let index = 0; index < 256; index += 1) {
967
+ let value = index;
968
+ for (let bit = 0; bit < 8; bit += 1) value = value & 1 ? 3988292384 ^ value >>> 1 : value >>> 1;
969
+ crcTable[index] = value >>> 0;
970
+ }
971
+ }
972
+ let crc = 4294967295;
973
+ for (const byte of bytes) crc = crcTable[(crc ^ byte) & 255] ^ crc >>> 8;
974
+ return (crc ^ 4294967295) >>> 0;
975
+ }
853
976
  function coveredFraction(image) {
854
977
  const total = image.width * image.height;
855
978
  if (total === 0) return 0;
@@ -904,8 +1027,14 @@ async function takeInventory(page, regions) {
904
1027
  const rendered = (element) => {
905
1028
  const style = styleOf(element);
906
1029
  if (style.visibility === "hidden" || style.display === "none") return false;
1030
+ const clip = style.clip.replace(/\s|px/g, "");
1031
+ if (clip === "rect(0,0,0,0)" || clip === "rect(0000)") return false;
1032
+ if (/\binset\(\s*50%/.test(style.clipPath)) return false;
1033
+ const view = element.ownerDocument?.defaultView ?? window;
907
1034
  for (const box of Array.from(element.getClientRects())) {
908
- if (box.width > 0 && box.height > 0) return true;
1035
+ if (box.width <= 1 || box.height <= 1) continue;
1036
+ if (box.bottom + view.scrollY <= 0 || box.right + view.scrollX <= 0) continue;
1037
+ return true;
909
1038
  }
910
1039
  return false;
911
1040
  };
@@ -1010,6 +1139,7 @@ async function readPaintedMarkup(page, regions) {
1010
1139
  const style = window.getComputedStyle(element);
1011
1140
  if (style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse") return false;
1012
1141
  if (style.display === "contents") return true;
1142
+ if (typeof element.checkVisibility === "function" && !element.checkVisibility()) return false;
1013
1143
  for (const box of Array.from(element.getClientRects())) {
1014
1144
  if (box.width > 0 && box.height > 0) return true;
1015
1145
  }
@@ -1090,6 +1220,7 @@ async function readPageLayout(page, cover = []) {
1090
1220
  };
1091
1221
  const unseen = (element) => {
1092
1222
  if (element.closest('[aria-hidden="true"], [inert]')) return true;
1223
+ if (typeof element.checkVisibility === "function" && !element.checkVisibility()) return true;
1093
1224
  for (let node = element; node; node = node.parentElement) {
1094
1225
  const style = window.getComputedStyle(node);
1095
1226
  if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") return true;
@@ -1169,6 +1300,61 @@ async function readPageLayout(page, cover = []) {
1169
1300
  }
1170
1301
  return contentOf(element) || clean(element.getAttribute("title"));
1171
1302
  };
1303
+ const readable = (node) => {
1304
+ const box = node.getBoundingClientRect();
1305
+ return !unseen(node) && box.width >= 4 && box.height >= 4;
1306
+ };
1307
+ const labelText = (label) => {
1308
+ let text2 = label.innerText ?? "";
1309
+ for (const inner of Array.from(label.querySelectorAll("select, textarea, button, [contenteditable]"))) {
1310
+ const own = inner.innerText ?? "";
1311
+ if (own) text2 = text2.replace(own, " ");
1312
+ }
1313
+ return clean(text2);
1314
+ };
1315
+ const labelsOf = (element, role) => {
1316
+ const found = [];
1317
+ const add = (text2) => {
1318
+ if (text2 && !found.includes(text2)) found.push(text2);
1319
+ };
1320
+ const tag = element.tagName.toLowerCase();
1321
+ const native = tag === "input" || tag === "textarea" || tag === "select";
1322
+ for (const id of clean(element.getAttribute("aria-labelledby")).split(" ").filter(Boolean)) {
1323
+ const target = document.getElementById(id);
1324
+ if (target && readable(target)) add(labelText(target));
1325
+ }
1326
+ if (native) {
1327
+ const field = element;
1328
+ const type = clean(field.getAttribute("type")).toLowerCase();
1329
+ if (type === "submit" || type === "reset" || type === "button") add(clean(field.value));
1330
+ for (const label of Array.from(field.labels ?? [])) if (readable(label)) add(labelText(label));
1331
+ if (tag !== "select" && field.value === "") add(clean(field.getAttribute("placeholder")));
1332
+ } else if (role === "textbox" || role === "searchbox") {
1333
+ if (!clean(element.innerText)) add(clean(element.getAttribute("aria-placeholder")));
1334
+ } else {
1335
+ add(clean(element.innerText));
1336
+ }
1337
+ return found.map((text2) => text2.slice(0, 200)).slice(0, 4);
1338
+ };
1339
+ const fieldOf = (element, role) => {
1340
+ if (role !== "textbox" && role !== "searchbox" && role !== "spinbutton" && role !== "combobox") return void 0;
1341
+ const tag = element.tagName.toLowerCase();
1342
+ if (tag !== "input" && tag !== "textarea") {
1343
+ if (role !== "textbox") return void 0;
1344
+ return { kind: "textarea", empty: !clean(element.innerText) };
1345
+ }
1346
+ const field = element;
1347
+ const attribute = (name) => clean(field.getAttribute(name)).toLowerCase();
1348
+ const maxLength = field.maxLength > 0 ? field.maxLength : void 0;
1349
+ return {
1350
+ kind: tag === "textarea" ? "textarea" : attribute("type") || "text",
1351
+ empty: field.value === "",
1352
+ ...attribute("autocomplete") ? { autocomplete: attribute("autocomplete").slice(0, 60) } : {},
1353
+ ...attribute("inputmode") ? { inputmode: attribute("inputmode").slice(0, 20) } : {},
1354
+ ...field.required || element.getAttribute("aria-required") === "true" ? { required: true } : {},
1355
+ ...maxLength ? { maxLength } : {}
1356
+ };
1357
+ };
1172
1358
  const stateOf = (element, role) => {
1173
1359
  const state = {};
1174
1360
  const aria = (name) => element.getAttribute(`aria-${name}`);
@@ -1278,7 +1464,8 @@ async function readPageLayout(page, cover = []) {
1278
1464
  if (box.width < 2 || box.height < 2) continue;
1279
1465
  if (CONTROLS.includes(role)) {
1280
1466
  const href = role === "link" ? element.href || element.getAttribute("href") : null;
1281
- controls.push({ role, name: nameOf(element), box, state: stateOf(element, role), ...href ? { href } : {} });
1467
+ const field = fieldOf(element, role);
1468
+ controls.push({ role, name: nameOf(element), labels: labelsOf(element, role), box, state: stateOf(element, role), ...href ? { href } : {}, ...field ? { field } : {} });
1282
1469
  } else if (LANDMARKS.includes(role)) {
1283
1470
  regions.push({ kind: role === "dialog" || role === "alertdialog" ? "dialog" : "landmark", role, label: labelOf(element), box });
1284
1471
  } else if (role === "heading") {
@@ -1447,7 +1634,27 @@ function ownPath(href, origin) {
1447
1634
  return void 0;
1448
1635
  }
1449
1636
  }
1450
- function buildLayoutManifest(layout, frame) {
1637
+ var MAX_LABELS = 4;
1638
+ function readLabels(value) {
1639
+ if (!Array.isArray(value)) return void 0;
1640
+ return [...new Set(value.map(textOf).filter(Boolean))].slice(0, MAX_LABELS);
1641
+ }
1642
+ function readField(value) {
1643
+ const given = recordOf(value);
1644
+ const kind = typeof given["kind"] === "string" ? given["kind"].toLowerCase() : "";
1645
+ if (!/^[a-z-]{1,30}$/.test(kind) || typeof given["empty"] !== "boolean") return void 0;
1646
+ const word = (key, limit) => typeof given[key] === "string" && given[key] ? { [key]: given[key].toLowerCase().slice(0, limit) } : {};
1647
+ const maxLength = given["maxLength"];
1648
+ return {
1649
+ kind,
1650
+ empty: given["empty"],
1651
+ ...word("autocomplete", 60),
1652
+ ...word("inputmode", 20),
1653
+ ...given["required"] === true ? { required: true } : {},
1654
+ ...typeof maxLength === "number" && Number.isInteger(maxLength) && maxLength > 0 ? { maxLength } : {}
1655
+ };
1656
+ }
1657
+ function buildLayoutManifest(layout, frame, options = {}) {
1451
1658
  const { viewport, scale } = layout;
1452
1659
  const regions = [];
1453
1660
  for (const raw of layout.regions.slice(0, MAX_REGIONS)) {
@@ -1464,7 +1671,18 @@ function buildLayoutManifest(layout, frame) {
1464
1671
  if (!where) continue;
1465
1672
  const href = raw.role === "link" ? ownPath(raw.href, frame.origin) : void 0;
1466
1673
  const state = readState(raw.state);
1467
- controls.push({ role: raw.role, name: textOf(raw.name), box: where.box, clipped: where.clipped, ...state ? { state } : {}, ...href ? { href } : {} });
1674
+ const labels = options.live ? readLabels(raw.labels) : void 0;
1675
+ const field = options.live ? readField(raw.field) : void 0;
1676
+ controls.push({
1677
+ role: raw.role,
1678
+ name: textOf(raw.name),
1679
+ box: where.box,
1680
+ clipped: where.clipped,
1681
+ ...state ? { state } : {},
1682
+ ...href ? { href } : {},
1683
+ ...labels ? { labels } : {},
1684
+ ...field ? { field } : {}
1685
+ });
1468
1686
  }
1469
1687
  return {
1470
1688
  schema: LAYOUT_MANIFEST_SCHEMA,
@@ -1510,7 +1728,9 @@ function assemble(regions, controls) {
1510
1728
  ...region ? { region } : {},
1511
1729
  ...control.state ? { state: control.state } : {},
1512
1730
  ...control.href ? { href: control.href } : {},
1513
- ...control.clipped ? { clipped: true } : {}
1731
+ ...control.clipped ? { clipped: true } : {},
1732
+ ...control.labels ? { labels: control.labels } : {},
1733
+ ...control.field ? { field: control.field } : {}
1514
1734
  };
1515
1735
  })
1516
1736
  };
@@ -1529,7 +1749,7 @@ var CLIP_MAX_GIF_BYTES = 8 * 1024 * 1024;
1529
1749
  var CLIP_GIF_WIDTH = 960;
1530
1750
  var CLIP_ACTIONS = ["click", "fill", "select", "check", "auto"];
1531
1751
  function actionForRole(role, value) {
1532
- if (role === "textbox" || role === "searchbox") return "fill";
1752
+ if (role === "textbox" || role === "searchbox" || role === "spinbutton") return "fill";
1533
1753
  if ((role === "combobox" || role === "listbox") && value) return "select";
1534
1754
  if (role === "checkbox" || role === "radio" || role === "switch") return "check";
1535
1755
  return "click";
@@ -1572,11 +1792,17 @@ function readStep(value, at) {
1572
1792
  if (action !== "auto" && !typed && given !== void 0) return `step ${at} gives a value to ${action}, which takes none`;
1573
1793
  if (action === "auto" && given !== void 0 && !valued) return `step ${at} has a value nothing can type`;
1574
1794
  if (step["follows"] !== void 0 && step["follows"] !== true) return `step ${at} says neither that it follows the step before it nor that it does not`;
1795
+ const demo = step["demo"] === void 0 ? void 0 : cleanText(step["demo"]);
1796
+ const opens = step["opens"] === void 0 ? void 0 : cleanText(step["opens"]);
1797
+ if (demo !== void 0 && (!demo || demo.length > MAX_TEXT2 || action !== "auto" && action !== "fill")) return `step ${at} has a demo value nothing types`;
1798
+ if (opens !== void 0 && (!isPathLike(opens) || !fillsPattern(route, opens.split("?")[0]))) return `step ${at} opens at ${opens || "nowhere"}, which is not ${route}`;
1575
1799
  return {
1576
1800
  route,
1577
1801
  control: { ...role === void 0 ? {} : { role }, name, ...scope ? { scope } : {} },
1578
1802
  action,
1579
1803
  ...valued && (typed || action === "auto") ? { value: given } : {},
1804
+ ...demo ? { demo } : {},
1805
+ ...opens ? { opens } : {},
1580
1806
  says,
1581
1807
  ...step["follows"] === true ? { follows: true } : {}
1582
1808
  };
@@ -1650,7 +1876,16 @@ function readClipScripts(raw, limit = MAX_SEEDED_SCRIPTS) {
1650
1876
  return { scripts, refused };
1651
1877
  }
1652
1878
  function comparable(text2) {
1653
- return cleanText(text2).toLowerCase().replace(/(…|\.\.\.)$/, "").trim();
1879
+ return cleanText(text2).toLowerCase().replace(/\s*\*$/, "").replace(/(…|\.\.\.)$/, "").trim();
1880
+ }
1881
+ function wordsFor(control, step) {
1882
+ return !step.control.role && control.labels ? control.labels : [control.name];
1883
+ }
1884
+ function emptyFieldsBeside(manifest, control) {
1885
+ const region = control.region ? /* @__PURE__ */ new Set([control.region]) : void 0;
1886
+ const empty = manifest.controls.filter((other) => other !== control && other.field?.empty && (!region || within(manifest, other, region)));
1887
+ const required = empty.filter((other) => other.field?.required);
1888
+ return (required.length ? required : empty).map((other) => other.labels?.[0] ?? other.name).filter(Boolean).slice(0, 4);
1654
1889
  }
1655
1890
  var CHROME_ROLES = ["navigation", "complementary", "banner", "contentinfo"];
1656
1891
  var IN_CHROME = /\b(sidebar|side bar|menu|navigation|nav bar|top bar|header)\b/i;
@@ -1677,30 +1912,35 @@ function inChrome(manifest, control) {
1677
1912
  }
1678
1913
  return false;
1679
1914
  }
1680
- function resolveStep(manifest, step) {
1915
+ function resolveStep(manifest, step, options = {}) {
1681
1916
  const name = comparable(step.control.name);
1682
1917
  const where = manifest.screen.path;
1683
1918
  const role = step.control.role;
1684
1919
  const what = role ?? "control";
1685
1920
  const candidates = manifest.controls.filter((control) => !role || control.role === role);
1686
- let matches = candidates.filter((control) => comparable(control.name) === name);
1687
- if (!role && !matches.length) matches = candidates.filter((control) => namesControl(control.name, step.control.name));
1921
+ let matches = candidates.filter((control) => wordsFor(control, step).some((word) => comparable(word) === name));
1922
+ if (!role && !matches.length) matches = candidates.filter((control) => wordsFor(control, step).some((word) => namesControl(word, step.control.name)));
1688
1923
  const scope = step.control.scope;
1689
1924
  if (scope && matches.length > 1) {
1690
1925
  const regions = new Set(manifest.regions.filter((region) => region.id === scope || comparable(region.label) === comparable(scope)).map((region) => region.id));
1691
1926
  const scoped = matches.filter((control) => within(manifest, control, regions));
1692
1927
  if (scoped.length) matches = scoped;
1693
1928
  }
1694
- if (!role && matches.length > 1) {
1695
- const chrome = IN_CHROME.test(step.says);
1696
- const meant = matches.filter((control) => inChrome(manifest, control) === chrome);
1697
- if (meant.length === 1) matches = meant;
1698
- }
1699
- if (matches.length === 0) return { ok: false, because: `no ${what} "${step.control.name}" on ${where}` };
1929
+ const narrow = (keep) => {
1930
+ const kept = matches.filter(keep);
1931
+ if (kept.length === 1) matches = kept;
1932
+ };
1933
+ if (!role && matches.length > 1 && options.after) narrow((control) => within(manifest, control, /* @__PURE__ */ new Set([options.after])));
1934
+ if (!role && matches.length > 1) narrow((control) => inChrome(manifest, control) === IN_CHROME.test(step.says));
1935
+ if (!role && matches.length > 1) narrow((control) => !control.state?.disabled);
1936
+ if (matches.length === 0) return { ok: false, kind: "missing", because: `no ${what} "${step.control.name}" on ${where}` };
1700
1937
  if (matches.length > 1) {
1701
- return { ok: false, because: `${matches.length} of ${what} "${step.control.name}" on ${where}${scope ? ` in ${scope}` : ""}, so the step does not say which` };
1938
+ return { ok: false, kind: "ambiguous", because: `${matches.length} of ${what} "${step.control.name}" on ${where}${scope ? ` in ${scope}` : ""}, so the step does not say which` };
1939
+ }
1940
+ if (matches[0].state?.disabled) {
1941
+ const waiting = emptyFieldsBeside(manifest, matches[0]);
1942
+ return { ok: false, kind: "disabled", because: `${what} "${step.control.name}" on ${where} is disabled${waiting.length ? `, with nothing yet in ${waiting.join(", ")} beside it` : ""}` };
1702
1943
  }
1703
- if (matches[0].state?.disabled) return { ok: false, because: `${what} "${step.control.name}" on ${where} is disabled` };
1704
1944
  return { ok: true, control: matches[0] };
1705
1945
  }
1706
1946
  function onScreen(route, location2) {
@@ -1715,6 +1955,7 @@ function clipFingerprint(script, touched) {
1715
1955
  role: step.control.role ?? null,
1716
1956
  name: step.control.name,
1717
1957
  ...step.control.scope ? { scope: step.control.scope } : {},
1958
+ ...step.demo ? { demo: step.demo } : {},
1718
1959
  action: step.action,
1719
1960
  value: step.value ?? null,
1720
1961
  says: step.says
@@ -1946,7 +2187,8 @@ function readRenderedFactRecords(html, options = {}) {
1946
2187
  })
1947
2188
  );
1948
2189
  };
1949
- for (const element of elements(visibleMarkup(html))) {
2190
+ const markup = visibleMarkup(html);
2191
+ for (const element of elements(markup)) {
1950
2192
  const { tag, attributes, inner, landmark } = element;
1951
2193
  const role = attributes["role"]?.toLowerCase() ?? "";
1952
2194
  const label = accessibleName(element);
@@ -1967,11 +2209,12 @@ function readRenderedFactRecords(html, options = {}) {
1967
2209
  continue;
1968
2210
  }
1969
2211
  if (tag === "input" || tag === "textarea" || tag === "select") {
1970
- const field = fieldFrom(element, html);
2212
+ const field = fieldFrom(element, html, markup);
1971
2213
  if (field) {
1972
2214
  add("field", field.label, landmark, {
1973
2215
  fieldKind: field.kind,
1974
2216
  ...field.choices ? { choices: field.choices } : {},
2217
+ ...field.placeholder ? { placeholder: field.placeholder } : {},
1975
2218
  ...controlState(element)
1976
2219
  });
1977
2220
  }
@@ -2020,6 +2263,7 @@ function toScreenFacts(facts, from) {
2020
2263
  case "field": {
2021
2264
  const field = { label: entry.value, kind: entry.fieldKind ?? "text" };
2022
2265
  if (entry.choices?.length) field.choices = [...entry.choices];
2266
+ if (entry.placeholder) field.placeholder = entry.placeholder;
2023
2267
  fields.push(field);
2024
2268
  break;
2025
2269
  }
@@ -2205,20 +2449,32 @@ function submitLike(element) {
2205
2449
  const type = element.attributes["type"]?.toLowerCase();
2206
2450
  return type === "submit" || type === "button" || type === "reset";
2207
2451
  }
2208
- function fieldFrom(element, body) {
2452
+ function fieldFrom(element, body, markup) {
2209
2453
  const { tag, attributes } = element;
2210
2454
  const type = attributes["type"]?.toLowerCase() ?? "text";
2211
2455
  if (tag === "input" && (type === "hidden" || submitLike(element))) return void 0;
2212
2456
  const kind = tag === "input" ? type : tag;
2213
- const label = attributes["aria-label"]?.trim() || attributes["placeholder"]?.trim() || labelFor(body, attributes["id"]) || humanise(attributes["name"] ?? "");
2457
+ const label = attributes["aria-label"]?.trim() || labelAround(markup, element.at) || attributes["placeholder"]?.trim() || labelFor(body, attributes["id"]) || humanise(attributes["name"] ?? "");
2214
2458
  if (!label) return void 0;
2215
2459
  const field = { label: trim(label.replace(/\s+/g, " "), LIMITS.label), kind };
2460
+ const placeholder = attributes["placeholder"]?.trim().replace(/\s+/g, " ");
2461
+ if (placeholder && placeholder !== label.trim()) field.placeholder = trim(placeholder, LIMITS.label);
2216
2462
  if (tag === "select") {
2217
2463
  const choices = [...element.inner.matchAll(/<option\b[^>]*>([\s\S]*?)<\/option>/gi)].map((match) => text(match[1] ?? "")).filter(Boolean).slice(0, LIMITS.choices);
2218
2464
  if (choices.length) field.choices = choices;
2219
2465
  }
2220
2466
  return field;
2221
2467
  }
2468
+ function labelAround(markup, at) {
2469
+ const open = markup.lastIndexOf("<label", at);
2470
+ if (open < 0 || !/^<label[\s>]/i.test(markup.slice(open, open + 7))) return "";
2471
+ const start = markup.indexOf(">", open) + 1;
2472
+ const close = markup.indexOf("</label>", start);
2473
+ if (start <= 0 || close < at) return "";
2474
+ const inner = markup.slice(start, close).replace(/<(select|textarea|button)\b[\s\S]*?<\/\1>/gi, " ").replace(/<input\b[^>]*>/gi, " ").trim();
2475
+ const first = /^(?:<(\w+)\b[^>]*>([\s\S]*?)<\/\1>|([^<]+))/.exec(inner);
2476
+ return text(first?.[2] ?? first?.[3] ?? "");
2477
+ }
2222
2478
  function labelFor(body, id) {
2223
2479
  if (!id) return "";
2224
2480
  const match = new RegExp(
@@ -3071,6 +3327,8 @@ async function playwrightBrowser(options = {}) {
3071
3327
  // on who took it.
3072
3328
  timezoneId: CAPTURE_CONTRACT.timezone,
3073
3329
  locale: CAPTURE_CONTRACT.locale,
3330
+ // A page that offers a still version of itself to somebody who asked for less motion shows that one.
3331
+ reducedMotion: "reduce",
3074
3332
  /**
3075
3333
  * A session, when one is supplied — and only ever from a file on the
3076
3334
  * machine running this.
@@ -3092,7 +3350,11 @@ async function playwrightBrowser(options = {}) {
3092
3350
  */
3093
3351
  ...options.sessionPath ? { storageState: options.sessionPath } : { storageState: void 0 }
3094
3352
  };
3353
+ const elsewhere = /* @__PURE__ */ new Map();
3095
3354
  return {
3355
+ thrownElsewhere() {
3356
+ return [...elsewhere.values()].map((held) => ({ by: held.by, message: held.message, screens: [...held.screens] }));
3357
+ },
3096
3358
  async page(wanted) {
3097
3359
  const phone = phoneViewport(wanted?.width);
3098
3360
  const context = await browser.newContext(
@@ -3107,7 +3369,17 @@ async function playwrightBrowser(options = {}) {
3107
3369
  const consoleErrors = [];
3108
3370
  const failedRequests = [];
3109
3371
  let status;
3110
- page.on("pageerror", (error) => errors.push(error.message));
3372
+ page.on("pageerror", (error) => {
3373
+ const by = thrownElsewhere(error, page.url());
3374
+ if (!by) {
3375
+ errors.push(error.message);
3376
+ return;
3377
+ }
3378
+ const key = `${by}\0${error.message}`;
3379
+ const held = elsewhere.get(key) ?? { by, message: error.message, screens: /* @__PURE__ */ new Set() };
3380
+ held.screens.add(pathOf(page.url()));
3381
+ elsewhere.set(key, held);
3382
+ });
3111
3383
  page.on("console", (message) => {
3112
3384
  if (message.type() === "error") consoleErrors.push(message.text());
3113
3385
  });
@@ -3126,6 +3398,16 @@ async function playwrightBrowser(options = {}) {
3126
3398
  await page.addStyleTag({ content: CAPTURE_CONTRACT.hideOff }).catch(() => void 0);
3127
3399
  await page.evaluate((name) => document.documentElement.setAttribute(name, ""), CAPTURE_CONTRACT.marker).catch(() => void 0);
3128
3400
  await page.evaluate(() => document.fonts?.ready).catch(() => void 0);
3401
+ await page.evaluate(() => {
3402
+ for (const animation of document.getAnimations()) {
3403
+ const end = animation.effect?.getComputedTiming().endTime;
3404
+ if (typeof end !== "number" || !Number.isFinite(end)) continue;
3405
+ try {
3406
+ animation.finish();
3407
+ } catch {
3408
+ }
3409
+ }
3410
+ }).catch(() => void 0);
3129
3411
  },
3130
3412
  async text() {
3131
3413
  return page.locator("body").innerText().catch(() => "");
@@ -3195,7 +3477,14 @@ async function playwrightBrowser(options = {}) {
3195
3477
  return;
3196
3478
  }
3197
3479
  await page.mouse.click(action.at.x, action.at.y);
3198
- if (action.kind === "focus") await page.keyboard.press("ControlOrMeta+A");
3480
+ if (action.kind !== "focus") return;
3481
+ const field = await page.evaluate(() => {
3482
+ const focused = document.activeElement;
3483
+ const kind = focused instanceof HTMLInputElement ? focused.type : "";
3484
+ return Boolean(focused && (focused.isContentEditable || focused instanceof HTMLTextAreaElement || focused instanceof HTMLInputElement && !["button", "checkbox", "color", "file", "image", "radio", "range", "reset", "submit"].includes(kind)));
3485
+ });
3486
+ if (!field) throw new Error(`the press at (${action.at.x}, ${action.at.y}) focused no field, so nothing could be typed`);
3487
+ await page.keyboard.press("ControlOrMeta+A");
3199
3488
  },
3200
3489
  async type(text2) {
3201
3490
  await page.keyboard.type(text2);
@@ -3217,6 +3506,16 @@ async function playwrightBrowser(options = {}) {
3217
3506
  ...masks.length > 0 ? { mask: masks, maskColor: PUBLICATION_REDACTION_CSS } : {}
3218
3507
  });
3219
3508
  },
3509
+ async frame(cover) {
3510
+ for (let attempt = 1; ; attempt += 1) {
3511
+ const before = await coversOf(page, cover ?? []).catch(() => void 0);
3512
+ const bytes = before ? await page.screenshot({ type: "png", fullPage: false }).catch(() => void 0) : void 0;
3513
+ const after = bytes ? await coversOf(page, cover ?? []).catch(() => void 0) : void 0;
3514
+ if (before && bytes && after && sameCovers(before, after)) return paintCovers(bytes, after);
3515
+ if (attempt >= 4) throw new Error("the page kept changing while its frame was taken, so no frame of it could be covered");
3516
+ await page.waitForLoadState("load", { timeout: 1e4 }).catch(() => void 0);
3517
+ }
3518
+ },
3220
3519
  async close() {
3221
3520
  await context.close();
3222
3521
  }
@@ -3258,6 +3557,43 @@ function coverageOf(image) {
3258
3557
  return 0;
3259
3558
  }
3260
3559
  }
3560
+ async function coversOf(page, selectors) {
3561
+ const found = await page.evaluate((wanted) => {
3562
+ const boxes = [];
3563
+ for (const selector of wanted) {
3564
+ let elements2;
3565
+ try {
3566
+ elements2 = Array.from(document.querySelectorAll(selector));
3567
+ } catch {
3568
+ return { unreadable: selector };
3569
+ }
3570
+ for (const element of elements2) {
3571
+ for (const box of Array.from(element.getClientRects())) boxes.push({ x: box.x, y: box.y, width: box.width, height: box.height });
3572
+ }
3573
+ }
3574
+ return { document: performance.timeOrigin, scale: window.devicePixelRatio, boxes };
3575
+ }, [...selectors]);
3576
+ if ("unreadable" in found) throw new Error(`the cover ${found.unreadable} is not a selector this page can find`);
3577
+ return found;
3578
+ }
3579
+ function sameCovers(before, after) {
3580
+ return before.document === after.document && before.scale === after.scale && JSON.stringify(before.boxes) === JSON.stringify(after.boxes);
3581
+ }
3582
+ function paintCovers(bytes, covers) {
3583
+ if (covers.boxes.length === 0) return bytes;
3584
+ const image = decodePng(Buffer.from(bytes));
3585
+ const { r, g, b } = PUBLICATION_REDACTION;
3586
+ for (const box of covers.boxes) {
3587
+ const left = Math.max(0, Math.floor(box.x * covers.scale));
3588
+ const top = Math.max(0, Math.floor(box.y * covers.scale));
3589
+ const right = Math.min(image.width, Math.ceil((box.x + box.width) * covers.scale));
3590
+ const bottom = Math.min(image.height, Math.ceil((box.y + box.height) * covers.scale));
3591
+ for (let y = top; y < bottom; y += 1) {
3592
+ for (let x = left; x < right; x += 1) image.pixels.set([r, g, b, 255], (y * image.width + x) * 4);
3593
+ }
3594
+ }
3595
+ return new Uint8Array(encodePng(image));
3596
+ }
3261
3597
  var PUBLICATION_REDACTION_CSS = `#${[PUBLICATION_REDACTION.r, PUBLICATION_REDACTION.g, PUBLICATION_REDACTION.b].map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
3262
3598
  async function readOpaqueSurfaces(page, regions) {
3263
3599
  return page.evaluate(
@@ -3436,6 +3772,34 @@ import { spawn } from "node:child_process";
3436
3772
  import { createHash as createHash3 } from "node:crypto";
3437
3773
  import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
3438
3774
  import path2 from "node:path";
3775
+
3776
+ // core/capture/demo-fill.ts
3777
+ var DEMO_KEY = "demo-key-not-real";
3778
+ var UNTYPED = /* @__PURE__ */ new Set(["date", "datetime-local", "month", "week", "time", "color", "range", "file", "checkbox", "radio", "hidden"]);
3779
+ function demoValueForField(control) {
3780
+ const field = control.field;
3781
+ const kind = field?.kind ?? (control.role === "spinbutton" ? "number" : control.role === "searchbox" ? "search" : "text");
3782
+ const autocomplete = field?.autocomplete ?? "";
3783
+ const inputmode = field?.inputmode ?? "";
3784
+ const words = [...control.labels ?? [], control.name].join(" ").toLowerCase();
3785
+ const value = (() => {
3786
+ if (UNTYPED.has(kind)) return void 0;
3787
+ if (kind === "password" || autocomplete.includes("password") || /\b(key|token|secret|password|passphrase)\b/.test(words)) return DEMO_KEY;
3788
+ if (kind === "email" || autocomplete.includes("email") || inputmode === "email" || /\be-?mail\b/.test(words) || /[^\s@]+@[^\s@]+\.[a-z]{2,}/.test(words)) return "demo@example.com";
3789
+ if (/\bsub-?domain\b|\bslug\b/.test(words)) return "demo";
3790
+ if (kind === "url" || autocomplete === "url" || inputmode === "url" || /\b(url|website|web address|domain|link)\b/.test(words) || /\bhttps?:\/\/|\bwww\./.test(words)) return "https://demo.example.com";
3791
+ if (kind === "tel" || autocomplete.startsWith("tel") || inputmode === "tel" || /\bphone\b/.test(words)) return "555-0100";
3792
+ if (kind === "number" || inputmode === "numeric" || inputmode === "decimal") return "3";
3793
+ if (/\bjson\b/.test(words)) return '{"demo": true}';
3794
+ if (autocomplete === "username" || /\b(user ?name|login|handle)\b/.test(words)) return "demo";
3795
+ if (kind === "search") return "demo";
3796
+ if (kind === "textarea") return "Demo text, typed for this recording.";
3797
+ return "Demo";
3798
+ })();
3799
+ return value && field?.maxLength ? value.slice(0, field.maxLength) : value;
3800
+ }
3801
+
3802
+ // adapters/capture-clips.ts
3439
3803
  var FRAME_MS = 1e3 / CLIP_FPS;
3440
3804
  var OPENING_MS = 700;
3441
3805
  var MOVE_FRAMES = 6;
@@ -3449,6 +3813,7 @@ var CLOSING_MS = 1200;
3449
3813
  var SCROLL_TO = 0.45;
3450
3814
  var WHOLE_SHIFT = 1e5;
3451
3815
  var START = { x: Math.round(CLIP_VIEWPORT.width / 2), y: Math.round(CLIP_VIEWPORT.height * 0.66) };
3816
+ var CLIP_DEADLINE_MS = 12e4;
3452
3817
  function wholePage(raw) {
3453
3818
  const down = (item) => ({ ...item, box: { ...item.box, y: item.box.y + WHOLE_SHIFT } });
3454
3819
  return { ...raw, viewport: { width: raw.viewport.width, height: 2 * WHOLE_SHIFT }, regions: raw.regions.map(down), controls: raw.controls.map(down) };
@@ -3456,14 +3821,40 @@ function wholePage(raw) {
3456
3821
  function chunksOf(text2, size) {
3457
3822
  return Array.from({ length: Math.ceil(text2.length / size) }, (_, index) => text2.slice(index * size, (index + 1) * size));
3458
3823
  }
3824
+ var CLIP_LATE = "so its page was closed and the run went on";
3459
3825
  async function recordClip(options, script, touched) {
3826
+ const limit = options.deadlineMs ?? CLIP_DEADLINE_MS;
3827
+ const late = { ok: false, status: "failed", because: `it did not finish within ${limit / 1e3} s, ${CLIP_LATE}` };
3828
+ let page;
3829
+ let expired = false;
3830
+ let deadline;
3831
+ const lapsed = new Promise((resolve) => {
3832
+ deadline = setTimeout(() => {
3833
+ expired = true;
3834
+ void page?.close().catch(() => void 0);
3835
+ resolve("late");
3836
+ }, limit);
3837
+ });
3838
+ const opening = options.browser.page({ variant: "clip" }).catch(() => void 0);
3839
+ void opening.then((opened) => expired ? opened?.close().catch(() => void 0) : void 0);
3840
+ try {
3841
+ const opened = await Promise.race([opening, lapsed]);
3842
+ if (opened === "late") return late;
3843
+ if (!opened) return { ok: false, status: "failed", because: "the browser would not open a page to record in" };
3844
+ page = opened;
3845
+ const result = await Promise.race([recordOn(options, script, touched, opened), lapsed]);
3846
+ return result === "late" || expired ? late : result;
3847
+ } finally {
3848
+ clearTimeout(deadline);
3849
+ if (page) await Promise.race([page.close().catch(() => void 0), new Promise((resolve) => setTimeout(resolve, 5e3))]);
3850
+ }
3851
+ }
3852
+ async function recordOn(options, script, touched, page) {
3460
3853
  const dir = path2.join(options.workDir, script.id);
3461
3854
  const frames = [];
3462
3855
  const checked = /* @__PURE__ */ new Set();
3463
3856
  const fail = (because) => ({ ok: false, status: "failed", because });
3464
3857
  const refuse = (because) => ({ ok: false, status: "refused", because });
3465
- const page = await options.browser.page({ variant: "clip" }).catch(() => void 0);
3466
- if (!page) return fail("the browser would not open a page to record in");
3467
3858
  try {
3468
3859
  await rm(dir, { recursive: true, force: true });
3469
3860
  await mkdir(dir, { recursive: true });
@@ -3476,20 +3867,22 @@ async function recordClip(options, script, touched) {
3476
3867
  let last = new Uint8Array();
3477
3868
  const judge = async (where) => {
3478
3869
  const route = where.split("?")[0];
3479
- const verdict = gateFrame(await page.text(), {
3870
+ const text2 = await page.text();
3871
+ if (showsErrorPage(text2)) return fail(`${route} answered with its error page, not the screen`);
3872
+ const verdict = gateFrame(text2, {
3480
3873
  route,
3481
3874
  ...options.brands ? { brands: options.brands } : {},
3482
3875
  ...options.clearance?.expectedBrands?.length ? { expected: options.clearance.expectedBrands } : {}
3483
3876
  });
3484
- if (!verdict.safe) return verdict.reason ?? `the gate refused ${route}`;
3877
+ if (!verdict.safe) return refuse(verdict.reason ?? `the gate refused ${route}`);
3485
3878
  if (!options.clearance) return null;
3486
3879
  const clearance = options.clearance;
3487
3880
  const reading = await readScreen(page, clearance);
3488
- if (!reading) return "this browser cannot read a screen against a clearance";
3489
- if (reading.inventory.unaccounted.length) return `${reading.inventory.unaccounted.length} value(s) on ${route} are outside every region the clearance names`;
3881
+ if (!reading) return refuse("this browser cannot read a screen against a clearance");
3882
+ if (reading.inventory.unaccounted.length) return refuse(`${reading.inventory.unaccounted.length} value(s) on ${route} are outside every region the clearance names`);
3490
3883
  const approved = new Set(clearance.approved);
3491
3884
  const loose = reading.surfaces.filter((surface) => !surface.read && !clearance.covers.includes(surface.where) && !approved.has(surface.where));
3492
- if (loose.length && !clearance.coverUnnamed) return `${loose.length} surface(s) on ${route} are neither approved nor covered`;
3885
+ if (loose.length && !clearance.coverUnnamed) return refuse(`${loose.length} surface(s) on ${route} are neither approved nor covered`);
3493
3886
  cover = reading.surfaces.filter((surface) => !surface.read && !approved.has(surface.where)).map((surface) => surface.where);
3494
3887
  return null;
3495
3888
  };
@@ -3501,13 +3894,14 @@ async function recordClip(options, script, touched) {
3501
3894
  at += ms;
3502
3895
  return file;
3503
3896
  };
3504
- const shoot = async (ms) => keep(await page.screenshot(cover), ms);
3897
+ const picture = (covered) => page.frame ? page.frame(covered) : page.screenshot(covered);
3898
+ const shoot = async (ms) => keep(await picture(cover), ms);
3505
3899
  const hold = (ms) => {
3506
3900
  frames[frames.length - 1].ms += ms;
3507
3901
  at += ms;
3508
3902
  };
3509
3903
  const film = async () => {
3510
- const next = await page.screenshot(cover);
3904
+ const next = await picture(cover);
3511
3905
  if (sameBytes(next, last)) {
3512
3906
  hold(FRAME_MS);
3513
3907
  return false;
@@ -3516,30 +3910,37 @@ async function recordClip(options, script, touched) {
3516
3910
  return true;
3517
3911
  };
3518
3912
  const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
3519
- const manifestOf = (raw, step, where) => buildLayoutManifest(raw, {
3520
- shotId: script.id,
3521
- route: step.route,
3522
- path: where.split("?")[0],
3523
- variant: "desktop",
3524
- image: { width: raw.viewport.width, height: raw.viewport.height },
3525
- origin: new URL(options.baseUrl).origin
3526
- });
3913
+ let redirected;
3914
+ const isOn = (route, where) => onScreen(route, where) || redirected?.route === route && where.split("?")[0] === redirected.path;
3915
+ const manifestOf = (raw, step, where) => buildLayoutManifest(
3916
+ raw,
3917
+ {
3918
+ shotId: script.id,
3919
+ route: step.route,
3920
+ path: where.split("?")[0],
3921
+ variant: "desktop",
3922
+ image: { width: raw.viewport.width, height: raw.viewport.height },
3923
+ origin: new URL(options.baseUrl).origin
3924
+ },
3925
+ { live: true }
3926
+ );
3527
3927
  const steady = async () => {
3528
- let previous = await page.screenshot(cover);
3928
+ let previous = await picture(cover);
3529
3929
  for (let waited = 0; waited < SETTLE_MS; waited += FRAME_MS) {
3530
3930
  await pause(FRAME_MS);
3531
- const next = await page.screenshot(cover);
3931
+ const next = await picture(cover);
3532
3932
  if (sameBytes(next, previous)) return;
3533
3933
  previous = next;
3534
3934
  }
3535
3935
  };
3536
3936
  let resting = START;
3937
+ let lastRegion;
3537
3938
  const find = async (step, where, filming) => {
3538
3939
  const raw = await layout(cover);
3539
- const shown = resolveStep(manifestOf(raw, step, where), step);
3940
+ const shown = resolveStep(manifestOf(raw, step, where), step, { after: lastRegion });
3540
3941
  if (shown.ok) return shown;
3541
- const whole = resolveStep(manifestOf(wholePage(raw), step, where), step);
3542
- if (!whole.ok) return shown;
3942
+ const whole = resolveStep(manifestOf(wholePage(raw), step, where), step, { after: lastRegion });
3943
+ if (!whole.ok) return whole.kind === "missing" ? shown : whole;
3543
3944
  const box = whole.control.box;
3544
3945
  const by = Math.round(box.y - WHOLE_SHIFT + box.height / 2 - CLIP_VIEWPORT.height * SCROLL_TO);
3545
3946
  const wheel = { x: Math.round(Math.min(Math.max(box.x + box.width / 2, 1), CLIP_VIEWPORT.width - 1)), y: Math.round(CLIP_VIEWPORT.height / 2) };
@@ -3560,15 +3961,20 @@ async function recordClip(options, script, touched) {
3560
3961
  } else {
3561
3962
  await steady();
3562
3963
  }
3563
- return resolveStep(manifestOf(await layout(cover), step, where), step);
3964
+ return resolveStep(manifestOf(await layout(cover), step, where), step, { after: lastRegion });
3965
+ };
3966
+ const open = async (route, opens, where) => {
3967
+ if (!opens || isOn(route, where)) return where;
3968
+ await page.goto(new URL(opens, options.baseUrl).toString());
3969
+ return location2();
3564
3970
  };
3565
- const reach = async (route) => {
3566
- let where = await location2();
3567
- for (let waited = 0; !onScreen(route, where) && waited < STEP_WAIT_MS; waited += FRAME_MS) {
3971
+ const reach = async (route, opens) => {
3972
+ let where = await open(route, opens, await location2());
3973
+ for (let waited = 0; !isOn(route, where) && waited < STEP_WAIT_MS; waited += FRAME_MS) {
3568
3974
  await pause(FRAME_MS);
3569
3975
  where = await location2();
3570
3976
  }
3571
- if (onScreen(route, where)) {
3977
+ if (isOn(route, where)) {
3572
3978
  await page.quiet?.(SETTLE_MS);
3573
3979
  await steady();
3574
3980
  }
@@ -3579,22 +3985,45 @@ async function recordClip(options, script, touched) {
3579
3985
  await steady();
3580
3986
  return location2();
3581
3987
  };
3582
- const performedOn = (step, where) => onScreen(step.route, where) ? step.route : [...script.before ?? [], ...script.steps].map((each) => each.route).find((route) => onScreen(route, where)) ?? where.split("?")[0];
3988
+ const focus = (at2) => pointer({ kind: "focus", at: at2 }).then(
3989
+ () => null,
3990
+ (error) => error instanceof Error ? error.message : String(error)
3991
+ );
3992
+ const landed = async (typedInto, step, where) => {
3993
+ const at2 = centreOf(typedInto.box);
3994
+ const field = manifestOf(await layout(cover), step, where).controls.find(
3995
+ (control) => control.field && at2.x >= control.box.x && at2.x <= control.box.x + control.box.width && at2.y >= control.box.y && at2.y <= control.box.y + control.box.height
3996
+ );
3997
+ return field?.name !== typedInto.name || !field.field?.empty;
3998
+ };
3999
+ const here = async (step, filming) => {
4000
+ if (step.control.role || step.action !== "auto" || step.follows) return void 0;
4001
+ const where = await location2();
4002
+ if (isOn(step.route, where)) return void 0;
4003
+ const resolution = await find(step, where, filming);
4004
+ return resolution.ok ? { where, resolution } : void 0;
4005
+ };
4006
+ const performedOn = (step, where) => isOn(step.route, where) ? step.route : [...script.before ?? [], ...script.steps].map((each) => each.route).find((route) => isOn(route, where)) ?? where.split("?")[0];
3583
4007
  const lead = async (step) => {
3584
- const where = step.follows ? await settled() : await reach(step.route);
3585
- if (!step.follows && !onScreen(step.route, where)) return `the browser is on ${where}, not ${step.route}`;
3586
- let resolution = await find(step, where, false);
4008
+ const shown = await here(step, false);
4009
+ const where = shown?.where ?? (step.follows ? await settled() : await reach(step.route, step.opens));
4010
+ if (!shown && !step.follows && !isOn(step.route, where)) return `the browser is on ${where}, not ${step.route}`;
4011
+ let resolution = shown?.resolution ?? await find(step, where, false);
3587
4012
  for (let waited = 0; !resolution.ok && waited < STEP_WAIT_MS; waited += FRAME_MS) {
3588
4013
  await pause(FRAME_MS);
3589
4014
  resolution = await find(step, where, false);
3590
4015
  }
3591
4016
  if (!resolution.ok) return resolution.because;
4017
+ lastRegion = resolution.control.region;
3592
4018
  const target = centreOf(resolution.control.box);
3593
4019
  const action = step.action === "auto" ? actionForRole(resolution.control.role, step.value) : step.action;
4020
+ const typed = step.value ?? step.demo ?? demoValueForField(resolution.control);
3594
4021
  if (action === "fill") {
3595
- if (!step.value) return `it types into ${resolution.control.name}, and says nothing to type`;
3596
- await pointer({ kind: "focus", at: target });
3597
- await type(step.value);
4022
+ if (!typed) return `it types into ${resolution.control.name}, and says nothing to type`;
4023
+ const unfocused = await focus(target);
4024
+ if (unfocused) return unfocused;
4025
+ await type(typed);
4026
+ if (!await landed(resolution.control, step, where)) return `what was typed into ${resolution.control.name} did not land, and the field still holds nothing`;
3598
4027
  } else if (action === "select") {
3599
4028
  await pointer({ kind: "select", at: target, value: step.value ?? "" });
3600
4029
  } else {
@@ -3608,26 +4037,30 @@ async function recordClip(options, script, touched) {
3608
4037
  if (await page.isSignIn()) return refuse("its first screen asked for a sign-in, so this session is not the demo tenant's");
3609
4038
  const opened = await location2();
3610
4039
  const performedFirst = script.before?.[0] ?? script.steps[0];
3611
- if (!onScreen(performedFirst.route, opened)) return fail(`it opened on ${opened}, not ${performedFirst.route}`);
4040
+ if (!onScreen(performedFirst.route, opened)) {
4041
+ if (!options.screens?.some((route) => onScreen(route, opened))) return fail(`it opened on ${opened}, not ${performedFirst.route}`);
4042
+ redirected = { route: performedFirst.route, path: opened.split("?")[0] };
4043
+ }
3612
4044
  for (const [index, step] of (script.before ?? []).entries()) {
3613
4045
  const problem = await lead(step);
3614
4046
  if (problem) return fail(`before the recording, step ${index + 1}: ${problem}`);
3615
4047
  }
3616
4048
  const first = script.steps[0];
3617
- const starting = !script.before?.length ? opened : first.follows ? await settled() : await reach(first.route);
3618
- if (!first.follows && !onScreen(first.route, starting)) return fail(`once the steps before it were done, the browser is on ${starting}, not ${first.route}`);
4049
+ const already = script.before?.length ? await here(first, false) : void 0;
4050
+ const starting = !script.before?.length ? opened : already?.where ?? (first.follows ? await settled() : await reach(first.route, first.opens));
4051
+ if (!already && !first.follows && !isOn(first.route, starting)) return fail(`once the steps before it were done, the browser is on ${starting}, not ${first.route}`);
3619
4052
  const before = await judge(starting);
3620
- if (before) return refuse(before);
4053
+ if (before) return before;
3621
4054
  let poster;
3622
4055
  const steps = [];
3623
- const arrive = async (route, follows = false) => {
3624
- let where = await location2();
3625
- for (let waited = 0; !follows && !onScreen(route, where) && waited < STEP_WAIT_MS; waited += FRAME_MS) {
4056
+ const arrive = async (route, follows = false, opens) => {
4057
+ let where = follows ? await location2() : await open(route, opens, await location2());
4058
+ for (let waited = 0; !follows && !isOn(route, where) && waited < STEP_WAIT_MS; waited += FRAME_MS) {
3626
4059
  await cursor(resting);
3627
4060
  await film();
3628
4061
  where = await location2();
3629
4062
  }
3630
- if (!follows && !onScreen(route, where)) return where;
4063
+ if (!follows && !isOn(route, where)) return where;
3631
4064
  await page.quiet?.(SETTLE_MS);
3632
4065
  await cursor(resting);
3633
4066
  for (let waited = 0; waited < SETTLE_MS; waited += FRAME_MS) {
@@ -3638,21 +4071,24 @@ async function recordClip(options, script, touched) {
3638
4071
  await cursor(resting);
3639
4072
  checked.add(await shoot(OPENING_MS));
3640
4073
  for (const [index, step] of script.steps.entries()) {
3641
- const where = await arrive(step.route, step.follows === true);
3642
- if (!step.follows && !onScreen(step.route, where)) return fail(`step ${index + 1}: the browser is on ${where}, not ${step.route}`);
3643
- const arrived = await judge(where);
3644
- if (arrived) return refuse(arrived);
3645
- let resolution = await find(step, where, true);
4074
+ const shown = await here(step, true);
4075
+ const where = shown?.where ?? await arrive(step.route, step.follows === true, step.opens);
4076
+ if (!shown && !step.follows && !isOn(step.route, where)) return fail(`step ${index + 1}: the browser is on ${where}, not ${step.route}`);
4077
+ const arrived = shown ? null : await judge(where);
4078
+ if (arrived) return arrived;
4079
+ let resolution = shown?.resolution ?? await find(step, where, true);
3646
4080
  for (let waited = 0; !resolution.ok && waited < STEP_WAIT_MS; waited += FRAME_MS) {
3647
4081
  await cursor(resting);
3648
4082
  await film();
3649
4083
  resolution = await find(step, where, true);
3650
4084
  }
3651
4085
  if (!resolution.ok) return fail(`step ${index + 1}: ${resolution.because}`);
4086
+ lastRegion = resolution.control.region;
3652
4087
  const target = centreOf(resolution.control.box);
3653
4088
  const startedAt = at;
3654
4089
  const action = step.action === "auto" ? actionForRole(resolution.control.role, step.value) : step.action;
3655
- if (action === "fill" && !step.value) return fail(`step ${index + 1}: it types into ${resolution.control.name}, and says nothing to type`);
4090
+ const typed = step.value ?? step.demo ?? demoValueForField(resolution.control);
4091
+ if (action === "fill" && !typed) return fail(`step ${index + 1}: it types into ${resolution.control.name}, and says nothing to type`);
3656
4092
  for (const point of cursorPath(resting, target, MOVE_FRAMES)) {
3657
4093
  await cursor(point);
3658
4094
  await shoot(FRAME_MS);
@@ -3663,13 +4099,15 @@ async function recordClip(options, script, touched) {
3663
4099
  await cursor(target, true);
3664
4100
  await shoot(PRESS_MS);
3665
4101
  if (action === "fill") {
3666
- const value = step.value ?? "";
3667
- await pointer({ kind: "focus", at: target });
4102
+ const value = typed ?? "";
4103
+ const unfocused = await focus(target);
4104
+ if (unfocused) return fail(`step ${index + 1}: ${unfocused}`);
3668
4105
  await cursor(target, false);
3669
- for (const chunk of chunksOf(value, Math.max(CHARS_PER_FRAME, Math.ceil(value.length / TYPING_FRAMES)))) {
3670
- await type(chunk);
4106
+ for (const chunk2 of chunksOf(value, Math.max(CHARS_PER_FRAME, Math.ceil(value.length / TYPING_FRAMES)))) {
4107
+ await type(chunk2);
3671
4108
  await shoot(FRAME_MS);
3672
4109
  }
4110
+ if (!await landed(resolution.control, step, where)) return fail(`step ${index + 1}: what was typed into ${resolution.control.name} did not land, and the field still holds nothing`);
3673
4111
  } else if (action === "select") {
3674
4112
  await pointer({ kind: "select", at: target, value: step.value ?? "" });
3675
4113
  } else {
@@ -3685,7 +4123,7 @@ async function recordClip(options, script, touched) {
3685
4123
  checked.add(frames[frames.length - 1].file);
3686
4124
  const now = await location2();
3687
4125
  const after = await judge(now);
3688
- if (after) return refuse(after);
4126
+ if (after) return after;
3689
4127
  steps.push({
3690
4128
  route: performedOn(step, where),
3691
4129
  role: resolution.control.role,
@@ -3697,10 +4135,10 @@ async function recordClip(options, script, touched) {
3697
4135
  });
3698
4136
  }
3699
4137
  if (script.lands) {
3700
- const landed = await arrive(script.lands);
3701
- if (!onScreen(script.lands, landed)) return fail(`the last step left the browser on ${landed}, not ${script.lands}`);
3702
- const seen = await judge(landed);
3703
- if (seen) return refuse(seen);
4138
+ const landed2 = await arrive(script.lands);
4139
+ if (!isOn(script.lands, landed2)) return fail(`the last step left the browser on ${landed2}, not ${script.lands}`);
4140
+ const seen = await judge(landed2);
4141
+ if (seen) return seen;
3704
4142
  }
3705
4143
  hold(CLOSING_MS);
3706
4144
  checked.add(frames[frames.length - 1].file);
@@ -3736,15 +4174,18 @@ async function recordClip(options, script, touched) {
3736
4174
  } catch (error) {
3737
4175
  return fail(error instanceof Error ? error.message : String(error));
3738
4176
  } finally {
3739
- await page.close().catch(() => void 0);
3740
4177
  await rm(dir, { recursive: true, force: true }).catch(() => void 0);
3741
4178
  }
3742
4179
  }
4180
+ function leavesBrowserWaiting(because) {
4181
+ return because.includes(CLIP_LATE) || /execution context was destroyed|because of a navigation/i.test(because);
4182
+ }
3743
4183
  var CLIP_BUDGET_MS = 20 * 6e4;
3744
4184
  async function runClips(options) {
3745
4185
  const started = Date.now();
3746
4186
  const outcomes = [];
3747
4187
  let spent;
4188
+ let browser = options.browser;
3748
4189
  for (const [index, script] of options.scripts.entries()) {
3749
4190
  const touched = clipRoutes(script).map((route) => ({
3750
4191
  route,
@@ -3761,9 +4202,16 @@ async function runClips(options) {
3761
4202
  } else if (!options.force && options.held.get(script.id) === fingerprint) {
3762
4203
  outcome = { ...base, status: "unchanged", reason: "neither its steps nor a screen they touch has changed" };
3763
4204
  } else {
3764
- const result = await recordClip(options, script, touched);
4205
+ const result = await recordClip({ ...options, browser, screens: options.screens ?? [...options.frames.keys()] }, script, touched);
3765
4206
  if (!result.ok) {
3766
4207
  outcome = { ...base, status: result.status, reason: result.because };
4208
+ if (options.relaunch && leavesBrowserWaiting(result.because)) {
4209
+ const fresh = await options.relaunch().catch(() => void 0);
4210
+ if (fresh) {
4211
+ if (browser !== options.browser) void browser.close().catch(() => void 0);
4212
+ browser = fresh;
4213
+ }
4214
+ }
3767
4215
  } else {
3768
4216
  const delivered = await options.send(result.clip);
3769
4217
  if (delivered.ok) {
@@ -3785,14 +4233,15 @@ async function runClips(options) {
3785
4233
  outcomes.push(outcome);
3786
4234
  options.onProgress?.(outcome, index + 1, options.scripts.length);
3787
4235
  }
4236
+ if (browser !== options.browser) await Promise.race([browser.close().catch(() => void 0), new Promise((resolve) => setTimeout(resolve, 5e3))]);
3788
4237
  return outcomes;
3789
4238
  }
3790
4239
  function run(binary, args) {
3791
4240
  return new Promise((resolve, reject) => {
3792
4241
  const child = spawn(binary, args, { stdio: ["ignore", "ignore", "pipe"] });
3793
4242
  let stderr = "";
3794
- child.stderr.on("data", (chunk) => {
3795
- stderr = `${stderr}${chunk.toString()}`.slice(-2e3);
4243
+ child.stderr.on("data", (chunk2) => {
4244
+ stderr = `${stderr}${chunk2.toString()}`.slice(-2e3);
3796
4245
  });
3797
4246
  child.on(
3798
4247
  "error",
@@ -3932,10 +4381,10 @@ function leadsToTheSamePlaces(children) {
3932
4381
  }
3933
4382
  function parameterName(segments, depth2) {
3934
4383
  const parent = depth2 > 0 ? segments[depth2 - 1] : "";
3935
- const base = parent && !parent.startsWith(":") ? singular(parent) : "id";
4384
+ const base = parent && !parent.startsWith(":") ? singular2(parent) : "id";
3936
4385
  return base.replace(/[^a-z0-9]+/gi, "") || "id";
3937
4386
  }
3938
- function singular(word) {
4387
+ function singular2(word) {
3939
4388
  const lower = word.toLowerCase();
3940
4389
  if (/(analysis|status|address|series|species)$/.test(lower)) return lower;
3941
4390
  if (lower.endsWith("ies")) return `${lower.slice(0, -3)}y`;
@@ -3995,7 +4444,7 @@ var MISS_STATUS = RECORDING_MISS_STATUS;
3995
4444
  function bodyOf(message) {
3996
4445
  return new Promise((resolve, reject) => {
3997
4446
  const chunks = [];
3998
- message.on("data", (chunk) => chunks.push(chunk));
4447
+ message.on("data", (chunk2) => chunks.push(chunk2));
3999
4448
  message.on("end", () => resolve(Buffer.concat(chunks)));
4000
4449
  message.on("error", reject);
4001
4450
  });
@@ -4169,18 +4618,12 @@ async function waitForApp(url, deadline, onLog, gaveUp) {
4169
4618
  }
4170
4619
  return false;
4171
4620
  }
4172
- async function stageAgainstRecording(options) {
4173
- const replay = await replayFrom({
4174
- cassette: options.cassette,
4175
- ...options.replayPort ? { port: options.replayPort } : {},
4176
- onMiss: (miss) => options.onLog?.(` the recording has no ${miss.method} ${miss.path}${miss.from ? ` (wanted by ${miss.from})` : ""}`)
4177
- });
4178
- const variable = options.appEnv ?? DEFAULT_APP_ENV;
4621
+ async function startProduct(options) {
4179
4622
  let app;
4180
4623
  let died;
4181
4624
  const clock = options.appCommand ? await productClock(options.pinnedClock) : { env: {} };
4182
4625
  if (options.appCommand) {
4183
- options.onLog?.(`Starting your product with ${variable}=${replay.url}${clockNote(options.pinnedClock)}`);
4626
+ options.onLog?.(options.starting);
4184
4627
  app = spawn2(options.appCommand, {
4185
4628
  shell: true,
4186
4629
  // Its own process group, so it can be taken down as one.
@@ -4191,22 +4634,19 @@ async function stageAgainstRecording(options) {
4191
4634
  // the address occupied by the last one — and on a build agent, the job
4192
4635
  // never finishes.
4193
4636
  detached: true,
4194
- env: { ...process.env, [variable]: replay.url, ...clock.env },
4637
+ env: { ...process.env, ...options.env, ...clock.env },
4195
4638
  stdio: ["ignore", "pipe", "pipe"]
4196
4639
  });
4197
- app.stdout?.on("data", (chunk) => options.onLog?.(` [app] ${chunk.toString().trimEnd()}`));
4198
- app.stderr?.on("data", (chunk) => options.onLog?.(` [app] ${chunk.toString().trimEnd()}`));
4640
+ app.stdout?.on("data", (chunk2) => options.onLog?.(` [app] ${chunk2.toString().trimEnd()}`));
4641
+ app.stderr?.on("data", (chunk2) => options.onLog?.(` [app] ${chunk2.toString().trimEnd()}`));
4199
4642
  app.on("exit", (code) => {
4200
4643
  died = code ?? 1;
4201
4644
  });
4202
- } else {
4203
- options.onLog?.(`The recording is at ${replay.url}. Start your product with ${variable} set to it.`);
4204
4645
  }
4205
4646
  const up = await waitForApp(options.appUrl, Date.now() + (options.waitMs ?? 6e4), options.onLog, () => died !== void 0);
4206
4647
  if (app) await new Promise((resolve) => setTimeout(resolve, 250));
4207
4648
  if (died === void 0 && app?.exitCode !== null && app?.exitCode !== void 0) died = app.exitCode;
4208
4649
  if (died !== void 0) {
4209
- await replay.close();
4210
4650
  stop(app);
4211
4651
  await clock.written?.remove();
4212
4652
  throw new Error(
@@ -4216,25 +4656,66 @@ Something else is already answering on ${options.appUrl}. That is almost always
4216
4656
  );
4217
4657
  }
4218
4658
  if (!up) {
4219
- await replay.close();
4220
4659
  stop(app);
4221
4660
  await clock.written?.remove();
4222
4661
  throw new Error(
4223
- `${options.appUrl} never answered. ` + (options.appCommand ? "Check the start command above, and that your product listens on that address." : `Start your product with ${variable}=${replay.url}, then run this again.`)
4662
+ `${options.appUrl} never answered. ` + (options.appCommand ? "Check the start command above, and that your product listens on that address." : options.unstarted)
4224
4663
  );
4225
4664
  }
4665
+ return {
4666
+ close: async () => {
4667
+ stop(app);
4668
+ await clock.written?.remove();
4669
+ }
4670
+ };
4671
+ }
4672
+ async function stageAgainstRecording(options) {
4673
+ const replay = await replayFrom({
4674
+ cassette: options.cassette,
4675
+ ...options.replayPort ? { port: options.replayPort } : {},
4676
+ onMiss: (miss) => options.onLog?.(` the recording has no ${miss.method} ${miss.path}${miss.from ? ` (wanted by ${miss.from})` : ""}`)
4677
+ });
4678
+ const variable = options.appEnv ?? DEFAULT_APP_ENV;
4679
+ if (!options.appCommand) options.onLog?.(`The recording is at ${replay.url}. Start your product with ${variable} set to it.`);
4680
+ let running;
4681
+ try {
4682
+ running = await startProduct({
4683
+ appUrl: options.appUrl,
4684
+ appCommand: options.appCommand,
4685
+ env: { [variable]: replay.url },
4686
+ pinnedClock: options.pinnedClock,
4687
+ waitMs: options.waitMs,
4688
+ onLog: options.onLog,
4689
+ starting: `Starting your product with ${variable}=${replay.url}${clockNote(options.pinnedClock)}`,
4690
+ unstarted: `Start your product with ${variable}=${replay.url}, then run this again.`
4691
+ });
4692
+ } catch (error) {
4693
+ await replay.close();
4694
+ throw error;
4695
+ }
4226
4696
  return {
4227
4697
  appUrl: options.appUrl,
4228
4698
  replayUrl: replay.url,
4229
4699
  replay,
4230
4700
  cassette: options.cassette,
4231
4701
  close: async () => {
4232
- stop(app);
4702
+ await running.close();
4233
4703
  await replay.close();
4234
- await clock.written?.remove();
4235
4704
  }
4236
4705
  };
4237
4706
  }
4707
+ async function stageApp(options) {
4708
+ const running = await startProduct({
4709
+ appUrl: options.appUrl,
4710
+ appCommand: options.appCommand,
4711
+ pinnedClock: options.pinnedClock,
4712
+ waitMs: options.waitMs,
4713
+ onLog: options.onLog,
4714
+ starting: `Starting your product with ${options.appCommand}${clockNote(options.pinnedClock)}`,
4715
+ unstarted: ""
4716
+ });
4717
+ return { appUrl: options.appUrl, close: () => running.close() };
4718
+ }
4238
4719
  function clockNote(pinnedClock) {
4239
4720
  return pinnedClock ? `, its clock pinned to ${pinnedClock}` : "";
4240
4721
  }
@@ -4275,51 +4756,21 @@ Nothing has been photographed. A run that carried on from here would sign in to
4275
4756
  const named = options.examplesOut ? await examplesFrom(options.examplesOut, onLog) : void 0;
4276
4757
  const examples = named?.examples;
4277
4758
  const clips = named?.clips ?? [];
4278
- let app;
4279
- let died;
4280
- const clock = options.appCommand ? await productClock(options.pinnedClock) : { env: {} };
4281
- if (options.appCommand) {
4282
- onLog?.(`Starting your product with ${options.appCommand}${clockNote(options.pinnedClock)}`);
4283
- app = spawn2(options.appCommand, {
4284
- shell: true,
4285
- detached: true,
4286
- env: { ...process.env, ...clock.env },
4287
- stdio: ["ignore", "pipe", "pipe"]
4288
- });
4289
- app.stdout?.on("data", (chunk) => onLog?.(` [app] ${chunk.toString().trimEnd()}`));
4290
- app.stderr?.on("data", (chunk) => onLog?.(` [app] ${chunk.toString().trimEnd()}`));
4291
- app.on("exit", (code) => {
4292
- died = code ?? 1;
4293
- });
4294
- }
4295
- const up = await waitForApp(options.appUrl, Date.now() + (options.waitMs ?? 6e4), onLog, () => died !== void 0);
4296
- if (app) await new Promise((resolve) => setTimeout(resolve, 250));
4297
- if (died === void 0 && app?.exitCode !== null && app?.exitCode !== void 0) died = app.exitCode;
4298
- if (died !== void 0) {
4299
- stop(app);
4300
- await clock.written?.remove();
4301
- throw new Error(
4302
- `The start command exited (${died}) before the product was ready. Its output is above.` + (up ? `
4303
-
4304
- Something else is already answering on ${options.appUrl}, which is almost always a server left behind by an earlier run. This will not photograph it, because it is not the product this run started.` : "")
4305
- );
4306
- }
4307
- if (!up) {
4308
- stop(app);
4309
- await clock.written?.remove();
4310
- throw new Error(
4311
- `${options.appUrl} never answered. Check the start command above, and that your product listens on that address.`
4312
- );
4313
- }
4759
+ const running = await startProduct({
4760
+ appUrl: options.appUrl,
4761
+ appCommand: options.appCommand,
4762
+ pinnedClock: options.pinnedClock,
4763
+ waitMs: options.waitMs,
4764
+ onLog,
4765
+ starting: `Starting your product with ${options.appCommand}${clockNote(options.pinnedClock)}`,
4766
+ unstarted: "Check that your product listens on that address."
4767
+ });
4314
4768
  return {
4315
4769
  appUrl: options.appUrl,
4316
4770
  ...sessionPath ? { sessionPath } : {},
4317
4771
  ...examples ? { examples } : {},
4318
4772
  ...clips.length ? { clips } : {},
4319
- close: async () => {
4320
- stop(app);
4321
- await clock.written?.remove();
4322
- }
4773
+ close: () => running.close()
4323
4774
  };
4324
4775
  }
4325
4776
  async function examplesFrom(path5, onLog) {
@@ -4363,8 +4814,8 @@ async function runToCompletion(command, timeoutMs, env, onLog) {
4363
4814
  stop(child);
4364
4815
  resolve({ ok: false, timedOut: true });
4365
4816
  }, timeoutMs);
4366
- child.stdout?.on("data", (chunk) => onLog?.(` [seed] ${chunk.toString().trimEnd()}`));
4367
- child.stderr?.on("data", (chunk) => onLog?.(` [seed] ${chunk.toString().trimEnd()}`));
4817
+ child.stdout?.on("data", (chunk2) => onLog?.(` [seed] ${chunk2.toString().trimEnd()}`));
4818
+ child.stderr?.on("data", (chunk2) => onLog?.(` [seed] ${chunk2.toString().trimEnd()}`));
4368
4819
  child.on("error", (error) => {
4369
4820
  clearTimeout(timer);
4370
4821
  onLog?.(` [seed] ${error.message}`);
@@ -4602,6 +5053,34 @@ function depth(path5) {
4602
5053
  return path5 === "/" ? 0 : path5.split("/").filter(Boolean).length;
4603
5054
  }
4604
5055
 
5056
+ // core/discovery/apps.ts
5057
+ var ROOT_SHARE = 0.8;
5058
+ function appRoot(routes) {
5059
+ const screens = documentable(routes);
5060
+ const root = [];
5061
+ for (; ; ) {
5062
+ const counts = /* @__PURE__ */ new Map();
5063
+ for (const route of screens) {
5064
+ const segments = staticSegments(route.path);
5065
+ const next = segments[root.length];
5066
+ if (next && root.every((segment2, index) => segments[index] === segment2)) counts.set(next, (counts.get(next) ?? 0) + 1);
5067
+ }
5068
+ const [segment, count = 0] = [...counts].sort((a, b) => b[1] - a[1])[0] ?? [];
5069
+ if (!segment || count < Math.max(3, screens.length * ROOT_SHARE)) return root;
5070
+ root.push(segment);
5071
+ }
5072
+ }
5073
+ function belowAppRoot(path5, root) {
5074
+ const segments = path5.split("/").filter(Boolean);
5075
+ return root.length && root.every((segment, index) => segments[index] === segment) ? segments.slice(root.length) : segments;
5076
+ }
5077
+ function staticSegments(path5) {
5078
+ return path5.split("/").filter((segment) => segment && !segment.startsWith("["));
5079
+ }
5080
+ function documentable(routes) {
5081
+ return routes.filter((route) => !route.skipped);
5082
+ }
5083
+
4605
5084
  // core/discovery/article-plan.ts
4606
5085
  var MONEY_OR_ACCESS = /* @__PURE__ */ new Set([
4607
5086
  "billing",
@@ -4680,15 +5159,15 @@ function slugFor(path5) {
4680
5159
  const segments = path5.split("/").filter(Boolean).filter((segment) => !segment.startsWith("["));
4681
5160
  return segments.length ? segments.join("/") : "overview";
4682
5161
  }
4683
- function titleFor(route) {
4684
- const segments = route.path.split("/").filter(Boolean).filter((s) => !s.startsWith("["));
4685
- if (!segments.length) return "Getting around";
5162
+ function titleFor(route, root) {
5163
+ const segments = belowAppRoot(route.path, root).filter((s) => !s.startsWith("["));
5164
+ if (!segments.length) return route.params.length ? "Working with a record" : "Getting around";
4686
5165
  const words = segments[segments.length - 1].replace(/[-_]/g, " ");
4687
5166
  const subject = words.charAt(0).toUpperCase() + words.slice(1);
4688
5167
  if (!route.params.length) return subject;
4689
- return `Working with ${article(singular2(subject.toLowerCase()))}`;
5168
+ return `Working with ${article(singular3(subject.toLowerCase()))}`;
4690
5169
  }
4691
- function singular2(word) {
5170
+ function singular3(word) {
4692
5171
  if (/(sis|ss|us|is)$/.test(word)) return word;
4693
5172
  if (/(ches|shes|xes|zes)$/.test(word)) return word.slice(0, -2);
4694
5173
  if (/[^aeiou]ies$/.test(word)) return `${word.slice(0, -3)}y`;
@@ -4734,6 +5213,7 @@ function buildArticlePlan(options) {
4734
5213
  }
4735
5214
  }
4736
5215
  let covered = 0;
5216
+ const root = appRoot(options.routes);
4737
5217
  const needs = [...merged.entries()].map(([slug, group], index) => {
4738
5218
  const rank = index + 1;
4739
5219
  const claimed = [group.entry.route.path, ...group.routes.slice(1) ?? []].map((path5) => options.documented?.[path5]).find(Boolean);
@@ -4741,7 +5221,7 @@ function buildArticlePlan(options) {
4741
5221
  if (already) covered += 1;
4742
5222
  return {
4743
5223
  slug,
4744
- title: group.title ?? titleFor(group.entry.route),
5224
+ title: group.title ?? titleFor(group.entry.route, root),
4745
5225
  route: group.entry.route.path,
4746
5226
  ...group.routes.length > 1 ? { alsoCovers: group.routes.slice(1) } : {},
4747
5227
  tier: tierFor(rank, merged.size),
@@ -4776,8 +5256,8 @@ function shotIdFor(route) {
4776
5256
  const parts = route.split("/").filter(Boolean).map((segment) => segment.replace(/^\[+\.{0,3}|\]+$/g, ""));
4777
5257
  return parts.length ? parts.join("-").toLowerCase() : "overview";
4778
5258
  }
4779
- function describe(route, isEntry) {
4780
- const segments = route.path.split("/").filter(Boolean);
5259
+ function describe(route, isEntry, root) {
5260
+ const segments = belowAppRoot(route.path, root);
4781
5261
  const last = segments[segments.length - 1] ?? "";
4782
5262
  const screen = route.title ? `The ${route.title} screen` : last.startsWith("[") ? `The screen for a single ${(segments[segments.length - 2] ?? "record").replace(/s$/, "")}` : last ? `The ${last.replace(/[-_]/g, " ")} screen` : "The home screen";
4783
5263
  return isEntry ? `${screen} as someone arrives on it` : `${screen}, reached from ${segments.slice(0, -1).join("/") || "the top level"}`;
@@ -4795,10 +5275,11 @@ function buildStoryboard(options) {
4795
5275
  const own = [...covered].map((path5) => byPath.get(path5)).filter((route) => Boolean(route) && !route.skipped);
4796
5276
  const ordered = options.inOrder ? own : own.sort((a, b) => a.path.length - b.path.length).concat(beneath.sort((a, b) => a.path.length - b.path.length));
4797
5277
  const limit = options.maxSteps ?? 5;
5278
+ const root = appRoot(options.routes);
4798
5279
  const steps = ordered.slice(0, limit).map((route, index) => ({
4799
5280
  route: route.path,
4800
5281
  shotId: shotIdFor(route.path),
4801
- depicts: describe(route, index === 0),
5282
+ depicts: describe(route, index === 0, root),
4802
5283
  kind: kindFor(route, index === 0),
4803
5284
  needsExampleFor: route.params
4804
5285
  }));
@@ -4851,7 +5332,7 @@ function parseArgs(argv) {
4851
5332
  if (args.out) {
4852
5333
  args.key ??= "";
4853
5334
  args.to ??= "";
4854
- if (!args.baseUrl) return "Missing --base-url. That is where your product is running.";
5335
+ if (!args.baseUrl && !args.appCommand && !args.seedCommand && !args.cassette) return "Missing --base-url. That is where your product is running.";
4855
5336
  return args;
4856
5337
  }
4857
5338
  if (!args.key) return "Missing --key, or --out <dir> to keep the frames here instead.";
@@ -4952,11 +5433,21 @@ function articleClipsToRecord(raw, examples) {
4952
5433
  if (!Array.isArray(raw)) return readClipScripts(raw, CLIP_MAX_ARTICLE_SCRIPTS);
4953
5434
  const named = examples ?? { paths: /* @__PURE__ */ new Map(), refused: [] };
4954
5435
  const unopened = [];
5436
+ const recordOf3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
5437
+ const withOpens = (steps, from) => Array.isArray(steps) ? steps.map((value, index) => {
5438
+ const step = recordOf3(value);
5439
+ const later = from + index > 0 && step["follows"] !== true;
5440
+ const resolved = later && typeof step["route"] === "string" ? resolveVisit(step["route"], named) : void 0;
5441
+ return resolved && "visit" in resolved && resolved.visit.includes("?") ? { ...step, opens: resolved.visit } : value;
5442
+ }) : steps;
4955
5443
  const opened = raw.map((value) => {
4956
- const script = typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
5444
+ const script = recordOf3(value);
4957
5445
  const visit = typeof script["visit"] === "string" ? script["visit"] : "";
4958
5446
  const resolved = resolveVisit(visit, named);
4959
- if (!("needs" in resolved)) return { ...script, visit: resolved.visit };
5447
+ const lead = Array.isArray(script["before"]) ? script["before"].length : 0;
5448
+ if (!("needs" in resolved)) {
5449
+ return { ...script, visit: resolved.visit, ...lead ? { before: withOpens(script["before"], 0) } : {}, steps: withOpens(script["steps"], lead) };
5450
+ }
4960
5451
  unopened.push({ id: typeof script["id"] === "string" ? script["id"] : "a clip", because: describeMissingExample(visit, resolved.needs) });
4961
5452
  return void 0;
4962
5453
  });
@@ -5020,6 +5511,7 @@ The seed command named ${args.named} clip(s)${asked ? `, and the help desk's how
5020
5511
  const outcomes = await runClips({
5021
5512
  baseUrl: args.baseUrl,
5022
5513
  browser: args.browser,
5514
+ ...args.relaunch ? { relaunch: args.relaunch } : {},
5023
5515
  encoder: ffmpegEncoder(),
5024
5516
  workDir: path4.join(tmpdir2(), `helpdesk-clips-${process.pid}`),
5025
5517
  clearance: args.clearance,
@@ -5044,6 +5536,9 @@ The seed command named ${args.named} clip(s)${asked ? `, and the help desk's how
5044
5536
  const kb = (bytes) => `${Math.round(bytes / 1024)} KB`;
5045
5537
  const detail = outcome.status === "recorded" ? `${((outcome.durationMs ?? 0) / 1e3).toFixed(1)} s, ${kb(outcome.mp4Bytes ?? 0)} MP4, ${outcome.gifBytes ? `${kb(outcome.gifBytes)} GIF` : "no GIF"}` : outcome.reason ?? "";
5046
5538
  console.log(`${mark} clip ${index}/${total} ${outcome.title}: ${detail}`);
5539
+ if (outcome.status === "refused" || outcome.status === "failed") {
5540
+ for (const line of clipStepLines(args.scripts.find((script) => script.id === outcome.clipId))) console.log(` ${line}`);
5541
+ }
5047
5542
  }
5048
5543
  });
5049
5544
  if (args.local) {
@@ -5055,15 +5550,30 @@ The seed command named ${args.named} clip(s)${asked ? `, and the help desk's how
5055
5550
  await writeFile3(path4.join(args.local, "clips", "manifest.json"), `${JSON.stringify({ schema: "olomon.clip-manifest/1", clips: kept }, null, 2)}
5056
5551
  `, "utf8");
5057
5552
  }
5058
- const count = (status) => outcomes.filter((outcome) => outcome.status === status).length;
5059
5553
  console.log(`
5060
- Clips: ${count("recorded")} recorded, ${count("unchanged")} unchanged, ${count("deferred")} deferred, ${count("refused")} refused, ${count("failed")} failed.`);
5554
+ Clips: ${clipCounts(countClips(outcomes))}.`);
5061
5555
  const missed = outcomes.filter((outcome) => outcome.status === "refused" || outcome.status === "failed");
5062
5556
  if (missed.length && process.env["GITHUB_ACTIONS"] === "true") {
5063
5557
  console.log(`::warning title=${missed.length} clip(s) not recorded::${missed.map((outcome) => `${outcome.clipId}: ${outcome.reason}`).join("; ")}`);
5064
5558
  }
5065
5559
  return outcomes;
5066
5560
  }
5561
+ function countClips(outcomes) {
5562
+ const count = (status) => outcomes.filter((outcome) => outcome.status === status).length;
5563
+ return { recorded: count("recorded"), unchanged: count("unchanged"), deferred: count("deferred"), refused: count("refused"), failed: count("failed") };
5564
+ }
5565
+ function clipStepLines(script) {
5566
+ if (!script) return [];
5567
+ const line = (step, label) => `${label}: ${step.route} "${step.control.name}"${step.control.role ? ` (${step.control.role})` : ""}${step.control.scope ? ` in ${step.control.scope}` : ""}${step.follows ? ", after the press before it" : ""}`;
5568
+ return [...(script.before ?? []).map((step, index) => line(step, `before ${index + 1}`)), ...script.steps.map((step, index) => line(step, `step ${index + 1}`))];
5569
+ }
5570
+ function elsewhereLines(thrown) {
5571
+ if (thrown.length === 0) return [];
5572
+ return [
5573
+ "Scripts other sites serve into these screens threw, and no screen was counted as crashed for it:",
5574
+ ...thrown.map((held) => ` ${held.by}, on ${held.screens.length} screen${held.screens.length === 1 ? "" : "s"}: ${held.message}`)
5575
+ ];
5576
+ }
5067
5577
  function frameForm(frame) {
5068
5578
  const form = new FormData();
5069
5579
  form.set(
@@ -5158,7 +5668,13 @@ Approve one on the plan page, then run this again.`
5158
5668
  return 1;
5159
5669
  }
5160
5670
  }
5161
- const baseUrl = parsed.cassette || parsed.seedCommand ? parsed.baseUrl ?? "http://127.0.0.1:3000" : parsed.baseUrl ?? orders.baseUrl;
5671
+ const signedOut = orders.capture?.signedOut === true;
5672
+ if (signedOut && parsed.session) {
5673
+ console.log(`This workspace approved the screens anybody sees with nobody signed in, so ${parsed.session} is not used.
5674
+ `);
5675
+ delete parsed.session;
5676
+ }
5677
+ const baseUrl = parsed.cassette || parsed.seedCommand || parsed.appCommand ? parsed.baseUrl ?? "http://127.0.0.1:3000" : parsed.baseUrl ?? orders.baseUrl;
5162
5678
  if (!baseUrl) {
5163
5679
  console.error("No product address. Pass --base-url, or set one on the help desk's plan page.");
5164
5680
  return 2;
@@ -5182,7 +5698,7 @@ Approve one on the plan page, then run this again.`
5182
5698
  examplesOut: path4.join(process.cwd(), "helpdesk-examples.json"),
5183
5699
  onLog: (line) => console.log(line)
5184
5700
  });
5185
- if (seeded.sessionPath) parsed.session = seeded.sessionPath;
5701
+ if (seeded.sessionPath && !signedOut) parsed.session = seeded.sessionPath;
5186
5702
  else delete parsed.session;
5187
5703
  console.log("");
5188
5704
  } catch (error) {
@@ -5209,6 +5725,16 @@ Approve one on the plan page, then run this again.`
5209
5725
  return 1;
5210
5726
  }
5211
5727
  }
5728
+ let app;
5729
+ if (parsed.appCommand && !seeded && !stage) {
5730
+ try {
5731
+ app = await stageApp({ appUrl: baseUrl, appCommand: parsed.appCommand, ...pinnedClock, onLog: (line) => console.log(line) });
5732
+ console.log("");
5733
+ } catch (error) {
5734
+ console.error(String(error.message));
5735
+ return 1;
5736
+ }
5737
+ }
5212
5738
  const browser = await playwrightBrowser({
5213
5739
  ...parsed.session ? { sessionPath: parsed.session } : {},
5214
5740
  ...parsed.liveClock ? { liveClock: true } : {}
@@ -5231,6 +5757,7 @@ Approve one on the plan page, then run this again.`
5231
5757
  await browser.close();
5232
5758
  await stage?.close();
5233
5759
  await seeded?.close();
5760
+ await app?.close();
5234
5761
  return 2;
5235
5762
  }
5236
5763
  if (stage) {
@@ -5356,6 +5883,7 @@ ${unclassified.join("\n")}`);
5356
5883
  console.log(`
5357
5884
  Written to ${local}: ${kept.length} frame(s) and manifest.json.`);
5358
5885
  }
5886
+ let clips;
5359
5887
  if (parsed.clips) {
5360
5888
  const named = seeded?.clips ?? [];
5361
5889
  const articles = seeded ? articleClipsToRecord(orders.clipScripts, seeded.examples) : { scripts: [], refused: [] };
@@ -5367,10 +5895,14 @@ The help desk's articles ask for ${asked} clip(s), and none is recorded: clips a
5367
5895
  const unrecorded = seeded ? unrecordedLines(articles.refused, Array.isArray(orders.clipsSkipped) ? orders.clipsSkipped : []) : [];
5368
5896
  if (unrecorded.length) console.log(`
5369
5897
  ${unrecorded.join("\n")}`);
5370
- await recordSeededClips({
5898
+ const recorded = await recordSeededClips({
5371
5899
  scripts: clipsToRecord(named, articles.scripts),
5372
5900
  named: named.length,
5373
5901
  browser,
5902
+ relaunch: () => playwrightBrowser({
5903
+ ...parsed.session ? { sessionPath: parsed.session } : {},
5904
+ ...parsed.liveClock ? { liveClock: true } : {}
5905
+ }),
5374
5906
  baseUrl,
5375
5907
  clearance,
5376
5908
  brands: parsed.brands,
@@ -5381,6 +5913,7 @@ ${unrecorded.join("\n")}`);
5381
5913
  held: new Map((orders.clips ?? []).map((clip) => [clip.id, clip.fingerprint])),
5382
5914
  force: Boolean(parsed.rebaseline)
5383
5915
  });
5916
+ if (recorded.length) clips = countClips(recorded);
5384
5917
  }
5385
5918
  if (report.survey.length) {
5386
5919
  const survey = captureReport(report.survey);
@@ -5401,6 +5934,9 @@ whether they changed. Usually a spinner that never resolves or a looping animati
5401
5934
  );
5402
5935
  }
5403
5936
  }
5937
+ const elsewhere = elsewhereLines(browser.thrownElsewhere?.() ?? []);
5938
+ if (elsewhere.length) console.log(`
5939
+ ${elsewhere.join("\n")}`);
5404
5940
  const gated = report.outcomes.filter((outcome) => outcome.needsAccount);
5405
5941
  if (gated.length) {
5406
5942
  console.log(
@@ -5438,7 +5974,8 @@ ${gapReport(gapsFrom([...stage.replay.misses])).join("\n")}`);
5438
5974
  unchanged: report.unchanged,
5439
5975
  skipped: report.skipped,
5440
5976
  refused: report.refused,
5441
- failed: report.failed
5977
+ failed: report.failed,
5978
+ ...clips ? { clips } : {}
5442
5979
  };
5443
5980
  if (runId) {
5444
5981
  await told;
@@ -5471,6 +6008,7 @@ ${verdict.sentence}`);
5471
6008
  await browser.close();
5472
6009
  await stage?.close();
5473
6010
  await seeded?.close();
6011
+ await app?.close();
5474
6012
  }
5475
6013
  }
5476
6014
  async function openRun(to, headers, total) {
@@ -5535,7 +6073,10 @@ if (runningAsCommand(import.meta.url)) {
5535
6073
  export {
5536
6074
  articleClipsToRecord,
5537
6075
  clipForm,
6076
+ clipStepLines,
5538
6077
  clipsToRecord,
6078
+ countClips,
6079
+ elsewhereLines,
5539
6080
  frameForm,
5540
6081
  framesOnDisk,
5541
6082
  main,