@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.
- package/helpdesk-capture.mjs +379 -152
- package/helpdesk-seed.mjs +21 -13
- package/helpdesk.mjs +218 -24
- 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";
|
|
@@ -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("/")
|
|
170
|
-
refused.push({ route, because: "it is not a route
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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}
|
|
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
|
|
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 ? "
|
|
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
|
|
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)
|
|
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}
|
|
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
|
|
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
|
|
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
|
|
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, (
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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}
|
|
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
|
|
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 ? `
|
|
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
|
|
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
|
|
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
|
|
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 ?
|
|
3445
|
+
console.log(`${mark} ${index}/${total} ${outcome.route}${outcome.reason ? `: ${outcome.reason}` : ""}`);
|
|
3246
3446
|
if (runId) {
|
|
3247
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
`
|