@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.
- package/helpdesk-capture.mjs +325 -33
- package/helpdesk-seed.mjs +14 -4
- package/helpdesk.mjs +30 -7
- package/package.json +1 -1
package/helpdesk-capture.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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:
|
|
1738
|
-
locale:
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
|
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:
|
|
3026
|
-
timezone:
|
|
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
|
-
|
|
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
|
|
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\
|
|
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
|
|
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
|
|
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 {
|
|
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 (
|
|
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
|
|
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";
|