@olomonhelpdesk2/helpdesk 0.1.4 → 0.1.9

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,102 +1,4 @@
1
1
  #!/usr/bin/env node
2
- // core/capture/contract.ts
3
- var CAPTURE_CONTRACT = {
4
- /** Same for every shot, so type is the same size across the whole set. */
5
- viewport: { width: 1600, height: 1e3 },
6
- /** Stills render at 2x → 3200x2000. */
7
- stillScale: 2,
8
- /** Clips at 1x; video is resampled downstream anyway. */
9
- clipScale: 1,
10
- /** Below this a frame is a failed shot rather than a small one. */
11
- minStillWidth: 2300,
12
- /**
13
- * Clip length.
14
- *
15
- * A CAP rather than a target, and inherited from a marketing site's autoplay
16
- * loop — which is not this product's constraint. A help-center clip showing a
17
- * multi-step task may well want twenty seconds, so the window is the contract
18
- * and the number is configurable.
19
- */
20
- clipSeconds: { min: 6, target: 9, max: 10 },
21
- maxClipBytes: 2 * 1024 * 1024,
22
- /** Everything renders as though it were this instant. */
23
- pinnedClock: "2026-01-15T09:00:00.000Z",
24
- /**
25
- * And in this locale and this zone, set on the browser rather than inherited
26
- * from whoever runs it, so a date on a screen reads the same from any machine.
27
- *
28
- * American English, because the products this documents are read in it: a
29
- * frame showing "15/01/2026" beside an article written for an American
30
- * reader is a screenshot of a product nobody uses.
31
- */
32
- locale: "en-US",
33
- timezone: "UTC",
34
- /**
35
- * Injected before every shot. Necessary, not sufficient — see above.
36
- */
37
- motionOff: "*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important}"
38
- };
39
- var VISUAL_STABILITY = {
40
- /** Gap between comparison frames. Short enough to be cheap, long enough to differ. */
41
- intervalMs: 120,
42
- /** Consecutive identical frames required. */
43
- identicalFrames: 2,
44
- /** Give up rather than hang; a page that never settles is a finding. */
45
- timeoutMs: 1e4
46
- };
47
- function pngSize(bytes) {
48
- const SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10];
49
- if (bytes.length < 24) return void 0;
50
- if (SIGNATURE.some((byte, index) => bytes[index] !== byte)) return void 0;
51
- const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
52
- return { width: view.getUint32(16), height: view.getUint32(20) };
53
- }
54
- var RECORDING_MISS_STATUS = 599;
55
- function isPublishable(result) {
56
- return result === "ok" || result === "empty-state";
57
- }
58
- function actionFor(outcome) {
59
- switch (outcome.result) {
60
- case "ok":
61
- case "empty-state":
62
- case "canonical-redirect":
63
- return void 0;
64
- case "not-found": {
65
- const noun = subjectOf(outcome.missingEndpoint ?? outcome.route);
66
- return `Create one ${noun} in your test environment, then run this again.`;
67
- }
68
- case "thin":
69
- return `Nothing rendered on ${outcome.route}. If it needs data, add some; if it is not a real screen, we will leave it out.`;
70
- case "bounced":
71
- return `${outcome.route} redirected${outcome.landed ? ` to ${outcome.landed}` : " somewhere else"}. If it needs a permission your test account lacks, use one that has it.`;
72
- case "crashed":
73
- return `${outcome.route} hit an error${outcome.error ? `: ${outcome.error}` : ""}. This is a bug in the app rather than in the setup.`;
74
- case "unrecorded":
75
- return `${outcome.route} asked for ${outcome.missingEndpoint ?? "data"}, which is not in the recording. Visit this screen while recording, then run this again.`;
76
- }
77
- }
78
- function subjectOf(path3) {
79
- const segments = path3.split("/").filter(Boolean).filter((segment) => !/^(api|v\d+)$/.test(segment));
80
- while (segments.length > 1 && looksLikeAnId(segments[segments.length - 1])) segments.pop();
81
- const last = segments[segments.length - 1] ?? "record";
82
- return last.replace(/[-_]/g, " ").replace(/s$/, "");
83
- }
84
- function looksLikeAnId(segment) {
85
- 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
86
- // in it. Deliberately narrow — `sign-up` must survive this.
87
- /^[a-z]{2,}[_-][a-z0-9]*\d[a-z0-9]*$/i.test(segment) || /^[0-9a-z]{16,}$/i.test(segment);
88
- }
89
- function captureReport(outcomes) {
90
- const ready = outcomes.filter((outcome) => isPublishable(outcome.result));
91
- const actionable = outcomes.map((outcome) => ({ outcome, action: actionFor(outcome) })).filter((entry) => Boolean(entry.action));
92
- const lines = [
93
- `${ready.length} screens ready \xB7 ${actionable.length} need attention`,
94
- "",
95
- ...actionable.map(({ outcome, action }) => ` ${outcome.route.padEnd(30)} ${action}`)
96
- ];
97
- return { ready: ready.length, needData: actionable.length, lines };
98
- }
99
-
100
2
  // core/capture/examples.ts
101
3
  var CI_EXAMPLES_OUT_ENV = "HELPDESK_EXAMPLES_OUT";
102
4
  var EXAMPLES_SCHEMA = "helpdesk.examples/1";
@@ -149,6 +51,18 @@ function unusablePath(path3) {
149
51
  if (/\[|\]/.test(path3)) return "the path still holds a dynamic segment rather than a record";
150
52
  return void 0;
151
53
  }
