@olomonhelpdesk2/helpdesk 0.1.3 → 0.1.5

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.
@@ -1,4 +1,106 @@
1
1
  #!/usr/bin/env node
2
+ // core/capture/examples.ts
3
+ var CI_EXAMPLES_OUT_ENV = "HELPDESK_EXAMPLES_OUT";
4
+ var EXAMPLES_SCHEMA = "helpdesk.examples/1";
5
+ var MAX_PATH = 2048;
6
+ var MAX_ROUTES = 500;
7
+ function segmentsOf(path3) {
8
+ return path3.split("/").filter((part) => part !== "");
9
+ }
10
+ function patternOf(route) {
11
+ return segmentsOf(route).map((part) => {
12
+ const optional = /^\[\[\.\.\.([^\]]+)\]\]$/.exec(part);
13
+ if (optional) return { kind: "any", name: optional[1] };
14
+ const catchAll = /^\[\.\.\.([^\]]+)\]$/.exec(part);
15
+ if (catchAll) return { kind: "many", name: catchAll[1] };
16
+ const one = /^\[([^\]]+)\]$/.exec(part);
17
+ if (one) return { kind: "one", name: one[1] };
18
+ return { kind: "static", text: part };
19
+ });
20
+ }
21
+ function dynamicSegments(route) {
22
+ return patternOf(route).filter((segment) => segment.kind !== "static").map((segment) => segment.name);
23
+ }
24
+ function fillsPattern(pattern, path3) {
25
+ const want = patternOf(pattern);
26
+ const have = segmentsOf(path3);
27
+ const match = (w, h) => {
28
+ if (w === want.length) return h === have.length;
29
+ const segment = want[w];
30
+ if (segment.kind === "static") return have[h] === segment.text && match(w + 1, h + 1);
31
+ if (segment.kind === "one") return h < have.length && match(w + 1, h + 1);
32
+ const least = segment.kind === "many" ? 1 : 0;
33
+ for (let take = have.length - h; take >= least; take -= 1) {
34
+ if (match(w + 1, h + take)) return true;
35
+ }
36
+ return false;
37
+ };
38
+ return match(0, 0);
39
+ }
40
+ function unusablePath(path3) {
41
+ if (path3.length > MAX_PATH) return "the path is longer than any screen's";
42
+ if (!path3.startsWith("/") || path3.startsWith("//")) return "the path is not a path inside the product";
43
+ if (/[?#\\]/.test(path3)) return "the path carries a query, a fragment or a backslash";
44
+ if (/\s/.test(path3) || [...path3].some((character) => character.charCodeAt(0) < 32)) {
45
+ return "the path carries whitespace or a control character";
46
+ }
47
+ const segments = segmentsOf(path3);
48
+ if (segments.some((segment) => segment === "." || segment === ".." || /%2e%2e|%2f|%5c/i.test(segment))) {
49
+ return "the path climbs out of its position";
50
+ }
51
+ if (/\[|\]/.test(path3)) return "the path still holds a dynamic segment rather than a record";
52
+ return void 0;
53
+ }
54
+ function readExampleRoutes(raw) {
55
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
56
+ return { ok: false, because: "the file is not a JSON object" };
57
+ }
58
+ const body = raw;
59
+ if (body["schema"] !== EXAMPLES_SCHEMA) {
60
+ return { ok: false, because: `the file does not say "schema": "${EXAMPLES_SCHEMA}"` };
61
+ }
62
+ const routes = body["routes"];
63
+ if (typeof routes !== "object" || routes === null || Array.isArray(routes)) {
64
+ return { ok: false, because: 'the file has no "routes" object' };
65
+ }
66
+ const entries = Object.entries(routes);
67
+ if (entries.length > MAX_ROUTES) return { ok: false, because: `the file names more than ${MAX_ROUTES} routes` };
68
+ const paths = /* @__PURE__ */ new Map();
69
+ const refused = [];
70
+ for (const [route, value] of entries) {
71
+ if (!route.startsWith("/") || dynamicSegments(route).length === 0) {
72
+ refused.push({ route, because: "it is not a route with a dynamic segment" });
73
+ continue;
74
+ }
75
+ if (typeof value !== "string") {
76
+ refused.push({ route, because: "its example is not a path" });
77
+ continue;
78
+ }
79
+ const path3 = value.trim();
80
+ const unusable = unusablePath(path3);
81
+ if (unusable) {
82
+ refused.push({ route, because: unusable });
83
+ continue;
84
+ }
85
+ if (!fillsPattern(route, path3)) {
86
+ refused.push({ route, because: `${path3} is not ${route} with its dynamic segments filled in` });
87
+ continue;
88
+ }
89
+ paths.set(route, path3);
90
+ }
91
+ return { ok: true, examples: { paths, refused } };
92
+ }
93
+ function resolveVisit(route, examples) {
94
+ const needs = dynamicSegments(route);
95
+ if (needs.length === 0) return { visit: route };
96
+ const path3 = examples.paths.get(route);
97
+ return path3 ? { visit: path3 } : { needs };
98
+ }
99
+ function describeMissingExample(route, needs) {
100
+ const named = needs.map((name) => `[${name}]`).join(" and ");
101
+ return `the seed command named no record for ${named} on ${route}, so there is nothing real to open. Add one to the file it writes at ${CI_EXAMPLES_OUT_ENV}`;
102
+ }
103
+
2
104
  // core/capture/contract.ts
3
105
  var CAPTURE_CONTRACT = {
4
106
  /** Same for every shot, so type is the same size across the whole set. */
@@ -21,6 +123,16 @@ var CAPTURE_CONTRACT = {
21
123
  maxClipBytes: 2 * 1024 * 1024,
22
124
  /** Everything renders as though it were this instant. */
23
125
  pinnedClock: "2026-01-15T09:00:00.000Z",
126
+ /**
127
+ * And in this locale and this zone, set on the browser rather than inherited
128
+ * from whoever runs it, so a date on a screen reads the same from any machine.
129
+ *
130
+ * American English, because the products this documents are read in it: a
131
+ * frame showing "15/01/2026" beside an article written for an American
132
+ * reader is a screenshot of a product nobody uses.
133
+ */
134
+ locale: "en-US",
135
+ timezone: "UTC",
24
136
  /**
25
137
  * Injected before every shot. Necessary, not sufficient — see above.
26
138
  */
@@ -52,7 +164,11 @@ function actionFor(outcome) {
52
164
  case "canonical-redirect":
53
165
  return void 0;
54
166
  case "not-found": {
55
- const noun = subjectOf(outcome.missingEndpoint ?? outcome.route);
167
+ if ((!outcome.missingEndpoint || outcome.missingEndpoint === outcome.route) && !outcome.route.includes("[")) {
168
+ return `${outcome.route} answered Not Found here, and its address names no record to create. If it is behind a feature flag, turn the flag on where the run happens; if it is gone, it will drop out of the plan.`;
169
+ }
170
+ const asked = outcome.missingEndpoint ?? outcome.route;
171
+ const noun = subjectOf(fillsPattern(outcome.route, asked) ? outcome.route : asked);
56
172
  return `Create one ${noun} in your test environment, then run this again.`;
57
173
  }
58
174
  case "thin":
@@ -72,7 +188,7 @@ function subjectOf(path3) {
72
188
  return last.replace(/[-_]/g, " ").replace(/s$/, "");
73
189
  }
74
190
  function looksLikeAnId(segment) {
75
- return /^\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
191
+ 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
76
192
  // in it. Deliberately narrow — `sign-up` must survive this.
77
193
  /^[a-z]{2,}[_-][a-z0-9]*\d[a-z0-9]*$/i.test(segment) || /^[0-9a-z]{16,}$/i.test(segment);
78
194
  }
@@ -122,7 +238,7 @@ var EMPTY_STATE_PHRASES = [
122
238
  /\bno results\b/i,
123
239
  /\bthis (list|table) is empty\b/i
124
240
  ];
125
- function classifyRoute(observation) {
241
+ function classifyRoute(observation, options = {}) {
126
242
  const text2 = observation.text.trim();
127
243
  const unrecorded = observation.failedRequests?.find((request) => request.status === RECORDING_MISS_STATUS);
128
244
  if (unrecorded) {
@@ -132,21 +248,26 @@ function classifyRoute(observation) {
132
248
  if (crash) return { route: observation.route, result: "crashed", error: crash };
133
249
  const missing = missingBecause(observation, text2);
134
250
  if (missing) return { route: observation.route, result: "not-found", missingEndpoint: missing };
251
+ let followed;
135
252
  if (observation.landedOn && !samePlace(observation.route, observation.landedOn)) {
136
253
  const canonical = canonicalisation(observation.route, observation.landedOn);
137
- return {
138
- route: observation.route,
139
- result: canonical ? "canonical-redirect" : "bounced",
140
- // Carried only when it is a finding. A parent handing off to its default
141
- // child needs no row, so it needs no detail either.
142
- ...canonical ? {} : { landed: observation.landedOn }
143
- };
254
+ if (!canonical && options.follow?.(observation.landedOn)) {
255
+ followed = { landed: observation.landedOn };
256
+ } else {
257
+ return {
258
+ route: observation.route,
259
+ result: canonical ? "canonical-redirect" : "bounced",
260
+ // Carried only when it is a finding. A parent handing off to its default
261
+ // child needs no row, so it needs no detail either.
262
+ ...canonical ? {} : { landed: observation.landedOn }
263
+ };
264
+ }
144
265
  }
145
266
  if (EMPTY_STATE_PHRASES.some((phrase) => phrase.test(text2))) {
146
- return { route: observation.route, result: "empty-state" };
267
+ return { route: observation.route, result: "empty-state", ...followed };
147
268
  }
148
- if (text2.length < THIN_TEXT_CHARS) return { route: observation.route, result: "thin" };
149
- return { route: observation.route, result: "ok" };
269
+ if (text2.length < THIN_TEXT_CHARS) return { route: observation.route, result: "thin", ...followed };
270
+ return { route: observation.route, result: "ok", ...followed };
150
271
  }
151
272
  function crashedBecause(observation, text2) {
152
273
  const uncaught = observation.errors?.[0];
@@ -304,6 +425,82 @@ function attestation(run) {
304
425
  ].filter(Boolean).join("\n");
305
426
  }
306
427
 
428
+ // core/capture/run-verdict.ts
429
+ function runVerdict(run) {
430
+ const delivered = run.captured + run.unchanged;
431
+ const missed = run.skipped + run.refused + run.failed;
432
+ const counts = `${run.captured} photographed, ${run.unchanged} unchanged, ${run.skipped} skipped, ${run.refused} refused, ${run.failed} failed`;
433
+ if (run.screens > 0 && delivered === 0) {
434
+ return {
435
+ exit: 1,
436
+ annotation: `::error title=No screenshots were delivered::None of the ${run.screens} screens was photographed (${counts}). The step log says why for each one.`,
437
+ sentence: `Nothing was delivered, so this run fails rather than passing with no screenshots: ${counts}.`
438
+ };
439
+ }
440
+ if (missed > 0) {
441
+ return {
442
+ exit: run.failed > 0 ? 1 : 0,
443
+ annotation: `::warning title=${missed} of ${run.screens} screens were not photographed::${counts}. The step log says why for each one.`
444
+ };
445
+ }
446
+ return { exit: 0 };
447
+ }
448
+
449
+ // core/capture/unclassified.ts
450
+ var SEPARATOR = " > ";
451
+ function commonRegion(selectors) {
452
+ const parts = selectors.map((selector) => selector.split(SEPARATOR));
453
+ const first = parts[0] ?? [];
454
+ let shared = 0;
455
+ while (shared < first.length && parts.every((steps) => steps[shared] === first[shared])) shared += 1;
456
+ return shared >= 3 ? first.slice(0, shared).join(SEPARATOR) : "";
457
+ }
458
+ function groupUnclassified(refused) {
459
+ const enough = Math.max(2, Math.ceil(new Set(refused.map((screen) => screen.route)).size / 2));
460
+ const where = /* @__PURE__ */ new Map();
461
+ for (const screen of refused) {
462
+ for (const value of screen.values) {
463
+ const seen = where.get(value.at) ?? { screens: /* @__PURE__ */ new Set(), lengths: /* @__PURE__ */ new Set() };
464
+ seen.screens.add(screen.route);
465
+ seen.lengths.add(value.length);
466
+ where.set(value.at, seen);
467
+ }
468
+ }
469
+ const shared = [...where.entries()].filter(([, seen]) => seen.screens.size >= enough).map(([at, seen]) => ({ at, screens: [...seen.screens].sort(), lengths: [...seen.lengths].sort((a, b) => a - b) })).sort((a, b) => b.screens.length - a.screens.length || a.at.localeCompare(b.at));
470
+ const isShared = new Set(shared.map((element) => element.at));
471
+ const screens = [];
472
+ const onlyShared = [];
473
+ for (const screen of refused) {
474
+ const rest = screen.values.filter((value) => !isShared.has(value.at));
475
+ if (rest.length) {
476
+ screens.push({ route: screen.route, count: rest.length, within: commonRegion(rest.map((value) => value.at)) });
477
+ } else if (screen.values.length) {
478
+ onlyShared.push(screen.route);
479
+ }
480
+ }
481
+ return { shared, screens, onlyShared: [...new Set(onlyShared)].sort() };
482
+ }
483
+ function describeLengths(lengths) {
484
+ const low = lengths[0] ?? 0;
485
+ const high = lengths[lengths.length - 1] ?? low;
486
+ return low === high ? `${low} character${low === 1 ? "" : "s"}` : `${low} to ${high} characters`;
487
+ }
488
+ function describeShared(element, summary) {
489
+ const unblocks = summary.onlyShared.filter((route) => element.screens.includes(route)).length;
490
+ return `One element sits outside every part of the page the clearance names, in the same place on ${element.screens.length} screens: ${element.at} (${describeLengths(element.lengths)}). Something in the same place on every screen is part of the app's own layout, such as a feedback or help button, so it is classified once. Say what it is on Setup` + (unblocks ? `, and the ${unblocks} screen${unblocks === 1 ? "" : "s"} held back only by it can be photographed.` : ".");
491
+ }
492
+ function describeRemainder(screen) {
493
+ return `${screen.route}: ${screen.count} value${screen.count === 1 ? "" : "s"} outside every part of the page the clearance names` + (screen.within ? `, all inside ${screen.within}.` : ", spread across the page.");
494
+ }
495
+ function unclassifiedLines(summary) {
496
+ if (!summary.shared.length && !summary.screens.length) return [];
497
+ return [
498
+ "Not photographed until somebody says what is there:",
499
+ ...summary.shared.map((element) => ` ${describeShared(element, summary)}`),
500
+ ...summary.screens.map((screen) => ` ${describeRemainder(screen)}`)
501
+ ];
502
+ }
503
+
307
504
  // adapters/capture-cli.ts
308
505
  import { createRequire } from "node:module";
309
506
  import path from "node:path";
@@ -438,7 +635,7 @@ var PATTERNS = [
438
635
  {
439
636
  kind: "passport",
440
637
  regex: /\b[A-Z]{1,2}\d{6,9}\b/g,
441
- because: "the shape of a passport or licence number",
638
+ because: "the shape of a passport or license number",
442
639
  replace: (match) => `${match.slice(0, 1)}${"0".repeat(match.length - 1)}`
443
640
  },
444
641
  {
@@ -504,6 +701,20 @@ function gateFrame(pageText, options = {}) {
504
701
  };
505
702
  }
506
703
 
704
+ // core/capture/sign-in.ts
705
+ var IDENTIFIER_HINT = /\b(username|email)\b/;
706
+ var IDENTIFIER_NAME = /user|login|e-?mail/;
707
+ function identifies(field) {
708
+ if (field.type === "email") return true;
709
+ if (IDENTIFIER_HINT.test(field.autocomplete)) return true;
710
+ return (field.type === "text" || field.type === "") && IDENTIFIER_NAME.test(field.name);
711
+ }
712
+ function looksLikeSignIn(fields) {
713
+ const secrets = fields.filter((field) => field.type === "password");
714
+ if (secrets.some((field) => field.autocomplete.split(/\s+/).includes("current-password"))) return true;
715
+ return secrets.some((secret) => fields.some((field) => field.form === secret.form && identifies(field)));
716
+ }
717
+
507
718
  // core/capture/publication/pixels.ts
508
719
  import { deflateSync, inflateSync } from "node:zlib";
509
720
  var MediaRefused = class extends Error {
@@ -537,7 +748,7 @@ function decodePng(buffer) {
537
748
  offset += 12 + length;
538
749
  }
539
750
  if (bitDepth !== 8 || colourType !== 2 && colourType !== 6) {
540
- throw new MediaRefused(`only 8-bit RGB and RGBA PNGs are supported here, not colour type ${colourType} at ${bitDepth} bits`);
751
+ throw new MediaRefused(`only 8-bit RGB and RGBA PNGs are supported here, not color type ${colourType} at ${bitDepth} bits`);
541
752
  }
542
753
  const channels = colourType === 6 ? 4 : 3;
543
754
  const raw = inflateSync(Buffer.concat(data));
@@ -1438,10 +1649,14 @@ async function runCapture(options) {
1438
1649
  };
1439
1650
  }
1440
1651
  async function captureOne(options, shot, build) {
1652
+ if (shot.unvisitable) {
1653
+ return { outcome: { shotId: shot.id, route: shot.route, status: "skipped", reason: shot.unvisitable } };
1654
+ }
1655
+ const visit = shot.visit ?? shot.route;
1441
1656
  let page;
1442
1657
  try {
1443
1658
  page = await options.browser.page();
1444
- await page.goto(new URL(shot.route, options.baseUrl).toString());
1659
+ await page.goto(new URL(visit, options.baseUrl).toString());
1445
1660
  if (await page.isSignIn()) {
1446
1661
  return {
1447
1662
  outcome: {
@@ -1478,7 +1693,9 @@ async function captureOne(options, shot, build) {
1478
1693
  };
1479
1694
  }
1480
1695
  const seen = await page.observe?.() ?? {};
1481
- const observed = classifyRoute({ ...seen, route: shot.route, text: pageText });
1696
+ const origin = new URL(options.baseUrl).origin;
1697
+ const follow = (landed) => seen.landedOrigin === origin && options.shots.some((other) => fillsPattern(other.route, landed.split(/[?#]/)[0] ?? landed));
1698
+ const observed = { ...classifyRoute({ ...seen, route: visit, text: pageText }, { follow }), route: shot.route };
1482
1699
  if (!isPublishable(observed.result)) {
1483
1700
  return {
1484
1701
  outcome: {
@@ -1491,7 +1708,7 @@ async function captureOne(options, shot, build) {
1491
1708
  observed
1492
1709
  };
1493
1710
  }
1494
- const evidence = await readEvidence(settledPage, shot.route, options, build);
1711
+ const evidence = await readEvidence(settledPage, shot.route, options, build, visit);
1495
1712
  const reading = options.clearance ? await readScreen(settledPage, options.clearance) : void 0;
1496
1713
  if (options.clearance && !reading) {
1497
1714
  return {
@@ -1518,7 +1735,8 @@ async function captureOne(options, shot, build) {
1518
1735
  // Where and never what: a value nobody has classified is a value
1519
1736
  // nobody has established is safe to write down, and a reason that
1520
1737
  // quoted it would put it in this run's log.
1521
- reason: `${unaccounted.length} value(s) on this screen are outside every region the clearance names (${unaccounted.map((value) => `${value.at} (${value.length} character(s))`).join("; ")}), so it is restricted until somebody classifies them`
1738
+ reason: `${unaccounted.length} value(s) on this screen are outside every region the clearance names (${unaccounted.map((value) => `${value.at} (${value.length} character(s))`).join("; ")}), so it is restricted until somebody classifies them`,
1739
+ unaccounted: unaccounted.map((value) => ({ at: value.at, length: value.length }))
1522
1740
  },
1523
1741
  observed,
1524
1742
  ...evidence ? { seen: evidence } : {}
@@ -1634,7 +1852,7 @@ async function captureOne(options, shot, build) {
1634
1852
  result: observed.result,
1635
1853
  sha256,
1636
1854
  settled: frame.settled,
1637
- ...frame.settled ? {} : {
1855
+ ...frame.settled ? observed.landed ? { reason: `followed its redirect to ${observed.landed}` } : {} : {
1638
1856
  reason: "photographed, but this screen never stopped moving \u2014 its hash will differ every run, so drift on it means nothing"
1639
1857
  }
1640
1858
  } : {
@@ -1660,9 +1878,9 @@ async function captureOne(options, shot, build) {
1660
1878
  await page?.close().catch(() => void 0);
1661
1879
  }
1662
1880
  }
1663
- async function readEvidence(page, route, options, build) {
1881
+ async function readEvidence(page, route, options, build, visited = route) {
1664
1882
  if (!options.commit || !options.repository || !options.session || !build) return void 0;
1665
- const url = new URL(route, options.baseUrl).toString();
1883
+ const url = new URL(visited, options.baseUrl).toString();
1666
1884
  const html = await page.html?.().catch(() => "");
1667
1885
  const aria = await page.aria?.().catch(() => "");
1668
1886
  if (!html && !aria) return void 0;
@@ -1734,8 +1952,8 @@ async function playwrightBrowser(options = {}) {
1734
1952
  // Pinned as well as the clock. A date formatted in the runner's own zone
1735
1953
  // renders differently in London and in Denver, which makes a frame depend
1736
1954
  // on who took it.
1737
- timezoneId: "UTC",
1738
- locale: "en-GB",
1955
+ timezoneId: CAPTURE_CONTRACT.timezone,
1956
+ locale: CAPTURE_CONTRACT.locale,
1739
1957
  /**
1740
1958
  * A session, when one is supplied — and only ever from a file on the
1741
1959
  * machine running this.
@@ -1797,11 +2015,21 @@ async function playwrightBrowser(options = {}) {
1797
2015
  return page.locator("body").ariaSnapshot().catch(() => "");
1798
2016
  },
1799
2017
  async isSignIn() {
1800
- return await page.locator('input[type="password"]').count() > 0;
2018
+ const fields = await page.evaluate(() => {
2019
+ const forms = Array.from(document.forms);
2020
+ return Array.from(document.querySelectorAll("input")).map((input) => ({
2021
+ type: (input.getAttribute("type") ?? "text").toLowerCase(),
2022
+ autocomplete: (input.getAttribute("autocomplete") ?? "").toLowerCase(),
2023
+ name: `${input.name} ${input.id}`.toLowerCase(),
2024
+ form: input.form ? forms.indexOf(input.form) : -1
2025
+ }));
2026
+ }).catch(() => []);
2027
+ return looksLikeSignIn(fields);
1801
2028
  },
1802
2029
  async observe() {
1803
2030
  return {
1804
2031
  landedOn: pathOf(page.url()),
2032
+ landedOrigin: new URL(page.url()).origin,
1805
2033
  ...status === void 0 ? {} : { status },
1806
2034
  errors: [...errors],
1807
2035
  consoleErrors: [...consoleErrors],
@@ -2351,15 +2579,21 @@ Something else is already answering on ${options.appUrl}. That is almost always
2351
2579
  };
2352
2580
  }
2353
2581
  var SESSION_OUT_ENV = CI_SESSION_OUT_ENV;
2582
+ var EXAMPLES_OUT_ENV = CI_EXAMPLES_OUT_ENV;
2354
2583
  async function stageSeeded(options) {
2355
2584
  const onLog = options.onLog;
2356
2585
  const { access, unlink } = await import("node:fs/promises");
2357
2586
  await unlink(options.sessionOut).catch(() => void 0);
2587
+ if (options.examplesOut) await unlink(options.examplesOut).catch(() => void 0);
2358
2588
  onLog?.(`Seeding a demo tenant with ${options.seedCommand}`);
2359
2589
  const seeded = await runToCompletion(
2360
2590
  options.seedCommand,
2361
2591
  options.seedMs ?? 6e5,
2362
- { [CI_SESSION_OUT_ENV]: options.sessionOut, [CI_BASE_URL_ENV]: options.appUrl },
2592
+ {
2593
+ [CI_SESSION_OUT_ENV]: options.sessionOut,
2594
+ [CI_BASE_URL_ENV]: options.appUrl,
2595
+ ...options.examplesOut ? { [CI_EXAMPLES_OUT_ENV]: options.examplesOut } : {}
2596
+ },
2363
2597
  onLog
2364
2598
  );
2365
2599
  if (!seeded.ok) {
@@ -2377,6 +2611,7 @@ Nothing has been photographed. A run that carried on from here would sign in to
2377
2611
  onLog?.(
2378
2612
  sessionPath ? `The seed command left a session at ${sessionPath}, so the screens behind your login can be photographed.` : `The seed command wrote no session to ${SESSION_OUT_ENV}. Only the screens a signed-out visitor can reach will be photographed.`
2379
2613
  );
2614
+ const examples = options.examplesOut ? await examplesFrom(options.examplesOut, onLog) : void 0;
2380
2615
  let app;
2381
2616
  let died;
2382
2617
  if (options.appCommand) {
@@ -2413,11 +2648,38 @@ Something else is already answering on ${options.appUrl}, which is almost always
2413
2648
  return {
2414
2649
  appUrl: options.appUrl,
2415
2650
  ...sessionPath ? { sessionPath } : {},
2651
+ ...examples ? { examples } : {},
2416
2652
  close: async () => {
2417
2653
  stop(app);
2418
2654
  }
2419
2655
  };
2420
2656
  }
2657
+ async function examplesFrom(path3, onLog) {
2658
+ const { readFile: readFile2 } = await import("node:fs/promises");
2659
+ let text2;
2660
+ try {
2661
+ text2 = await readFile2(path3, "utf8");
2662
+ } catch {
2663
+ return void 0;
2664
+ }
2665
+ let parsed;
2666
+ try {
2667
+ parsed = JSON.parse(text2);
2668
+ } catch {
2669
+ onLog?.(`The seed command wrote ${EXAMPLES_OUT_ENV}, but it is not JSON, so no screen with an id in its address will be opened.`);
2670
+ return void 0;
2671
+ }
2672
+ const reading = readExampleRoutes(parsed);
2673
+ if (!reading.ok) {
2674
+ onLog?.(`The seed command wrote ${EXAMPLES_OUT_ENV}, but ${reading.because}, so no screen with an id in its address will be opened.`);
2675
+ return void 0;
2676
+ }
2677
+ onLog?.(
2678
+ `The seed command named a record for ${reading.examples.paths.size} screen(s) with an id in the address.`
2679
+ );
2680
+ for (const refused of reading.examples.refused) onLog?.(` not using ${refused.route}: ${refused.because}`);
2681
+ return reading.examples;
2682
+ }
2421
2683
  async function runToCompletion(command, timeoutMs, env, onLog) {
2422
2684
  return new Promise((resolve) => {
2423
2685
  const child = spawn(command, {
@@ -2895,7 +3157,7 @@ function parseArgs(argv) {
2895
3157
  else if (flag === "--live-clock") args.liveClock = true;
2896
3158
  else if (flag === "--allow-production") args.allowProduction = true;
2897
3159
  else if (flag === "--rebaseline" || flag === "--force") {
2898
- return '--rebaseline needs a reason: `--rebaseline "new brand colours shipped"`. Replacing every stored frame moves every checksum at once, and a re-baseline nobody wrote down is indistinguishable from the product changing.';
3160
+ return '--rebaseline needs a reason: `--rebaseline "new brand colors shipped"`. Replacing every stored frame moves every checksum at once, and a re-baseline nobody wrote down is indistinguishable from the product changing.';
2899
3161
  }
2900
3162
  }
2901
3163
  if (args.out) {
@@ -2920,6 +3182,13 @@ function shotsFromRepo(repo, previous) {
2920
3182
  ...previous.has(step.shotId) ? { sha256: previous.get(step.shotId) } : {}
2921
3183
  }));
2922
3184
  }
3185
+ function withExamples(shots, examples) {
3186
+ return shots.map((shot) => {
3187
+ const resolved = resolveVisit(shot.route, examples);
3188
+ if ("needs" in resolved) return { ...shot, unvisitable: describeMissingExample(shot.route, resolved.needs) };
3189
+ return resolved.visit === shot.route ? shot : { ...shot, visit: resolved.visit };
3190
+ });
3191
+ }
2923
3192
  async function previousFrames(out) {
2924
3193
  try {
2925
3194
  const manifest = JSON.parse(await readFile(path2.join(out, "manifest.json"), "utf8"));
@@ -2988,6 +3257,7 @@ Approve one on the plan page, then run this again.`
2988
3257
  seedCommand: parsed.seedCommand,
2989
3258
  ...parsed.appCommand ? { appCommand: parsed.appCommand } : {},
2990
3259
  sessionOut: parsed.session ?? path2.join(process.cwd(), "helpdesk-demo-session.json"),
3260
+ examplesOut: path2.join(process.cwd(), "helpdesk-examples.json"),
2991
3261
  onLog: (line) => console.log(line)
2992
3262
  });
2993
3263
  if (seeded.sessionPath) parsed.session = seeded.sessionPath;
@@ -2998,6 +3268,7 @@ Approve one on the plan page, then run this again.`
2998
3268
  return 1;
2999
3269
  }
3000
3270
  }
3271
+ const planned = seeded?.examples ? withExamples(shots, seeded.examples) : shots;
3001
3272
  if (parsed.cassette) {
3002
3273
  try {
3003
3274
  stage = await stageAgainstRecording({
@@ -3022,8 +3293,8 @@ Approve one on the plan page, then run this again.`
3022
3293
  baseUrl,
3023
3294
  browser: await browser.build?.().catch(() => void 0),
3024
3295
  clockPinned: !parsed.liveClock,
3025
- locale: "en-GB",
3026
- timezone: "UTC",
3296
+ locale: CAPTURE_CONTRACT.locale,
3297
+ timezone: CAPTURE_CONTRACT.timezone,
3027
3298
  session: Boolean(parsed.session),
3028
3299
  ...stage ? { replaying: parsed.cassette } : {},
3029
3300
  ...parsed.allowProduction ? { productionGranted: true } : {},
@@ -3054,7 +3325,7 @@ Approve one on the plan page, then run this again.`
3054
3325
  try {
3055
3326
  const report = await runCapture({
3056
3327
  baseUrl,
3057
- shots,
3328
+ shots: planned,
3058
3329
  browser,
3059
3330
  ...orders.capture ? {
3060
3331
  clearance: {
@@ -3090,7 +3361,9 @@ Approve one on the plan page, then run this again.`
3090
3361
  jobId: runId,
3091
3362
  item: outcome.route,
3092
3363
  status: outcome.status === "captured" || outcome.status === "unchanged" ? "ok" : outcome.status === "failed" ? "failed" : "skipped",
3093
- ...outcome.reason ? { note: outcome.reason } : {}
3364
+ ...outcome.reason ? { note: outcome.reason } : {},
3365
+ // So Setup can ask about them, once each. See `core/capture/unclassified.ts`.
3366
+ ...outcome.unaccounted?.length ? { unaccounted: outcome.unaccounted } : {}
3094
3367
  });
3095
3368
  }
3096
3369
  },
@@ -3144,6 +3417,13 @@ Approve one on the plan page, then run this again.`
3144
3417
  `
3145
3418
  ${report.captured} photographed, ${report.unchanged} unchanged, ${report.skipped} skipped, ${report.refused} refused, ${report.failed} failed.`
3146
3419
  );
3420
+ const unclassified = unclassifiedLines(
3421
+ groupUnclassified(
3422
+ report.outcomes.flatMap((outcome) => outcome.unaccounted?.length ? [{ route: outcome.route, values: outcome.unaccounted }] : [])
3423
+ )
3424
+ );
3425
+ if (unclassified.length) console.log(`
3426
+ ${unclassified.join("\n")}`);
3147
3427
  if (local) {
3148
3428
  const kept = new Map(frames.map((frame) => [frame.id, frame]));
3149
3429
  for (const shot of shots) {
@@ -3235,7 +3515,18 @@ ${gapReport(gapsFrom([...stage.replay.misses])).join("\n")}`);
3235
3515
  ...report.survey.length ? { survey: report.survey } : {}
3236
3516
  });
3237
3517
  }
3238
- return report.failed ? 1 : 0;
3518
+ const verdict = runVerdict({
3519
+ screens: shots.length,
3520
+ captured: report.captured,
3521
+ unchanged: report.unchanged,
3522
+ skipped: report.skipped,
3523
+ refused: report.refused,
3524
+ failed: report.failed
3525
+ });
3526
+ if (verdict.sentence) console.log(`
3527
+ ${verdict.sentence}`);
3528
+ if (verdict.annotation && process.env["GITHUB_ACTIONS"] === "true") console.log(verdict.annotation);
3529
+ return verdict.exit;
3239
3530
  } catch (error) {
3240
3531
  if (runId) {
3241
3532
  await tellHelpDesk(parsed.to, headers, {
@@ -3310,5 +3601,6 @@ if (runningAsCommand(import.meta.url)) {
3310
3601
  );
3311
3602
  }
3312
3603
  export {
3313
- main
3604
+ main,
3605
+ withExamples
3314
3606
  };
package/helpdesk-seed.mjs CHANGED
@@ -788,7 +788,7 @@ var PATTERNS = [
788
788
  {
789
789
  kind: "passport",
790
790
  regex: /\b[A-Z]{1,2}\d{6,9}\b/g,
791
- because: "the shape of a passport or licence number",
791
+ because: "the shape of a passport or license number",
792
792
  replace: (match) => `${match.slice(0, 1)}${"0".repeat(match.length - 1)}`
793
793
  },
794
794
  {
@@ -981,6 +981,16 @@ var CAPTURE_CONTRACT = {
981
981
  maxClipBytes: 2 * 1024 * 1024,
982
982
  /** Everything renders as though it were this instant. */
983
983
  pinnedClock: "2026-01-15T09:00:00.000Z",
984
+ /**
985
+ * And in this locale and this zone, set on the browser rather than inherited
986
+ * from whoever runs it, so a date on a screen reads the same from any machine.
987
+ *
988
+ * American English, because the products this documents are read in it: a
989
+ * frame showing "15/01/2026" beside an article written for an American
990
+ * reader is a screenshot of a product nobody uses.
991
+ */
992
+ locale: "en-US",
993
+ timezone: "UTC",
984
994
  /**
985
995
  * Injected before every shot. Necessary, not sufficient — see above.
986
996
  */
@@ -1260,7 +1270,7 @@ ${carriedReport(sealed.carried, args.cassette).join("\n")}`);
1260
1270
  console.log(`
1261
1271
  ${describeScrub({ scrubbed: "", findings: sealed.findings, needsReview: true })}`);
1262
1272
  console.log(
1263
- "\nThese survived the field pass, which means they were in free text or under a key nothing\nrecognised. Read them before you commit this."
1273
+ "\nThese survived the field pass, which means they were in free text or under a key nothing\nrecognized. Read them before you commit this."
1264
1274
  );
1265
1275
  return 1;
1266
1276
  }
@@ -1268,7 +1278,7 @@ ${describeScrub({ scrubbed: "", findings: sealed.findings, needsReview: true })}
1268
1278
  return 0;
1269
1279
  }
1270
1280
  function carriedReport(carried, cassette) {
1271
- if (!carried.length) return ["Every key in the recording was recognised and rewritten."];
1281
+ if (!carried.length) return ["Every key in the recording was recognized and rewritten."];
1272
1282
  const ranked = [...carried].sort((a, b) => score(b) - score(a) || b.count - a.count);
1273
1283
  const width = Math.max(...ranked.map((field) => field.key.length));
1274
1284
  const worth = ranked.filter((field) => score(field) > 0);
@@ -1370,7 +1380,7 @@ async function routes(args) {
1370
1380
  const cassette = parseCassette(await readFile(args.cassette, "utf8"));
1371
1381
  const found = exampleRoutes(cassette);
1372
1382
  if (!found.length) {
1373
- console.log("The recording holds no parameterised routes, so there are no example ids to offer.");
1383
+ console.log("The recording holds no parameterized routes, so there are no example ids to offer.");
1374
1384
  return 0;
1375
1385
  }
1376
1386
  console.log("Routes the recording can answer, with an identifier it has already served:\n");
package/helpdesk.mjs CHANGED
@@ -519,8 +519,17 @@ function baseUrlFromTsconfig(tsconfig) {
519
519
  }
520
520
 
521
521
  // core/discovery/screen-facts.ts
522
- function noScreenFacts() {
523
- return { headings: [], actions: [], links: [], fields: [], notices: [], warnings: [], readFrom: [] };
522
+ function noScreenFacts(read) {
523
+ return {
524
+ headings: [],
525
+ actions: [],
526
+ links: [],
527
+ fields: [],
528
+ notices: [],
529
+ warnings: [],
530
+ readFrom: [],
531
+ ...read ? { read } : {}
532
+ };
524
533
  }
525
534
  var LIMITS = {
526
535
  headings: 12,
@@ -784,6 +793,7 @@ function readProduct(routes, options) {
784
793
  };
785
794
  const documentable = routes.filter((route) => !route.skipped);
786
795
  const total = Math.min(documentable.length, maxScreens);
796
+ const reading = { how: "source", commit: options.commit ?? null };
787
797
  let done = 0;
788
798
  let withFacts = 0;
789
799
  const out = routes.map((route) => {
@@ -794,12 +804,15 @@ function readProduct(routes, options) {
794
804
  const source = read(reached.path);
795
805
  if (source !== void 0) files.push({ path: reached.path, contents: source, depth: reached.depth });
796
806
  }
797
- const facts = readScreenFacts(files);
798
807
  done += 1;
799
808
  options.onScreen?.(route, done, total);
800
- if (!hasAnything(facts)) return route;
809
+ if (files.length === 0) return route;
810
+ const facts = readScreenFacts(files);
811
+ if (!hasAnything(facts)) {
812
+ return { ...route, facts: noScreenFacts(reading) };
813
+ }
801
814
  withFacts += 1;
802
- return { ...route, facts };
815
+ return { ...route, facts: { ...facts, read: reading } };
803
816
  });
804
817
  return { routes: out, read: withFacts, filesOpened: opened.size };
805
818
  }
@@ -928,7 +941,7 @@ function markersPullRequestBody(result) {
928
941
  const lines = [
929
942
  "This adds one-line comments that link screens in this repository to the help articles that describe them.",
930
943
  "",
931
- "**Nothing here changes behaviour.** Each line is a comment. Once merged, a change to one of these files tells our help desk which article may now be out of date, so somebody can check it before a customer does.",
944
+ "**Nothing here changes behavior.** Each line is a comment. Once merged, a change to one of these files tells our help desk which article may now be out of date, so somebody can check it before a customer does.",
932
945
  "",
933
946
  `${result.proposals.length} marker${result.proposals.length === 1 ? "" : "s"} proposed.`
934
947
  ];
@@ -991,7 +1004,10 @@ async function runMap(options) {
991
1004
  }
992
1005
  },
993
1006
  aliases: aliasesFromTsconfig(tsconfig),
994
- baseUrl: baseUrlFromTsconfig(tsconfig)
1007
+ baseUrl: baseUrlFromTsconfig(tsconfig),
1008
+ // Stamped onto every reading, so a map carries the commit its labels and
1009
+ // controls were read at and a later one can tell fresh from stale.
1010
+ commit: headCommit(options.cwd)
995
1011
  });
996
1012
  routes = read.routes;
997
1013
  const reading = describeReading(routes);
@@ -1066,6 +1082,13 @@ function originOf(cwd) {
1066
1082
  return void 0;
1067
1083
  }
1068
1084
  }
1085
+ function headCommit(cwd) {
1086
+ try {
1087
+ return execFileSync("git", ["rev-parse", "HEAD"], { cwd, encoding: "utf8" }).trim() || null;
1088
+ } catch {
1089
+ return null;
1090
+ }
1091
+ }
1069
1092
 
1070
1093
  // adapters/invoked-as-command.ts
1071
1094
  import { realpathSync } from "node:fs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olomonhelpdesk2/helpdesk",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Reads a product's screens, plans its help articles, and photographs them.",
5
5
  "type": "module",
6
6
  "engines": {