@olomonhelpdesk2/helpdesk 0.1.18 → 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
@@ -348,6 +360,35 @@ function firstSentence(text2) {
348
360
  const sentence = text2.trim().split(/(?<=[.!?])\s|\n/)[0] ?? text2;
349
361
  return sentence.length > 160 ? `${sentence.slice(0, 157)}\u2026` : sentence;
350
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
+ }
351
392
  function surveyLine(outcomes) {
352
393
  const count = (result) => outcomes.filter((outcome) => outcome.result === result).length;
353
394
  const crashed = count("crashed");
@@ -487,6 +528,15 @@ function runIdentityFromEnv(env) {
487
528
  }
488
529
 
489
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
+ }
490
540
  function runCounts(run2) {
491
541
  return `${run2.captured} photographed, ${run2.unchanged} unchanged, ${run2.skipped} skipped, ${run2.refused} refused, ${run2.failed} failed`;
492
542
  }
@@ -859,6 +909,70 @@ function paeth(a, b, c) {
859
909
  const pc = Math.abs(p - c);
860
910
  return pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
861
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
+ }
862
976
  function coveredFraction(image) {
863
977
  const total = image.width * image.height;
864
978
  if (total === 0) return 0;
@@ -913,8 +1027,14 @@ async function takeInventory(page, regions) {
913
1027
  const rendered = (element) => {
914
1028
  const style = styleOf(element);
915
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;
916
1034
  for (const box of Array.from(element.getClientRects())) {
917
- 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;
918
1038
  }
919
1039
  return false;
920
1040
  };
@@ -1180,6 +1300,61 @@ async function readPageLayout(page, cover = []) {
1180
1300
  }
1181
1301
  return contentOf(element) || clean(element.getAttribute("title"));
1182
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
+ };
1183
1358
  const stateOf = (element, role) => {
1184
1359
  const state = {};
1185
1360
  const aria = (name) => element.getAttribute(`aria-${name}`);
@@ -1289,7 +1464,8 @@ async function readPageLayout(page, cover = []) {
1289
1464
  if (box.width < 2 || box.height < 2) continue;
1290
1465
  if (CONTROLS.includes(role)) {
1291
1466
  const href = role === "link" ? element.href || element.getAttribute("href") : null;
1292
- 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 } : {} });
1293
1469
  } else if (LANDMARKS.includes(role)) {
1294
1470
  regions.push({ kind: role === "dialog" || role === "alertdialog" ? "dialog" : "landmark", role, label: labelOf(element), box });
1295
1471
  } else if (role === "heading") {
@@ -1458,7 +1634,27 @@ function ownPath(href, origin) {
1458
1634
  return void 0;
1459
1635
  }
1460
1636
  }
1461
- 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 = {}) {
1462
1658
  const { viewport, scale } = layout;
1463
1659
  const regions = [];
1464
1660
  for (const raw of layout.regions.slice(0, MAX_REGIONS)) {
@@ -1475,7 +1671,18 @@ function buildLayoutManifest(layout, frame) {
1475
1671
  if (!where) continue;
1476
1672
  const href = raw.role === "link" ? ownPath(raw.href, frame.origin) : void 0;
1477
1673
  const state = readState(raw.state);
1478
- 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
+ });
1479
1686
  }
1480
1687
  return {
1481
1688
  schema: LAYOUT_MANIFEST_SCHEMA,
@@ -1521,7 +1728,9 @@ function assemble(regions, controls) {
1521
1728
  ...region ? { region } : {},
1522
1729
  ...control.state ? { state: control.state } : {},
1523
1730
  ...control.href ? { href: control.href } : {},
1524
- ...control.clipped ? { clipped: true } : {}
1731
+ ...control.clipped ? { clipped: true } : {},
1732
+ ...control.labels ? { labels: control.labels } : {},
1733
+ ...control.field ? { field: control.field } : {}
1525
1734
  };
1526
1735
  })
1527
1736
  };
@@ -1540,7 +1749,7 @@ var CLIP_MAX_GIF_BYTES = 8 * 1024 * 1024;
1540
1749
  var CLIP_GIF_WIDTH = 960;
1541
1750
  var CLIP_ACTIONS = ["click", "fill", "select", "check", "auto"];
1542
1751
  function actionForRole(role, value) {
1543
- if (role === "textbox" || role === "searchbox") return "fill";
1752
+ if (role === "textbox" || role === "searchbox" || role === "spinbutton") return "fill";
1544
1753
  if ((role === "combobox" || role === "listbox") && value) return "select";
1545
1754
  if (role === "checkbox" || role === "radio" || role === "switch") return "check";
1546
1755
  return "click";
@@ -1667,7 +1876,16 @@ function readClipScripts(raw, limit = MAX_SEEDED_SCRIPTS) {
1667
1876
  return { scripts, refused };
1668
1877
  }
1669
1878
  function comparable(text2) {
1670
- 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);
1671
1889
  }
1672
1890
  var CHROME_ROLES = ["navigation", "complementary", "banner", "contentinfo"];
1673
1891
  var IN_CHROME = /\b(sidebar|side bar|menu|navigation|nav bar|top bar|header)\b/i;
@@ -1700,8 +1918,8 @@ function resolveStep(manifest, step, options = {}) {
1700
1918
  const role = step.control.role;
1701
1919
  const what = role ?? "control";
1702
1920
  const candidates = manifest.controls.filter((control) => !role || control.role === role);
1703
- let matches = candidates.filter((control) => comparable(control.name) === name);
1704
- 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)));
1705
1923
  const scope = step.control.scope;