54
+ var PLAIN_QUERY = /^[\w.~%:+,-]+=[\w.~%:+,-]*(?:&[\w.~%:+,-]+=[\w.~%:+,-]*)*$/;
55
+ function queryFor(route, named) {
56
+ const mark = named.indexOf("?");
57
+ const path3 = mark < 0 ? named : named.slice(0, mark);
58
+ const query = mark < 0 ? "" : named.slice(mark + 1);
59
+ if (!query) return { because: "it has no dynamic segment, so the only thing to name for it is the query it opens with" };
60
+ if (path3 !== route) return { because: `${named} is not ${route} with a query` };
61
+ const unusable = unusablePath(route);
62
+ if (unusable) return { because: unusable };
63
+ if (named.length > MAX_PATH || !PLAIN_QUERY.test(query)) return { because: "the query is not a plain list of names and values" };
64
+ return { visit: named };
65
+ }
152
66
  function readExampleRoutes(raw) {
153
67
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
154
68
  return { ok: false, because: "the file is not a JSON object" };
@@ -166,14 +80,20 @@ function readExampleRoutes(raw) {
166
80
  const paths = /* @__PURE__ */ new Map();
167
81
  const refused = [];
168
82
  for (const [route, value] of entries) {
169
- if (!route.startsWith("/") || dynamicSegments(route).length === 0) {
170
- refused.push({ route, because: "it is not a route with a dynamic segment" });
83
+ if (!route.startsWith("/")) {
84
+ refused.push({ route, because: "it is not a route" });
171
85
  continue;
172
86
  }
173
87
  if (typeof value !== "string") {
174
88
  refused.push({ route, because: "its example is not a path" });
175
89
  continue;
176
90
  }
91
+ if (dynamicSegments(route).length === 0) {
92
+ const opened = queryFor(route, value.trim());
93
+ if ("because" in opened) refused.push({ route, because: opened.because });
94
+ else paths.set(route, opened.visit);
95
+ continue;
96
+ }
177
97
  const path3 = value.trim();
178
98
  const unusable = unusablePath(path3);
179
99
  if (unusable) {
@@ -190,8 +110,8 @@ function readExampleRoutes(raw) {
190
110
  }
191
111
  function resolveVisit(route, examples) {
192
112
  const needs = dynamicSegments(route);
193
- if (needs.length === 0) return { visit: route };
194
113
  const path3 = examples.paths.get(route);
114
+ if (needs.length === 0) return { visit: path3 ?? route };
195
115
  return path3 ? { visit: path3 } : { needs };
196
116
  }
197
117
  function describeMissingExample(route, needs) {
@@ -199,6 +119,108 @@ function describeMissingExample(route, needs) {
199
119
  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}`;
200
120
  }
201
121
 
122
+ // core/capture/contract.ts
123
+ var CAPTURE_CONTRACT = {
124
+ /** Same for every shot, so type is the same size across the whole set. */
125
+ viewport: { width: 1600, height: 1e3 },
126
+ /** Stills render at 2x → 3200x2000. */
127
+ stillScale: 2,
128
+ /** Clips at 1x; video is resampled downstream anyway. */
129
+ clipScale: 1,
130
+ /** Below this a frame is a failed shot rather than a small one. */
131
+ minStillWidth: 2300,
132
+ /**
133
+ * Clip length.
134
+ *
135
+ * A CAP rather than a target, and inherited from a marketing site's autoplay
136
+ * loop — which is not this product's constraint. A help-center clip showing a
137
+ * multi-step task may well want twenty seconds, so the window is the contract
138
+ * and the number is configurable.
139
+ */
140
+ clipSeconds: { min: 6, target: 9, max: 10 },
141
+ maxClipBytes: 2 * 1024 * 1024,
142
+ /** Everything renders as though it were this instant. */
143
+ pinnedClock: "2026-01-15T09:00:00.000Z",
144
+ /**
145
+ * And in this locale and this zone, set on the browser rather than inherited
146
+ * from whoever runs it, so a date on a screen reads the same from any machine.
147
+ *
148
+ * American English, because the products this documents are read in it: a
149
+ * frame showing "15/01/2026" beside an article written for an American
150
+ * reader is a screenshot of a product nobody uses.
151
+ */
152
+ locale: "en-US",
153
+ timezone: "UTC",
154
+ /**
155
+ * Injected before every shot. Necessary, not sufficient — see above.
156
+ */
157
+ motionOff: "*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important}"
158
+ };
159
+ var VISUAL_STABILITY = {
160
+ /** Gap between comparison frames. Short enough to be cheap, long enough to differ. */
161
+ intervalMs: 120,
162
+ /** Consecutive identical frames required. */
163
+ identicalFrames: 2,
164
+ /** Give up rather than hang; a page that never settles is a finding. */
165
+ timeoutMs: 1e4
166
+ };
167
+ function pngSize(bytes) {
168
+ const SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10];
169
+ if (bytes.length < 24) return void 0;
170
+ if (SIGNATURE.some((byte, index) => bytes[index] !== byte)) return void 0;
171
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
172
+ return { width: view.getUint32(16), height: view.getUint32(20) };
173
+ }
174
+ var RECORDING_MISS_STATUS = 599;
175
+ function isPublishable(result) {
176
+ return result === "ok" || result === "empty-state";
177
+ }
178
+ function actionFor(outcome) {
179
+ switch (outcome.result) {
180
+ case "ok":
181
+ case "empty-state":
182
+ case "canonical-redirect":
183
+ return void 0;
184
+ case "not-found": {
185
+ if ((!outcome.missingEndpoint || outcome.missingEndpoint === outcome.route) && !outcome.route.includes("[")) {
186
+ 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 opens only from a record, as a form started from one does, name the address it opens at in the file the seed command writes at ${CI_EXAMPLES_OUT_ENV}. If it is gone, it will drop out of the plan.`;
187
+ }
188
+ const asked = outcome.missingEndpoint ?? outcome.route;
189
+ const noun = subjectOf(fillsPattern(outcome.route, asked) ? outcome.route : asked);
190
+ return `Create one ${noun} in your test environment, then run this again.`;
191
+ }
192
+ case "thin":
193
+ return `Nothing rendered on ${outcome.route}. If it needs data, add some; if it is not a real screen, we will leave it out.`;
194
+ case "bounced":
195
+ return `${outcome.route} redirected${outcome.landed ? ` to ${outcome.landed}` : " somewhere else"}. If it needs a permission your test account lacks, use one that has it.`;
196
+ case "crashed":
197
+ return `${outcome.route} hit an error${outcome.error ? `: ${outcome.error}` : ""}. This is a bug in the app rather than in the setup.`;
198
+ case "unrecorded":
199
+ return `${outcome.route} asked for ${outcome.missingEndpoint ?? "data"}, which is not in the recording. Visit this screen while recording, then run this again.`;
200
+ }
201
+ }
202
+ function subjectOf(path3) {
203
+ const segments = path3.split("/").filter(Boolean).filter((segment) => !/^(api|v\d+)$/.test(segment));
204
+ while (segments.length > 1 && looksLikeAnId(segments[segments.length - 1])) segments.pop();
205
+ const last = segments[segments.length - 1] ?? "record";
206
+ return last.replace(/[-_]/g, " ").replace(/s$/, "");
207
+ }
208
+ function looksLikeAnId(segment) {
209
+ 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
210
+ // in it. Deliberately narrow — `sign-up` must survive this.
211
+ /^[a-z]{2,}[_-][a-z0-9]*\d[a-z0-9]*$/i.test(segment) || /^[0-9a-z]{16,}$/i.test(segment);
212
+ }
213
+ function captureReport(outcomes) {
214
+ const ready = outcomes.filter((outcome) => isPublishable(outcome.result));
215
+ const actionable = outcomes.map((outcome) => ({ outcome, action: actionFor(outcome) })).filter((entry) => Boolean(entry.action));
216
+ const lines = [
217
+ `${ready.length} screens ready \xB7 ${actionable.length} need attention`,
218
+ "",
219
+ ...actionable.map(({ outcome, action }) => ` ${outcome.route.padEnd(30)} ${action}`)
220
+ ];
221
+ return { ready: ready.length, needData: actionable.length, lines };
222
+ }
223
+
202
224
  // core/cli-package.ts
203
225
  var HELPDESK_CLI_PACKAGE = "@olomonhelpdesk2/helpdesk";
204
226
  function helpdeskCommand(command, args = []) {
@@ -234,7 +256,7 @@ var EMPTY_STATE_PHRASES = [
234
256
  /\bno results\b/i,
235
257
  /\bthis (list|table) is empty\b/i
236
258
  ];
237
- function classifyRoute(observation) {
259
+ function classifyRoute(observation, options = {}) {
238
260
  const text2 = observation.text.trim();
239
261
  const unrecorded = observation.failedRequests?.find((request) => request.status === RECORDING_MISS_STATUS);
240
262
  if (unrecorded) {
@@ -244,21 +266,26 @@ function classifyRoute(observation) {
244
266
  if (crash) return { route: observation.route, result: "crashed", error: crash };
245
267
  const missing = missingBecause(observation, text2);
246
268
  if (missing) return { route: observation.route, result: "not-found", missingEndpoint: missing };
269
+ let followed;
247
270
  if (observation.landedOn && !samePlace(observation.route, observation.landedOn)) {
248
271
  const canonical = canonicalisation(observation.route, observation.landedOn);
249
- return {
250
- route: observation.route,
251
- result: canonical ? "canonical-redirect" : "bounced",
252
- // Carried only when it is a finding. A parent handing off to its default
253
- // child needs no row, so it needs no detail either.
254
- ...canonical ? {} : { landed: observation.landedOn }
255
- };
272
+ if (!canonical && options.follow?.(observation.landedOn)) {
273
+ followed = { landed: observation.landedOn };
274
+ } else {
275
+ return {
276
+ route: observation.route,
277
+ result: canonical ? "canonical-redirect" : "bounced",
278
+ // Carried only when it is a finding. A parent handing off to its default
279
+ // child needs no row, so it needs no detail either.
280
+ ...canonical ? {} : { landed: observation.landedOn }
281
+ };
282
+ }
256
283
  }
257
284
  if (EMPTY_STATE_PHRASES.some((phrase) => phrase.test(text2))) {
258
- return { route: observation.route, result: "empty-state" };
285
+ return { route: observation.route, result: "empty-state", ...followed };
259
286
  }
260
- if (text2.length < THIN_TEXT_CHARS) return { route: observation.route, result: "thin" };
261
- return { route: observation.route, result: "ok" };
287
+ if (text2.length < THIN_TEXT_CHARS) return { route: observation.route, result: "thin", ...followed };
288
+ return { route: observation.route, result: "ok", ...followed };
262
289
  }
263
290
  function crashedBecause(observation, text2) {
264
291
  const uncaught = observation.errors?.[0];
@@ -331,40 +358,40 @@ function preflight(facts) {
331
358
  name: "target",
332
359
  ok: true,
333
360
  blocking: false,
334
- detail: `${facts.baseUrl}, reading from ${facts.replaying} \u2014 no live backend is reachable from this run, so nothing on these screens can be a real person's`
361
+ detail: `${facts.baseUrl}, reading from ${facts.replaying}. No live backend is reachable from this run, so nothing on these screens can be a real person's`
335
362
  });
336
363
  } else if (facts.session && live && !facts.productionGranted) {
337
364
  checks.push({
338
365
  name: "target",
339
366
  ok: false,
340
367
  blocking: true,
341
- detail: `${facts.baseUrl} does not look like a demo or staging address, and this run has a session. A help article shows the typical state of a screen, not somebody's data \u2014 point this at a demo tenant, or pass --allow-production if that address really is the seeded one.`
368
+ detail: `${facts.baseUrl} does not look like a demo or staging address, and this run has a session. A help article shows the typical state of a screen, not somebody's data. Point this at a demo tenant, or pass --allow-production if that address really is the seeded one.`
342
369
  });
343
370
  } else {
344
371
  checks.push({
345
372
  name: "target",
346
373
  ok: true,
347
374
  blocking: false,
348
- detail: facts.session ? `${facts.baseUrl}, signed in${live ? " \u2014 accepted as seeded by --allow-production" : " as a demo tenant"}` : `${facts.baseUrl}, signed in as nobody`
375
+ detail: facts.session ? `${facts.baseUrl}, signed in${live ? ", accepted as seeded by --allow-production" : " as a demo tenant"}` : `${facts.baseUrl}, signed in as nobody`
349
376
  });
350
377
  }
351
378
  checks.push({
352
379
  name: "browser",
353
380
  ok: Boolean(facts.browser),
354
381
  blocking: false,
355
- detail: facts.browser ? `${facts.browser}, recorded on every frame` : "not identified \u2014 a browser upgrade will look like the product changing"
382
+ detail: facts.browser ? `${facts.browser}, recorded on every frame` : "not identified. A browser upgrade will look like the product changing"
356
383
  });
357
384
  checks.push({
358
385
  name: "clock",
359
386
  ok: facts.clockPinned,
360
387
  blocking: false,
361
- detail: facts.clockPinned ? "pinned, with randomness and generated ids seeded from it" : "running live (--live-clock) \u2014 screens showing a date will differ every run, so nothing here can tell you whether they changed"
388
+ detail: facts.clockPinned ? "pinned, with randomness and generated ids seeded from it" : "running live (--live-clock). Screens showing a date will differ every run, so nothing here can tell you whether they changed"
362
389
  });
363
390
  checks.push({
364
391
  name: "locale",
365
392
  ok: true,
366
393
  blocking: false,
367
- detail: `${facts.locale}, ${facts.timezone} \u2014 set explicitly rather than inherited from this machine`
394
+ detail: `${facts.locale}, ${facts.timezone}, set explicitly rather than inherited from this machine`
368
395
  });
369
396
  checks.push({
370
397
  name: "isolation",
@@ -389,7 +416,7 @@ function preflight(facts) {
389
416
  name: "rebaseline",
390
417
  ok: true,
391
418
  blocking: false,
392
- detail: `replacing every stored frame \u2014 "${facts.rebaselineReason}"`
419
+ detail: `replacing every stored frame: "${facts.rebaselineReason}"`
393
420
  });
394
421
  }
395
422
  const blocked = checks.filter((check) => check.blocking && !check.ok);
@@ -416,6 +443,101 @@ function attestation(run) {
416
443
  ].filter(Boolean).join("\n");
417
444
  }
418
445
 
446
+ // core/capture/run-record.ts
447
+ function runIdentityFromEnv(env) {
448
+ if (env["GITHUB_ACTIONS"] !== "true" || !env["GITHUB_RUN_NUMBER"]) return void 0;
449
+ const said = { number: env["GITHUB_RUN_NUMBER"] };
450
+ for (const [key, name] of [
451
+ ["id", "GITHUB_RUN_ID"],
452
+ ["attempt", "GITHUB_RUN_ATTEMPT"],
453
+ ["repository", "GITHUB_REPOSITORY"],
454
+ ["server", "GITHUB_SERVER_URL"]
455
+ ]) {
456
+ const value = env[name];
457
+ if (value) said[key] = value;
458
+ }
459
+ return said;
460
+ }
461
+
462
+ // core/capture/run-verdict.ts
463
+ function runCounts(run) {
464
+ return `${run.captured} photographed, ${run.unchanged} unchanged, ${run.skipped} skipped, ${run.refused} refused, ${run.failed} failed`;
465
+ }
466
+ function runVerdict(run) {
467
+ const delivered = run.captured + run.unchanged;
468
+ const missed = run.skipped + run.refused + run.failed;
469
+ const counts = runCounts(run);
470
+ if (run.screens > 0 && delivered === 0) {
471
+ return {
472
+ exit: 1,
473
+ annotation: `::error title=No screenshots were delivered::None of the ${run.screens} screens was photographed (${counts}). The step log says why for each one.`,
474
+ sentence: `Nothing was delivered, so this run fails rather than passing with no screenshots: ${counts}.`
475
+ };
476
+ }
477
+ if (missed > 0) {
478
+ return {
479
+ exit: run.failed > 0 ? 1 : 0,
480
+ annotation: `::warning title=${missed} of ${run.screens} screens were not photographed::${counts}. The step log says why for each one.`
481
+ };
482
+ }
483
+ return { exit: 0 };
484
+ }
485
+
486
+ // core/capture/unclassified.ts
487
+ var SEPARATOR = " > ";
488
+ function commonRegion(selectors) {
489
+ const parts = selectors.map((selector) => selector.split(SEPARATOR));
490
+ const first = parts[0] ?? [];
491
+ let shared = 0;
492
+ while (shared < first.length && parts.every((steps) => steps[shared] === first[shared])) shared += 1;
493
+ return shared >= 3 ? first.slice(0, shared).join(SEPARATOR) : "";
494
+ }
495
+ function groupUnclassified(refused) {
496
+ const enough = Math.max(2, Math.ceil(new Set(refused.map((screen) => screen.route)).size / 2));
497
+ const where = /* @__PURE__ */ new Map();
498
+ for (const screen of refused) {
499
+ for (const value of screen.values) {
500
+ const seen = where.get(value.at) ?? { screens: /* @__PURE__ */ new Set(), lengths: /* @__PURE__ */ new Set() };
501
+ seen.screens.add(screen.route);
502
+ seen.lengths.add(value.length);
503
+ where.set(value.at, seen);
504
+ }
505
+ }
506
+ 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));
507
+ const isShared = new Set(shared.map((element) => element.at));
508
+ const screens = [];
509
+ const onlyShared = [];
510
+ for (const screen of refused) {
511
+ const rest = screen.values.filter((value) => !isShared.has(value.at));
512
+ if (rest.length) {
513
+ screens.push({ route: screen.route, count: rest.length, within: commonRegion(rest.map((value) => value.at)) });
514
+ } else if (screen.values.length) {
515
+ onlyShared.push(screen.route);
516
+ }
517
+ }
518
+ return { shared, screens, onlyShared: [...new Set(onlyShared)].sort() };
519
+ }
520
+ function describeLengths(lengths) {
521
+ const low = lengths[0] ?? 0;
522
+ const high = lengths[lengths.length - 1] ?? low;
523
+ return low === high ? `${low} character${low === 1 ? "" : "s"}` : `${low} to ${high} characters`;
524
+ }
525
+ function describeShared(element, summary) {
526
+ const unblocks = summary.onlyShared.filter((route) => element.screens.includes(route)).length;
527
+ 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.` : ".");
528
+ }
529
+ function describeRemainder(screen) {
530
+ 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.");
531
+ }
532
+ function unclassifiedLines(summary) {
533
+ if (!summary.shared.length && !summary.screens.length) return [];
534
+ return [
535
+ "Not photographed until somebody says what is there:",
536
+ ...summary.shared.map((element) => ` ${describeShared(element, summary)}`),
537
+ ...summary.screens.map((screen) => ` ${describeRemainder(screen)}`)
538
+ ];
539
+ }
540
+
419
541
  // adapters/capture-cli.ts
420
542
  import { createRequire } from "node:module";
421
543
  import path from "node:path";
@@ -495,6 +617,8 @@ var ALREADY_SAFE = [
495
617
  function alreadySafe(kind, value) {
496
618
  return ALREADY_SAFE.some((entry) => entry.kind === kind && entry.regex.test(value));
497
619
  }
620
+ var BIRTH_LABEL = /(?:\bdate[\s_-]*of[\s_-]*birth|\bbirth[\s_-]*(?:date|day)|\bdob\b|\bd\.o\.b\b|\bborn\b)/i;
621
+ var LABEL_REACH = 48;
498
622
  var PATTERNS = [
499
623
  {
500
624
  kind: "email",
@@ -544,13 +668,14 @@ var PATTERNS = [
544
668
  {
545
669
  kind: "date-of-birth",
546
670
  regex: /\b(19|20)\d{2}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])\b/g,
547
- because: "a full date \u2014 if it is a birth date it identifies somebody",
548
- replace: () => "1970-01-01"
671
+ because: "a date labeled as a birth date, which identifies somebody",
672
+ replace: () => "1970-01-01",
673
+ labelled: BIRTH_LABEL
549
674
  },
550
675
  {
551
676
  kind: "passport",
552
677
  regex: /\b[A-Z]{1,2}\d{6,9}\b/g,
553
- because: "the shape of a passport or licence number",
678
+ because: "the shape of a passport or license number",
554
679
  replace: (match) => `${match.slice(0, 1)}${"0".repeat(match.length - 1)}`
555
680
  },
556
681
  {
@@ -566,8 +691,13 @@ function scrub(text2, options = {}) {
566
691
  let output = text2;
567
692
  for (const pattern of PATTERNS) {
568
693
  const seen = /* @__PURE__ */ new Map();
569
- output = output.replace(pattern.regex, (match) => {
694
+ output = output.replace(pattern.regex, (...args) => {
695
+ const match = args[0];
696
+ const named = typeof args[args.length - 1] === "object";
697
+ const offset = args[args.length - (named ? 3 : 2)];
698
+ const whole = args[args.length - (named ? 2 : 1)];
570
699
  if (alreadySafe(pattern.kind, match)) return match;
700
+ if (pattern.labelled && !pattern.labelled.test(whole.slice(Math.max(0, offset - LABEL_REACH), offset))) return match;
571
701
  const already = seen.get(match);
572
702
  if (already) return already;
573
703
  const replacement = pattern.replace(match, seen.size);
@@ -584,7 +714,7 @@ function scrub(text2, options = {}) {
584
714
  found: brand,
585
715
  replacement: brand,
586
716
  where,
587
- because: "a real institution in a published screenshot implies a partnership \u2014 a legal review item, not a design nit"
717
+ because: "a real institution in a published screenshot implies a partnership: a legal review item, not a design nit"
588
718
  });
589
719
  }
590
720
  }
@@ -612,10 +742,24 @@ function gateFrame(pageText, options = {}) {
612
742
  return {
613
743
  safe: false,
614
744
  findings,
615
- reason: onlyBrands ? `Not photographed${where}: the screen names ${named.join(", ")}. A real company in a published screenshot implies a partnership, which is a legal review item rather than a design nit \u2014 so somebody has to say whether it belongs there. If these names are on this screen on purpose, list them on the capture approval and this screen is photographed as it is.` : `Not photographed${where}: the screen showed ${kinds.join(", ")} that looks real. A screenshot cannot be redacted afterwards, so it is refused rather than cleaned \u2014 point the capture at a demo tenant, or fix the seed that produced this.`
745
+ reason: onlyBrands ? `Not photographed${where}: the screen names ${named.join(", ")}. A real company in a published screenshot implies a partnership, which is a legal review item rather than a design nit, so somebody has to say whether it belongs there. If these names are on this screen on purpose, list them on the capture approval and this screen is photographed as it is.` : `Not photographed${where}: the screen showed ${kinds.join(", ")} that looks real. A screenshot cannot be redacted afterwards, so it is refused rather than cleaned. Point the capture at a demo tenant, or fix the seed that produced this.`
616
746
  };
617
747
  }
618
748
 
749
+ // core/capture/sign-in.ts
750
+ var IDENTIFIER_HINT = /\b(username|email)\b/;
751
+ var IDENTIFIER_NAME = /user|login|e-?mail/;
752
+ function identifies(field) {
753
+ if (field.type === "email") return true;
754
+ if (IDENTIFIER_HINT.test(field.autocomplete)) return true;
755
+ return (field.type === "text" || field.type === "") && IDENTIFIER_NAME.test(field.name);
756
+ }
757
+ function looksLikeSignIn(fields) {
758
+ const secrets = fields.filter((field) => field.type === "password");
759
+ if (secrets.some((field) => field.autocomplete.split(/\s+/).includes("current-password"))) return true;
760
+ return secrets.some((secret) => fields.some((field) => field.form === secret.form && identifies(field)));
761
+ }
762
+
619
763
  // core/capture/publication/pixels.ts
620
764
  import { deflateSync, inflateSync } from "node:zlib";
621
765
  var MediaRefused = class extends Error {
@@ -649,7 +793,7 @@ function decodePng(buffer) {
649
793
  offset += 12 + length;
650
794
  }
651
795
  if (bitDepth !== 8 || colourType !== 2 && colourType !== 6) {
652
- throw new MediaRefused(`only 8-bit RGB and RGBA PNGs are supported here, not colour type ${colourType} at ${bitDepth} bits`);
796
+ throw new MediaRefused(`only 8-bit RGB and RGBA PNGs are supported here, not color type ${colourType} at ${bitDepth} bits`);
653
797
  }
654
798
  const channels = colourType === 6 ? 4 : 3;
655
799
  const raw = inflateSync(Buffer.concat(data));
@@ -938,11 +1082,22 @@ var LIMITS = {
938
1082
  fields: 24,
939
1083
  notices: 20,
940
1084
  warnings: 12,
1085
+ disabled: 24,
1086
+ empty: 12,
1087
+ gatedBy: 8,
941
1088
  choices: 12,
942
1089
  /** Longer than this is a paragraph of copy, not a label. */
943
1090
  label: 120,
944
1091
  sentence: 300
945
1092
  };
1093
+ var EMPTY_STATE = /^(?:no\s+(?!(?:longer|need|problem|worries|matter|one|way|thanks)\b)[a-z]|nothing\b|none\s|you (?:have|haven't|don't have|do not have)\s+(?:no|any|not)\b|there(?:'s| (?:are|is|were))\s+(?:no|nothing)\b|there aren't any\b|(?:you|we) haven't\b.*\byet\b)/i;
1094
+ function isEmptyState(text2) {
1095
+ const sentence = text2.replace(/[\u2018\u2019]/g, "'").replace(/\s+/g, " ").trim();
1096
+ if (!sentence || sentence.length > LIMITS.sentence || sentence.includes("?")) return false;
1097
+ if (/\b(?:until|unless)\b/i.test(sentence)) return false;
1098
+ if (sentence.split(" ").length < 2) return false;
1099
+ return EMPTY_STATE.test(sentence);
1100
+ }
946
1101
 
947
1102
  // core/discovery/aria-facts.ts
948
1103
  var LANDMARK = {
@@ -993,6 +1148,12 @@ function readAriaFactRecords(snapshot, options = {}) {
993
1148
  const state = controlState(node);
994
1149
  if (node.role === "heading") {
995
1150
  add("heading", node.name || node.text, node);
1151
+ if (isEmptyState(node.name || node.text)) add("empty", node.name || node.text, node);
1152
+ continue;
1153
+ }
1154
+ if (node.role === "paragraph" || node.role === "text") {
1155
+ const said = node.text || node.name;
1156
+ if (isEmptyState(said)) add("empty", trim(said, LIMITS.sentence), node);
996
1157
  continue;
997
1158
  }
998
1159
  if (node.role === "link") {
@@ -1016,7 +1177,9 @@ function readAriaFactRecords(snapshot, options = {}) {
1016
1177
  continue;
1017
1178
  }
1018
1179
  if (NOTICE.has(node.role)) {
1019
- add("notice", trim(node.text || node.name, LIMITS.sentence), node);
1180
+ const said = trim(node.text || node.name, LIMITS.sentence);
1181
+ add("notice", said, node);
1182
+ if (isEmptyState(said)) add("empty", said, node);
1020
1183
  }
1021
1184
  }
1022
1185
  return facts;
@@ -1053,7 +1216,7 @@ function parse(snapshot) {
1053
1216
  if (node.name || node.text) parent.choices.push(node.name || node.text);
1054
1217
  continue;
1055
1218
  }
1056
- if (role !== "text") out.push(node);
1219
+ out.push(node);
1057
1220
  stack.push({ depth: depth2, node });
1058
1221
  }
1059
1222
  return out;
@@ -1136,6 +1299,7 @@ function readRenderedFactRecords(html, options = {}) {
1136
1299
  const label = accessibleName(element);
1137
1300
  if (HEADING.test(tag) || role === "heading") {
1138
1301
  add("heading", label, landmark);
1302
+ if (isEmptyState(label)) add("empty", label, landmark);
1139
1303
  continue;
1140
1304
  }
1141
1305
  if (tag === "button" || PRESSABLE.has(role) || submitLike(element)) {
@@ -1161,11 +1325,20 @@ function readRenderedFactRecords(html, options = {}) {
1161
1325
  continue;
1162
1326
  }
1163
1327
  if (ANNOUNCEMENT.has(role) || attributes["aria-live"]) {
1164
- add("notice", trim2(text(inner), LIMITS.sentence), landmark);
1328
+ const said = trim2(text(inner), LIMITS.sentence);
1329
+ add("notice", said, landmark);
1330
+ if (isEmptyState(said)) add("empty", said, landmark);
1331
+ continue;
1332
+ }
1333
+ if (TEXT_HOLDER.has(tag) && !BLOCK_INSIDE.test(inner)) {
1334
+ const said = trim2(text(inner).replace(/\s+([.,;:!])/g, "$1"), LIMITS.sentence);
1335
+ if (isEmptyState(said)) add("empty", said, landmark);
1165
1336
  }
1166
1337
  }
1167
1338
  return facts;
1168
1339
  }
1340
+ var TEXT_HOLDER = /* @__PURE__ */ new Set(["p", "span", "small", "td", "li", "div"]);
1341
+ var BLOCK_INSIDE = /<(?!\/?(?:strong|em|b|i|a|code|br|span|small)\b)[a-z]/i;
1169
1342
  var OVERLAY_ROLE = {
1170
1343
  dialog: "dialog",
1171
1344
  alertdialog: "dialog",
@@ -1517,7 +1690,7 @@ function requireDeployment(producer, input) {
1517
1690
  const value = deployment.value.trim();
1518
1691
  if (BROWSER_SHAPED.test(value) || value === input.captureEnvironment?.trim()) {
1519
1692
  throw new UnstampedObservation(
1520
- `A ${producer} observation gives "${value}" as the deployment that answered it. That names the browser that did the reading, which belongs in captureEnvironment. What is wanted here is what the application was running \u2014 a deployment id, a build id, a release SHA \u2014 or unknownDeployment("why not").`
1693
+ `A ${producer} observation gives "${value}" as the deployment that answered it. That names the browser that did the reading, which belongs in captureEnvironment. What is wanted here is what the application was running (a deployment id, a build id, a release SHA) or unknownDeployment("why not").`
1521
1694
  );
1522
1695
  }
1523
1696
  }
@@ -1566,7 +1739,7 @@ async function captureOne(options, shot, build) {
1566
1739
  status: "skipped",
1567
1740
  result: "bounced",
1568
1741
  needsAccount: true,
1569
- reason: "a signed-out visitor is shown a sign-in here, so this screen needs a demo tenant \u2014 never a real account"
1742
+ reason: "a signed-out visitor is shown a sign-in here, so this screen needs a demo tenant, never a real account"
1570
1743
  },
1571
1744
  observed: { route: shot.route, result: "bounced" }
1572
1745
  };
@@ -1594,7 +1767,9 @@ async function captureOne(options, shot, build) {
1594
1767
  };
1595
1768
  }
1596
1769
  const seen = await page.observe?.() ?? {};
1597
- const observed = { ...classifyRoute({ ...seen, route: visit, text: pageText }), route: shot.route };
1770
+ const origin = new URL(options.baseUrl).origin;
1771
+ const follow = (landed) => seen.landedOrigin === origin && options.shots.some((other) => fillsPattern(other.route, landed.split(/[?#]/)[0] ?? landed));
1772
+ const observed = { ...classifyRoute({ ...seen, route: visit, text: pageText }, { follow }), route: shot.route };
1598
1773
  if (!isPublishable(observed.result)) {
1599
1774
  return {
1600
1775
  outcome: {
@@ -1634,7 +1809,8 @@ async function captureOne(options, shot, build) {
1634
1809
  // Where and never what: a value nobody has classified is a value
1635
1810
  // nobody has established is safe to write down, and a reason that
1636
1811
  // quoted it would put it in this run's log.
1637
- 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`
1812
+ 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`,
1813
+ unaccounted: unaccounted.map((value) => ({ at: value.at, length: value.length }))
1638
1814
  },
1639
1815
  observed,
1640
1816
  ...evidence ? { seen: evidence } : {}
@@ -1750,8 +1926,8 @@ async function captureOne(options, shot, build) {
1750
1926
  result: observed.result,
1751
1927
  sha256,
1752
1928
  settled: frame.settled,
1753
- ...frame.settled ? {} : {
1754
- reason: "photographed, but this screen never stopped moving \u2014 its hash will differ every run, so drift on it means nothing"
1929
+ ...frame.settled ? observed.landed ? { reason: `followed its redirect to ${observed.landed}` } : {} : {
1930
+ reason: "photographed, but this screen never stopped moving. Its hash will differ every run, so drift on it means nothing"
1755
1931
  }
1756
1932
  } : {
1757
1933
  shotId: shot.id,
@@ -1913,11 +2089,21 @@ async function playwrightBrowser(options = {}) {
1913
2089
  return page.locator("body").ariaSnapshot().catch(() => "");
1914
2090
  },
1915
2091
  async isSignIn() {
1916
- return await page.locator('input[type="password"]').count() > 0;
2092
+ const fields = await page.evaluate(() => {
2093
+ const forms = Array.from(document.forms);
2094
+ return Array.from(document.querySelectorAll("input")).map((input) => ({
2095
+ type: (input.getAttribute("type") ?? "text").toLowerCase(),
2096
+ autocomplete: (input.getAttribute("autocomplete") ?? "").toLowerCase(),
2097
+ name: `${input.name} ${input.id}`.toLowerCase(),
2098
+ form: input.form ? forms.indexOf(input.form) : -1
2099
+ }));
2100
+ }).catch(() => []);
2101
+ return looksLikeSignIn(fields);
1917
2102
  },
1918
2103
  async observe() {
1919
2104
  return {
1920
2105
  landedOn: pathOf(page.url()),
2106
+ landedOrigin: new URL(page.url()).origin,
1921
2107
  ...status === void 0 ? {} : { status },
1922
2108
  errors: [...errors],
1923
2109
  consoleErrors: [...consoleErrors],
@@ -2434,7 +2620,7 @@ async function stageAgainstRecording(options) {
2434
2620
  died = code ?? 1;
2435
2621
  });
2436
2622
  } else {
2437
- options.onLog?.(`The recording is at ${replay.url} \u2014 start your product with ${variable} set to it.`);
2623
+ options.onLog?.(`The recording is at ${replay.url}. Start your product with ${variable} set to it.`);
2438
2624
  }
2439
2625
  const up = await waitForApp(options.appUrl, Date.now() + (options.waitMs ?? 6e4), options.onLog, () => died !== void 0);
2440
2626
  if (app) await new Promise((resolve) => setTimeout(resolve, 250));
@@ -2445,7 +2631,7 @@ async function stageAgainstRecording(options) {
2445
2631
  throw new Error(
2446
2632
  `The start command exited (${died}) before the product was ready. Its output is above.` + (up ? `
2447
2633
 
2448
- Something else is already answering on ${options.appUrl}. That is almost always a server left behind by an earlier run \u2014 this will not photograph it, because it is not the product this run started.` : "")
2634
+ Something else is already answering on ${options.appUrl}. That 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.` : "")
2449
2635
  );
2450
2636
  }
