@olomonhelpdesk2/helpdesk 0.1.4 → 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 +250 -118
- package/helpdesk-seed.mjs +4 -4
- package/helpdesk.mjs +19 -5
- package/package.json +1 -1
package/helpdesk-capture.mjs
CHANGED
|
@@ -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";
|
|
@@ -199,6 +101,108 @@ function describeMissingExample(route, needs) {
|
|
|
199
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}`;
|
|
200
102
|
}
|
|
201
103
|
|
|
104
|
+
// core/capture/contract.ts
|
|
105
|
+
var CAPTURE_CONTRACT = {
|
|
106
|
+
/** Same for every shot, so type is the same size across the whole set. */
|
|
107
|
+
viewport: { width: 1600, height: 1e3 },
|
|
108
|
+
/** Stills render at 2x → 3200x2000. */
|
|
109
|
+
stillScale: 2,
|
|
110
|
+
/** Clips at 1x; video is resampled downstream anyway. */
|
|
111
|
+
clipScale: 1,
|
|
112
|
+
/** Below this a frame is a failed shot rather than a small one. */
|
|
113
|
+
minStillWidth: 2300,
|
|
114
|
+
/**
|
|
115
|
+
* Clip length.
|
|
116
|
+
*
|
|
117
|
+
* A CAP rather than a target, and inherited from a marketing site's autoplay
|
|
118
|
+
* loop — which is not this product's constraint. A help-center clip showing a
|
|
119
|
+
* multi-step task may well want twenty seconds, so the window is the contract
|
|
120
|
+
* and the number is configurable.
|
|
121
|
+
*/
|
|
122
|
+
clipSeconds: { min: 6, target: 9, max: 10 },
|
|
123
|
+
maxClipBytes: 2 * 1024 * 1024,
|
|
124
|
+
/** Everything renders as though it were this instant. */
|
|
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",
|
|
136
|
+
/**
|
|
137
|
+
* Injected before every shot. Necessary, not sufficient — see above.
|
|
138
|
+
*/
|
|
139
|
+
motionOff: "*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important}"
|
|
140
|
+
};
|
|
141
|
+
var VISUAL_STABILITY = {
|
|
142
|
+
/** Gap between comparison frames. Short enough to be cheap, long enough to differ. */
|
|
143
|
+
intervalMs: 120,
|
|
144
|
+
/** Consecutive identical frames required. */
|
|
145
|
+
identicalFrames: 2,
|
|
146
|
+
/** Give up rather than hang; a page that never settles is a finding. */
|
|
147
|
+
timeoutMs: 1e4
|
|
148
|
+
};
|
|
149
|
+
function pngSize(bytes) {
|
|
150
|
+
const SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10];
|
|
151
|
+
if (bytes.length < 24) return void 0;
|
|
152
|
+
if (SIGNATURE.some((byte, index) => bytes[index] !== byte)) return void 0;
|
|
153
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
154
|
+
return { width: view.getUint32(16), height: view.getUint32(20) };
|
|
155
|
+
}
|
|
156
|
+
var RECORDING_MISS_STATUS = 599;
|
|
157
|
+
function isPublishable(result) {
|
|
158
|
+
return result === "ok" || result === "empty-state";
|
|
159
|
+
}
|
|
160
|
+
function actionFor(outcome) {
|
|
161
|
+
switch (outcome.result) {
|
|
162
|
+
case "ok":
|
|
163
|
+
case "empty-state":
|
|
164
|
+
case "canonical-redirect":
|
|
165
|
+
return void 0;
|
|
166
|
+
case "not-found": {
|
|
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);
|
|
172
|
+
return `Create one ${noun} in your test environment, then run this again.`;
|
|
173
|
+
}
|
|
174
|
+
case "thin":
|
|
175
|
+
return `Nothing rendered on ${outcome.route}. If it needs data, add some; if it is not a real screen, we will leave it out.`;
|
|
176
|
+
case "bounced":
|
|
177
|
+
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.`;
|
|
178
|
+
case "crashed":
|
|
179
|
+
return `${outcome.route} hit an error${outcome.error ? `: ${outcome.error}` : ""}. This is a bug in the app rather than in the setup.`;
|
|
180
|
+
case "unrecorded":
|
|
181
|
+
return `${outcome.route} asked for ${outcome.missingEndpoint ?? "data"}, which is not in the recording. Visit this screen while recording, then run this again.`;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
function subjectOf(path3) {
|
|
185
|
+
const segments = path3.split("/").filter(Boolean).filter((segment) => !/^(api|v\d+)$/.test(segment));
|
|
186
|
+
while (segments.length > 1 && looksLikeAnId(segments[segments.length - 1])) segments.pop();
|
|
187
|
+
const last = segments[segments.length - 1] ?? "record";
|
|
188
|
+
return last.replace(/[-_]/g, " ").replace(/s$/, "");
|
|
189
|
+
}
|
|
190
|
+
function looksLikeAnId(segment) {
|
|
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
|
|
192
|
+
// in it. Deliberately narrow — `sign-up` must survive this.
|
|
193
|
+
/^[a-z]{2,}[_-][a-z0-9]*\d[a-z0-9]*$/i.test(segment) || /^[0-9a-z]{16,}$/i.test(segment);
|
|
194
|
+
}
|
|
195
|
+
function captureReport(outcomes) {
|
|
196
|
+
const ready = outcomes.filter((outcome) => isPublishable(outcome.result));
|
|
197
|
+
const actionable = outcomes.map((outcome) => ({ outcome, action: actionFor(outcome) })).filter((entry) => Boolean(entry.action));
|
|
198
|
+
const lines = [
|
|
199
|
+
`${ready.length} screens ready \xB7 ${actionable.length} need attention`,
|
|
200
|
+
"",
|
|
201
|
+
...actionable.map(({ outcome, action }) => ` ${outcome.route.padEnd(30)} ${action}`)
|
|
202
|
+
];
|
|
203
|
+
return { ready: ready.length, needData: actionable.length, lines };
|
|
204
|
+
}
|
|
205
|
+
|
|
202
206
|
// core/cli-package.ts
|
|
203
207
|
var HELPDESK_CLI_PACKAGE = "@olomonhelpdesk2/helpdesk";
|
|
204
208
|
function helpdeskCommand(command, args = []) {
|
|
@@ -234,7 +238,7 @@ var EMPTY_STATE_PHRASES = [
|
|
|
234
238
|
/\bno results\b/i,
|
|
235
239
|
/\bthis (list|table) is empty\b/i
|
|
236
240
|
];
|
|
237
|
-
function classifyRoute(observation) {
|
|
241
|
+
function classifyRoute(observation, options = {}) {
|
|
238
242
|
const text2 = observation.text.trim();
|
|
239
243
|
const unrecorded = observation.failedRequests?.find((request) => request.status === RECORDING_MISS_STATUS);
|
|
240
244
|
if (unrecorded) {
|
|
@@ -244,21 +248,26 @@ function classifyRoute(observation) {
|
|
|
244
248
|
if (crash) return { route: observation.route, result: "crashed", error: crash };
|
|
245
249
|
const missing = missingBecause(observation, text2);
|
|
246
250
|
if (missing) return { route: observation.route, result: "not-found", missingEndpoint: missing };
|
|
251
|
+
let followed;
|
|
247
252
|
if (observation.landedOn && !samePlace(observation.route, observation.landedOn)) {
|
|
248
253
|
const canonical = canonicalisation(observation.route, observation.landedOn);
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
+
}
|
|
256
265
|
}
|
|
257
266
|
if (EMPTY_STATE_PHRASES.some((phrase) => phrase.test(text2))) {
|
|
258
|
-
return { route: observation.route, result: "empty-state" };
|
|
267
|
+
return { route: observation.route, result: "empty-state", ...followed };
|
|
259
268
|
}
|
|
260
|
-
if (text2.length < THIN_TEXT_CHARS) return { route: observation.route, result: "thin" };
|
|
261
|
-
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 };
|
|
262
271
|
}
|
|
263
272
|
function crashedBecause(observation, text2) {
|
|
264
273
|
const uncaught = observation.errors?.[0];
|
|
@@ -416,6 +425,82 @@ function attestation(run) {
|
|
|
416
425
|
].filter(Boolean).join("\n");
|
|
417
426
|
}
|
|
418
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
|
+
|
|
419
504
|
// adapters/capture-cli.ts
|
|
420
505
|
import { createRequire } from "node:module";
|
|
421
506
|
import path from "node:path";
|
|
@@ -550,7 +635,7 @@ var PATTERNS = [
|
|
|
550
635
|
{
|
|
551
636
|
kind: "passport",
|
|
552
637
|
regex: /\b[A-Z]{1,2}\d{6,9}\b/g,
|
|
553
|
-
because: "the shape of a passport or
|
|
638
|
+
because: "the shape of a passport or license number",
|
|
554
639
|
replace: (match) => `${match.slice(0, 1)}${"0".repeat(match.length - 1)}`
|
|
555
640
|
},
|
|
556
641
|
{
|
|
@@ -616,6 +701,20 @@ function gateFrame(pageText, options = {}) {
|
|
|
616
701
|
};
|
|
617
702
|
}
|
|
618
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
|
+
|
|
619
718
|
// core/capture/publication/pixels.ts
|
|
620
719
|
import { deflateSync, inflateSync } from "node:zlib";
|
|
621
720
|
var MediaRefused = class extends Error {
|
|
@@ -649,7 +748,7 @@ function decodePng(buffer) {
|
|
|
649
748
|
offset += 12 + length;
|
|
650
749
|
}
|
|
651
750
|
if (bitDepth !== 8 || colourType !== 2 && colourType !== 6) {
|
|
652
|
-
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`);
|
|
653
752
|
}
|
|
654
753
|
const channels = colourType === 6 ? 4 : 3;
|
|
655
754
|
const raw = inflateSync(Buffer.concat(data));
|
|
@@ -1594,7 +1693,9 @@ async function captureOne(options, shot, build) {
|
|
|
1594
1693
|
};
|
|
1595
1694
|
}
|
|
1596
1695
|
const seen = await page.observe?.() ?? {};
|
|
1597
|
-
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 };
|
|
1598
1699
|
if (!isPublishable(observed.result)) {
|
|
1599
1700
|
return {
|
|
1600
1701
|
outcome: {
|
|
@@ -1634,7 +1735,8 @@ async function captureOne(options, shot, build) {
|
|
|
1634
1735
|
// Where and never what: a value nobody has classified is a value
|
|
1635
1736
|
// nobody has established is safe to write down, and a reason that
|
|
1636
1737
|
// 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
|
|
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 }))
|
|
1638
1740
|
},
|
|
1639
1741
|
observed,
|
|
1640
1742
|
...evidence ? { seen: evidence } : {}
|
|
@@ -1750,7 +1852,7 @@ async function captureOne(options, shot, build) {
|
|
|
1750
1852
|
result: observed.result,
|
|
1751
1853
|
sha256,
|
|
1752
1854
|
settled: frame.settled,
|
|
1753
|
-
...frame.settled ? {} : {
|
|
1855
|
+
...frame.settled ? observed.landed ? { reason: `followed its redirect to ${observed.landed}` } : {} : {
|
|
1754
1856
|
reason: "photographed, but this screen never stopped moving \u2014 its hash will differ every run, so drift on it means nothing"
|
|
1755
1857
|
}
|
|
1756
1858
|
} : {
|
|
@@ -1913,11 +2015,21 @@ async function playwrightBrowser(options = {}) {
|
|
|
1913
2015
|
return page.locator("body").ariaSnapshot().catch(() => "");
|
|
1914
2016
|
},
|
|
1915
2017
|
async isSignIn() {
|
|
1916
|
-
|
|
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);
|
|
1917
2028
|
},
|
|
1918
2029
|
async observe() {
|
|
1919
2030
|
return {
|
|
1920
2031
|
landedOn: pathOf(page.url()),
|
|
2032
|
+
landedOrigin: new URL(page.url()).origin,
|
|
1921
2033
|
...status === void 0 ? {} : { status },
|
|
1922
2034
|
errors: [...errors],
|
|
1923
2035
|
consoleErrors: [...consoleErrors],
|
|
@@ -3045,7 +3157,7 @@ function parseArgs(argv) {
|
|
|
3045
3157
|
else if (flag === "--live-clock") args.liveClock = true;
|
|
3046
3158
|
else if (flag === "--allow-production") args.allowProduction = true;
|
|
3047
3159
|
else if (flag === "--rebaseline" || flag === "--force") {
|
|
3048
|
-
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.';
|
|
3049
3161
|
}
|
|
3050
3162
|
}
|
|
3051
3163
|
if (args.out) {
|
|
@@ -3249,7 +3361,9 @@ Approve one on the plan page, then run this again.`
|
|
|
3249
3361
|
jobId: runId,
|
|
3250
3362
|
item: outcome.route,
|
|
3251
3363
|
status: outcome.status === "captured" || outcome.status === "unchanged" ? "ok" : outcome.status === "failed" ? "failed" : "skipped",
|
|
3252
|
-
...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 } : {}
|
|
3253
3367
|
});
|
|
3254
3368
|
}
|
|
3255
3369
|
},
|
|
@@ -3303,6 +3417,13 @@ Approve one on the plan page, then run this again.`
|
|
|
3303
3417
|
`
|
|
3304
3418
|
${report.captured} photographed, ${report.unchanged} unchanged, ${report.skipped} skipped, ${report.refused} refused, ${report.failed} failed.`
|
|
3305
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")}`);
|
|
3306
3427
|
if (local) {
|
|
3307
3428
|
const kept = new Map(frames.map((frame) => [frame.id, frame]));
|
|
3308
3429
|
for (const shot of shots) {
|
|
@@ -3394,7 +3515,18 @@ ${gapReport(gapsFrom([...stage.replay.misses])).join("\n")}`);
|
|
|
3394
3515
|
...report.survey.length ? { survey: report.survey } : {}
|
|
3395
3516
|
});
|
|
3396
3517
|
}
|
|
3397
|
-
|
|
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;
|
|
3398
3530
|
} catch (error) {
|
|
3399
3531
|
if (runId) {
|
|
3400
3532
|
await tellHelpDesk(parsed.to, headers, {
|
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
|
{
|
|
@@ -1270,7 +1270,7 @@ ${carriedReport(sealed.carried, args.cassette).join("\n")}`);
|
|
|
1270
1270
|
console.log(`
|
|
1271
1271
|
${describeScrub({ scrubbed: "", findings: sealed.findings, needsReview: true })}`);
|
|
1272
1272
|
console.log(
|
|
1273
|
-
"\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."
|
|
1274
1274
|
);
|
|
1275
1275
|
return 1;
|
|
1276
1276
|
}
|
|
@@ -1278,7 +1278,7 @@ ${describeScrub({ scrubbed: "", findings: sealed.findings, needsReview: true })}
|
|
|
1278
1278
|
return 0;
|
|
1279
1279
|
}
|
|
1280
1280
|
function carriedReport(carried, cassette) {
|
|
1281
|
-
if (!carried.length) return ["Every key in the recording was
|
|
1281
|
+
if (!carried.length) return ["Every key in the recording was recognized and rewritten."];
|
|
1282
1282
|
const ranked = [...carried].sort((a, b) => score(b) - score(a) || b.count - a.count);
|
|
1283
1283
|
const width = Math.max(...ranked.map((field) => field.key.length));
|
|
1284
1284
|
const worth = ranked.filter((field) => score(field) > 0);
|
|
@@ -1380,7 +1380,7 @@ async function routes(args) {
|
|
|
1380
1380
|
const cassette = parseCassette(await readFile(args.cassette, "utf8"));
|
|
1381
1381
|
const found = exampleRoutes(cassette);
|
|
1382
1382
|
if (!found.length) {
|
|
1383
|
-
console.log("The recording holds no
|
|
1383
|
+
console.log("The recording holds no parameterized routes, so there are no example ids to offer.");
|
|
1384
1384
|
return 0;
|
|
1385
1385
|
}
|
|
1386
1386
|
console.log("Routes the recording can answer, with an identifier it has already served:\n");
|
package/helpdesk.mjs
CHANGED
|
@@ -793,6 +793,7 @@ function readProduct(routes, options) {
|
|
|
793
793
|
};
|
|
794
794
|
const documentable = routes.filter((route) => !route.skipped);
|
|
795
795
|
const total = Math.min(documentable.length, maxScreens);
|
|
796
|
+
const reading = { how: "source", commit: options.commit ?? null };
|
|
796
797
|
let done = 0;
|
|
797
798
|
let withFacts = 0;
|
|
798
799
|
const out = routes.map((route) => {
|
|
@@ -803,12 +804,15 @@ function readProduct(routes, options) {
|
|
|
803
804
|
const source = read(reached.path);
|
|
804
805
|
if (source !== void 0) files.push({ path: reached.path, contents: source, depth: reached.depth });
|
|
805
806
|
}
|
|
806
|
-
const facts = readScreenFacts(files);
|
|
807
807
|
done += 1;
|
|
808
808
|
options.onScreen?.(route, done, total);
|
|
809
|
-
if (
|
|
809
|
+
if (files.length === 0) return route;
|
|
810
|
+
const facts = readScreenFacts(files);
|
|
811
|
+
if (!hasAnything(facts)) {
|
|
812
|
+
return { ...route, facts: noScreenFacts(reading) };
|
|
813
|
+
}
|
|
810
814
|
withFacts += 1;
|
|
811
|
-
return { ...route, facts };
|
|
815
|
+
return { ...route, facts: { ...facts, read: reading } };
|
|
812
816
|
});
|
|
813
817
|
return { routes: out, read: withFacts, filesOpened: opened.size };
|
|
814
818
|
}
|
|
@@ -937,7 +941,7 @@ function markersPullRequestBody(result) {
|
|
|
937
941
|
const lines = [
|
|
938
942
|
"This adds one-line comments that link screens in this repository to the help articles that describe them.",
|
|
939
943
|
"",
|
|
940
|
-
"**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.",
|
|
941
945
|
"",
|
|
942
946
|
`${result.proposals.length} marker${result.proposals.length === 1 ? "" : "s"} proposed.`
|
|
943
947
|
];
|
|
@@ -1000,7 +1004,10 @@ async function runMap(options) {
|
|
|
1000
1004
|
}
|
|
1001
1005
|
},
|
|
1002
1006
|
aliases: aliasesFromTsconfig(tsconfig),
|
|
1003
|
-
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)
|
|
1004
1011
|
});
|
|
1005
1012
|
routes = read.routes;
|
|
1006
1013
|
const reading = describeReading(routes);
|
|
@@ -1075,6 +1082,13 @@ function originOf(cwd) {
|
|
|
1075
1082
|
return void 0;
|
|
1076
1083
|
}
|
|
1077
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
|
+
}
|
|
1078
1092
|
|
|
1079
1093
|
// adapters/invoked-as-command.ts
|
|
1080
1094
|
import { realpathSync } from "node:fs";
|