1706
1924
  if (scope && matches.length > 1) {
1707
1925
  const regions = new Set(manifest.regions.filter((region) => region.id === scope || comparable(region.label) === comparable(scope)).map((region) => region.id));
@@ -1719,7 +1937,10 @@ function resolveStep(manifest, step, options = {}) {
1719
1937
  if (matches.length > 1) {
1720
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` };
1721
1939
  }
1722
- if (matches[0].state?.disabled) return { ok: false, kind: "disabled", because: `${what} "${step.control.name}" on ${where} is disabled` };
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` : ""}` };
1943
+ }
1723
1944
  return { ok: true, control: matches[0] };
1724
1945
  }
1725
1946
  function onScreen(route, location2) {
@@ -1966,7 +2187,8 @@ function readRenderedFactRecords(html, options = {}) {
1966
2187
  })
1967
2188
  );
1968
2189
  };
1969
- for (const element of elements(visibleMarkup(html))) {
2190
+ const markup = visibleMarkup(html);
2191
+ for (const element of elements(markup)) {
1970
2192
  const { tag, attributes, inner, landmark } = element;
1971
2193
  const role = attributes["role"]?.toLowerCase() ?? "";
1972
2194
  const label = accessibleName(element);
@@ -1987,11 +2209,12 @@ function readRenderedFactRecords(html, options = {}) {
1987
2209
  continue;
1988
2210
  }
1989
2211
  if (tag === "input" || tag === "textarea" || tag === "select") {
1990
- const field = fieldFrom(element, html);
2212
+ const field = fieldFrom(element, html, markup);
1991
2213
  if (field) {
1992
2214
  add("field", field.label, landmark, {
1993
2215
  fieldKind: field.kind,
1994
2216
  ...field.choices ? { choices: field.choices } : {},
2217
+ ...field.placeholder ? { placeholder: field.placeholder } : {},
1995
2218
  ...controlState(element)
1996
2219
  });
1997
2220
  }
@@ -2040,6 +2263,7 @@ function toScreenFacts(facts, from) {
2040
2263
  case "field": {
2041
2264
  const field = { label: entry.value, kind: entry.fieldKind ?? "text" };
2042
2265
  if (entry.choices?.length) field.choices = [...entry.choices];
2266
+ if (entry.placeholder) field.placeholder = entry.placeholder;
2043
2267
  fields.push(field);
2044
2268
  break;
2045
2269
  }
@@ -2225,20 +2449,32 @@ function submitLike(element) {
2225
2449
  const type = element.attributes["type"]?.toLowerCase();
2226
2450
  return type === "submit" || type === "button" || type === "reset";
2227
2451
  }
2228
- function fieldFrom(element, body) {
2452
+ function fieldFrom(element, body, markup) {
2229
2453
  const { tag, attributes } = element;
2230
2454
  const type = attributes["type"]?.toLowerCase() ?? "text";
2231
2455
  if (tag === "input" && (type === "hidden" || submitLike(element))) return void 0;
2232
2456
  const kind = tag === "input" ? type : tag;
2233
- 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"] ?? "");
2234
2458
  if (!label) return void 0;
2235
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);
2236
2462
  if (tag === "select") {
2237
2463
  const choices = [...element.inner.matchAll(/<option\b[^>]*>([\s\S]*?)<\/option>/gi)].map((match) => text(match[1] ?? "")).filter(Boolean).slice(0, LIMITS.choices);
2238
2464
  if (choices.length) field.choices = choices;
2239
2465
  }
2240
2466
  return field;
2241
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
+ }
2242
2478
  function labelFor(body, id) {
2243
2479
  if (!id) return "";
2244
2480
  const match = new RegExp(
@@ -3091,6 +3327,8 @@ async function playwrightBrowser(options = {}) {
3091
3327
  // on who took it.
3092
3328
  timezoneId: CAPTURE_CONTRACT.timezone,
3093
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",
3094
3332
  /**
3095
3333
  * A session, when one is supplied — and only ever from a file on the
3096
3334
  * machine running this.
@@ -3112,7 +3350,11 @@ async function playwrightBrowser(options = {}) {
3112
3350
  */
3113
3351
  ...options.sessionPath ? { storageState: options.sessionPath } : { storageState: void 0 }
3114
3352
  };
3353
+ const elsewhere = /* @__PURE__ */ new Map();
3115
3354
  return {
3355
+ thrownElsewhere() {
3356
+ return [...elsewhere.values()].map((held) => ({ by: held.by, message: held.message, screens: [...held.screens] }));
3357
+ },
3116
3358
  async page(wanted) {
3117
3359
  const phone = phoneViewport(wanted?.width);
3118
3360
  const context = await browser.newContext(
@@ -3127,7 +3369,17 @@ async function playwrightBrowser(options = {}) {
3127
3369
  const consoleErrors = [];
3128
3370
  const failedRequests = [];
3129
3371
  let status;
3130
- 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
+ });
3131
3383
  page.on("console", (message) => {
3132
3384
  if (message.type() === "error") consoleErrors.push(message.text());
3133
3385
  });
@@ -3146,6 +3398,16 @@ async function playwrightBrowser(options = {}) {
3146
3398
  await page.addStyleTag({ content: CAPTURE_CONTRACT.hideOff }).catch(() => void 0);
3147
3399
  await page.evaluate((name) => document.documentElement.setAttribute(name, ""), CAPTURE_CONTRACT.marker).catch(() => void 0);
3148
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);
3149
3411
  },
3150
3412
  async text() {
3151
3413
  return page.locator("body").innerText().catch(() => "");
@@ -3244,6 +3506,16 @@ async function playwrightBrowser(options = {}) {
3244
3506
  ...masks.length > 0 ? { mask: masks, maskColor: PUBLICATION_REDACTION_CSS } : {}
3245
3507
  });
3246
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
+ },
3247
3519
  async close() {
3248
3520
  await context.close();
3249
3521
  }
@@ -3285,6 +3557,43 @@ function coverageOf(image) {
3285
3557
  return 0;
3286
3558
  }
3287
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
+ }
3288
3597
  var PUBLICATION_REDACTION_CSS = `#${[PUBLICATION_REDACTION.r, PUBLICATION_REDACTION.g, PUBLICATION_REDACTION.b].map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
3289
3598
  async function readOpaqueSurfaces(page, regions) {
3290
3599
  return page.evaluate(
@@ -3463,6 +3772,34 @@ import { spawn } from "node:child_process";
3463
3772
  import { createHash as createHash3 } from "node:crypto";
3464
3773
  import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
3465
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
3466
3803
  var FRAME_MS = 1e3 / CLIP_FPS;
3467
3804
  var OPENING_MS = 700;
3468
3805
  var MOVE_FRAMES = 6;
@@ -3476,6 +3813,7 @@ var CLOSING_MS = 1200;
3476
3813
  var SCROLL_TO = 0.45;
3477
3814
  var WHOLE_SHIFT = 1e5;
3478
3815
  var START = { x: Math.round(CLIP_VIEWPORT.width / 2), y: Math.round(CLIP_VIEWPORT.height * 0.66) };
3816
+ var CLIP_DEADLINE_MS = 12e4;
3479
3817
  function wholePage(raw) {
3480
3818
  const down = (item) => ({ ...item, box: { ...item.box, y: item.box.y + WHOLE_SHIFT } });
3481
3819
  return { ...raw, viewport: { width: raw.viewport.width, height: 2 * WHOLE_SHIFT }, regions: raw.regions.map(down), controls: raw.controls.map(down) };
@@ -3483,14 +3821,40 @@ function wholePage(raw) {
3483
3821
  function chunksOf(text2, size) {
3484
3822
  return Array.from({ length: Math.ceil(text2.length / size) }, (_, index) => text2.slice(index * size, (index + 1) * size));
3485
3823
  }
3824
+ var CLIP_LATE = "so its page was closed and the run went on";
3486
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) {
3487
3853
  const dir = path2.join(options.workDir, script.id);
3488
3854
  const frames = [];
3489
3855
  const checked = /* @__PURE__ */ new Set();
3490
3856
  const fail = (because) => ({ ok: false, status: "failed", because });
3491
3857
  const refuse = (because) => ({ ok: false, status: "refused", because });
3492
- const page = await options.browser.page({ variant: "clip" }).catch(() => void 0);
3493
- if (!page) return fail("the browser would not open a page to record in");
3494
3858
  try {
3495
3859
  await rm(dir, { recursive: true, force: true });
3496
3860
  await mkdir(dir, { recursive: true });
@@ -3530,13 +3894,14 @@ async function recordClip(options, script, touched) {
3530
3894
  at += ms;
3531
3895
  return file;
3532
3896
  };
3533
- 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);
3534
3899
  const hold = (ms) => {
3535
3900
  frames[frames.length - 1].ms += ms;
3536
3901
  at += ms;
3537
3902
  };
3538
3903
  const film = async () => {
3539
- const next = await page.screenshot(cover);
3904
+ const next = await picture(cover);
3540
3905
  if (sameBytes(next, last)) {
3541
3906
  hold(FRAME_MS);
3542
3907
  return false;
@@ -3547,19 +3912,23 @@ async function recordClip(options, script, touched) {
3547
3912
  const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
3548
3913
  let redirected;
3549
3914
  const isOn = (route, where) => onScreen(route, where) || redirected?.route === route && where.split("?")[0] === redirected.path;
3550
- const manifestOf = (raw, step, where) => buildLayoutManifest(raw, {
3551
- shotId: script.id,
3552
- route: step.route,
3553
- path: where.split("?")[0],
3554
- variant: "desktop",
3555
- image: { width: raw.viewport.width, height: raw.viewport.height },
3556
- origin: new URL(options.baseUrl).origin
3557
- });
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
+ );
3558
3927
  const steady = async () => {
3559
- let previous = await page.screenshot(cover);
3928
+ let previous = await picture(cover);
3560
3929
  for (let waited = 0; waited < SETTLE_MS; waited += FRAME_MS) {
3561
3930
  await pause(FRAME_MS);
3562
- const next = await page.screenshot(cover);
3931
+ const next = await picture(cover);
3563
3932
  if (sameBytes(next, previous)) return;
3564
3933
  previous = next;
3565
3934
  }
@@ -3620,11 +3989,26 @@ async function recordClip(options, script, touched) {
3620
3989
  () => null,
3621
3990
  (error) => error instanceof Error ? error.message : String(error)
3622
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
+ };
3623
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];
3624
4007
  const lead = async (step) => {
3625
- const where = step.follows ? await settled() : await reach(step.route, step.opens);
3626
- if (!step.follows && !isOn(step.route, where)) return `the browser is on ${where}, not ${step.route}`;
3627
- 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);
3628
4012
  for (let waited = 0; !resolution.ok && waited < STEP_WAIT_MS; waited += FRAME_MS) {
3629
4013
  await pause(FRAME_MS);
3630
4014
  resolution = await find(step, where, false);
@@ -3633,12 +4017,13 @@ async function recordClip(options, script, touched) {
3633
4017
  lastRegion = resolution.control.region;
3634
4018
  const target = centreOf(resolution.control.box);
3635
4019
  const action = step.action === "auto" ? actionForRole(resolution.control.role, step.value) : step.action;
3636
- const typed = step.value ?? step.demo;
4020
+ const typed = step.value ?? step.demo ?? demoValueForField(resolution.control);
3637
4021
  if (action === "fill") {
3638
4022
  if (!typed) return `it types into ${resolution.control.name}, and says nothing to type`;
3639
4023
  const unfocused = await focus(target);
3640
4024
  if (unfocused) return unfocused;
3641
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`;
3642
4027
  } else if (action === "select") {
3643
4028
  await pointer({ kind: "select", at: target, value: step.value ?? "" });
3644
4029
  } else {
@@ -3661,8 +4046,9 @@ async function recordClip(options, script, touched) {
3661
4046
  if (problem) return fail(`before the recording, step ${index + 1}: ${problem}`);
3662
4047
  }
3663
4048
  const first = script.steps[0];
3664
- const starting = !script.before?.length ? opened : first.follows ? await settled() : await reach(first.route, first.opens);
3665
- if (!first.follows && !isOn(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}`);
3666
4052
  const before = await judge(starting);
3667
4053
  if (before) return before;
3668
4054
  let poster;
@@ -3685,11 +4071,12 @@ async function recordClip(options, script, touched) {
3685
4071
  await cursor(resting);
3686
4072
  checked.add(await shoot(OPENING_MS));
3687
4073
  for (const [index, step] of script.steps.entries()) {
3688
- const where = await arrive(step.route, step.follows === true, step.opens);
3689
- if (!step.follows && !isOn(step.route, where)) return fail(`step ${index + 1}: the browser is on ${where}, not ${step.route}`);
3690
- const arrived = await judge(where);
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);
3691
4078
  if (arrived) return arrived;
3692
- let resolution = await find(step, where, true);
4079
+ let resolution = shown?.resolution ?? await find(step, where, true);
3693
4080
  for (let waited = 0; !resolution.ok && waited < STEP_WAIT_MS; waited += FRAME_MS) {
3694
4081
  await cursor(resting);
3695
4082
  await film();
@@ -3700,7 +4087,7 @@ async function recordClip(options, script, touched) {
3700
4087
  const target = centreOf(resolution.control.box);
3701
4088
  const startedAt = at;
3702
4089
  const action = step.action === "auto" ? actionForRole(resolution.control.role, step.value) : step.action;
3703
- const typed = step.value ?? step.demo;
4090
+ const typed = step.value ?? step.demo ?? demoValueForField(resolution.control);
3704
4091
  if (action === "fill" && !typed) return fail(`step ${index + 1}: it types into ${resolution.control.name}, and says nothing to type`);
3705
4092
  for (const point of cursorPath(resting, target, MOVE_FRAMES)) {
3706
4093
  await cursor(point);
@@ -3716,10 +4103,11 @@ async function recordClip(options, script, touched) {
3716
4103
  const unfocused = await focus(target);
3717
4104
  if (unfocused) return fail(`step ${index + 1}: ${unfocused}`);
3718
4105
  await cursor(target, false);
3719
- for (const chunk of chunksOf(value, Math.max(CHARS_PER_FRAME, Math.ceil(value.length / TYPING_FRAMES)))) {
3720
- 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);
3721
4108
  await shoot(FRAME_MS);
3722
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`);
3723
4111
  } else if (action === "select") {
3724
4112
  await pointer({ kind: "select", at: target, value: step.value ?? "" });
3725
4113
  } else {
@@ -3747,9 +4135,9 @@ async function recordClip(options, script, touched) {
3747
4135
  });
3748
4136
  }
3749
4137
  if (script.lands) {
3750
- const landed = await arrive(script.lands);
3751
- if (!isOn(script.lands, landed)) return fail(`the last step left the browser on ${landed}, not ${script.lands}`);
3752
- const seen = await judge(landed);
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);
3753
4141
  if (seen) return seen;
3754
4142
  }
3755
4143
  hold(CLOSING_MS);
@@ -3786,15 +4174,18 @@ async function recordClip(options, script, touched) {
3786
4174
  } catch (error) {
3787
4175
  return fail(error instanceof Error ? error.message : String(error));
3788
4176
  } finally {
3789
- await page.close().catch(() => void 0);
3790
4177
  await rm(dir, { recursive: true, force: true }).catch(() => void 0);
3791
4178
  }
3792
4179
  }
4180
+ function leavesBrowserWaiting(because) {
4181
+ return because.includes(CLIP_LATE) || /execution context was destroyed|because of a navigation/i.test(because);
4182
+ }
3793
4183
  var CLIP_BUDGET_MS = 20 * 6e4;
3794
4184
  async function runClips(options) {
3795
4185
  const started = Date.now();
3796
4186
  const outcomes = [];
3797
4187
  let spent;
4188
+ let browser = options.browser;
3798
4189
  for (const [index, script] of options.scripts.entries()) {
3799
4190
  const touched = clipRoutes(script).map((route) => ({
3800
4191
  route,
@@ -3811,9 +4202,16 @@ async function runClips(options) {
3811
4202
  } else if (!options.force && options.held.get(script.id) === fingerprint) {
3812
4203
  outcome = { ...base, status: "unchanged", reason: "neither its steps nor a screen they touch has changed" };
3813
4204
  } else {
3814
- const result = await recordClip({ ...options, screens: options.screens ?? [...options.frames.keys()] }, script, touched);
4205
+ const result = await recordClip({ ...options, browser, screens: options.screens ?? [...options.frames.keys()] }, script, touched);
3815
4206
  if (!result.ok) {
3816
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
+ }
3817
4215
  } else {
3818
4216
  const delivered = await options.send(result.clip);
3819
4217
  if (delivered.ok) {
@@ -3835,14 +4233,15 @@ async function runClips(options) {
3835
4233
  outcomes.push(outcome);
3836
4234
  options.onProgress?.(outcome, index + 1, options.scripts.length);
3837
4235
  }
4236
+ if (browser !== options.browser) await Promise.race([browser.close().catch(() => void 0), new Promise((resolve) => setTimeout(resolve, 5e3))]);
3838
4237
  return outcomes;
3839
4238
  }
3840
4239
  function run(binary, args) {
3841
4240
  return new Promise((resolve, reject) => {
3842
4241
  const child = spawn(binary, args, { stdio: ["ignore", "ignore", "pipe"] });
3843
4242
  let stderr = "";
3844
- child.stderr.on("data", (chunk) => {
3845
- stderr = `${stderr}${chunk.toString()}`.slice(-2e3);
4243
+ child.stderr.on("data", (chunk2) => {
4244
+ stderr = `${stderr}${chunk2.toString()}`.slice(-2e3);
3846
4245
  });
3847
4246
  child.on(
3848
4247
  "error",
@@ -3982,10 +4381,10 @@ function leadsToTheSamePlaces(children) {
3982
4381
  }
3983
4382
  function parameterName(segments, depth2) {
3984
4383
  const parent = depth2 > 0 ? segments[depth2 - 1] : "";
3985
- const base = parent && !parent.startsWith(":") ? singular(parent) : "id";
4384
+ const base = parent && !parent.startsWith(":") ? singular2(parent) : "id";
3986
4385
  return base.replace(/[^a-z0-9]+/gi, "") || "id";
3987
4386
  }
3988
- function singular(word) {
4387
+ function singular2(word) {
3989
4388
  const lower = word.toLowerCase();
3990
4389
  if (/(analysis|status|address|series|species)$/.test(lower)) return lower;
3991
4390
  if (lower.endsWith("ies")) return `${lower.slice(0, -3)}y`;
@@ -4045,7 +4444,7 @@ var MISS_STATUS = RECORDING_MISS_STATUS;
4045
4444
  function bodyOf(message) {
4046
4445
  return new Promise((resolve, reject) => {
4047
4446
  const chunks = [];
4048
- message.on("data", (chunk) => chunks.push(chunk));
4447
+ message.on("data", (chunk2) => chunks.push(chunk2));
4049
4448
  message.on("end", () => resolve(Buffer.concat(chunks)));
4050
4449
  message.on("error", reject);
4051
4450
  });
@@ -4219,18 +4618,12 @@ async function waitForApp(url, deadline, onLog, gaveUp) {
4219
4618
  }
4220
4619
  return false;
4221
4620
  }
4222
- async function stageAgainstRecording(options) {
4223
- const replay = await replayFrom({
4224
- cassette: options.cassette,
4225
- ...options.replayPort ? { port: options.replayPort } : {},
4226
- onMiss: (miss) => options.onLog?.(` the recording has no ${miss.method} ${miss.path}${miss.from ? ` (wanted by ${miss.from})` : ""}`)
4227
- });
4228
- const variable = options.appEnv ?? DEFAULT_APP_ENV;
4621
+ async function startProduct(options) {
4229
4622
  let app;
4230
4623
  let died;
4231
4624
  const clock = options.appCommand ? await productClock(options.pinnedClock) : { env: {} };
4232
4625
  if (options.appCommand) {
4233
- options.onLog?.(`Starting your product with ${variable}=${replay.url}${clockNote(options.pinnedClock)}`);
4626
+ options.onLog?.(options.starting);
4234
4627
  app = spawn2(options.appCommand, {
4235
4628
  shell: true,
4236
4629
  // Its own process group, so it can be taken down as one.
@@ -4241,22 +4634,19 @@ async function stageAgainstRecording(options) {
4241
4634
  // the address occupied by the last one — and on a build agent, the job
4242
4635
  // never finishes.
4243
4636
  detached: true,
4244
- env: { ...process.env, [variable]: replay.url, ...clock.env },
4637
+ env: { ...process.env, ...options.env, ...clock.env },
4245
4638
  stdio: ["ignore", "pipe", "pipe"]
4246
4639
  });
4247
- app.stdout?.on("data", (chunk) => options.onLog?.(` [app] ${chunk.toString().trimEnd()}`));
4248
- 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()}`));
4249
4642
  app.on("exit", (code) => {
4250
4643
  died = code ?? 1;
4251
4644
  });
4252
- } else {
4253
- options.onLog?.(`The recording is at ${replay.url}. Start your product with ${variable} set to it.`);
4254
4645
  }
4255
4646
  const up = await waitForApp(options.appUrl, Date.now() + (options.waitMs ?? 6e4), options.onLog, () => died !== void 0);
4256
4647
  if (app) await new Promise((resolve) => setTimeout(resolve, 250));
4257
4648
  if (died === void 0 && app?.exitCode !== null && app?.exitCode !== void 0) died = app.exitCode;
4258
4649
  if (died !== void 0) {
4259
- await replay.close();
4260
4650
  stop(app);
4261
4651
  await clock.written?.remove();
4262
4652
  throw new Error(
@@ -4266,25 +4656,66 @@ Something else is already answering on ${options.appUrl}. That is almost always
4266
4656
  );
4267
4657
  }
4268
4658
  if (!up) {
4269
- await replay.close();
4270
4659
  stop(app);
4271
4660
  await clock.written?.remove();
4272
4661
  throw new Error(
4273
- `${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)
4274
4663
  );
4275
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
+ }
4276
4696
  return {
4277
4697
  appUrl: options.appUrl,
4278
4698
  replayUrl: replay.url,
4279
4699
  replay,
4280
4700
  cassette: options.cassette,
4281
4701
  close: async () => {
4282
- stop(app);
4702
+ await running.close();
4283
4703
  await replay.close();
4284
- await clock.written?.remove();
4285
4704
  }
4286
4705
  };
4287
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
+ }
4288
4719
  function clockNote(pinnedClock) {
4289
4720
  return pinnedClock ? `, its clock pinned to ${pinnedClock}` : "";
4290
4721
  }
@@ -4325,51 +4756,21 @@ Nothing has been photographed. A run that carried on from here would sign in to
4325
4756
  const named = options.examplesOut ? await examplesFrom(options.examplesOut, onLog) : void 0;
4326
4757
  const examples = named?.examples;
4327
4758
  const clips = named?.clips ?? [];
4328
- let app;
4329
- let died;
4330
- const clock = options.appCommand ? await productClock(options.pinnedClock) : { env: {} };
4331
- if (options.appCommand) {
4332
- onLog?.(`Starting your product with ${options.appCommand}${clockNote(options.pinnedClock)}`);
4333
- app = spawn2(options.appCommand, {
4334
- shell: true,
4335
- detached: true,
4336
- env: { ...process.env, ...clock.env },
4337
- stdio: ["ignore", "pipe", "pipe"]
4338
- });
4339
- app.stdout?.on("data", (chunk) => onLog?.(` [app] ${chunk.toString().trimEnd()}`));
4340
- app.stderr?.on("data", (chunk) => onLog?.(` [app] ${chunk.toString().trimEnd()}`));
4341
- app.on("exit", (code) => {
4342
- died = code ?? 1;
4343
- });
4344
- }
4345
- const up = await waitForApp(options.appUrl, Date.now() + (options.waitMs ?? 6e4), onLog, () => died !== void 0);
4346
- if (app) await new Promise((resolve) => setTimeout(resolve, 250));
4347
- if (died === void 0 && app?.exitCode !== null && app?.exitCode !== void 0) died = app.exitCode;
4348
- if (died !== void 0) {
4349
- stop(app);
4350
- await clock.written?.remove();
4351
- throw new Error(
4352
- `The start command exited (${died}) before the product was ready. Its output is above.` + (up ? `
4353
-
4354
- 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.` : "")
4355
- );
4356
- }
4357
- if (!up) {
4358
- stop(app);
4359
- await clock.written?.remove();
4360
- throw new Error(
4361
- `${options.appUrl} never answered. Check the start command above, and that your product listens on that address.`
4362
- );
4363
- }
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
+ });
4364
4768
  return {
4365
4769
  appUrl: options.appUrl,
4366
4770
  ...sessionPath ? { sessionPath } : {},
4367
4771
  ...examples ? { examples } : {},
4368
4772
  ...clips.length ? { clips } : {},
4369
- close: async () => {
4370
- stop(app);
4371
- await clock.written?.remove();
4372
- }
4773
+ close: () => running.close()
4373
4774
  };
4374
4775
  }
4375
4776
  async function examplesFrom(path5, onLog) {
@@ -4413,8 +4814,8 @@ async function runToCompletion(command, timeoutMs, env, onLog) {
4413
4814
  stop(child);
4414
4815
  resolve({ ok: false, timedOut: true });
4415
4816
  }, timeoutMs);
4416
- child.stdout?.on("data", (chunk) => onLog?.(` [seed] ${chunk.toString().trimEnd()}`));
4417
- 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()}`));
4418
4819
  child.on("error", (error) => {
4419
4820
  clearTimeout(timer);
4420
4821
  onLog?.(` [seed] ${error.message}`);
@@ -4652,6 +5053,34 @@ function depth(path5) {
4652
5053
  return path5 === "/" ? 0 : path5.split("/").filter(Boolean).length;
4653
5054
  }
4654
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
+
4655
5084
  // core/discovery/article-plan.ts
4656
5085
  var MONEY_OR_ACCESS = /* @__PURE__ */ new Set([
4657
5086
  "billing",
@@ -4730,15 +5159,15 @@ function slugFor(path5) {
4730
5159
  const segments = path5.split("/").filter(Boolean).filter((segment) => !segment.startsWith("["));
4731
5160
  return segments.length ? segments.join("/") : "overview";
4732
5161
  }
4733
- function titleFor(route) {
4734
- const segments = route.path.split("/").filter(Boolean).filter((s) => !s.startsWith("["));
4735
- 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";
4736
5165
  const words = segments[segments.length - 1].replace(/[-_]/g, " ");
4737
5166
  const subject = words.charAt(0).toUpperCase() + words.slice(1);
4738
5167
  if (!route.params.length) return subject;
4739
- return `Working with ${article(singular2(subject.toLowerCase()))}`;
5168
+ return `Working with ${article(singular3(subject.toLowerCase()))}`;
4740
5169
  }
4741
- function singular2(word) {
5170
+ function singular3(word) {
4742
5171
  if (/(sis|ss|us|is)$/.test(word)) return word;
4743
5172
  if (/(ches|shes|xes|zes)$/.test(word)) return word.slice(0, -2);
4744
5173
  if (/[^aeiou]ies$/.test(word)) return `${word.slice(0, -3)}y`;
@@ -4784,6 +5213,7 @@ function buildArticlePlan(options) {
4784
5213
  }
4785
5214
  }
4786
5215
  let covered = 0;
5216
+ const root = appRoot(options.routes);
4787
5217
  const needs = [...merged.entries()].map(([slug, group], index) => {
4788
5218
  const rank = index + 1;
4789
5219
  const claimed = [group.entry.route.path, ...group.routes.slice(1) ?? []].map((path5) => options.documented?.[path5]).find(Boolean);
@@ -4791,7 +5221,7 @@ function buildArticlePlan(options) {
4791
5221
  if (already) covered += 1;
4792
5222
  return {
4793
5223
  slug,
4794
- title: group.title ?? titleFor(group.entry.route),
5224
+ title: group.title ?? titleFor(group.entry.route, root),
4795
5225
  route: group.entry.route.path,
4796
5226
  ...group.routes.length > 1 ? { alsoCovers: group.routes.slice(1) } : {},
4797
5227
  tier: tierFor(rank, merged.size),
@@ -4826,8 +5256,8 @@ function shotIdFor(route) {
4826
5256
  const parts = route.split("/").filter(Boolean).map((segment) => segment.replace(/^\[+\.{0,3}|\]+$/g, ""));
4827
5257
  return parts.length ? parts.join("-").toLowerCase() : "overview";
4828
5258
  }
4829
- function describe(route, isEntry) {
4830
- const segments = route.path.split("/").filter(Boolean);
5259
+ function describe(route, isEntry, root) {
5260
+ const segments = belowAppRoot(route.path, root);
4831
5261
  const last = segments[segments.length - 1] ?? "";
4832
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";
4833
5263
  return isEntry ? `${screen} as someone arrives on it` : `${screen}, reached from ${segments.slice(0, -1).join("/") || "the top level"}`;
@@ -4845,10 +5275,11 @@ function buildStoryboard(options) {
4845
5275
  const own = [...covered].map((path5) => byPath.get(path5)).filter((route) => Boolean(route) && !route.skipped);
4846
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));
4847
5277
  const limit = options.maxSteps ?? 5;
5278
+ const root = appRoot(options.routes);
4848
5279
  const steps = ordered.slice(0, limit).map((route, index) => ({
4849
5280
  route: route.path,
4850
5281
  shotId: shotIdFor(route.path),
4851
- depicts: describe(route, index === 0),
5282
+ depicts: describe(route, index === 0, root),
4852
5283
  kind: kindFor(route, index === 0),
4853
5284
  needsExampleFor: route.params
4854
5285
  }));
@@ -4901,7 +5332,7 @@ function parseArgs(argv) {
4901
5332
  if (args.out) {
4902
5333
  args.key ??= "";
4903
5334
  args.to ??= "";
4904
- 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.";
4905
5336
  return args;
4906
5337
  }
4907
5338
  if (!args.key) return "Missing --key, or --out <dir> to keep the frames here instead.";
@@ -5080,6 +5511,7 @@ The seed command named ${args.named} clip(s)${asked ? `, and the help desk's how
5080
5511
  const outcomes = await runClips({
5081
5512
  baseUrl: args.baseUrl,
5082
5513
  browser: args.browser,
5514
+ ...args.relaunch ? { relaunch: args.relaunch } : {},
5083
5515
  encoder: ffmpegEncoder(),
5084
5516
  workDir: path4.join(tmpdir2(), `helpdesk-clips-${process.pid}`),
5085
5517
  clearance: args.clearance,
@@ -5104,6 +5536,9 @@ The seed command named ${args.named} clip(s)${asked ? `, and the help desk's how
5104
5536
  const kb = (bytes) => `${Math.round(bytes / 1024)} KB`;
5105
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 ?? "";
5106
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
+ }
5107
5542
  }
5108
5543
  });
5109
5544
  if (args.local) {
@@ -5115,15 +5550,30 @@ The seed command named ${args.named} clip(s)${asked ? `, and the help desk's how
5115
5550
  await writeFile3(path4.join(args.local, "clips", "manifest.json"), `${JSON.stringify({ schema: "olomon.clip-manifest/1", clips: kept }, null, 2)}
5116
5551
  `, "utf8");
5117
5552
  }
5118
- const count = (status) => outcomes.filter((outcome) => outcome.status === status).length;
5119
5553
  console.log(`
5120
- Clips: ${count("recorded")} recorded, ${count("unchanged")} unchanged, ${count("deferred")} deferred, ${count("refused")} refused, ${count("failed")} failed.`);
5554
+ Clips: ${clipCounts(countClips(outcomes))}.`);
5121
5555
  const missed = outcomes.filter((outcome) => outcome.status === "refused" || outcome.status === "failed");
5122
5556
  if (missed.length && process.env["GITHUB_ACTIONS"] === "true") {
5123
5557
  console.log(`::warning title=${missed.length} clip(s) not recorded::${missed.map((outcome) => `${outcome.clipId}: ${outcome.reason}`).join("; ")}`);
5124
5558
  }
5125
5559
  return outcomes;
5126
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
+ }
5127
5577
  function frameForm(frame) {
5128
5578
  const form = new FormData();
5129
5579
  form.set(
@@ -5218,7 +5668,13 @@ Approve one on the plan page, then run this again.`
5218
5668
  return 1;
5219
5669
  }
5220
5670
  }
5221
- 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;
5222
5678
  if (!baseUrl) {
5223
5679
  console.error("No product address. Pass --base-url, or set one on the help desk's plan page.");
5224
5680
  return 2;
@@ -5242,7 +5698,7 @@ Approve one on the plan page, then run this again.`
5242
5698
  examplesOut: path4.join(process.cwd(), "helpdesk-examples.json"),
5243
5699
  onLog: (line) => console.log(line)
5244
5700
  });
5245
- if (seeded.sessionPath) parsed.session = seeded.sessionPath;
5701
+ if (seeded.sessionPath && !signedOut) parsed.session = seeded.sessionPath;
5246
5702
  else delete parsed.session;
5247
5703
  console.log("");
5248
5704
  } catch (error) {
@@ -5269,6 +5725,16 @@ Approve one on the plan page, then run this again.`
5269
5725
  return 1;
5270
5726
  }
5271
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
+ }
5272
5738
  const browser = await playwrightBrowser({
5273
5739
  ...parsed.session ? { sessionPath: parsed.session } : {},
5274
5740
  ...parsed.liveClock ? { liveClock: true } : {}
@@ -5291,6 +5757,7 @@ Approve one on the plan page, then run this again.`
5291
5757
  await browser.close();
5292
5758
  await stage?.close();
5293
5759
  await seeded?.close();
5760
+ await app?.close();
5294
5761
  return 2;
5295
5762
  }
5296
5763
  if (stage) {
@@ -5416,6 +5883,7 @@ ${unclassified.join("\n")}`);
5416
5883
  console.log(`
5417
5884
  Written to ${local}: ${kept.length} frame(s) and manifest.json.`);
5418
5885
  }
5886
+ let clips;
5419
5887
  if (parsed.clips) {
5420
5888
  const named = seeded?.clips ?? [];
5421
5889
  const articles = seeded ? articleClipsToRecord(orders.clipScripts, seeded.examples) : { scripts: [], refused: [] };
@@ -5427,10 +5895,14 @@ The help desk's articles ask for ${asked} clip(s), and none is recorded: clips a
5427
5895
  const unrecorded = seeded ? unrecordedLines(articles.refused, Array.isArray(orders.clipsSkipped) ? orders.clipsSkipped : []) : [];
5428
5896
  if (unrecorded.length) console.log(`
5429
5897
  ${unrecorded.join("\n")}`);
5430
- await recordSeededClips({
5898
+ const recorded = await recordSeededClips({
5431
5899
  scripts: clipsToRecord(named, articles.scripts),
5432
5900
  named: named.length,
5433
5901
  browser,
5902
+ relaunch: () => playwrightBrowser({
5903
+ ...parsed.session ? { sessionPath: parsed.session } : {},
5904
+ ...parsed.liveClock ? { liveClock: true } : {}
5905
+ }),
5434
5906
  baseUrl,
5435
5907
  clearance,
5436
5908
  brands: parsed.brands,
@@ -5441,6 +5913,7 @@ ${unrecorded.join("\n")}`);
5441
5913
  held: new Map((orders.clips ?? []).map((clip) => [clip.id, clip.fingerprint])),
5442
5914
  force: Boolean(parsed.rebaseline)
5443
5915
  });
5916
+ if (recorded.length) clips = countClips(recorded);
5444
5917
  }
5445
5918
  if (report.survey.length) {
5446
5919
  const survey = captureReport(report.survey);
@@ -5461,6 +5934,9 @@ whether they changed. Usually a spinner that never resolves or a looping animati
5461
5934
  );
5462
5935
  }
5463
5936
  }
5937
+ const elsewhere = elsewhereLines(browser.thrownElsewhere?.() ?? []);
5938
+ if (elsewhere.length) console.log(`
5939
+ ${elsewhere.join("\n")}`);
5464
5940
  const gated = report.outcomes.filter((outcome) => outcome.needsAccount);
5465
5941
  if (gated.length) {
5466
5942
  console.log(
@@ -5498,7 +5974,8 @@ ${gapReport(gapsFrom([...stage.replay.misses])).join("\n")}`);
5498
5974
  unchanged: report.unchanged,
5499
5975
  skipped: report.skipped,
5500
5976
  refused: report.refused,
5501
- failed: report.failed
5977
+ failed: report.failed,
5978
+ ...clips ? { clips } : {}
5502
5979
  };
5503
5980
  if (runId) {
5504
5981
  await told;
@@ -5531,6 +6008,7 @@ ${verdict.sentence}`);
5531
6008
  await browser.close();
5532
6009
  await stage?.close();
5533
6010
  await seeded?.close();
6011
+ await app?.close();
5534
6012
  }
5535
6013
  }
5536
6014
  async function openRun(to, headers, total) {
@@ -5595,7 +6073,10 @@ if (runningAsCommand(import.meta.url)) {
5595
6073
  export {
5596
6074
  articleClipsToRecord,
5597
6075
  clipForm,
6076
+ clipStepLines,
5598
6077
  clipsToRecord,
6078
+ countClips,
6079
+ elsewhereLines,
5599
6080
  frameForm,
5600
6081
  framesOnDisk,
5601
6082
  main,