2451
2637
  if (!up) {
@@ -2636,7 +2822,7 @@ function gapReport(gaps) {
2636
2822
  ""
2637
2823
  ];
2638
2824
  for (const gap of gaps) {
2639
- const broke = gap.broke.length ? ` \u2014 breaks ${gap.broke.join(", ")}` : "";
2825
+ const broke = gap.broke.length ? ` (breaks ${gap.broke.join(", ")})` : "";
2640
2826
  lines.push(` ${gap.method} ${gap.path}${broke}`);
2641
2827
  if (gap.path !== gap.example) lines.push(` for example ${gap.example}`);
2642
2828
  }
@@ -2683,7 +2869,7 @@ function coverageReport(found, total) {
2683
2869
  ...found.missing.map((route) => ` ${route}`),
2684
2870
  "",
2685
2871
  "They will not be photographed. Record again and visit them, or they stay out of the",
2686
- "plan \u2014 an empty screen photographed from a gap in the recording looks exactly like an",
2872
+ "plan: an empty screen photographed from a gap in the recording looks exactly like an",
2687
2873
  "empty screen belonging to a customer with nothing, which is how a help article ends up",
2688
2874
  "showing a product with no content in it."
2689
2875
  ];
@@ -2879,7 +3065,7 @@ function score(route, signals) {
2879
3065
  }
2880
3066
  if (hasSegment(route.path, MONEY_OR_ACCESS)) {
2881
3067
  total += 18;
2882
- because.push("money or access \u2014 asked about out of proportion to its traffic");
3068
+ because.push("money or access: asked about out of proportion to its traffic");
2883
3069
  }
2884
3070
  if (hasSegment(route.path, ENTRY_POINT)) {
2885
3071
  total += 16;
@@ -2975,6 +3161,16 @@ function buildArticlePlan(options) {
2975
3161
  function repositoryFiles(cwd) {
2976
3162
  return execFileSync("git", ["ls-files"], { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }).split("\n").filter(Boolean);
2977
3163
  }
3164
+ var WORKFLOW_ACTIONS = {
3165
+ checkout: "actions/checkout@v7",
3166
+ setupNode: "actions/setup-node@v7"
3167
+ };
3168
+ var SETUP_STEPS = ` - uses: ${WORKFLOW_ACTIONS.checkout}
3169
+ - uses: ${WORKFLOW_ACTIONS.setupNode}
3170
+ with:
3171
+ node-version: "22"
3172
+ package-manager-cache: false
3173
+ `;
2978
3174
 
2979
3175
  // core/discovery/storyboard.ts
2980
3176
  function shotIdFor(route) {
@@ -3045,7 +3241,7 @@ function parseArgs(argv) {
3045
3241
  else if (flag === "--live-clock") args.liveClock = true;
3046
3242
  else if (flag === "--allow-production") args.allowProduction = true;
3047
3243
  else if (flag === "--rebaseline" || flag === "--force") {
3048
- 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.';
3244
+ 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.';
3049
3245
  }
3050
3246
  }
3051
3247
  if (args.out) {
@@ -3210,6 +3406,10 @@ Approve one on the plan page, then run this again.`
3210
3406
  `
3211
3407
  );
3212
3408
  const runId = local ? void 0 : await openRun(parsed.to, headers, shots.length);
3409
+ let told = Promise.resolve();
3410
+ const tell = (body) => {
3411
+ told = told.then(() => tellHelpDesk(parsed.to, headers, body));
3412
+ };
3213
3413
  try {
3214
3414
  const report = await runCapture({
3215
3415
  baseUrl,
@@ -3242,14 +3442,19 @@ Approve one on the plan page, then run this again.`
3242
3442
  role: parsed.session ? unknownRole() : noRole("this run was signed out"),
3243
3443
  onProgress: (outcome, index, total) => {
3244
3444
  const mark = { captured: "\u2713", unchanged: "=", skipped: "\u2013", refused: "\u2717", failed: "!" }[outcome.status];
3245
- console.log(`${mark} ${index}/${total} ${outcome.route}${outcome.reason ? ` \u2014 ${outcome.reason}` : ""}`);
3445
+ console.log(`${mark} ${index}/${total} ${outcome.route}${outcome.reason ? `: ${outcome.reason}` : ""}`);
3246
3446
  if (runId) {
3247
- void tellHelpDesk(parsed.to, headers, {
3447
+ tell({
3248
3448
  action: "progress",
3249
3449
  jobId: runId,
3250
3450
  item: outcome.route,
3451
+ // Kept for a help desk that predates `outcome`, which folds the
3452
+ // five into three and cannot tell a refusal from a skip.
3251
3453
  status: outcome.status === "captured" || outcome.status === "unchanged" ? "ok" : outcome.status === "failed" ? "failed" : "skipped",
3252
- ...outcome.reason ? { note: outcome.reason } : {}
3454
+ outcome: outcome.status,
3455
+ ...outcome.reason ? { note: outcome.reason } : {},
3456
+ // So Setup can ask about them, once each. See `core/capture/unclassified.ts`.
3457
+ ...outcome.unaccounted?.length ? { unaccounted: outcome.unaccounted } : {}
3253
3458
  });
3254
3459
  }
3255
3460
  },
@@ -3299,10 +3504,15 @@ Approve one on the plan page, then run this again.`
3299
3504
  return { ok: false, error: body.error ?? `the help desk answered ${response.status}` };
3300
3505
  }
3301
3506
  });
3302
- console.log(
3303
- `
3304
- ${report.captured} photographed, ${report.unchanged} unchanged, ${report.skipped} skipped, ${report.refused} refused, ${report.failed} failed.`
3507
+ console.log(`
3508
+ ${runCounts(report)}.`);
3509
+ const unclassified = unclassifiedLines(
3510
+ groupUnclassified(
3511
+ report.outcomes.flatMap((outcome) => outcome.unaccounted?.length ? [{ route: outcome.route, values: outcome.unaccounted }] : [])
3512
+ )
3305
3513
  );
3514
+ if (unclassified.length) console.log(`
3515
+ ${unclassified.join("\n")}`);
3306
3516
  if (local) {
3307
3517
  const kept = new Map(frames.map((frame) => [frame.id, frame]));
3308
3518
  for (const shot of shots) {
@@ -3360,7 +3570,7 @@ whether they changed. Usually a spinner that never resolves or a looping animati
3360
3570
  const gated = report.outcomes.filter((outcome) => outcome.needsAccount);
3361
3571
  if (gated.length) {
3362
3572
  console.log(
3363
- stage ? "\nSome screens still showed a sign-in. A recording answers the backend, but it cannot\ngive the product a session \u2014 record the sign-in itself, or save one against the replayed\nproduct and pass it with --session." : parsed.session ? "\nSome screens still showed a sign-in. The saved session may have expired \u2014 run `sign-in` again." : `
3573
+ stage ? "\nSome screens still showed a sign-in. A recording answers the backend, but it cannot\ngive the product a session: record the sign-in itself, or save one against the replayed\nproduct and pass it with --session." : parsed.session ? "\nSome screens still showed a sign-in. The saved session may have expired. Run `sign-in` again." : `
3364
3574
  Some screens are behind your sign-in. To photograph them, save a session against a
3365
3575
  DEMO tenant and pass it back:
3366
3576
 
@@ -3387,16 +3597,32 @@ ${attestation({
3387
3597
  console.log(`
3388
3598
  ${gapReport(gapsFrom([...stage.replay.misses])).join("\n")}`);
3389
3599
  }
3600
+ const counts = {
3601
+ screens: shots.length,
3602
+ captured: report.captured,
3603
+ unchanged: report.unchanged,
3604
+ skipped: report.skipped,
3605
+ refused: report.refused,
3606
+ failed: report.failed
3607
+ };
3390
3608
  if (runId) {
3609
+ await told;
3391
3610
  await tellHelpDesk(parsed.to, headers, {
3392
3611
  action: "finish",
3393
3612
  jobId: runId,
3613
+ // What the verdict below prints, so Setup can say what this log says.
3614
+ counts,
3394
3615
  ...report.survey.length ? { survey: report.survey } : {}
3395
3616
  });
3396
3617
  }
3397
- return report.failed ? 1 : 0;
3618
+ const verdict = runVerdict(counts);
3619
+ if (verdict.sentence) console.log(`
3620
+ ${verdict.sentence}`);
3621
+ if (verdict.annotation && process.env["GITHUB_ACTIONS"] === "true") console.log(verdict.annotation);
3622
+ return verdict.exit;
3398
3623
  } catch (error) {
3399
3624
  if (runId) {
3625
+ await told;
3400
3626
  await tellHelpDesk(parsed.to, headers, {
3401
3627
  action: "finish",
3402
3628
  jobId: runId,
@@ -3411,11 +3637,12 @@ ${gapReport(gapsFrom([...stage.replay.misses])).join("\n")}`);
3411
3637
  }
3412
3638
  }
3413
3639
  async function openRun(to, headers, total) {
3640
+ const run = runIdentityFromEnv(process.env);
3414
3641
  try {
3415
3642
  const response = await fetch(`${to}/api/helpdesk/captures`, {
3416
3643
  method: "POST",
3417
3644
  headers: { ...headers, "content-type": "application/json" },
3418
- body: JSON.stringify({ action: "start", total })
3645
+ body: JSON.stringify({ action: "start", total, ...run ? { run } : {} })
3419
3646
  });
3420
3647
  if (!response.ok) return void 0;
3421
3648
  const body = await response.json();
@@ -3445,7 +3672,7 @@ async function signIn(argv) {
3445
3672
  return 2;
3446
3673
  }
3447
3674
  console.log(
3448
- `Opening ${baseUrl}. Sign in as your DEMO tenant \u2014 never a real customer.
3675
+ `Opening ${baseUrl}. Sign in as your DEMO tenant, never as a real customer.
3449
3676
  This window is yours: use SSO, a one-time code, whatever you normally do.
3450
3677
  The session will be saved to ${sessionPath} and sent nowhere.
3451
3678
  `