@olomonhelpdesk2/helpdesk 0.1.0
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/README.md +25 -0
- package/helpdesk-capture.mjs +3275 -0
- package/helpdesk-seed.mjs +1373 -0
- package/helpdesk.mjs +1097 -0
- package/package.json +22 -0
|
@@ -0,0 +1,3275 @@
|
|
|
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
|
+
* Injected before every shot. Necessary, not sufficient — see above.
|
|
26
|
+
*/
|
|
27
|
+
motionOff: "*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important}"
|
|
28
|
+
};
|
|
29
|
+
var VISUAL_STABILITY = {
|
|
30
|
+
/** Gap between comparison frames. Short enough to be cheap, long enough to differ. */
|
|
31
|
+
intervalMs: 120,
|
|
32
|
+
/** Consecutive identical frames required. */
|
|
33
|
+
identicalFrames: 2,
|
|
34
|
+
/** Give up rather than hang; a page that never settles is a finding. */
|
|
35
|
+
timeoutMs: 1e4
|
|
36
|
+
};
|
|
37
|
+
function pngSize(bytes) {
|
|
38
|
+
const SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10];
|
|
39
|
+
if (bytes.length < 24) return void 0;
|
|
40
|
+
if (SIGNATURE.some((byte, index) => bytes[index] !== byte)) return void 0;
|
|
41
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
42
|
+
return { width: view.getUint32(16), height: view.getUint32(20) };
|
|
43
|
+
}
|
|
44
|
+
var RECORDING_MISS_STATUS = 599;
|
|
45
|
+
function isPublishable(result) {
|
|
46
|
+
return result === "ok" || result === "empty-state";
|
|
47
|
+
}
|
|
48
|
+
function actionFor(outcome) {
|
|
49
|
+
switch (outcome.result) {
|
|
50
|
+
case "ok":
|
|
51
|
+
case "empty-state":
|
|
52
|
+
case "canonical-redirect":
|
|
53
|
+
return void 0;
|
|
54
|
+
case "not-found": {
|
|
55
|
+
const noun = subjectOf(outcome.missingEndpoint ?? outcome.route);
|
|
56
|
+
return `Create one ${noun} in your test environment, then run this again.`;
|
|
57
|
+
}
|
|
58
|
+
case "thin":
|
|
59
|
+
return `Nothing rendered on ${outcome.route}. If it needs data, add some; if it is not a real screen, we will leave it out.`;
|
|
60
|
+
case "bounced":
|
|
61
|
+
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.`;
|
|
62
|
+
case "crashed":
|
|
63
|
+
return `${outcome.route} hit an error${outcome.error ? `: ${outcome.error}` : ""}. This is a bug in the app rather than in the setup.`;
|
|
64
|
+
case "unrecorded":
|
|
65
|
+
return `${outcome.route} asked for ${outcome.missingEndpoint ?? "data"}, which is not in the recording. Visit this screen while recording, then run this again.`;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function subjectOf(path2) {
|
|
69
|
+
const segments = path2.split("/").filter(Boolean).filter((segment) => !/^(api|v\d+)$/.test(segment));
|
|
70
|
+
while (segments.length > 1 && looksLikeAnId(segments[segments.length - 1])) segments.pop();
|
|
71
|
+
const last = segments[segments.length - 1] ?? "record";
|
|
72
|
+
return last.replace(/[-_]/g, " ").replace(/s$/, "");
|
|
73
|
+
}
|
|
74
|
+
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
|
|
76
|
+
// in it. Deliberately narrow — `sign-up` must survive this.
|
|
77
|
+
/^[a-z]{2,}[_-][a-z0-9]*\d[a-z0-9]*$/i.test(segment) || /^[0-9a-z]{16,}$/i.test(segment);
|
|
78
|
+
}
|
|
79
|
+
function captureReport(outcomes) {
|
|
80
|
+
const ready = outcomes.filter((outcome) => isPublishable(outcome.result));
|
|
81
|
+
const actionable = outcomes.map((outcome) => ({ outcome, action: actionFor(outcome) })).filter((entry) => Boolean(entry.action));
|
|
82
|
+
const lines = [
|
|
83
|
+
`${ready.length} screens ready \xB7 ${actionable.length} need attention`,
|
|
84
|
+
"",
|
|
85
|
+
...actionable.map(({ outcome, action }) => ` ${outcome.route.padEnd(30)} ${action}`)
|
|
86
|
+
];
|
|
87
|
+
return { ready: ready.length, needData: actionable.length, lines };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// core/cli-package.ts
|
|
91
|
+
var HELPDESK_CLI_PACKAGE = "@olomonhelpdesk2/helpdesk";
|
|
92
|
+
function helpdeskCommand(command, args = []) {
|
|
93
|
+
const parts = ["npx", "--yes", "-p", HELPDESK_CLI_PACKAGE, command, ...args];
|
|
94
|
+
return parts.join(" ");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// core/capture/classify.ts
|
|
98
|
+
var THIN_TEXT_CHARS = 25;
|
|
99
|
+
var BOUNDARY_PHRASES = [
|
|
100
|
+
/\bsomething went wrong\b/i,
|
|
101
|
+
/\bapplication error\b/i,
|
|
102
|
+
/\bunhandled (runtime )?error\b/i,
|
|
103
|
+
/\bunexpected error\b/i,
|
|
104
|
+
/\binternal server error\b/i,
|
|
105
|
+
/\bthis page (could not|couldn'?t) be loaded\b/i,
|
|
106
|
+
/\bwe('| a)re sorry[ —,-]+something\b/i,
|
|
107
|
+
/\berror (id|digest)\b/i
|
|
108
|
+
];
|
|
109
|
+
var NOT_FOUND_PHRASES = [
|
|
110
|
+
/\b(page|it) (was )?not found\b/i,
|
|
111
|
+
/\b404\b/,
|
|
112
|
+
/\bwe (could not|couldn'?t) find\b/i,
|
|
113
|
+
/\bno longer exists\b/i,
|
|
114
|
+
/\bdoes not exist\b/i
|
|
115
|
+
];
|
|
116
|
+
var EMPTY_STATE_PHRASES = [
|
|
117
|
+
/\bno [a-z ]{0,20}(yet|here)\b/i,
|
|
118
|
+
/\bnothing (to show|here|yet)\b/i,
|
|
119
|
+
/\byou (have not|haven'?t|do not have|don'?t have)\b/i,
|
|
120
|
+
/\b(create|add|import) your first\b/i,
|
|
121
|
+
/\bget started by\b/i,
|
|
122
|
+
/\bno results\b/i,
|
|
123
|
+
/\bthis (list|table) is empty\b/i
|
|
124
|
+
];
|
|
125
|
+
function classifyRoute(observation) {
|
|
126
|
+
const text2 = observation.text.trim();
|
|
127
|
+
const unrecorded = observation.failedRequests?.find((request) => request.status === RECORDING_MISS_STATUS);
|
|
128
|
+
if (unrecorded) {
|
|
129
|
+
return { route: observation.route, result: "unrecorded", missingEndpoint: unrecorded.url };
|
|
130
|
+
}
|
|
131
|
+
const crash = crashedBecause(observation, text2);
|
|
132
|
+
if (crash) return { route: observation.route, result: "crashed", error: crash };
|
|
133
|
+
const missing = missingBecause(observation, text2);
|
|
134
|
+
if (missing) return { route: observation.route, result: "not-found", missingEndpoint: missing };
|
|
135
|
+
if (observation.landedOn && !samePlace(observation.route, observation.landedOn)) {
|
|
136
|
+
const canonical = canonicalisation(observation.route, observation.landedOn);
|
|
137
|
+
return {
|
|
138
|
+
route: observation.route,
|
|
139
|
+
result: canonical ? "canonical-redirect" : "bounced",
|
|
140
|
+
// Carried only when it is a finding. A parent handing off to its default
|
|
141
|
+
// child needs no row, so it needs no detail either.
|
|
142
|
+
...canonical ? {} : { landed: observation.landedOn }
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
if (EMPTY_STATE_PHRASES.some((phrase) => phrase.test(text2))) {
|
|
146
|
+
return { route: observation.route, result: "empty-state" };
|
|
147
|
+
}
|
|
148
|
+
if (text2.length < THIN_TEXT_CHARS) return { route: observation.route, result: "thin" };
|
|
149
|
+
return { route: observation.route, result: "ok" };
|
|
150
|
+
}
|
|
151
|
+
function crashedBecause(observation, text2) {
|
|
152
|
+
const uncaught = observation.errors?.[0];
|
|
153
|
+
if (uncaught) return uncaught;
|
|
154
|
+
if (observation.status !== void 0 && observation.status >= 500) {
|
|
155
|
+
return `the server answered ${observation.status}`;
|
|
156
|
+
}
|
|
157
|
+
if (BOUNDARY_PHRASES.some((phrase) => phrase.test(text2))) {
|
|
158
|
+
return observation.consoleErrors?.[0] ?? firstSentence(text2);
|
|
159
|
+
}
|
|
160
|
+
return void 0;
|
|
161
|
+
}
|
|
162
|
+
function missingBecause(observation, text2) {
|
|
163
|
+
const notFoundRequest = observation.failedRequests?.find((request) => request.status === 404);
|
|
164
|
+
if (notFoundRequest) return notFoundRequest.url;
|
|
165
|
+
if (observation.status === 404) return observation.route;
|
|
166
|
+
if (NOT_FOUND_PHRASES.some((phrase) => phrase.test(text2))) return observation.route;
|
|
167
|
+
return void 0;
|
|
168
|
+
}
|
|
169
|
+
function canonicalisation(asked, landed) {
|
|
170
|
+
return normalise(asked) === normalise(landed);
|
|
171
|
+
}
|
|
172
|
+
function samePlace(asked, landed) {
|
|
173
|
+
return stripQuery(asked) === stripQuery(landed);
|
|
174
|
+
}
|
|
175
|
+
function stripQuery(path2) {
|
|
176
|
+
return path2.split(/[?#]/)[0] ?? path2;
|
|
177
|
+
}
|
|
178
|
+
function normalise(path2) {
|
|
179
|
+
return stripQuery(path2).toLowerCase().replace(/\/index(\.html?)?$/, "/").replace(/\/+$/, "").replace(/^$/, "/");
|
|
180
|
+
}
|
|
181
|
+
function firstSentence(text2) {
|
|
182
|
+
const sentence = text2.trim().split(/(?<=[.!?])\s|\n/)[0] ?? text2;
|
|
183
|
+
return sentence.length > 160 ? `${sentence.slice(0, 157)}\u2026` : sentence;
|
|
184
|
+
}
|
|
185
|
+
function surveyLine(outcomes) {
|
|
186
|
+
const count = (result) => outcomes.filter((outcome) => outcome.result === result).length;
|
|
187
|
+
const crashed = count("crashed");
|
|
188
|
+
const parts = [
|
|
189
|
+
`${count("ok") + count("empty-state")} screens rendered`,
|
|
190
|
+
crashed ? `${crashed} crashed` : "",
|
|
191
|
+
count("not-found") ? `${count("not-found")} need data` : "",
|
|
192
|
+
count("bounced") ? `${count("bounced")} redirected away` : "",
|
|
193
|
+
count("thin") ? `${count("thin")} rendered nothing` : "",
|
|
194
|
+
// Counted apart from "need data" on purpose: the fix is to record the
|
|
195
|
+
// screen, not to go and create a record in the test environment.
|
|
196
|
+
count("unrecorded") ? `${count("unrecorded")} not in the recording` : ""
|
|
197
|
+
].filter(Boolean);
|
|
198
|
+
return parts.join(" \xB7 ");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// core/capture/preflight.ts
|
|
202
|
+
var NOT_LIVE = /(^|[.\-/])(localhost|127\.0\.0\.1|0\.0\.0\.0|staging|stage|sandbox|demo|preview|test|qa|dev|develop|development|uat)([.\-:/]|$)/i;
|
|
203
|
+
var NOT_LIVE_SUFFIX = /\.(local|test|localhost|internal|vercel\.app|netlify\.app|ngrok\.io|ngrok-free\.app)$/i;
|
|
204
|
+
function looksLive(baseUrl) {
|
|
205
|
+
let host;
|
|
206
|
+
try {
|
|
207
|
+
host = new URL(baseUrl).hostname;
|
|
208
|
+
} catch {
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
if (NOT_LIVE.test(host) || NOT_LIVE_SUFFIX.test(host)) return false;
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
function preflight(facts) {
|
|
215
|
+
const checks = [];
|
|
216
|
+
const live = looksLive(facts.baseUrl);
|
|
217
|
+
if (facts.replaying) {
|
|
218
|
+
checks.push({
|
|
219
|
+
name: "target",
|
|
220
|
+
ok: true,
|
|
221
|
+
blocking: false,
|
|
222
|
+
detail: `${facts.baseUrl}, reading from ${facts.replaying} \u2014 no live backend is reachable from this run, so nothing on these screens can be a real person's`
|
|
223
|
+
});
|
|
224
|
+
} else if (facts.session && live && !facts.productionGranted) {
|
|
225
|
+
checks.push({
|
|
226
|
+
name: "target",
|
|
227
|
+
ok: false,
|
|
228
|
+
blocking: true,
|
|
229
|
+
detail: `${facts.baseUrl} does not look like a demo or staging address, and this run has a session. A help article shows the typical state of a screen, not somebody's data \u2014 point this at a demo tenant, or pass --allow-production if that address really is the seeded one.`
|
|
230
|
+
});
|
|
231
|
+
} else {
|
|
232
|
+
checks.push({
|
|
233
|
+
name: "target",
|
|
234
|
+
ok: true,
|
|
235
|
+
blocking: false,
|
|
236
|
+
detail: facts.session ? `${facts.baseUrl}, signed in${live ? " \u2014 accepted as seeded by --allow-production" : " as a demo tenant"}` : `${facts.baseUrl}, signed in as nobody`
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
checks.push({
|
|
240
|
+
name: "browser",
|
|
241
|
+
ok: Boolean(facts.browser),
|
|
242
|
+
blocking: false,
|
|
243
|
+
detail: facts.browser ? `${facts.browser}, recorded on every frame` : "not identified \u2014 a browser upgrade will look like the product changing"
|
|
244
|
+
});
|
|
245
|
+
checks.push({
|
|
246
|
+
name: "clock",
|
|
247
|
+
ok: facts.clockPinned,
|
|
248
|
+
blocking: false,
|
|
249
|
+
detail: facts.clockPinned ? "pinned, with randomness and generated ids seeded from it" : "running live (--live-clock) \u2014 screens showing a date will differ every run, so nothing here can tell you whether they changed"
|
|
250
|
+
});
|
|
251
|
+
checks.push({
|
|
252
|
+
name: "locale",
|
|
253
|
+
ok: true,
|
|
254
|
+
blocking: false,
|
|
255
|
+
detail: `${facts.locale}, ${facts.timezone} \u2014 set explicitly rather than inherited from this machine`
|
|
256
|
+
});
|
|
257
|
+
checks.push({
|
|
258
|
+
name: "isolation",
|
|
259
|
+
ok: true,
|
|
260
|
+
blocking: false,
|
|
261
|
+
detail: "a fresh browser context per screen, so no frame depends on which frames ran before it"
|
|
262
|
+
});
|
|
263
|
+
checks.push({
|
|
264
|
+
name: "stability",
|
|
265
|
+
ok: true,
|
|
266
|
+
blocking: false,
|
|
267
|
+
detail: "the shutter waits for two identical frames; a screen that never settles is reported, not silently kept"
|
|
268
|
+
});
|
|
269
|
+
checks.push({
|
|
270
|
+
name: "posture",
|
|
271
|
+
ok: true,
|
|
272
|
+
blocking: false,
|
|
273
|
+
detail: "read-only: this navigates and photographs, and activates no control"
|
|
274
|
+
});
|
|
275
|
+
if (facts.rebaselineReason) {
|
|
276
|
+
checks.push({
|
|
277
|
+
name: "rebaseline",
|
|
278
|
+
ok: true,
|
|
279
|
+
blocking: false,
|
|
280
|
+
detail: `replacing every stored frame \u2014 "${facts.rebaselineReason}"`
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
const blocked = checks.filter((check) => check.blocking && !check.ok);
|
|
284
|
+
const lines = [
|
|
285
|
+
"Before anything is photographed:",
|
|
286
|
+
"",
|
|
287
|
+
...checks.map((check) => ` ${check.ok ? "\u2713" : "\u2717"} ${check.name.padEnd(10)} ${check.detail}`)
|
|
288
|
+
];
|
|
289
|
+
return { ok: !blocked.length, checks, lines };
|
|
290
|
+
}
|
|
291
|
+
function attestation(run) {
|
|
292
|
+
const took = run.unchanged ? `${run.captured} new frame(s) and ${run.unchanged} verified unchanged` : `${run.captured} frame(s)`;
|
|
293
|
+
return [
|
|
294
|
+
`${took} from ${run.baseUrl}${run.browser ? ` with ${run.browser}` : ""}.`,
|
|
295
|
+
"Every one was checked before the shutter and again on arrival: no frame in this set showed an email address,",
|
|
296
|
+
"an account or card number, a national or tax id, a date of birth, a passport number or a named institution.",
|
|
297
|
+
run.refused ? `${run.refused} screen(s) were refused for showing something that looked real, and no picture of them exists.` : "Nothing was refused.",
|
|
298
|
+
// Two different claims, and the second is much stronger. "Nothing that
|
|
299
|
+
// looked real got through" is a gate doing its job on unknown data; "there
|
|
300
|
+
// was no real data behind any of it" is a fact about where the data came
|
|
301
|
+
// from, and it is the one a compliance reader actually wants.
|
|
302
|
+
run.replaying ? `Every screen was served from ${run.replaying}, so no customer data was reachable during this run at all.` : "",
|
|
303
|
+
run.clockPinned && run.replaying ? "The clock was pinned and the data was fixed, so re-running this reproduces the same bytes." : run.clockPinned ? "The clock was pinned. The backend was live, so any screen showing its data can still move between runs." : "The clock ran live, so these frames are not reproducible."
|
|
304
|
+
].filter(Boolean).join("\n");
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// core/capture/scrub.ts
|
|
308
|
+
var BRANDS = [
|
|
309
|
+
"Chase",
|
|
310
|
+
"JPMorgan",
|
|
311
|
+
"J.P. Morgan",
|
|
312
|
+
"Bank of America",
|
|
313
|
+
"Wells Fargo",
|
|
314
|
+
"Citibank",
|
|
315
|
+
"Citigroup",
|
|
316
|
+
"Goldman Sachs",
|
|
317
|
+
"Morgan Stanley",
|
|
318
|
+
"Schwab",
|
|
319
|
+
"Charles Schwab",
|
|
320
|
+
"Fidelity",
|
|
321
|
+
"Vanguard",
|
|
322
|
+
"BlackRock",
|
|
323
|
+
"Merrill",
|
|
324
|
+
"Merrill Lynch",
|
|
325
|
+
"TD Ameritrade",
|
|
326
|
+
"E*TRADE",
|
|
327
|
+
"Robinhood",
|
|
328
|
+
"Coinbase",
|
|
329
|
+
"American Express",
|
|
330
|
+
"Amex",
|
|
331
|
+
"Capital One",
|
|
332
|
+
"PNC",
|
|
333
|
+
"US Bank",
|
|
334
|
+
"Truist",
|
|
335
|
+
"HSBC",
|
|
336
|
+
"Barclays",
|
|
337
|
+
"Northern Trust",
|
|
338
|
+
"State Street",
|
|
339
|
+
"Pershing",
|
|
340
|
+
"LPL Financial",
|
|
341
|
+
"Raymond James",
|
|
342
|
+
"Edward Jones",
|
|
343
|
+
"Plaid",
|
|
344
|
+
"Yodlee",
|
|
345
|
+
"Addepar",
|
|
346
|
+
"Orion",
|
|
347
|
+
"Envestnet",
|
|
348
|
+
"eMoney",
|
|
349
|
+
"MoneyGuidePro"
|
|
350
|
+
];
|
|
351
|
+
var SAFE_EMAIL_DOMAIN = "example.invalid";
|
|
352
|
+
var ALREADY_SAFE = [
|
|
353
|
+
// RFC 2606 and RFC 6761 reserve these precisely so they can never resolve.
|
|
354
|
+
{ kind: "email", regex: /@[A-Za-z0-9.-]*\.(invalid|test|example|localhost)$/i },
|
|
355
|
+
{ kind: "email", regex: /@(example\.(com|net|org))$/i },
|
|
356
|
+
// 555-01xx is reserved for fiction and cannot reach a telephone.
|
|
357
|
+
{ kind: "phone", regex: /^(\+?1[-. ]?)?\(?555\)?[-. ]?01\d{2}$/ },
|
|
358
|
+
/*
|
|
359
|
+
* This module's own replacements, which it must not report a second time.
|
|
360
|
+
*
|
|
361
|
+
* Without these it is not idempotent, and idempotence is not a nicety here:
|
|
362
|
+
* a scrubbed fixture is scrubbed AGAIN on the way into a screenshot, and a
|
|
363
|
+
* zeroed account number matching the long-digit-run rule means the gate
|
|
364
|
+
* refuses a screen this module already made safe. Nothing is wrong, nothing
|
|
365
|
+
* can be done about it, and the run stops — which is the precise way a check
|
|
366
|
+
* teaches people to ignore it.
|
|
367
|
+
*
|
|
368
|
+
* No real account, card or identification number is all zeroes.
|
|
369
|
+
*/
|
|
370
|
+
{ kind: "account-number", regex: /^0+$/ },
|
|
371
|
+
{ kind: "account-number", regex: /^XX0+$/ },
|
|
372
|
+
{ kind: "national-id", regex: /^000-00-0000$/ },
|
|
373
|
+
{ kind: "national-id", regex: /^AA0{6}A$/ },
|
|
374
|
+
{ kind: "tax-id", regex: /^00-0000000$/ },
|
|
375
|
+
{ kind: "passport", regex: /^[A-Z]{1,2}0+$/ },
|
|
376
|
+
{ kind: "date-of-birth", regex: /^1970-01-01$/ }
|
|
377
|
+
];
|
|
378
|
+
function alreadySafe(kind, value) {
|
|
379
|
+
return ALREADY_SAFE.some((entry) => entry.kind === kind && entry.regex.test(value));
|
|
380
|
+
}
|
|
381
|
+
var PATTERNS = [
|
|
382
|
+
{
|
|
383
|
+
kind: "email",
|
|
384
|
+
regex: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g,
|
|
385
|
+
because: "a real address can receive mail from a fixture",
|
|
386
|
+
replace: (_match, index) => `person${index + 1}@${SAFE_EMAIL_DOMAIN}`
|
|
387
|
+
},
|
|
388
|
+
{
|
|
389
|
+
kind: "national-id",
|
|
390
|
+
// US SSN shape. Deliberately narrow: a looser rule matches dates and
|
|
391
|
+
// phone numbers, and a scrub that cries wolf gets click-throughed.
|
|
392
|
+
regex: /\b(?!000|666|9\d\d)\d{3}-(?!00)\d{2}-(?!0000)\d{4}\b/g,
|
|
393
|
+
because: "a national identification number",
|
|
394
|
+
replace: () => "000-00-0000"
|
|
395
|
+
},
|
|
396
|
+
{
|
|
397
|
+
kind: "tax-id",
|
|
398
|
+
regex: /\b\d{2}-\d{7}\b/g,
|
|
399
|
+
because: "an employer or tax identification number",
|
|
400
|
+
replace: () => "00-0000000"
|
|
401
|
+
},
|
|
402
|
+
{
|
|
403
|
+
kind: "national-id",
|
|
404
|
+
// UK National Insurance. Two letters, six digits, one suffix letter.
|
|
405
|
+
// Every product with a UK employee or patient holds one of these, which is
|
|
406
|
+
// why it sits beside the US shape rather than in a finance-specific list.
|
|
407
|
+
regex: /\b[A-CEGHJ-PR-TW-Z]{2}\s?\d{2}\s?\d{2}\s?\d{2}\s?[A-D]\b/g,
|
|
408
|
+
because: "the shape of a UK National Insurance number",
|
|
409
|
+
replace: () => "AA000000A"
|
|
410
|
+
},
|
|
411
|
+
{
|
|
412
|
+
kind: "account-number",
|
|
413
|
+
// IBAN. Checked before the bare digit run below, because an IBAN carries
|
|
414
|
+
// its country and bank in the leading letters and zeroing only the digits
|
|
415
|
+
// would leave those standing.
|
|
416
|
+
regex: /\b[A-Z]{2}\d{2}[A-Z0-9]{11,30}\b/g,
|
|
417
|
+
because: "an international bank account number",
|
|
418
|
+
replace: (match) => `XX00${"0".repeat(match.length - 4)}`
|
|
419
|
+
},
|
|
420
|
+
{
|
|
421
|
+
kind: "account-number",
|
|
422
|
+
// Long digit runs. Card numbers, account numbers, routing numbers.
|
|
423
|
+
regex: /\b\d{9,19}\b/g,
|
|
424
|
+
because: "long enough to be an account, card or routing number",
|
|
425
|
+
replace: (match) => "0".repeat(match.length)
|
|
426
|
+
},
|
|
427
|
+
{
|
|
428
|
+
kind: "date-of-birth",
|
|
429
|
+
regex: /\b(19|20)\d{2}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])\b/g,
|
|
430
|
+
because: "a full date \u2014 if it is a birth date it identifies somebody",
|
|
431
|
+
replace: () => "1970-01-01"
|
|
432
|
+
},
|
|
433
|
+
{
|
|
434
|
+
kind: "passport",
|
|
435
|
+
regex: /\b[A-Z]{1,2}\d{6,9}\b/g,
|
|
436
|
+
because: "the shape of a passport or licence number",
|
|
437
|
+
replace: (match) => `${match.slice(0, 1)}${"0".repeat(match.length - 1)}`
|
|
438
|
+
},
|
|
439
|
+
{
|
|
440
|
+
kind: "phone",
|
|
441
|
+
regex: /\b(?:\+?1[-. ]?)?\(?\d{3}\)?[-. ]\d{3}[-. ]\d{4}\b/g,
|
|
442
|
+
because: "a telephone number",
|
|
443
|
+
replace: () => "555-0100"
|
|
444
|
+
}
|
|
445
|
+
];
|
|
446
|
+
function scrub(text2, options = {}) {
|
|
447
|
+
const findings = [];
|
|
448
|
+
const where = options.where ?? "fixture";
|
|
449
|
+
let output = text2;
|
|
450
|
+
for (const pattern of PATTERNS) {
|
|
451
|
+
const seen = /* @__PURE__ */ new Map();
|
|
452
|
+
output = output.replace(pattern.regex, (match) => {
|
|
453
|
+
if (alreadySafe(pattern.kind, match)) return match;
|
|
454
|
+
const already = seen.get(match);
|
|
455
|
+
if (already) return already;
|
|
456
|
+
const replacement = pattern.replace(match, seen.size);
|
|
457
|
+
seen.set(match, replacement);
|
|
458
|
+
findings.push({ kind: pattern.kind, found: match, replacement, where, because: pattern.because });
|
|
459
|
+
return replacement;
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
for (const brand of [...BRANDS, ...options.brands ?? []]) {
|
|
463
|
+
const regex = new RegExp(`\\b${escapeRegExp(brand)}\\b`, "gi");
|
|
464
|
+
if (regex.test(output)) {
|
|
465
|
+
findings.push({
|
|
466
|
+
kind: "brand",
|
|
467
|
+
found: brand,
|
|
468
|
+
replacement: brand,
|
|
469
|
+
where,
|
|
470
|
+
because: "a real institution in a published screenshot implies a partnership \u2014 a legal review item, not a design nit"
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return { scrubbed: output, findings, needsReview: findings.length > 0 };
|
|
475
|
+
}
|
|
476
|
+
function escapeRegExp(value) {
|
|
477
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// core/capture/gate.ts
|
|
481
|
+
function gateFrame(pageText, options = {}) {
|
|
482
|
+
const result = scrub(pageText, {
|
|
483
|
+
...options.brands ? { brands: options.brands } : {},
|
|
484
|
+
...options.route ? { where: options.route } : {}
|
|
485
|
+
});
|
|
486
|
+
const expected = new Set((options.expected ?? []).map((name) => name.trim().toLowerCase()).filter(Boolean));
|
|
487
|
+
const findings = result.findings.filter(
|
|
488
|
+
(finding) => !(finding.kind === "brand" && expected.has(finding.found.trim().toLowerCase()))
|
|
489
|
+
);
|
|
490
|
+
if (!findings.length) return { safe: true, findings: [] };
|
|
491
|
+
const kinds = [...new Set(findings.map((finding) => finding.kind))];
|
|
492
|
+
const where = options.route ? ` on ${options.route}` : "";
|
|
493
|
+
const onlyBrands = findings.every((finding) => finding.kind === "brand");
|
|
494
|
+
const named = [...new Set(findings.filter((finding) => finding.kind === "brand").map((finding) => finding.found))];
|
|
495
|
+
return {
|
|
496
|
+
safe: false,
|
|
497
|
+
findings,
|
|
498
|
+
reason: onlyBrands ? `Not photographed${where}: the screen names ${named.join(", ")}. A real company in a published screenshot implies a partnership, which is a legal review item rather than a design nit \u2014 so somebody has to say whether it belongs there. If these names are on this screen on purpose, list them on the capture approval and this screen is photographed as it is.` : `Not photographed${where}: the screen showed ${kinds.join(", ")} that looks real. A screenshot cannot be redacted afterwards, so it is refused rather than cleaned \u2014 point the capture at a demo tenant, or fix the seed that produced this.`
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// core/capture/publication/pixels.ts
|
|
503
|
+
import { deflateSync, inflateSync } from "node:zlib";
|
|
504
|
+
var MediaRefused = class extends Error {
|
|
505
|
+
};
|
|
506
|
+
var PUBLICATION_REDACTION = { r: 100, g: 116, b: 139 };
|
|
507
|
+
function decodePng(buffer) {
|
|
508
|
+
if (buffer.length < 8 || buffer.readUInt32BE(0) !== 2303741511) {
|
|
509
|
+
throw new MediaRefused("that is not a PNG");
|
|
510
|
+
}
|
|
511
|
+
let offset = 8;
|
|
512
|
+
let width = 0;
|
|
513
|
+
let height = 0;
|
|
514
|
+
let colourType = 0;
|
|
515
|
+
let bitDepth = 0;
|
|
516
|
+
const data = [];
|
|
517
|
+
while (offset < buffer.length) {
|
|
518
|
+
const length = buffer.readUInt32BE(offset);
|
|
519
|
+
const type = buffer.toString("ascii", offset + 4, offset + 8);
|
|
520
|
+
const body = buffer.subarray(offset + 8, offset + 8 + length);
|
|
521
|
+
if (type === "IHDR") {
|
|
522
|
+
width = body.readUInt32BE(0);
|
|
523
|
+
height = body.readUInt32BE(4);
|
|
524
|
+
bitDepth = body.readUInt8(8);
|
|
525
|
+
colourType = body.readUInt8(9);
|
|
526
|
+
if (body.readUInt8(12) !== 0) throw new MediaRefused("interlaced PNGs are not supported here");
|
|
527
|
+
} else if (type === "IDAT") {
|
|
528
|
+
data.push(body);
|
|
529
|
+
} else if (type === "IEND") {
|
|
530
|
+
break;
|
|
531
|
+
}
|
|
532
|
+
offset += 12 + length;
|
|
533
|
+
}
|
|
534
|
+
if (bitDepth !== 8 || colourType !== 2 && colourType !== 6) {
|
|
535
|
+
throw new MediaRefused(`only 8-bit RGB and RGBA PNGs are supported here, not colour type ${colourType} at ${bitDepth} bits`);
|
|
536
|
+
}
|
|
537
|
+
const channels = colourType === 6 ? 4 : 3;
|
|
538
|
+
const raw = inflateSync(Buffer.concat(data));
|
|
539
|
+
const stride = width * channels;
|
|
540
|
+
const out = Buffer.alloc(width * height * 4);
|
|
541
|
+
const line = Buffer.alloc(stride);
|
|
542
|
+
const previous = Buffer.alloc(stride);
|
|
543
|
+
for (let y = 0; y < height; y += 1) {
|
|
544
|
+
const filter = raw[y * (stride + 1)];
|
|
545
|
+
const source = raw.subarray(y * (stride + 1) + 1, y * (stride + 1) + 1 + stride);
|
|
546
|
+
for (let index = 0; index < stride; index += 1) {
|
|
547
|
+
const x = source[index];
|
|
548
|
+
const a = index >= channels ? line[index - channels] : 0;
|
|
549
|
+
const b = previous[index];
|
|
550
|
+
const c = index >= channels ? previous[index - channels] : 0;
|
|
551
|
+
line[index] = filter === 0 ? x : filter === 1 ? x + a & 255 : filter === 2 ? x + b & 255 : filter === 3 ? x + (a + b >> 1) & 255 : x + paeth(a, b, c) & 255;
|
|
552
|
+
}
|
|
553
|
+
for (let x = 0; x < width; x += 1) {
|
|
554
|
+
const from = x * channels;
|
|
555
|
+
const to = (y * width + x) * 4;
|
|
556
|
+
out[to] = line[from];
|
|
557
|
+
out[to + 1] = line[from + 1];
|
|
558
|
+
out[to + 2] = line[from + 2];
|
|
559
|
+
out[to + 3] = channels === 4 ? line[from + 3] : 255;
|
|
560
|
+
}
|
|
561
|
+
line.copy(previous);
|
|
562
|
+
}
|
|
563
|
+
return { width, height, pixels: out };
|
|
564
|
+
}
|
|
565
|
+
function paeth(a, b, c) {
|
|
566
|
+
const p = a + b - c;
|
|
567
|
+
const pa = Math.abs(p - a);
|
|
568
|
+
const pb = Math.abs(p - b);
|
|
569
|
+
const pc = Math.abs(p - c);
|
|
570
|
+
return pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
|
|
571
|
+
}
|
|
572
|
+
function coveredFraction(image) {
|
|
573
|
+
const total = image.width * image.height;
|
|
574
|
+
if (total === 0) return 0;
|
|
575
|
+
let covered = 0;
|
|
576
|
+
for (let index = 0; index < total; index += 1) {
|
|
577
|
+
const at = index * 4;
|
|
578
|
+
if (image.pixels[at] === PUBLICATION_REDACTION.r && image.pixels[at + 1] === PUBLICATION_REDACTION.g && image.pixels[at + 2] === PUBLICATION_REDACTION.b) {
|
|
579
|
+
covered += 1;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
return covered / total;
|
|
583
|
+
}
|
|
584
|
+
var MOSTLY_COVERED = 0.5;
|
|
585
|
+
|
|
586
|
+
// browser/page-side.ts
|
|
587
|
+
function inPage(fn, arg) {
|
|
588
|
+
const shim = "(value) => value";
|
|
589
|
+
const argument = arg === void 0 ? "" : JSON.stringify(arg);
|
|
590
|
+
return `((__name) => (${fn.toString()}))(${shim})(${argument})`;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// core/transition/comparison.ts
|
|
594
|
+
var RECORD_LAYER_MAY_REACH = Object.freeze({
|
|
595
|
+
provider: false,
|
|
596
|
+
registry: true
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
// browser/media.ts
|
|
600
|
+
async function takeInventory(page, regions) {
|
|
601
|
+
const found = await page.evaluate(
|
|
602
|
+
inPage((spec) => {
|
|
603
|
+
const ignore = /* @__PURE__ */ new Set(["SCRIPT", "STYLE", "TITLE", "NOSCRIPT", "TEMPLATE", "HEAD", "META", "LINK", "OPTION"]);
|
|
604
|
+
const styleOf = (element, pseudo) => (element.ownerDocument?.defaultView ?? window).getComputedStyle(element, pseudo ?? null);
|
|
605
|
+
const rendered = (element) => {
|
|
606
|
+
const style = styleOf(element);
|
|
607
|
+
if (style.visibility === "hidden" || style.display === "none") return false;
|
|
608
|
+
for (const box of Array.from(element.getClientRects())) {
|
|
609
|
+
if (box.width > 0 && box.height > 0) return true;
|
|
610
|
+
}
|
|
611
|
+
return false;
|
|
612
|
+
};
|
|
613
|
+
const where = (element, prefix) => {
|
|
614
|
+
const steps = [];
|
|
615
|
+
for (let node = element; node && node.tagName !== "BODY" && node.tagName !== "HTML"; node = node.parentElement) {
|
|
616
|
+
if (node.id) {
|
|
617
|
+
steps.unshift(`#${node.id}`);
|
|
618
|
+
break;
|
|
619
|
+
}
|
|
620
|
+
const tag = node.tagName.toLowerCase();
|
|
621
|
+
const siblings2 = node.parentElement ? Array.from(node.parentElement.children) : [node];
|
|
622
|
+
steps.unshift(`${tag}:nth-child(${siblings2.indexOf(node) + 1})`);
|
|
623
|
+
}
|
|
624
|
+
return `${prefix}${steps.length === 0 ? "body" : steps.join(" > ")}`;
|
|
625
|
+
};
|
|
626
|
+
const accounts = (element, host) => {
|
|
627
|
+
const holding = [];
|
|
628
|
+
for (const region of spec.regions) {
|
|
629
|
+
try {
|
|
630
|
+
if (element.closest(region) || host?.closest(region)) holding.push(region);
|
|
631
|
+
} catch {
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
return holding;
|
|
635
|
+
};
|
|
636
|
+
const quoted = (content) => {
|
|
637
|
+
if (!content || content === "none" || content === "normal") return "";
|
|
638
|
+
const pattern = /"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'/g;
|
|
639
|
+
const text2 = content.replace(/url\(\s*(['"]?)[^)]*?\1\s*\)/g, " ");
|
|
640
|
+
const parts = [];
|
|
641
|
+
for (let match = pattern.exec(text2); match; match = pattern.exec(text2)) {
|
|
642
|
+
parts.push(match[1] ?? match[2] ?? "");
|
|
643
|
+
}
|
|
644
|
+
return parts.join("");
|
|
645
|
+
};
|
|
646
|
+
const values = [];
|
|
647
|
+
const walk = (root, prefix, host, depth2) => {
|
|
648
|
+
if (depth2 > 8) return;
|
|
649
|
+
const scope = root instanceof Document ? root.body : root;
|
|
650
|
+
if (!scope) return;
|
|
651
|
+
const walker = (scope.ownerDocument ?? document).createTreeWalker(scope, NodeFilter.SHOW_TEXT);
|
|
652
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
653
|
+
const text2 = (node.textContent ?? "").trim();
|
|
654
|
+
const parent = node.parentElement;
|
|
655
|
+
if (text2.length === 0 || !parent) continue;
|
|
656
|
+
if (ignore.has(parent.tagName) || parent.closest("select") || !rendered(parent)) continue;
|
|
657
|
+
values.push({ at: where(parent, prefix), length: text2.length, accountedBy: accounts(parent, host) });
|
|
658
|
+
}
|
|
659
|
+
for (const element of Array.from(root.querySelectorAll("input, textarea, select"))) {
|
|
660
|
+
const control = element;
|
|
661
|
+
const shown = control.tagName === "SELECT" ? control.selectedOptions[0]?.textContent ?? "" : (control.value ?? "") || (control.placeholder ?? "");
|
|
662
|
+
if (shown.trim().length === 0 || !rendered(control)) continue;
|
|
663
|
+
values.push({ at: where(control, prefix), length: shown.trim().length, accountedBy: accounts(control, host) });
|
|
664
|
+
}
|
|
665
|
+
for (const element of Array.from(root.querySelectorAll("*"))) {
|
|
666
|
+
if (!rendered(element)) continue;
|
|
667
|
+
for (const pseudo of ["::before", "::after"]) {
|
|
668
|
+
const text2 = quoted(styleOf(element, pseudo).content).trim();
|
|
669
|
+
if (text2.length === 0) continue;
|
|
670
|
+
values.push({
|
|
671
|
+
at: `${where(element, prefix)}${pseudo}`,
|
|
672
|
+
length: text2.length,
|
|
673
|
+
accountedBy: accounts(element, host)
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
if (element.shadowRoot) {
|
|
677
|
+
walk(element.shadowRoot, `${where(element, prefix)} >> shadow `, element, depth2 + 1);
|
|
678
|
+
}
|
|
679
|
+
const tag = element.tagName.toLowerCase();
|
|
680
|
+
if (tag === "iframe" || tag === "object" || tag === "embed") {
|
|
681
|
+
let inner = null;
|
|
682
|
+
try {
|
|
683
|
+
inner = element.contentDocument;
|
|
684
|
+
} catch {
|
|
685
|
+
inner = null;
|
|
686
|
+
}
|
|
687
|
+
if (inner) walk(inner, `${where(element, prefix)} >> frame `, element, depth2 + 1);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
};
|
|
691
|
+
walk(document, "", null, 0);
|
|
692
|
+
return values;
|
|
693
|
+
}, { regions: [...regions] })
|
|
694
|
+
);
|
|
695
|
+
const unaccounted = found.filter((value) => value.accountedBy.length === 0);
|
|
696
|
+
return {
|
|
697
|
+
observed: found.length,
|
|
698
|
+
accounted: found.length - unaccounted.length,
|
|
699
|
+
regions: [...regions],
|
|
700
|
+
unaccounted,
|
|
701
|
+
because: unaccounted.length === 0 ? `${found.length} value(s) are readable on this screen and every one of them falls inside one of the ${regions.length} region(s) the clearance enumerates` : `${unaccounted.length} of the ${found.length} readable value(s) on this screen fall outside every region the clearance enumerates`
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// core/capture/determinism.ts
|
|
706
|
+
import { createHash } from "node:crypto";
|
|
707
|
+
function freezeScript(pinnedClock, options = {}) {
|
|
708
|
+
const fixed = Date.parse(pinnedClock);
|
|
709
|
+
if (Number.isNaN(fixed)) {
|
|
710
|
+
throw new Error(`freezeScript needs a parsable instant, got ${JSON.stringify(pinnedClock)}`);
|
|
711
|
+
}
|
|
712
|
+
const seed = options.seed ?? 2654435769;
|
|
713
|
+
return `(() => {
|
|
714
|
+
const FIXED = ${fixed};
|
|
715
|
+
const RealDate = Date;
|
|
716
|
+
|
|
717
|
+
class FrozenDate extends RealDate {
|
|
718
|
+
constructor(...args) {
|
|
719
|
+
// Only the no-argument form is the clock. \`new Date(x)\` is arithmetic
|
|
720
|
+
// on a value the page already had, and rewriting it would corrupt real
|
|
721
|
+
// data rather than stabilise a render.
|
|
722
|
+
if (args.length === 0) super(FIXED);
|
|
723
|
+
else super(...args);
|
|
724
|
+
}
|
|
725
|
+
static now() { return FIXED; }
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// \`parse\` and \`UTC\` come along by static inheritance and must not be
|
|
729
|
+
// touched: they are pure functions of their arguments already.
|
|
730
|
+
FrozenDate.prototype.constructor = FrozenDate;
|
|
731
|
+
globalThis.Date = FrozenDate;
|
|
732
|
+
|
|
733
|
+
let state = ${seed} >>> 0;
|
|
734
|
+
// mulberry32: small, fast, and good enough that a seeded shuffle still looks
|
|
735
|
+
// shuffled. Cryptographic quality is the opposite of what is wanted here.
|
|
736
|
+
globalThis.Math.random = () => {
|
|
737
|
+
state = (state + 0x6d2b79f5) >>> 0;
|
|
738
|
+
let t = state;
|
|
739
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
740
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
741
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
742
|
+
};
|
|
743
|
+
|
|
744
|
+
if (globalThis.performance) {
|
|
745
|
+
let ticks = 0;
|
|
746
|
+
// Monotonic and deterministic. Frozen would deadlock anything that waits
|
|
747
|
+
// for time to pass.
|
|
748
|
+
globalThis.performance.now = () => (ticks += 16);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
if (globalThis.crypto && globalThis.crypto.randomUUID) {
|
|
752
|
+
let issued = 0;
|
|
753
|
+
globalThis.crypto.randomUUID = () => {
|
|
754
|
+
issued += 1;
|
|
755
|
+
const n = issued.toString(16).padStart(12, "0");
|
|
756
|
+
return "00000000-0000-4000-8000-" + n;
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
})();`;
|
|
760
|
+
}
|
|
761
|
+
function frameHash(bytes) {
|
|
762
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
763
|
+
}
|
|
764
|
+
async function settle(options) {
|
|
765
|
+
const wait = options.wait ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
766
|
+
const now = options.now ?? (() => Date.now());
|
|
767
|
+
const deadline = now() + options.timeoutMs;
|
|
768
|
+
let value = await options.sample();
|
|
769
|
+
let samples = 1;
|
|
770
|
+
let matches = 1;
|
|
771
|
+
while (matches < options.identicalSamples) {
|
|
772
|
+
if (now() >= deadline) return { value, samples, settled: false };
|
|
773
|
+
await wait(options.intervalMs);
|
|
774
|
+
const next = await options.sample();
|
|
775
|
+
samples += 1;
|
|
776
|
+
matches = options.same(value, next) ? matches + 1 : 1;
|
|
777
|
+
value = next;
|
|
778
|
+
}
|
|
779
|
+
return { value, samples, settled: true };
|
|
780
|
+
}
|
|
781
|
+
function sameBytes(a, b) {
|
|
782
|
+
if (a.length !== b.length) return false;
|
|
783
|
+
for (let index = 0; index < a.length; index += 1) {
|
|
784
|
+
if (a[index] !== b[index]) return false;
|
|
785
|
+
}
|
|
786
|
+
return true;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// core/coverage/normalize.ts
|
|
790
|
+
var CURRENCY = /[$£€¥₹]\s?[\d.,]+(?:\s?[A-Z]{3})?/g;
|
|
791
|
+
var DIGITS = /\d+(?:[.,]\d+)*/g;
|
|
792
|
+
function matchKey(value) {
|
|
793
|
+
const collapsed = value.normalize("NFKC").replace(/\s+/g, " ").trim();
|
|
794
|
+
const unpunctuated = collapsed.replace(/\s*(?::|\.{3}|…)$/u, "").trim();
|
|
795
|
+
return unpunctuated.toLowerCase().replace(CURRENCY, "\xA4").replace(DIGITS, "#").replace(/\s+/g, " ").trim();
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// core/coverage/fact.ts
|
|
799
|
+
function fact(parts) {
|
|
800
|
+
return { ...parts, key: matchKey(parts.value) };
|
|
801
|
+
}
|
|
802
|
+
var CHROME_LANDMARK = /* @__PURE__ */ new Set(["nav", "header", "footer"]);
|
|
803
|
+
function scopeFromLandmark(landmark) {
|
|
804
|
+
if (CHROME_LANDMARK.has(landmark)) return "application";
|
|
805
|
+
if (landmark === "aside") return "layout";
|
|
806
|
+
return "screen";
|
|
807
|
+
}
|
|
808
|
+
var VOLATILE = /(?:[$£€¥₹]\s?[\d.,]+)|(?:\b\d[\d.,]*\s?(?:%|USD|EUR|GBP|items?|results?|days?|hours?|minutes?)\b)|(?:\b\d{1,2}[/-]\d{1,2}[/-]\d{2,4}\b)|(?:\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\s+\d{1,2}\b)/i;
|
|
809
|
+
function classifyData(value, scope, context) {
|
|
810
|
+
if (VOLATILE.test(value)) return "volatile-transactional";
|
|
811
|
+
if (context.inSource) return "product-interface";
|
|
812
|
+
if (scope !== "screen") return "product-interface";
|
|
813
|
+
return "example-instance";
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// core/discovery/screen-facts.ts
|
|
817
|
+
var LIMITS = {
|
|
818
|
+
headings: 12,
|
|
819
|
+
actions: 24,
|
|
820
|
+
links: 24,
|
|
821
|
+
fields: 24,
|
|
822
|
+
notices: 20,
|
|
823
|
+
warnings: 12,
|
|
824
|
+
choices: 12,
|
|
825
|
+
/** Longer than this is a paragraph of copy, not a label. */
|
|
826
|
+
label: 120,
|
|
827
|
+
sentence: 300
|
|
828
|
+
};
|
|
829
|
+
|
|
830
|
+
// core/discovery/aria-facts.ts
|
|
831
|
+
var LANDMARK = {
|
|
832
|
+
main: "main",
|
|
833
|
+
navigation: "nav",
|
|
834
|
+
banner: "header",
|
|
835
|
+
contentinfo: "footer",
|
|
836
|
+
complementary: "aside",
|
|
837
|
+
dialog: "dialog",
|
|
838
|
+
alertdialog: "dialog",
|
|
839
|
+
form: "form",
|
|
840
|
+
search: "form"
|
|
841
|
+
};
|
|
842
|
+
var ACTION = /* @__PURE__ */ new Set(["button", "menuitem", "menuitemcheckbox", "menuitemradio", "tab", "switch"]);
|
|
843
|
+
var FIELD = {
|
|
844
|
+
textbox: "text",
|
|
845
|
+
searchbox: "search",
|
|
846
|
+
combobox: "select",
|
|
847
|
+
listbox: "select",
|
|
848
|
+
checkbox: "checkbox",
|
|
849
|
+
radio: "radio",
|
|
850
|
+
slider: "range",
|
|
851
|
+
spinbutton: "number"
|
|
852
|
+
};
|
|
853
|
+
var NOTICE = /* @__PURE__ */ new Set(["status", "alert"]);
|
|
854
|
+
var STATE = /* @__PURE__ */ new Set(["disabled", "checked", "selected", "expanded", "readonly", "required"]);
|
|
855
|
+
function readAriaFactRecords(snapshot, options = {}) {
|
|
856
|
+
const from = options.url ?? "";
|
|
857
|
+
const inSource = options.inSource ?? /* @__PURE__ */ new Set();
|
|
858
|
+
const facts = [];
|
|
859
|
+
const add = (kind, value, node, extra = {}) => {
|
|
860
|
+
if (!value) return;
|
|
861
|
+
const scope = scopeFromLandmark(node.landmark);
|
|
862
|
+
facts.push(
|
|
863
|
+
fact({
|
|
864
|
+
kind,
|
|
865
|
+
value: trim(value, LIMITS.label),
|
|
866
|
+
channel: "p3",
|
|
867
|
+
scope,
|
|
868
|
+
dataClass: classifyData(value, scope, { inSource: inSource.has(value) }),
|
|
869
|
+
from,
|
|
870
|
+
landmark: node.landmark,
|
|
871
|
+
...extra
|
|
872
|
+
})
|
|
873
|
+
);
|
|
874
|
+
};
|
|
875
|
+
for (const node of parse(snapshot)) {
|
|
876
|
+
const state = controlState(node);
|
|
877
|
+
if (node.role === "heading") {
|
|
878
|
+
add("heading", node.name || node.text, node);
|
|
879
|
+
continue;
|
|
880
|
+
}
|
|
881
|
+
if (node.role === "link") {
|
|
882
|
+
add("link", node.name || node.text || node.url || "", node, {
|
|
883
|
+
...node.url ? { href: node.url } : {},
|
|
884
|
+
...state
|
|
885
|
+
});
|
|
886
|
+
continue;
|
|
887
|
+
}
|
|
888
|
+
if (ACTION.has(node.role)) {
|
|
889
|
+
add("action", node.name || node.text, node, state);
|
|
890
|
+
continue;
|
|
891
|
+
}
|
|
892
|
+
const fieldKind = FIELD[node.role];
|
|
893
|
+
if (fieldKind) {
|
|
894
|
+
add("field", node.name || node.text, node, {
|
|
895
|
+
fieldKind,
|
|
896
|
+
...node.choices.length ? { choices: node.choices.slice(0, LIMITS.choices) } : {},
|
|
897
|
+
...state
|
|
898
|
+
});
|
|
899
|
+
continue;
|
|
900
|
+
}
|
|
901
|
+
if (NOTICE.has(node.role)) {
|
|
902
|
+
add("notice", trim(node.text || node.name, LIMITS.sentence), node);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
return facts;
|
|
906
|
+
}
|
|
907
|
+
var LINE = /^(\s*)-\s+(.*)$/;
|
|
908
|
+
var ENTRY = /^([a-z]+)(?:\s+"((?:[^"\\]|\\.)*)")?((?:\s*\[[^\]]*\])*)\s*(?::\s*(.*))?$/;
|
|
909
|
+
function parse(snapshot) {
|
|
910
|
+
const out = [];
|
|
911
|
+
const stack = [];
|
|
912
|
+
for (const raw of snapshot.split("\n")) {
|
|
913
|
+
const line = LINE.exec(raw);
|
|
914
|
+
if (!line) continue;
|
|
915
|
+
const depth2 = line[1].length;
|
|
916
|
+
const body = line[2];
|
|
917
|
+
while (stack.length && stack[stack.length - 1].depth >= depth2) stack.pop();
|
|
918
|
+
const parent = stack[stack.length - 1]?.node;
|
|
919
|
+
if (body.startsWith("/")) {
|
|
920
|
+
const [name, ...rest] = body.split(":");
|
|
921
|
+
if (parent && name?.trim() === "/url") parent.url = rest.join(":").trim();
|
|
922
|
+
continue;
|
|
923
|
+
}
|
|
924
|
+
const entry = ENTRY.exec(body);
|
|
925
|
+
if (!entry) continue;
|
|
926
|
+
const role = entry[1];
|
|
927
|
+
const node = {
|
|
928
|
+
role,
|
|
929
|
+
name: unescape(entry[2] ?? ""),
|
|
930
|
+
text: (entry[4] ?? "").trim(),
|
|
931
|
+
attributes: attributesOf(entry[3] ?? ""),
|
|
932
|
+
landmark: LANDMARK[role] ?? parent?.landmark ?? "none",
|
|
933
|
+
choices: []
|
|
934
|
+
};
|
|
935
|
+
if (role === "option" && parent) {
|
|
936
|
+
if (node.name || node.text) parent.choices.push(node.name || node.text);
|
|
937
|
+
continue;
|
|
938
|
+
}
|
|
939
|
+
if (role !== "text") out.push(node);
|
|
940
|
+
stack.push({ depth: depth2, node });
|
|
941
|
+
}
|
|
942
|
+
return out;
|
|
943
|
+
}
|
|
944
|
+
function attributesOf(raw) {
|
|
945
|
+
const attributes = {};
|
|
946
|
+
for (const match of raw.matchAll(/\[([^\]]*)\]/g)) {
|
|
947
|
+
const [name, ...rest] = match[1].split("=");
|
|
948
|
+
const key = name?.trim();
|
|
949
|
+
if (!key) continue;
|
|
950
|
+
attributes[key] = rest.length ? rest.join("=").trim() : true;
|
|
951
|
+
}
|
|
952
|
+
return attributes;
|
|
953
|
+
}
|
|
954
|
+
function controlState(node) {
|
|
955
|
+
const state = {};
|
|
956
|
+
for (const [name, value] of Object.entries(node.attributes)) {
|
|
957
|
+
if (STATE.has(name)) state[name] = value;
|
|
958
|
+
}
|
|
959
|
+
return Object.keys(state).length ? { controlState: state } : {};
|
|
960
|
+
}
|
|
961
|
+
function unescape(value) {
|
|
962
|
+
return value.replace(/\\(.)/g, "$1");
|
|
963
|
+
}
|
|
964
|
+
function trim(value, limit) {
|
|
965
|
+
const collapsed = value.replace(/\s+/g, " ").trim();
|
|
966
|
+
return collapsed.length <= limit ? collapsed : `${collapsed.slice(0, limit - 1).trimEnd()}\u2026`;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// core/discovery/rendered-facts.ts
|
|
970
|
+
var INVISIBLE = /<(script|style|noscript|template|svg|head)\b[^>]*>[\s\S]*?<\/\1>/gi;
|
|
971
|
+
var COMMENT = /<!--[\s\S]*?-->/g;
|
|
972
|
+
var HEADING = /^h[1-4]$/;
|
|
973
|
+
var PRESSABLE = /* @__PURE__ */ new Set(["button", "menuitem", "tab", "switch", "option"]);
|
|
974
|
+
var ANNOUNCEMENT = /* @__PURE__ */ new Set(["alert", "status", "alertdialog"]);
|
|
975
|
+
var LANDMARK_TAG = {
|
|
976
|
+
main: "main",
|
|
977
|
+
nav: "nav",
|
|
978
|
+
header: "header",
|
|
979
|
+
footer: "footer",
|
|
980
|
+
aside: "aside",
|
|
981
|
+
dialog: "dialog",
|
|
982
|
+
form: "form"
|
|
983
|
+
};
|
|
984
|
+
var LANDMARK_ROLE = {
|
|
985
|
+
main: "main",
|
|
986
|
+
navigation: "nav",
|
|
987
|
+
banner: "header",
|
|
988
|
+
contentinfo: "footer",
|
|
989
|
+
complementary: "aside",
|
|
990
|
+
dialog: "dialog",
|
|
991
|
+
alertdialog: "dialog",
|
|
992
|
+
form: "form",
|
|
993
|
+
search: "form"
|
|
994
|
+
};
|
|
995
|
+
function readRenderedFactRecords(html, options = {}) {
|
|
996
|
+
const from = options.url ?? "";
|
|
997
|
+
const inSource = options.inSource ?? /* @__PURE__ */ new Set();
|
|
998
|
+
const facts = [];
|
|
999
|
+
const add = (kind, value, landmark, extra = {}) => {
|
|
1000
|
+
if (!value) return;
|
|
1001
|
+
const scope = scopeFromLandmark(landmark);
|
|
1002
|
+
const context = { inSource: inSource.has(value) };
|
|
1003
|
+
facts.push(
|
|
1004
|
+
fact({
|
|
1005
|
+
kind,
|
|
1006
|
+
value,
|
|
1007
|
+
channel: "p3",
|
|
1008
|
+
scope,
|
|
1009
|
+
dataClass: classifyData(value, scope, context),
|
|
1010
|
+
from,
|
|
1011
|
+
landmark,
|
|
1012
|
+
...extra
|
|
1013
|
+
})
|
|
1014
|
+
);
|
|
1015
|
+
};
|
|
1016
|
+
for (const element of elements(visibleMarkup(html))) {
|
|
1017
|
+
const { tag, attributes, inner, landmark } = element;
|
|
1018
|
+
const role = attributes["role"]?.toLowerCase() ?? "";
|
|
1019
|
+
const label = accessibleName(element);
|
|
1020
|
+
if (HEADING.test(tag) || role === "heading") {
|
|
1021
|
+
add("heading", label, landmark);
|
|
1022
|
+
continue;
|
|
1023
|
+
}
|
|
1024
|
+
if (tag === "button" || PRESSABLE.has(role) || submitLike(element)) {
|
|
1025
|
+
add("action", label, landmark, controlState2(element));
|
|
1026
|
+
continue;
|
|
1027
|
+
}
|
|
1028
|
+
if (tag === "a") {
|
|
1029
|
+
const href = attributes["href"]?.trim();
|
|
1030
|
+
if (href && !href.startsWith("#") && !href.startsWith("javascript:")) {
|
|
1031
|
+
add("link", label || href, landmark, { href });
|
|
1032
|
+
}
|
|
1033
|
+
continue;
|
|
1034
|
+
}
|
|
1035
|
+
if (tag === "input" || tag === "textarea" || tag === "select") {
|
|
1036
|
+
const field = fieldFrom(element, html);
|
|
1037
|
+
if (field) {
|
|
1038
|
+
add("field", field.label, landmark, {
|
|
1039
|
+
fieldKind: field.kind,
|
|
1040
|
+
...field.choices ? { choices: field.choices } : {},
|
|
1041
|
+
...controlState2(element)
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
1044
|
+
continue;
|
|
1045
|
+
}
|
|
1046
|
+
if (ANNOUNCEMENT.has(role) || attributes["aria-live"]) {
|
|
1047
|
+
add("notice", trim2(text(inner), LIMITS.sentence), landmark);
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
return facts;
|
|
1051
|
+
}
|
|
1052
|
+
var OVERLAY_ROLE = {
|
|
1053
|
+
dialog: "dialog",
|
|
1054
|
+
alertdialog: "dialog",
|
|
1055
|
+
menu: "menu",
|
|
1056
|
+
listbox: "listbox",
|
|
1057
|
+
tablist: "tabs"
|
|
1058
|
+
};
|
|
1059
|
+
function readOverlays(html, options = {}) {
|
|
1060
|
+
const from = options.url ?? "";
|
|
1061
|
+
const found = /* @__PURE__ */ new Map();
|
|
1062
|
+
const all = elements(visibleMarkup(html));
|
|
1063
|
+
const byId = new Map(
|
|
1064
|
+
all.filter((element) => element.attributes["id"]).map((element) => [element.attributes["id"], element])
|
|
1065
|
+
);
|
|
1066
|
+
const remember = (overlay) => {
|
|
1067
|
+
const key = `${overlay.kind}\0${overlay.key || overlay.name}`;
|
|
1068
|
+
const existing = found.get(key);
|
|
1069
|
+
found.set(key, existing ? { ...existing, ...overlay, key: existing.key } : overlay);
|
|
1070
|
+
};
|
|
1071
|
+
for (const element of all) {
|
|
1072
|
+
const { tag, attributes } = element;
|
|
1073
|
+
const role = attributes["role"]?.toLowerCase() ?? "";
|
|
1074
|
+
const kind = OVERLAY_ROLE[role] ?? (tag === "dialog" ? "dialog" : void 0);
|
|
1075
|
+
if (kind) {
|
|
1076
|
+
const name = accessibleName(element) || attributes["aria-label"]?.trim() || "";
|
|
1077
|
+
const open = openState(element);
|
|
1078
|
+
remember({
|
|
1079
|
+
kind,
|
|
1080
|
+
name: name || `an unnamed ${kind}`,
|
|
1081
|
+
key: attributes["id"] ?? name,
|
|
1082
|
+
...open === void 0 ? {} : { expanded: open },
|
|
1083
|
+
from
|
|
1084
|
+
});
|
|
1085
|
+
continue;
|
|
1086
|
+
}
|
|
1087
|
+
const popup = attributes["aria-haspopup"];
|
|
1088
|
+
const expanded = attributes["aria-expanded"];
|
|
1089
|
+
if (!popup && expanded === void 0) continue;
|
|
1090
|
+
const controls = attributes["aria-controls"];
|
|
1091
|
+
const target = controls ? byId.get(controls) : void 0;
|
|
1092
|
+
const targetRole = target?.attributes["role"]?.toLowerCase() ?? "";
|
|
1093
|
+
const trigger = accessibleName(element);
|
|
1094
|
+
remember({
|
|
1095
|
+
kind: OVERLAY_ROLE[targetRole] ?? OVERLAY_ROLE[popup && popup !== "true" ? popup.toLowerCase() : ""] ?? (popup ? "menu" : "disclosure"),
|
|
1096
|
+
name: (target ? accessibleName(target) : "") || trigger || "an unnamed overlay",
|
|
1097
|
+
key: controls ?? trigger,
|
|
1098
|
+
...expanded === void 0 ? {} : { expanded: expanded === "true" },
|
|
1099
|
+
...trigger ? { openedBy: trigger } : {},
|
|
1100
|
+
from
|
|
1101
|
+
});
|
|
1102
|
+
}
|
|
1103
|
+
return [...found.values()].map(({ key: _key, ...overlay }) => overlay);
|
|
1104
|
+
}
|
|
1105
|
+
function openState(element) {
|
|
1106
|
+
const { attributes } = element;
|
|
1107
|
+
if ("open" in attributes) return attributes["open"] !== "false";
|
|
1108
|
+
if ("hidden" in attributes) return false;
|
|
1109
|
+
if (attributes["aria-hidden"] === "true") return false;
|
|
1110
|
+
return void 0;
|
|
1111
|
+
}
|
|
1112
|
+
function visibleMarkup(html) {
|
|
1113
|
+
return html.replace(INVISIBLE, " ").replace(COMMENT, " ");
|
|
1114
|
+
}
|
|
1115
|
+
var TAG = /<(\/?)([a-zA-Z][a-zA-Z0-9-]*)((?:"[^"]*"|'[^']*'|[^>"'])*?)(\/?)>/g;
|
|
1116
|
+
var VOID = /* @__PURE__ */ new Set([
|
|
1117
|
+
"area",
|
|
1118
|
+
"base",
|
|
1119
|
+
"br",
|
|
1120
|
+
"col",
|
|
1121
|
+
"embed",
|
|
1122
|
+
"hr",
|
|
1123
|
+
"img",
|
|
1124
|
+
"input",
|
|
1125
|
+
"link",
|
|
1126
|
+
"meta",
|
|
1127
|
+
"param",
|
|
1128
|
+
"source",
|
|
1129
|
+
"track",
|
|
1130
|
+
"wbr"
|
|
1131
|
+
]);
|
|
1132
|
+
function elements(markup) {
|
|
1133
|
+
TAG.lastIndex = 0;
|
|
1134
|
+
const stack = [];
|
|
1135
|
+
const out = [];
|
|
1136
|
+
let match;
|
|
1137
|
+
const landmarkNow = () => stack[stack.length - 1]?.landmark ?? "none";
|
|
1138
|
+
const close = (frame, endsAt) => {
|
|
1139
|
+
out.push({
|
|
1140
|
+
tag: frame.tag,
|
|
1141
|
+
attributes: frame.attributes,
|
|
1142
|
+
inner: markup.slice(frame.contentAt, endsAt),
|
|
1143
|
+
landmark: frame.landmark,
|
|
1144
|
+
at: frame.at
|
|
1145
|
+
});
|
|
1146
|
+
};
|
|
1147
|
+
while (match = TAG.exec(markup)) {
|
|
1148
|
+
const closing = match[1] === "/";
|
|
1149
|
+
const tag = match[2].toLowerCase();
|
|
1150
|
+
if (closing) {
|
|
1151
|
+
const depth2 = stack.map((frame2) => frame2.tag).lastIndexOf(tag);
|
|
1152
|
+
if (depth2 === -1) continue;
|
|
1153
|
+
for (let index = stack.length - 1; index >= depth2; index -= 1) {
|
|
1154
|
+
close(stack[index], match.index);
|
|
1155
|
+
}
|
|
1156
|
+
stack.length = depth2;
|
|
1157
|
+
continue;
|
|
1158
|
+
}
|
|
1159
|
+
const attributes = attributesOf2(match[3] ?? "");
|
|
1160
|
+
const own = LANDMARK_ROLE[attributes["role"]?.toLowerCase() ?? ""] ?? LANDMARK_TAG[tag];
|
|
1161
|
+
const frame = {
|
|
1162
|
+
tag,
|
|
1163
|
+
attributes,
|
|
1164
|
+
// An element opening a landmark is itself inside it, which is what makes
|
|
1165
|
+
// `<nav aria-label="Main">` classify as navigation rather than as content.
|
|
1166
|
+
landmark: own ?? landmarkNow(),
|
|
1167
|
+
at: match.index,
|
|
1168
|
+
contentAt: match.index + match[0].length
|
|
1169
|
+
};
|
|
1170
|
+
if (match[4] === "/" || VOID.has(tag)) {
|
|
1171
|
+
out.push({ ...frame, inner: "" });
|
|
1172
|
+
continue;
|
|
1173
|
+
}
|
|
1174
|
+
stack.push(frame);
|
|
1175
|
+
}
|
|
1176
|
+
for (const frame of stack) close(frame, markup.length);
|
|
1177
|
+
return out.sort((a, b) => a.at - b.at);
|
|
1178
|
+
}
|
|
1179
|
+
var ATTRIBUTE = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g;
|
|
1180
|
+
function attributesOf2(raw) {
|
|
1181
|
+
const attributes = {};
|
|
1182
|
+
ATTRIBUTE.lastIndex = 0;
|
|
1183
|
+
let match;
|
|
1184
|
+
while (match = ATTRIBUTE.exec(raw)) {
|
|
1185
|
+
const name = match[1].toLowerCase();
|
|
1186
|
+
const value = match[2] ?? match[3] ?? match[4];
|
|
1187
|
+
attributes[name] = value === void 0 ? "" : decode(value);
|
|
1188
|
+
}
|
|
1189
|
+
return attributes;
|
|
1190
|
+
}
|
|
1191
|
+
function controlState2(element) {
|
|
1192
|
+
const state = {};
|
|
1193
|
+
const { attributes } = element;
|
|
1194
|
+
const flag = (name) => name in attributes && attributes[name] !== "false";
|
|
1195
|
+
if (flag("disabled") || attributes["aria-disabled"] === "true") state["disabled"] = true;
|
|
1196
|
+
if (flag("required") || attributes["aria-required"] === "true") state["required"] = true;
|
|
1197
|
+
if (flag("checked") || attributes["aria-checked"] === "true") state["checked"] = true;
|
|
1198
|
+
if (flag("selected") || attributes["aria-selected"] === "true") state["selected"] = true;
|
|
1199
|
+
if (attributes["aria-expanded"]) state["expanded"] = attributes["aria-expanded"] === "true";
|
|
1200
|
+
if (attributes["aria-invalid"] === "true") state["invalid"] = true;
|
|
1201
|
+
return Object.keys(state).length ? { controlState: state } : {};
|
|
1202
|
+
}
|
|
1203
|
+
function accessibleName(element) {
|
|
1204
|
+
const explicit = element.attributes["aria-label"] ?? element.attributes["title"];
|
|
1205
|
+
if (explicit?.trim()) return trim2(explicit.replace(/\s+/g, " ").trim(), LIMITS.label);
|
|
1206
|
+
return trim2(text(element.inner), LIMITS.label);
|
|
1207
|
+
}
|
|
1208
|
+
function submitLike(element) {
|
|
1209
|
+
if (element.tag !== "input") return false;
|
|
1210
|
+
const type = element.attributes["type"]?.toLowerCase();
|
|
1211
|
+
return type === "submit" || type === "button" || type === "reset";
|
|
1212
|
+
}
|
|
1213
|
+
function fieldFrom(element, body) {
|
|
1214
|
+
const { tag, attributes } = element;
|
|
1215
|
+
const type = attributes["type"]?.toLowerCase() ?? "text";
|
|
1216
|
+
if (tag === "input" && (type === "hidden" || submitLike(element))) return void 0;
|
|
1217
|
+
const kind = tag === "input" ? type : tag;
|
|
1218
|
+
const label = attributes["aria-label"]?.trim() || attributes["placeholder"]?.trim() || labelFor(body, attributes["id"]) || humanise(attributes["name"] ?? "");
|
|
1219
|
+
if (!label) return void 0;
|
|
1220
|
+
const field = { label: trim2(label.replace(/\s+/g, " "), LIMITS.label), kind };
|
|
1221
|
+
if (tag === "select") {
|
|
1222
|
+
const choices = [...element.inner.matchAll(/<option\b[^>]*>([\s\S]*?)<\/option>/gi)].map((match) => text(match[1] ?? "")).filter(Boolean).slice(0, LIMITS.choices);
|
|
1223
|
+
if (choices.length) field.choices = choices;
|
|
1224
|
+
}
|
|
1225
|
+
return field;
|
|
1226
|
+
}
|
|
1227
|
+
function labelFor(body, id) {
|
|
1228
|
+
if (!id) return "";
|
|
1229
|
+
const match = new RegExp(
|
|
1230
|
+
`<label\\b[^>]*\\bfor\\s*=\\s*["']?${escape(id)}["']?[^>]*>([\\s\\S]*?)</label>`,
|
|
1231
|
+
"i"
|
|
1232
|
+
).exec(body);
|
|
1233
|
+
return match ? text(match[1] ?? "") : "";
|
|
1234
|
+
}
|
|
1235
|
+
function escape(value) {
|
|
1236
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1237
|
+
}
|
|
1238
|
+
function humanise(name) {
|
|
1239
|
+
const words = name.replace(/[_-]+/g, " ").replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/\s+/g, " ").trim();
|
|
1240
|
+
return words ? `${words.charAt(0).toUpperCase()}${words.slice(1).toLowerCase()}` : "";
|
|
1241
|
+
}
|
|
1242
|
+
function text(fragment) {
|
|
1243
|
+
return decode(fragment.replace(/<[^>]*>/g, " ")).replace(/\s+/g, " ").trim();
|
|
1244
|
+
}
|
|
1245
|
+
var ENTITY = {
|
|
1246
|
+
amp: "&",
|
|
1247
|
+
lt: "<",
|
|
1248
|
+
gt: ">",
|
|
1249
|
+
quot: '"',
|
|
1250
|
+
apos: "'",
|
|
1251
|
+
nbsp: " ",
|
|
1252
|
+
"#39": "'",
|
|
1253
|
+
"#x27": "'",
|
|
1254
|
+
"#34": '"',
|
|
1255
|
+
mdash: "\u2014",
|
|
1256
|
+
ndash: "\u2013",
|
|
1257
|
+
hellip: "\u2026"
|
|
1258
|
+
};
|
|
1259
|
+
function decode(value) {
|
|
1260
|
+
return value.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (whole, name) => {
|
|
1261
|
+
const known = ENTITY[name.toLowerCase()];
|
|
1262
|
+
if (known) return known;
|
|
1263
|
+
const numeric = /^#x/i.test(name) ? Number.parseInt(name.slice(2), 16) : /^#/.test(name) ? Number.parseInt(name.slice(1), 10) : NaN;
|
|
1264
|
+
return Number.isFinite(numeric) && numeric > 0 && numeric < 1114112 ? String.fromCodePoint(numeric) : whole;
|
|
1265
|
+
});
|
|
1266
|
+
}
|
|
1267
|
+
function trim2(value, limit) {
|
|
1268
|
+
return value.length <= limit ? value : `${value.slice(0, limit - 1).trimEnd()}\u2026`;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
// core/coverage/stamp.ts
|
|
1272
|
+
var RUNTIME = /* @__PURE__ */ new Set(["p3", "p4"]);
|
|
1273
|
+
var STATIC = /* @__PURE__ */ new Set(["p1", "p2", "p2-state", "p5"]);
|
|
1274
|
+
function isRuntimeChannel(channel) {
|
|
1275
|
+
return RUNTIME.has(channel);
|
|
1276
|
+
}
|
|
1277
|
+
function isStaticChannel(channel) {
|
|
1278
|
+
return STATIC.has(channel);
|
|
1279
|
+
}
|
|
1280
|
+
var ROLE_STATES = /* @__PURE__ */ new Set(["present", "unknown", "not-applicable"]);
|
|
1281
|
+
function unknownRole() {
|
|
1282
|
+
return { state: "unknown" };
|
|
1283
|
+
}
|
|
1284
|
+
function noRole(because) {
|
|
1285
|
+
return { state: "not-applicable", because };
|
|
1286
|
+
}
|
|
1287
|
+
function unknownDeployment(because) {
|
|
1288
|
+
return { state: "unknown", because };
|
|
1289
|
+
}
|
|
1290
|
+
var PROVENANCE_LINKS = /* @__PURE__ */ new Set([
|
|
1291
|
+
"verified",
|
|
1292
|
+
"asserted",
|
|
1293
|
+
"unknown"
|
|
1294
|
+
]);
|
|
1295
|
+
var UnstampedObservation = class extends Error {
|
|
1296
|
+
};
|
|
1297
|
+
var ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
1298
|
+
var RUNTIME_DIMENSIONS = ["url", "captureEnvironment", "session"];
|
|
1299
|
+
var BROWSER_SHAPED = /^(?:headless\s*)?(chromium|chrome|firefox|safari|webkit|edge|msedge|playwright)\b[\s/_v-]*\d/i;
|
|
1300
|
+
function requireStamp(input) {
|
|
1301
|
+
if (!input) throw new UnstampedObservation("An observation arrived with no stamp at all.");
|
|
1302
|
+
const producer = input.producer?.trim() ?? "";
|
|
1303
|
+
if (!producer) throw new UnstampedObservation("An observation arrived without naming its producer.");
|
|
1304
|
+
if (!isRuntimeChannel(producer) && !isStaticChannel(producer)) {
|
|
1305
|
+
throw new UnstampedObservation(`"${producer}" is not one of the five named producers.`);
|
|
1306
|
+
}
|
|
1307
|
+
const missing = [];
|
|
1308
|
+
if (!input.at?.trim()) missing.push("at");
|
|
1309
|
+
if (isRuntimeChannel(producer)) {
|
|
1310
|
+
for (const dimension of RUNTIME_DIMENSIONS) {
|
|
1311
|
+
if (!input[dimension]?.trim()) missing.push(dimension);
|
|
1312
|
+
}
|
|
1313
|
+
if (!input.target?.repository?.trim()) missing.push("target.repository");
|
|
1314
|
+
if (!input.target?.commit?.trim()) missing.push("target.commit");
|
|
1315
|
+
if (!input.link?.trim()) missing.push("link");
|
|
1316
|
+
const claimed = ["repository", "commit"].filter(
|
|
1317
|
+
(dimension) => input[dimension] !== void 0
|
|
1318
|
+
);
|
|
1319
|
+
if (claimed.length) {
|
|
1320
|
+
throw new UnstampedObservation(
|
|
1321
|
+
`A ${producer} observation carries ${claimed.join(" and ")} directly. A running URL does not tell us which commit built what answered it: name the repository and commit under \`target\`, and say under \`link\` whether the deployment has been verified against them.`
|
|
1322
|
+
);
|
|
1323
|
+
}
|
|
1324
|
+
if (!input.role) missing.push("role");
|
|
1325
|
+
else if (!ROLE_STATES.has(input.role.state)) {
|
|
1326
|
+
throw new UnstampedObservation(
|
|
1327
|
+
`A ${producer} observation gave its role as "${input.role.state}", which is not one of present, unknown or not-applicable.`
|
|
1328
|
+
);
|
|
1329
|
+
}
|
|
1330
|
+
if (!input.deployment) missing.push("deployment");
|
|
1331
|
+
else requireDeployment(producer, input);
|
|
1332
|
+
if (input.link && !PROVENANCE_LINKS.has(input.link.trim())) {
|
|
1333
|
+
throw new UnstampedObservation(
|
|
1334
|
+
`A ${producer} observation gave its link as "${input.link}", which is not one of verified, asserted or unknown.`
|
|
1335
|
+
);
|
|
1336
|
+
}
|
|
1337
|
+
if (input.link?.trim() === "verified" && input.deployment?.state !== "present") {
|
|
1338
|
+
throw new UnstampedObservation(
|
|
1339
|
+
`A ${producer} observation claims its deployment is verified against ${input.target?.commit ?? "a commit"}, and cannot say which deployment. A mapping to a deployment nobody can name is not a verification.`
|
|
1340
|
+
);
|
|
1341
|
+
}
|
|
1342
|
+
} else {
|
|
1343
|
+
if (!input.repository?.trim()) missing.push("repository");
|
|
1344
|
+
if (!input.commit?.trim()) missing.push("commit");
|
|
1345
|
+
const invented = ["url", "captureEnvironment", "session"].filter(
|
|
1346
|
+
(dimension) => input[dimension] !== void 0
|
|
1347
|
+
);
|
|
1348
|
+
for (const dimension of ["role", "deployment", "target", "link"]) {
|
|
1349
|
+
if (input[dimension] !== void 0) invented.push(dimension);
|
|
1350
|
+
}
|
|
1351
|
+
if (invented.length) {
|
|
1352
|
+
throw new UnstampedObservation(
|
|
1353
|
+
`A ${producer} observation carries ${invented.join(", ")}, and a repository read has none of those. Either the producer is wrong or the value was invented.`
|
|
1354
|
+
);
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
if (missing.length) {
|
|
1358
|
+
throw new UnstampedObservation(
|
|
1359
|
+
`A ${producer} observation is missing ${missing.join(", ")}. It cannot be stored, because it could never be invalidated.`
|
|
1360
|
+
);
|
|
1361
|
+
}
|
|
1362
|
+
if (!ISO.test(input.at)) {
|
|
1363
|
+
throw new UnstampedObservation(`"${input.at}" is not an ISO-8601 timestamp.`);
|
|
1364
|
+
}
|
|
1365
|
+
return isRuntimeChannel(producer) ? {
|
|
1366
|
+
producer,
|
|
1367
|
+
at: input.at,
|
|
1368
|
+
url: input.url.trim(),
|
|
1369
|
+
deployment: input.deployment,
|
|
1370
|
+
captureEnvironment: input.captureEnvironment.trim(),
|
|
1371
|
+
session: input.session.trim(),
|
|
1372
|
+
role: input.role,
|
|
1373
|
+
target: {
|
|
1374
|
+
repository: input.target.repository.trim(),
|
|
1375
|
+
commit: input.target.commit.trim()
|
|
1376
|
+
},
|
|
1377
|
+
link: input.link.trim()
|
|
1378
|
+
} : {
|
|
1379
|
+
producer,
|
|
1380
|
+
repository: input.repository.trim(),
|
|
1381
|
+
commit: input.commit.trim(),
|
|
1382
|
+
at: input.at
|
|
1383
|
+
};
|
|
1384
|
+
}
|
|
1385
|
+
function requireDeployment(producer, input) {
|
|
1386
|
+
const deployment = input.deployment;
|
|
1387
|
+
if (deployment.state === "unknown") {
|
|
1388
|
+
if (!deployment.because.trim()) {
|
|
1389
|
+
throw new UnstampedObservation(
|
|
1390
|
+
`A ${producer} observation says its deployment is unknown and does not say why. An unexplained unknown is the blank field this shape exists to abolish.`
|
|
1391
|
+
);
|
|
1392
|
+
}
|
|
1393
|
+
return;
|
|
1394
|
+
}
|
|
1395
|
+
if (deployment.state !== "present" || !deployment.value.trim()) {
|
|
1396
|
+
throw new UnstampedObservation(
|
|
1397
|
+
`A ${producer} observation gave its deployment as "${deployment.state}", which is not one of present or unknown.`
|
|
1398
|
+
);
|
|
1399
|
+
}
|
|
1400
|
+
const value = deployment.value.trim();
|
|
1401
|
+
if (BROWSER_SHAPED.test(value) || value === input.captureEnvironment?.trim()) {
|
|
1402
|
+
throw new UnstampedObservation(
|
|
1403
|
+
`A ${producer} observation gives "${value}" as the deployment that answered it. That names the browser that did the reading, which belongs in captureEnvironment. What is wanted here is what the application was running \u2014 a deployment id, a build id, a release SHA \u2014 or unknownDeployment("why not").`
|
|
1404
|
+
);
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
// adapters/capture-cli.ts
|
|
1409
|
+
async function runCapture(options) {
|
|
1410
|
+
const outcomes = [];
|
|
1411
|
+
const survey = [];
|
|
1412
|
+
const evidence = [];
|
|
1413
|
+
const build = await options.browser.build?.().catch(() => void 0);
|
|
1414
|
+
for (const [index, shot] of options.shots.entries()) {
|
|
1415
|
+
const { outcome, observed, seen } = await captureOne(options, shot, build);
|
|
1416
|
+
outcomes.push(outcome);
|
|
1417
|
+
if (observed) survey.push(observed);
|
|
1418
|
+
if (seen) evidence.push(seen);
|
|
1419
|
+
options.onProgress?.(outcome, index + 1, options.shots.length);
|
|
1420
|
+
}
|
|
1421
|
+
const count = (status) => outcomes.filter((outcome) => outcome.status === status).length;
|
|
1422
|
+
return {
|
|
1423
|
+
baseUrl: options.baseUrl,
|
|
1424
|
+
outcomes,
|
|
1425
|
+
evidence,
|
|
1426
|
+
survey,
|
|
1427
|
+
...build ? { browser: build } : {},
|
|
1428
|
+
captured: count("captured"),
|
|
1429
|
+
unchanged: count("unchanged"),
|
|
1430
|
+
skipped: count("skipped"),
|
|
1431
|
+
refused: count("refused"),
|
|
1432
|
+
failed: count("failed")
|
|
1433
|
+
};
|
|
1434
|
+
}
|
|
1435
|
+
async function captureOne(options, shot, build) {
|
|
1436
|
+
let page;
|
|
1437
|
+
try {
|
|
1438
|
+
page = await options.browser.page();
|
|
1439
|
+
await page.goto(new URL(shot.route, options.baseUrl).toString());
|
|
1440
|
+
if (await page.isSignIn()) {
|
|
1441
|
+
return {
|
|
1442
|
+
outcome: {
|
|
1443
|
+
shotId: shot.id,
|
|
1444
|
+
route: shot.route,
|
|
1445
|
+
status: "skipped",
|
|
1446
|
+
result: "bounced",
|
|
1447
|
+
needsAccount: true,
|
|
1448
|
+
reason: "a signed-out visitor is shown a sign-in here, so this screen needs a demo tenant \u2014 never a real account"
|
|
1449
|
+
},
|
|
1450
|
+
observed: { route: shot.route, result: "bounced" }
|
|
1451
|
+
};
|
|
1452
|
+
}
|
|
1453
|
+
const settledPage = page;
|
|
1454
|
+
const text2 = await settle({
|
|
1455
|
+
sample: () => settledPage.text(),
|
|
1456
|
+
same: (a, b) => a === b,
|
|
1457
|
+
identicalSamples: VISUAL_STABILITY.identicalFrames,
|
|
1458
|
+
intervalMs: VISUAL_STABILITY.intervalMs,
|
|
1459
|
+
timeoutMs: VISUAL_STABILITY.timeoutMs
|
|
1460
|
+
});
|
|
1461
|
+
const pageText = text2.value;
|
|
1462
|
+
const verdict = gateFrame(pageText, {
|
|
1463
|
+
route: shot.route,
|
|
1464
|
+
...options.brands ? { brands: options.brands } : {},
|
|
1465
|
+
// Read off the clearance the help desk holds, never off a flag. A run
|
|
1466
|
+
// that could name its own expected brands could name any of them, and the
|
|
1467
|
+
// one thing this list does is stop a name being flagged.
|
|
1468
|
+
...options.clearance?.expectedBrands?.length ? { expected: options.clearance.expectedBrands } : {}
|
|
1469
|
+
});
|
|
1470
|
+
if (!verdict.safe) {
|
|
1471
|
+
return {
|
|
1472
|
+
outcome: { shotId: shot.id, route: shot.route, status: "refused", reason: verdict.reason }
|
|
1473
|
+
};
|
|
1474
|
+
}
|
|
1475
|
+
const seen = await page.observe?.() ?? {};
|
|
1476
|
+
const observed = classifyRoute({ ...seen, route: shot.route, text: pageText });
|
|
1477
|
+
if (!isPublishable(observed.result)) {
|
|
1478
|
+
return {
|
|
1479
|
+
outcome: {
|
|
1480
|
+
shotId: shot.id,
|
|
1481
|
+
route: shot.route,
|
|
1482
|
+
status: "skipped",
|
|
1483
|
+
result: observed.result,
|
|
1484
|
+
reason: actionFor(observed) ?? `the screen came back ${observed.result}`
|
|
1485
|
+
},
|
|
1486
|
+
observed
|
|
1487
|
+
};
|
|
1488
|
+
}
|
|
1489
|
+
const evidence = await readEvidence(settledPage, shot.route, options, build);
|
|
1490
|
+
const reading = options.clearance ? await readScreen(settledPage, options.clearance) : void 0;
|
|
1491
|
+
if (options.clearance && !reading) {
|
|
1492
|
+
return {
|
|
1493
|
+
outcome: {
|
|
1494
|
+
shotId: shot.id,
|
|
1495
|
+
route: shot.route,
|
|
1496
|
+
status: "failed",
|
|
1497
|
+
result: observed.result,
|
|
1498
|
+
reason: "this browser cannot read a screen against a clearance, so nothing it photographed could be checked. Upgrade the capture runner: the help desk refuses a frame that arrives with no reading of what was on it"
|
|
1499
|
+
},
|
|
1500
|
+
observed,
|
|
1501
|
+
...evidence ? { seen: evidence } : {}
|
|
1502
|
+
};
|
|
1503
|
+
}
|
|
1504
|
+
if (reading) {
|
|
1505
|
+
const unaccounted = reading.inventory.unaccounted;
|
|
1506
|
+
if (unaccounted.length > 0) {
|
|
1507
|
+
return {
|
|
1508
|
+
outcome: {
|
|
1509
|
+
shotId: shot.id,
|
|
1510
|
+
route: shot.route,
|
|
1511
|
+
status: "refused",
|
|
1512
|
+
result: observed.result,
|
|
1513
|
+
// Where and never what: a value nobody has classified is a value
|
|
1514
|
+
// nobody has established is safe to write down, and a reason that
|
|
1515
|
+
// quoted it would put it in this run's log.
|
|
1516
|
+
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`
|
|
1517
|
+
},
|
|
1518
|
+
observed,
|
|
1519
|
+
...evidence ? { seen: evidence } : {}
|
|
1520
|
+
};
|
|
1521
|
+
}
|
|
1522
|
+
const clearance = options.clearance;
|
|
1523
|
+
const loose = reading.surfaces.filter(
|
|
1524
|
+
(surface) => !surface.read && !clearance.covers.includes(surface.where) && !clearance.approved.includes(surface.where)
|
|
1525
|
+
);
|
|
1526
|
+
if (loose.length > 0 && !clearance.coverUnnamed) {
|
|
1527
|
+
return {
|
|
1528
|
+
outcome: {
|
|
1529
|
+
shotId: shot.id,
|
|
1530
|
+
route: shot.route,
|
|
1531
|
+
status: "refused",
|
|
1532
|
+
result: observed.result,
|
|
1533
|
+
reason: `${loose.length} surface(s) on this screen are neither approved nor covered (${loose.map((surface) => `${surface.kind} at ${surface.where}`).join("; ")}); a surface nothing can read is approved against what it shows, covered, or it refuses the capture`
|
|
1534
|
+
},
|
|
1535
|
+
observed,
|
|
1536
|
+
...evidence ? { seen: evidence } : {}
|
|
1537
|
+
};
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
const approved = new Set(options.clearance?.approved ?? []);
|
|
1541
|
+
const cover = (reading?.surfaces ?? []).filter((surface) => !surface.read && !approved.has(surface.where)).map((surface) => surface.where);
|
|
1542
|
+
const frame = await settle({
|
|
1543
|
+
sample: () => settledPage.screenshot(cover),
|
|
1544
|
+
same: sameBytes,
|
|
1545
|
+
identicalSamples: VISUAL_STABILITY.identicalFrames,
|
|
1546
|
+
intervalMs: VISUAL_STABILITY.intervalMs,
|
|
1547
|
+
timeoutMs: VISUAL_STABILITY.timeoutMs
|
|
1548
|
+
});
|
|
1549
|
+
const image = frame.value;
|
|
1550
|
+
const size = pngSize(image);
|
|
1551
|
+
if (size && size.width < CAPTURE_CONTRACT.minStillWidth) {
|
|
1552
|
+
return {
|
|
1553
|
+
outcome: {
|
|
1554
|
+
shotId: shot.id,
|
|
1555
|
+
route: shot.route,
|
|
1556
|
+
status: "failed",
|
|
1557
|
+
result: observed.result,
|
|
1558
|
+
reason: `that frame came back ${size.width}px wide; the contract needs at least ${CAPTURE_CONTRACT.minStillWidth}px`
|
|
1559
|
+
},
|
|
1560
|
+
observed,
|
|
1561
|
+
// The picture is unusable; the reading of the screen is not.
|
|
1562
|
+
...evidence ? { seen: evidence } : {}
|
|
1563
|
+
};
|
|
1564
|
+
}
|
|
1565
|
+
const measured = cover.length > 0 ? coverageOf(image) : 0;
|
|
1566
|
+
if (measured > MOSTLY_COVERED) {
|
|
1567
|
+
return {
|
|
1568
|
+
outcome: {
|
|
1569
|
+
shotId: shot.id,
|
|
1570
|
+
route: shot.route,
|
|
1571
|
+
status: "refused",
|
|
1572
|
+
result: observed.result,
|
|
1573
|
+
reason: `covering the ${cover.length} unreadable surface(s) on this screen took ${Math.round(measured * 100)}% of the picture, so what came back is more cover than screen. Approve what those surfaces show, or document a screen with fewer of them`
|
|
1574
|
+
},
|
|
1575
|
+
observed,
|
|
1576
|
+
...evidence ? { seen: evidence } : {}
|
|
1577
|
+
};
|
|
1578
|
+
}
|
|
1579
|
+
const sha256 = frameHash(image);
|
|
1580
|
+
if (!options.force && shot.sha256 && shot.sha256 === sha256) {
|
|
1581
|
+
return {
|
|
1582
|
+
outcome: {
|
|
1583
|
+
shotId: shot.id,
|
|
1584
|
+
route: shot.route,
|
|
1585
|
+
status: "unchanged",
|
|
1586
|
+
result: observed.result,
|
|
1587
|
+
sha256,
|
|
1588
|
+
settled: frame.settled,
|
|
1589
|
+
reason: "byte-identical to the last run"
|
|
1590
|
+
},
|
|
1591
|
+
observed,
|
|
1592
|
+
...evidence ? { seen: evidence } : {}
|
|
1593
|
+
};
|
|
1594
|
+
}
|
|
1595
|
+
const sent = await options.send({
|
|
1596
|
+
shot,
|
|
1597
|
+
pageText,
|
|
1598
|
+
image,
|
|
1599
|
+
capturedFrom: options.baseUrl,
|
|
1600
|
+
sha256,
|
|
1601
|
+
settled: frame.settled,
|
|
1602
|
+
...reading ? {
|
|
1603
|
+
report: {
|
|
1604
|
+
// A covered surface makes this a redacted frame, which is what it
|
|
1605
|
+
// is: there is a slate rectangle in the picture.
|
|
1606
|
+
disposition: cover.length > 0 ? "publishable-redacted" : "publishable",
|
|
1607
|
+
inventory: reading.inventory,
|
|
1608
|
+
surfaces: reading.surfaces.map((surface) => ({
|
|
1609
|
+
id: surface.id,
|
|
1610
|
+
kind: surface.kind,
|
|
1611
|
+
at: surface.where,
|
|
1612
|
+
// What was actually done to it, which the server checks against
|
|
1613
|
+
// its own clearance. Reporting everything as covered when one
|
|
1614
|
+
// of them was left visible would be the report disagreeing with
|
|
1615
|
+
// the picture it is attached to.
|
|
1616
|
+
disposition: surface.read ? "read" : approved.has(surface.where) ? "approved" : "masked"
|
|
1617
|
+
})),
|
|
1618
|
+
styled: reading.styled
|
|
1619
|
+
}
|
|
1620
|
+
} : {},
|
|
1621
|
+
...build ? { browser: build } : {},
|
|
1622
|
+
...size ?? {}
|
|
1623
|
+
});
|
|
1624
|
+
return {
|
|
1625
|
+
outcome: sent.ok ? {
|
|
1626
|
+
shotId: shot.id,
|
|
1627
|
+
route: shot.route,
|
|
1628
|
+
status: "captured",
|
|
1629
|
+
result: observed.result,
|
|
1630
|
+
sha256,
|
|
1631
|
+
settled: frame.settled,
|
|
1632
|
+
...frame.settled ? {} : {
|
|
1633
|
+
reason: "photographed, but this screen never stopped moving \u2014 its hash will differ every run, so drift on it means nothing"
|
|
1634
|
+
}
|
|
1635
|
+
} : {
|
|
1636
|
+
shotId: shot.id,
|
|
1637
|
+
route: shot.route,
|
|
1638
|
+
status: "failed",
|
|
1639
|
+
result: observed.result,
|
|
1640
|
+
reason: sent.error ?? "the help desk refused it"
|
|
1641
|
+
},
|
|
1642
|
+
observed,
|
|
1643
|
+
...evidence ? { seen: evidence } : {}
|
|
1644
|
+
};
|
|
1645
|
+
} catch (error) {
|
|
1646
|
+
return {
|
|
1647
|
+
outcome: {
|
|
1648
|
+
shotId: shot.id,
|
|
1649
|
+
route: shot.route,
|
|
1650
|
+
status: "failed",
|
|
1651
|
+
reason: error instanceof Error ? error.message : String(error)
|
|
1652
|
+
}
|
|
1653
|
+
};
|
|
1654
|
+
} finally {
|
|
1655
|
+
await page?.close().catch(() => void 0);
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
async function readEvidence(page, route, options, build) {
|
|
1659
|
+
if (!options.commit || !options.repository || !options.session || !build) return void 0;
|
|
1660
|
+
const url = new URL(route, options.baseUrl).toString();
|
|
1661
|
+
const html = await page.html?.().catch(() => "");
|
|
1662
|
+
const aria = await page.aria?.().catch(() => "");
|
|
1663
|
+
if (!html && !aria) return void 0;
|
|
1664
|
+
const stamp = requireStamp({
|
|
1665
|
+
producer: "p3",
|
|
1666
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1667
|
+
url,
|
|
1668
|
+
/*
|
|
1669
|
+
* `build` is the browser, and goes where the browser goes. What was serving
|
|
1670
|
+
* the URL is a different question, and a run that was not told the answer
|
|
1671
|
+
* says so rather than letting the browser version imply one.
|
|
1672
|
+
*/
|
|
1673
|
+
captureEnvironment: build,
|
|
1674
|
+
deployment: options.deployment ?? unknownDeployment("this run was not told which deployment was serving the base URL"),
|
|
1675
|
+
session: options.session,
|
|
1676
|
+
role: options.role ?? unknownRole(),
|
|
1677
|
+
target: { repository: options.repository, commit: options.commit },
|
|
1678
|
+
link: options.link ?? "asserted"
|
|
1679
|
+
});
|
|
1680
|
+
return {
|
|
1681
|
+
route,
|
|
1682
|
+
facts: [
|
|
1683
|
+
...html ? readRenderedFactRecords(html, { url }) : [],
|
|
1684
|
+
...aria ? readAriaFactRecords(aria, { url }) : []
|
|
1685
|
+
],
|
|
1686
|
+
overlays: html ? readOverlays(html, { url }) : [],
|
|
1687
|
+
stamp,
|
|
1688
|
+
channels: { html: Boolean(html), aria: Boolean(aria) }
|
|
1689
|
+
};
|
|
1690
|
+
}
|
|
1691
|
+
async function playwrightBrowser(options = {}) {
|
|
1692
|
+
const { chromium } = await loadPlaywright();
|
|
1693
|
+
const executablePath = process.env["CAPTURE_CHROME"] ?? void 0;
|
|
1694
|
+
const browser = await chromium.launch({
|
|
1695
|
+
headless: options.headless ?? true,
|
|
1696
|
+
...executablePath ? { executablePath } : {},
|
|
1697
|
+
args: [
|
|
1698
|
+
"--no-sandbox",
|
|
1699
|
+
"--disable-dev-shm-usage",
|
|
1700
|
+
/**
|
|
1701
|
+
* Software rendering, deliberately, and it is slower.
|
|
1702
|
+
*
|
|
1703
|
+
* Anything drawn through the GPU — canvas, WebGL, most charting
|
|
1704
|
+
* libraries, some CSS filters — rasterises differently on different
|
|
1705
|
+
* drivers and driver versions. Two machines then produce different bytes
|
|
1706
|
+
* for identical input, so a run on a laptop and a run in CI disagree
|
|
1707
|
+
* about a screen that never changed. That failure is invisible until the
|
|
1708
|
+
* day somebody runs it somewhere new, which is the worst time to find it.
|
|
1709
|
+
*/
|
|
1710
|
+
"--disable-gpu",
|
|
1711
|
+
"--disable-software-rasterizer",
|
|
1712
|
+
/**
|
|
1713
|
+
* Font rasterisation, pinned as far as a flag can pin it. Subpixel
|
|
1714
|
+
* antialiasing is tuned per display and hinting varies by platform;
|
|
1715
|
+
* both change every pixel of every glyph without changing a word.
|
|
1716
|
+
*/
|
|
1717
|
+
"--font-render-hinting=none",
|
|
1718
|
+
"--disable-lcd-text",
|
|
1719
|
+
"--force-color-profile=srgb",
|
|
1720
|
+
// A scrollbar is an overlay on some platforms and consumes layout width
|
|
1721
|
+
// on others, so leaving it visible makes the page a different width
|
|
1722
|
+
// depending on who took the picture.
|
|
1723
|
+
"--hide-scrollbars"
|
|
1724
|
+
]
|
|
1725
|
+
});
|
|
1726
|
+
const contextOptions = {
|
|
1727
|
+
viewport: CAPTURE_CONTRACT.viewport,
|
|
1728
|
+
deviceScaleFactor: CAPTURE_CONTRACT.stillScale,
|
|
1729
|
+
// Pinned as well as the clock. A date formatted in the runner's own zone
|
|
1730
|
+
// renders differently in London and in Denver, which makes a frame depend
|
|
1731
|
+
// on who took it.
|
|
1732
|
+
timezoneId: "UTC",
|
|
1733
|
+
locale: "en-GB",
|
|
1734
|
+
/**
|
|
1735
|
+
* A session, when one is supplied — and only ever from a file on the
|
|
1736
|
+
* machine running this.
|
|
1737
|
+
*
|
|
1738
|
+
* Signing in was refused outright until now, and the refusal was doing two
|
|
1739
|
+
* jobs. One of them still stands: this tool must never hold a credential,
|
|
1740
|
+
* so the help desk is not told the session exists and it is never sent
|
|
1741
|
+
* anywhere. The other was collateral — it also meant the screens most
|
|
1742
|
+
* worth documenting, which are the ones behind the sign-in, could not be
|
|
1743
|
+
* photographed at all.
|
|
1744
|
+
*
|
|
1745
|
+
* What makes it safe is not the absence of a session but WHOSE session it
|
|
1746
|
+
* is. A demo tenant holds nothing real; a customer's account holds
|
|
1747
|
+
* everything. `gateFrame` is what enforces that, and with a session in
|
|
1748
|
+
* play it stops being a backstop and becomes the load-bearing check: a
|
|
1749
|
+
* screen showing anything that looks real is refused before the shutter,
|
|
1750
|
+
* so pointing this at the wrong account produces no photographs rather
|
|
1751
|
+
* than the wrong ones.
|
|
1752
|
+
*/
|
|
1753
|
+
...options.sessionPath ? { storageState: options.sessionPath } : { storageState: void 0 }
|
|
1754
|
+
};
|
|
1755
|
+
return {
|
|
1756
|
+
async page() {
|
|
1757
|
+
const context = await browser.newContext(contextOptions);
|
|
1758
|
+
if (!options.liveClock) {
|
|
1759
|
+
await context.addInitScript(freezeScript(CAPTURE_CONTRACT.pinnedClock));
|
|
1760
|
+
}
|
|
1761
|
+
await context.addInitScript(CLOSED_SHADOW_WITNESS);
|
|
1762
|
+
const page = await context.newPage();
|
|
1763
|
+
const errors = [];
|
|
1764
|
+
const consoleErrors = [];
|
|
1765
|
+
const failedRequests = [];
|
|
1766
|
+
let status;
|
|
1767
|
+
page.on("pageerror", (error) => errors.push(error.message));
|
|
1768
|
+
page.on("console", (message) => {
|
|
1769
|
+
if (message.type() === "error") consoleErrors.push(message.text());
|
|
1770
|
+
});
|
|
1771
|
+
page.on("response", (response) => {
|
|
1772
|
+
if (response.status() < 400) return;
|
|
1773
|
+
const kind = response.request().resourceType();
|
|
1774
|
+
if (kind !== "document" && kind !== "xhr" && kind !== "fetch") return;
|
|
1775
|
+
failedRequests.push({ url: pathOf(response.url()), status: response.status() });
|
|
1776
|
+
});
|
|
1777
|
+
await page.addStyleTag({ content: CAPTURE_CONTRACT.motionOff }).catch(() => void 0);
|
|
1778
|
+
return {
|
|
1779
|
+
async goto(url) {
|
|
1780
|
+
const response = await page.goto(url, { waitUntil: "networkidle", timeout: 3e4 });
|
|
1781
|
+
status = response?.status();
|
|
1782
|
+
await page.addStyleTag({ content: CAPTURE_CONTRACT.motionOff }).catch(() => void 0);
|
|
1783
|
+
await page.evaluate(() => document.fonts?.ready).catch(() => void 0);
|
|
1784
|
+
},
|
|
1785
|
+
async text() {
|
|
1786
|
+
return page.locator("body").innerText().catch(() => "");
|
|
1787
|
+
},
|
|
1788
|
+
async html() {
|
|
1789
|
+
return page.content().catch(() => "");
|
|
1790
|
+
},
|
|
1791
|
+
async aria() {
|
|
1792
|
+
return page.locator("body").ariaSnapshot().catch(() => "");
|
|
1793
|
+
},
|
|
1794
|
+
async isSignIn() {
|
|
1795
|
+
return await page.locator('input[type="password"]').count() > 0;
|
|
1796
|
+
},
|
|
1797
|
+
async observe() {
|
|
1798
|
+
return {
|
|
1799
|
+
landedOn: pathOf(page.url()),
|
|
1800
|
+
...status === void 0 ? {} : { status },
|
|
1801
|
+
errors: [...errors],
|
|
1802
|
+
consoleErrors: [...consoleErrors],
|
|
1803
|
+
failedRequests: [...failedRequests]
|
|
1804
|
+
};
|
|
1805
|
+
},
|
|
1806
|
+
async inspect(regions) {
|
|
1807
|
+
const inventory = await takeInventory(page, regions);
|
|
1808
|
+
const surfaces = await readOpaqueSurfaces(page, regions);
|
|
1809
|
+
const styled = await page.evaluate(() => {
|
|
1810
|
+
const fonts = document.fonts;
|
|
1811
|
+
const images = Array.from(document.images);
|
|
1812
|
+
return (fonts?.status ?? "loaded") === "loaded" && images.every((image) => image.complete);
|
|
1813
|
+
}).catch(() => false);
|
|
1814
|
+
return { inventory, surfaces, styled };
|
|
1815
|
+
},
|
|
1816
|
+
async screenshot(cover) {
|
|
1817
|
+
const masks = (cover ?? []).map((selector) => page.locator(selector));
|
|
1818
|
+
return page.screenshot({
|
|
1819
|
+
type: "png",
|
|
1820
|
+
fullPage: false,
|
|
1821
|
+
// The colour is not a caller's to choose here either. A publication
|
|
1822
|
+
// frame gets the neutral profile; the magenta sentinel is an
|
|
1823
|
+
// instrument and never leaves the machine it was taken on.
|
|
1824
|
+
...masks.length > 0 ? { mask: masks, maskColor: PUBLICATION_REDACTION_CSS } : {}
|
|
1825
|
+
});
|
|
1826
|
+
},
|
|
1827
|
+
async close() {
|
|
1828
|
+
await context.close();
|
|
1829
|
+
}
|
|
1830
|
+
};
|
|
1831
|
+
},
|
|
1832
|
+
async build() {
|
|
1833
|
+
return `chromium ${browser.version()}`;
|
|
1834
|
+
},
|
|
1835
|
+
async close() {
|
|
1836
|
+
await browser.close();
|
|
1837
|
+
}
|
|
1838
|
+
};
|
|
1839
|
+
}
|
|
1840
|
+
var CLOSED_SHADOW_WITNESS = `(() => {
|
|
1841
|
+
const real = Element.prototype.attachShadow;
|
|
1842
|
+
|
|
1843
|
+
if (!real) return;
|
|
1844
|
+
|
|
1845
|
+
Element.prototype.attachShadow = function (init) {
|
|
1846
|
+
const root = real.call(this, init);
|
|
1847
|
+
|
|
1848
|
+
if (init && init.mode === "closed") {
|
|
1849
|
+
try {
|
|
1850
|
+
this.setAttribute("data-help-closed-shadow", "");
|
|
1851
|
+
} catch {
|
|
1852
|
+
// An element that refuses an attribute is one this cannot mark, and a
|
|
1853
|
+
// failure here must not be a failure of the page. It stays unmarked,
|
|
1854
|
+
// which leaves it exactly as undetectable as it was before.
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
return root;
|
|
1859
|
+
};
|
|
1860
|
+
})()`;
|
|
1861
|
+
function coverageOf(image) {
|
|
1862
|
+
try {
|
|
1863
|
+
return coveredFraction(decodePng(Buffer.from(image)));
|
|
1864
|
+
} catch {
|
|
1865
|
+
return 0;
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
var PUBLICATION_REDACTION_CSS = `#${[PUBLICATION_REDACTION.r, PUBLICATION_REDACTION.g, PUBLICATION_REDACTION.b].map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
|
|
1869
|
+
async function readOpaqueSurfaces(page, regions) {
|
|
1870
|
+
return page.evaluate(
|
|
1871
|
+
inPage((spec) => {
|
|
1872
|
+
const where = (element) => {
|
|
1873
|
+
const steps = [];
|
|
1874
|
+
for (let node = element; node && node.tagName !== "BODY" && node.tagName !== "HTML"; node = node.parentElement) {
|
|
1875
|
+
if (node.id) {
|
|
1876
|
+
steps.unshift(`#${node.id}`);
|
|
1877
|
+
break;
|
|
1878
|
+
}
|
|
1879
|
+
const siblings2 = node.parentElement ? Array.from(node.parentElement.children) : [node];
|
|
1880
|
+
steps.unshift(`${node.tagName.toLowerCase()}:nth-child(${siblings2.indexOf(node) + 1})`);
|
|
1881
|
+
}
|
|
1882
|
+
return steps.length === 0 ? "body" : steps.join(" > ");
|
|
1883
|
+
};
|
|
1884
|
+
const painted = (element) => {
|
|
1885
|
+
const style = window.getComputedStyle(element);
|
|
1886
|
+
if (style.visibility === "hidden" || style.display === "none") return false;
|
|
1887
|
+
return Array.from(element.getClientRects()).some((box) => box.width > 0 && box.height > 0);
|
|
1888
|
+
};
|
|
1889
|
+
const found = [];
|
|
1890
|
+
const add = (element, kind) => {
|
|
1891
|
+
if (!painted(element)) return;
|
|
1892
|
+
const at = where(element);
|
|
1893
|
+
found.push({ id: `${kind}@${at}`, kind, where: at });
|
|
1894
|
+
};
|
|
1895
|
+
for (const element of Array.from(document.querySelectorAll("img"))) add(element, "image");
|
|
1896
|
+
for (const element of Array.from(document.querySelectorAll("canvas"))) add(element, "canvas");
|
|
1897
|
+
for (const element of Array.from(document.querySelectorAll("video"))) add(element, "video");
|
|
1898
|
+
for (const element of Array.from(document.querySelectorAll("iframe, object, embed"))) add(element, "frame");
|
|
1899
|
+
for (const element of Array.from(document.querySelectorAll("svg"))) {
|
|
1900
|
+
if (!painted(element)) continue;
|
|
1901
|
+
const at = where(element);
|
|
1902
|
+
const markup = element.outerHTML;
|
|
1903
|
+
const carries = element.querySelector("image, foreignObject, [href], [*|href]") !== null || /url\(/i.test(markup);
|
|
1904
|
+
const held = spec.regions.some((region) => {
|
|
1905
|
+
try {
|
|
1906
|
+
return element.closest(region) !== null;
|
|
1907
|
+
} catch {
|
|
1908
|
+
return false;
|
|
1909
|
+
}
|
|
1910
|
+
});
|
|
1911
|
+
found.push({
|
|
1912
|
+
id: `svg@${at}`,
|
|
1913
|
+
kind: "svg",
|
|
1914
|
+
where: at,
|
|
1915
|
+
// Reported either way. A drawing this resolved by reading is a thing
|
|
1916
|
+
// somebody may want to check, and dropping it from the report would
|
|
1917
|
+
// make "no surfaces on this screen" the answer to two very different
|
|
1918
|
+
// questions.
|
|
1919
|
+
...carries || !held ? {} : { read: true }
|
|
1920
|
+
});
|
|
1921
|
+
}
|
|
1922
|
+
for (const element of Array.from(document.querySelectorAll("*"))) {
|
|
1923
|
+
if (element.hasAttribute("data-help-closed-shadow") || element.tagName.includes("-") && !element.shadowRoot) {
|
|
1924
|
+
add(element, "shadow-opaque");
|
|
1925
|
+
}
|
|
1926
|
+
const style = window.getComputedStyle(element);
|
|
1927
|
+
for (const property of ["backgroundImage", "listStyleImage", "borderImageSource", "maskImage"]) {
|
|
1928
|
+
const value = style[property];
|
|
1929
|
+
if (value && value !== "none" && value.includes("url(")) {
|
|
1930
|
+
add(element, "css-image");
|
|
1931
|
+
break;
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1934
|
+
for (const pseudo of ["::before", "::after"]) {
|
|
1935
|
+
const content = window.getComputedStyle(element, pseudo).content;
|
|
1936
|
+
if (content && content !== "none" && content.includes("url(")) {
|
|
1937
|
+
add(element, "generated-image");
|
|
1938
|
+
break;
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
return [...new Map(found.map((surface) => [surface.id, surface])).values()];
|
|
1943
|
+
}, { regions: [...regions] })
|
|
1944
|
+
).catch(() => []).then((surfaces) => surfaces.map((surface) => ({ ...surface, kind: surface.kind })));
|
|
1945
|
+
}
|
|
1946
|
+
async function readScreen(page, clearance) {
|
|
1947
|
+
if (!page.inspect) return void 0;
|
|
1948
|
+
return page.inspect(clearance.regions);
|
|
1949
|
+
}
|
|
1950
|
+
function pathOf(url) {
|
|
1951
|
+
try {
|
|
1952
|
+
const parsed = new URL(url);
|
|
1953
|
+
return `${parsed.pathname}${parsed.search}`;
|
|
1954
|
+
} catch {
|
|
1955
|
+
return url;
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
async function captureSignIn(options) {
|
|
1959
|
+
const { chromium } = await loadPlaywright();
|
|
1960
|
+
const executablePath = process.env["CAPTURE_CHROME"] ?? void 0;
|
|
1961
|
+
const browser = await chromium.launch({
|
|
1962
|
+
headless: false,
|
|
1963
|
+
...executablePath ? { executablePath } : {},
|
|
1964
|
+
args: [
|
|
1965
|
+
"--no-sandbox",
|
|
1966
|
+
"--disable-dev-shm-usage",
|
|
1967
|
+
// Offering to remember the password is the one thing this window must
|
|
1968
|
+
// not do. The whole point of signing in here is that the credential is
|
|
1969
|
+
// spent and forgotten; a browser prompt to save it invites the opposite,
|
|
1970
|
+
// in a profile that is thrown away seconds later.
|
|
1971
|
+
"--disable-features=PasswordManagerEnabled,AutofillServerCommunication",
|
|
1972
|
+
"--password-store=basic"
|
|
1973
|
+
]
|
|
1974
|
+
});
|
|
1975
|
+
try {
|
|
1976
|
+
const context = await browser.newContext({ viewport: CAPTURE_CONTRACT.viewport });
|
|
1977
|
+
const page = await context.newPage();
|
|
1978
|
+
await page.goto(options.baseUrl, { waitUntil: "domcontentloaded", timeout: 6e4 });
|
|
1979
|
+
const deadline = Date.now() + (options.timeoutMs ?? 5 * 6e4);
|
|
1980
|
+
while (Date.now() < deadline) {
|
|
1981
|
+
const stillSigningIn = await page.locator('input[type="password"]').count() > 0;
|
|
1982
|
+
if (!stillSigningIn) {
|
|
1983
|
+
await page.waitForTimeout(1500);
|
|
1984
|
+
await context.storageState({ path: options.sessionPath });
|
|
1985
|
+
return { ok: true };
|
|
1986
|
+
}
|
|
1987
|
+
await page.waitForTimeout(1e3);
|
|
1988
|
+
}
|
|
1989
|
+
return { ok: false, reason: "Nobody finished signing in before the wait ran out." };
|
|
1990
|
+
} finally {
|
|
1991
|
+
await browser.close();
|
|
1992
|
+
}
|
|
1993
|
+
}
|
|
1994
|
+
async function loadPlaywright() {
|
|
1995
|
+
const specifier = "playwright-core";
|
|
1996
|
+
try {
|
|
1997
|
+
return await import(
|
|
1998
|
+
/* webpackIgnore: true */
|
|
1999
|
+
specifier
|
|
2000
|
+
);
|
|
2001
|
+
} catch {
|
|
2002
|
+
throw new Error(
|
|
2003
|
+
"Capture needs Playwright. Install it beside this command with `npm i -D playwright-core` and make sure a Chrome is available (set CAPTURE_CHROME to its path if it is not on the default one)."
|
|
2004
|
+
);
|
|
2005
|
+
}
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
// adapters/capture-stage.ts
|
|
2009
|
+
import { spawn } from "node:child_process";
|
|
2010
|
+
|
|
2011
|
+
// adapters/seed-proxy.ts
|
|
2012
|
+
import { createServer, request as httpRequest } from "node:http";
|
|
2013
|
+
|
|
2014
|
+
// core/seed/cassette.ts
|
|
2015
|
+
var CASSETTE_SCHEMA = "olomon.seed-cassette/1";
|
|
2016
|
+
var CACHE_BUSTERS = /* @__PURE__ */ new Set(["_", "cb", "cachebust", "cache_bust", "_t", "__ts", "nocache"]);
|
|
2017
|
+
function significantQuery(query) {
|
|
2018
|
+
const kept = Object.entries(query).filter(([name]) => !CACHE_BUSTERS.has(name)).sort(([a], [b]) => a.localeCompare(b));
|
|
2019
|
+
return Object.fromEntries(kept);
|
|
2020
|
+
}
|
|
2021
|
+
function interactionKey(args) {
|
|
2022
|
+
const query = significantQuery(args.query ?? {});
|
|
2023
|
+
const search = Object.entries(query).map(([name, value]) => `${name}=${value}`).join("&");
|
|
2024
|
+
return `${args.method.toUpperCase()} ${args.path}${search ? `?${search}` : ""}`;
|
|
2025
|
+
}
|
|
2026
|
+
function looksLikeIdentifier(segment) {
|
|
2027
|
+
if (!segment) return false;
|
|
2028
|
+
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(segment)) return true;
|
|
2029
|
+
if (/^\d+$/.test(segment)) return true;
|
|
2030
|
+
if (/^[0-9a-f]{8,}$/i.test(segment)) return true;
|
|
2031
|
+
if (segment.length >= 20) return true;
|
|
2032
|
+
return /\d/.test(segment) && segment.length >= 3;
|
|
2033
|
+
}
|
|
2034
|
+
function templatise(paths) {
|
|
2035
|
+
const split = paths.map((path2) => path2.split("/").filter(Boolean));
|
|
2036
|
+
const depth2 = Math.max(0, ...split.map((segments) => segments.length));
|
|
2037
|
+
const parameters = /* @__PURE__ */ new Set();
|
|
2038
|
+
const prefixOf = (segments, upTo) => {
|
|
2039
|
+
const parts = [];
|
|
2040
|
+
for (let index = 0; index < upTo; index += 1) {
|
|
2041
|
+
parts.push(parameters.has(`${parts.join("/")}\0${index}`) ? ":" : segments[index]);
|
|
2042
|
+
}
|
|
2043
|
+
return parts.join("/");
|
|
2044
|
+
};
|
|
2045
|
+
for (let index = 0; index < depth2; index += 1) {
|
|
2046
|
+
const groups = /* @__PURE__ */ new Map();
|
|
2047
|
+
const children = /* @__PURE__ */ new Map();
|
|
2048
|
+
for (const segments of split) {
|
|
2049
|
+
if (segments.length <= index) continue;
|
|
2050
|
+
const prefix = prefixOf(segments, index);
|
|
2051
|
+
const value = segments[index];
|
|
2052
|
+
(groups.get(prefix) ?? groups.set(prefix, /* @__PURE__ */ new Set()).get(prefix)).add(value);
|
|
2053
|
+
const perGroup = children.get(prefix) ?? children.set(prefix, /* @__PURE__ */ new Map()).get(prefix);
|
|
2054
|
+
const seen = perGroup.get(value) ?? perGroup.set(value, /* @__PURE__ */ new Set()).get(value);
|
|
2055
|
+
if (segments.length > index + 1) seen.add(segments[index + 1]);
|
|
2056
|
+
}
|
|
2057
|
+
for (const [prefix, values] of groups) {
|
|
2058
|
+
if (values.size < 2) continue;
|
|
2059
|
+
const identifier = [...values].some(looksLikeIdentifier);
|
|
2060
|
+
const parallel = leadsToTheSamePlaces(children.get(prefix));
|
|
2061
|
+
if (identifier || parallel) parameters.add(`${prefix}\0${index}`);
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
const templates = /* @__PURE__ */ new Map();
|
|
2065
|
+
for (const [index, segments] of split.entries()) {
|
|
2066
|
+
const named = [];
|
|
2067
|
+
let templated = false;
|
|
2068
|
+
for (let position = 0; position < segments.length; position += 1) {
|
|
2069
|
+
if (parameters.has(`${prefixOf(segments, position)}\0${position}`)) {
|
|
2070
|
+
named.push(`:${parameterName(segments, position)}`);
|
|
2071
|
+
templated = true;
|
|
2072
|
+
} else {
|
|
2073
|
+
named.push(segments[position]);
|
|
2074
|
+
}
|
|
2075
|
+
}
|
|
2076
|
+
if (templated) templates.set(paths[index], `/${named.join("/")}`);
|
|
2077
|
+
}
|
|
2078
|
+
return templates;
|
|
2079
|
+
}
|
|
2080
|
+
function leadsToTheSamePlaces(children) {
|
|
2081
|
+
if (!children || children.size < 2) return false;
|
|
2082
|
+
const sets = [...children.values()].map((set) => [...set].sort().join("\0"));
|
|
2083
|
+
return sets.every((set) => set && set === sets[0]);
|
|
2084
|
+
}
|
|
2085
|
+
function parameterName(segments, depth2) {
|
|
2086
|
+
const parent = depth2 > 0 ? segments[depth2 - 1] : "";
|
|
2087
|
+
const base = parent && !parent.startsWith(":") ? singular(parent) : "id";
|
|
2088
|
+
return base.replace(/[^a-z0-9]+/gi, "") || "id";
|
|
2089
|
+
}
|
|
2090
|
+
function singular(word) {
|
|
2091
|
+
const lower = word.toLowerCase();
|
|
2092
|
+
if (/(analysis|status|address|series|species)$/.test(lower)) return lower;
|
|
2093
|
+
if (lower.endsWith("ies")) return `${lower.slice(0, -3)}y`;
|
|
2094
|
+
if (/(ch|sh|ss|x|z)es$/.test(lower)) return lower.slice(0, -2);
|
|
2095
|
+
if (lower.endsWith("s") && !lower.endsWith("ss")) return lower.slice(0, -1);
|
|
2096
|
+
return lower;
|
|
2097
|
+
}
|
|
2098
|
+
function parseCassette(text2) {
|
|
2099
|
+
const parsed = JSON.parse(text2);
|
|
2100
|
+
if (parsed.schema !== CASSETTE_SCHEMA) {
|
|
2101
|
+
throw new Error(`Cassette schema ${JSON.stringify(parsed.schema)} is not one this understands.`);
|
|
2102
|
+
}
|
|
2103
|
+
return parsed;
|
|
2104
|
+
}
|
|
2105
|
+
var CassetteIndex = class {
|
|
2106
|
+
constructor(cassette) {
|
|
2107
|
+
this.cassette = cassette;
|
|
2108
|
+
for (const interaction of cassette.interactions) {
|
|
2109
|
+
this.exact.set(interactionKey(interaction), interaction);
|
|
2110
|
+
if (interaction.template) {
|
|
2111
|
+
const key = interactionKey({ ...interaction, path: interaction.template });
|
|
2112
|
+
if (!this.byTemplate.has(key)) this.byTemplate.set(key, interaction);
|
|
2113
|
+
}
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
exact = /* @__PURE__ */ new Map();
|
|
2117
|
+
byTemplate = /* @__PURE__ */ new Map();
|
|
2118
|
+
get anchor() {
|
|
2119
|
+
return this.cassette.anchor;
|
|
2120
|
+
}
|
|
2121
|
+
get size() {
|
|
2122
|
+
return this.exact.size;
|
|
2123
|
+
}
|
|
2124
|
+
find(args) {
|
|
2125
|
+
const direct = this.exact.get(interactionKey(args));
|
|
2126
|
+
if (direct) return direct;
|
|
2127
|
+
const templates = new Set(
|
|
2128
|
+
this.cassette.interactions.map((interaction) => interaction.template).filter(Boolean)
|
|
2129
|
+
);
|
|
2130
|
+
for (const template of [...templates].sort()) {
|
|
2131
|
+
if (!matchesTemplate(template, args.path)) continue;
|
|
2132
|
+
const found = this.byTemplate.get(interactionKey({ ...args, path: template }));
|
|
2133
|
+
if (found) return found;
|
|
2134
|
+
}
|
|
2135
|
+
return void 0;
|
|
2136
|
+
}
|
|
2137
|
+
};
|
|
2138
|
+
function matchesTemplate(template, path2) {
|
|
2139
|
+
const wanted = template.split("/").filter(Boolean);
|
|
2140
|
+
const actual = path2.split("/").filter(Boolean);
|
|
2141
|
+
if (wanted.length !== actual.length) return false;
|
|
2142
|
+
return wanted.every((segment, index) => segment.startsWith(":") || segment === actual[index]);
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2145
|
+
// adapters/seed-proxy.ts
|
|
2146
|
+
var MISS_STATUS = RECORDING_MISS_STATUS;
|
|
2147
|
+
function bodyOf(message) {
|
|
2148
|
+
return new Promise((resolve, reject) => {
|
|
2149
|
+
const chunks = [];
|
|
2150
|
+
message.on("data", (chunk) => chunks.push(chunk));
|
|
2151
|
+
message.on("end", () => resolve(Buffer.concat(chunks)));
|
|
2152
|
+
message.on("error", reject);
|
|
2153
|
+
});
|
|
2154
|
+
}
|
|
2155
|
+
function listen(server, port) {
|
|
2156
|
+
return new Promise((resolve, reject) => {
|
|
2157
|
+
server.once("error", reject);
|
|
2158
|
+
server.listen(port, "127.0.0.1", () => {
|
|
2159
|
+
const address = server.address();
|
|
2160
|
+
resolve(typeof address === "object" && address ? address.port : port);
|
|
2161
|
+
});
|
|
2162
|
+
});
|
|
2163
|
+
}
|
|
2164
|
+
function shut(server) {
|
|
2165
|
+
return new Promise((resolve) => {
|
|
2166
|
+
server.closeAllConnections?.();
|
|
2167
|
+
server.close(() => resolve());
|
|
2168
|
+
});
|
|
2169
|
+
}
|
|
2170
|
+
function splitUrl(url) {
|
|
2171
|
+
const [path2, search] = url.split("?");
|
|
2172
|
+
const query = {};
|
|
2173
|
+
for (const [name, value] of new URLSearchParams(search ?? "")) query[name] = value;
|
|
2174
|
+
return { path: path2 ?? "/", query };
|
|
2175
|
+
}
|
|
2176
|
+
async function replayFrom(options) {
|
|
2177
|
+
const index = new CassetteIndex(options.cassette);
|
|
2178
|
+
const misses = [];
|
|
2179
|
+
const served = /* @__PURE__ */ new Set();
|
|
2180
|
+
const server = createServer((incoming, outgoing) => {
|
|
2181
|
+
void (async () => {
|
|
2182
|
+
const { path: path2, query } = splitUrl(incoming.url ?? "/");
|
|
2183
|
+
await bodyOf(incoming);
|
|
2184
|
+
const found = index.find({ method: incoming.method ?? "GET", path: path2, query });
|
|
2185
|
+
if (!found) {
|
|
2186
|
+
const from = incoming.headers["referer"] ?? incoming.headers["x-seed-page"];
|
|
2187
|
+
const miss = {
|
|
2188
|
+
method: incoming.method ?? "GET",
|
|
2189
|
+
path: path2,
|
|
2190
|
+
...Object.keys(query).length ? { query } : {},
|
|
2191
|
+
...typeof from === "string" ? { from: pageOf(from) } : {}
|
|
2192
|
+
};
|
|
2193
|
+
misses.push(miss);
|
|
2194
|
+
options.onMiss?.(miss);
|
|
2195
|
+
outgoing.writeHead(MISS_STATUS, { "content-type": "application/json" });
|
|
2196
|
+
outgoing.end(
|
|
2197
|
+
JSON.stringify({
|
|
2198
|
+
error: "not in the recording",
|
|
2199
|
+
method: miss.method,
|
|
2200
|
+
path: miss.path,
|
|
2201
|
+
hint: "Record this endpoint, or the screen that needs it has no data."
|
|
2202
|
+
})
|
|
2203
|
+
);
|
|
2204
|
+
return;
|
|
2205
|
+
}
|
|
2206
|
+
served.add(`${found.method} ${found.path}`);
|
|
2207
|
+
const body = found.encoding === "base64" ? Buffer.from(found.body, "base64") : Buffer.from(found.body, "utf8");
|
|
2208
|
+
outgoing.writeHead(found.status, { ...found.headers, "content-length": String(body.byteLength) });
|
|
2209
|
+
outgoing.end(body);
|
|
2210
|
+
})();
|
|
2211
|
+
});
|
|
2212
|
+
const port = await listen(server, options.port ?? 0);
|
|
2213
|
+
return {
|
|
2214
|
+
url: `http://127.0.0.1:${port}`,
|
|
2215
|
+
port,
|
|
2216
|
+
misses,
|
|
2217
|
+
served,
|
|
2218
|
+
close: () => shut(server)
|
|
2219
|
+
};
|
|
2220
|
+
}
|
|
2221
|
+
function pageOf(referer) {
|
|
2222
|
+
try {
|
|
2223
|
+
return new URL(referer).pathname;
|
|
2224
|
+
} catch {
|
|
2225
|
+
return referer;
|
|
2226
|
+
}
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
// core/ci-api.ts
|
|
2230
|
+
var CI_SESSION_OUT_ENV = "HELPDESK_SESSION_OUT";
|
|
2231
|
+
var CI_BASE_URL_ENV = "HELPDESK_BASE_URL";
|
|
2232
|
+
|
|
2233
|
+
// adapters/capture-stage.ts
|
|
2234
|
+
var DEFAULT_APP_ENV = "API_BASE_URL";
|
|
2235
|
+
async function waitForApp(url, deadline, onLog, gaveUp) {
|
|
2236
|
+
let announced = false;
|
|
2237
|
+
while (Date.now() < deadline) {
|
|
2238
|
+
try {
|
|
2239
|
+
await fetch(url, { signal: AbortSignal.timeout(2e3) });
|
|
2240
|
+
return true;
|
|
2241
|
+
} catch {
|
|
2242
|
+
if (gaveUp?.()) return false;
|
|
2243
|
+
if (!announced) {
|
|
2244
|
+
onLog?.(`Waiting for ${url} to answer\u2026`);
|
|
2245
|
+
announced = true;
|
|
2246
|
+
}
|
|
2247
|
+
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2250
|
+
return false;
|
|
2251
|
+
}
|
|
2252
|
+
async function stageAgainstRecording(options) {
|
|
2253
|
+
const replay = await replayFrom({
|
|
2254
|
+
cassette: options.cassette,
|
|
2255
|
+
...options.replayPort ? { port: options.replayPort } : {},
|
|
2256
|
+
onMiss: (miss) => options.onLog?.(` the recording has no ${miss.method} ${miss.path}${miss.from ? ` (wanted by ${miss.from})` : ""}`)
|
|
2257
|
+
});
|
|
2258
|
+
const variable = options.appEnv ?? DEFAULT_APP_ENV;
|
|
2259
|
+
let app;
|
|
2260
|
+
let died;
|
|
2261
|
+
if (options.appCommand) {
|
|
2262
|
+
options.onLog?.(`Starting your product with ${variable}=${replay.url}`);
|
|
2263
|
+
app = spawn(options.appCommand, {
|
|
2264
|
+
shell: true,
|
|
2265
|
+
// Its own process group, so it can be taken down as one.
|
|
2266
|
+
//
|
|
2267
|
+
// A start command is almost never the server: it is a shell running
|
|
2268
|
+
// `npm start` running `next start`. Killing the pid we hold kills the
|
|
2269
|
+
// shell and leaves the server holding the port, so the next run finds
|
|
2270
|
+
// the address occupied by the last one — and on a build agent, the job
|
|
2271
|
+
// never finishes.
|
|
2272
|
+
detached: true,
|
|
2273
|
+
env: { ...process.env, [variable]: replay.url },
|
|
2274
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
2275
|
+
});
|
|
2276
|
+
app.stdout?.on("data", (chunk) => options.onLog?.(` [app] ${chunk.toString().trimEnd()}`));
|
|
2277
|
+
app.stderr?.on("data", (chunk) => options.onLog?.(` [app] ${chunk.toString().trimEnd()}`));
|
|
2278
|
+
app.on("exit", (code) => {
|
|
2279
|
+
died = code ?? 1;
|
|
2280
|
+
});
|
|
2281
|
+
} else {
|
|
2282
|
+
options.onLog?.(`The recording is at ${replay.url} \u2014 start your product with ${variable} set to it.`);
|
|
2283
|
+
}
|
|
2284
|
+
const up = await waitForApp(options.appUrl, Date.now() + (options.waitMs ?? 6e4), options.onLog, () => died !== void 0);
|
|
2285
|
+
if (app) await new Promise((resolve) => setTimeout(resolve, 250));
|
|
2286
|
+
if (died === void 0 && app?.exitCode !== null && app?.exitCode !== void 0) died = app.exitCode;
|
|
2287
|
+
if (died !== void 0) {
|
|
2288
|
+
await replay.close();
|
|
2289
|
+
stop(app);
|
|
2290
|
+
throw new Error(
|
|
2291
|
+
`The start command exited (${died}) before the product was ready. Its output is above.` + (up ? `
|
|
2292
|
+
|
|
2293
|
+
Something else is already answering on ${options.appUrl}. That is almost always a server left behind by an earlier run \u2014 this will not photograph it, because it is not the product this run started.` : "")
|
|
2294
|
+
);
|
|
2295
|
+
}
|
|
2296
|
+
if (!up) {
|
|
2297
|
+
await replay.close();
|
|
2298
|
+
stop(app);
|
|
2299
|
+
throw new Error(
|
|
2300
|
+
`${options.appUrl} never answered. ` + (options.appCommand ? "Check the start command above, and that your product listens on that address." : `Start your product with ${variable}=${replay.url}, then run this again.`)
|
|
2301
|
+
);
|
|
2302
|
+
}
|
|
2303
|
+
return {
|
|
2304
|
+
appUrl: options.appUrl,
|
|
2305
|
+
replayUrl: replay.url,
|
|
2306
|
+
replay,
|
|
2307
|
+
cassette: options.cassette,
|
|
2308
|
+
close: async () => {
|
|
2309
|
+
stop(app);
|
|
2310
|
+
await replay.close();
|
|
2311
|
+
}
|
|
2312
|
+
};
|
|
2313
|
+
}
|
|
2314
|
+
var SESSION_OUT_ENV = CI_SESSION_OUT_ENV;
|
|
2315
|
+
async function stageSeeded(options) {
|
|
2316
|
+
const onLog = options.onLog;
|
|
2317
|
+
const { access, unlink } = await import("node:fs/promises");
|
|
2318
|
+
await unlink(options.sessionOut).catch(() => void 0);
|
|
2319
|
+
onLog?.(`Seeding a demo tenant with ${options.seedCommand}`);
|
|
2320
|
+
const seeded = await runToCompletion(
|
|
2321
|
+
options.seedCommand,
|
|
2322
|
+
options.seedMs ?? 6e5,
|
|
2323
|
+
{ [CI_SESSION_OUT_ENV]: options.sessionOut, [CI_BASE_URL_ENV]: options.appUrl },
|
|
2324
|
+
onLog
|
|
2325
|
+
);
|
|
2326
|
+
if (!seeded.ok) {
|
|
2327
|
+
throw new Error(
|
|
2328
|
+
`The seed command ${seeded.timedOut ? "did not finish in time" : `exited ${seeded.code}`}. Its output is above.
|
|
2329
|
+
|
|
2330
|
+
Nothing has been photographed. A run that carried on from here would sign in to nothing and photograph the login screen of an empty product.`
|
|
2331
|
+
);
|
|
2332
|
+
}
|
|
2333
|
+
const wrote = await access(options.sessionOut).then(
|
|
2334
|
+
() => true,
|
|
2335
|
+
() => false
|
|
2336
|
+
);
|
|
2337
|
+
const sessionPath = wrote ? options.sessionOut : void 0;
|
|
2338
|
+
onLog?.(
|
|
2339
|
+
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.`
|
|
2340
|
+
);
|
|
2341
|
+
let app;
|
|
2342
|
+
let died;
|
|
2343
|
+
if (options.appCommand) {
|
|
2344
|
+
onLog?.(`Starting your product with ${options.appCommand}`);
|
|
2345
|
+
app = spawn(options.appCommand, {
|
|
2346
|
+
shell: true,
|
|
2347
|
+
detached: true,
|
|
2348
|
+
env: { ...process.env },
|
|
2349
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
2350
|
+
});
|
|
2351
|
+
app.stdout?.on("data", (chunk) => onLog?.(` [app] ${chunk.toString().trimEnd()}`));
|
|
2352
|
+
app.stderr?.on("data", (chunk) => onLog?.(` [app] ${chunk.toString().trimEnd()}`));
|
|
2353
|
+
app.on("exit", (code) => {
|
|
2354
|
+
died = code ?? 1;
|
|
2355
|
+
});
|
|
2356
|
+
}
|
|
2357
|
+
const up = await waitForApp(options.appUrl, Date.now() + (options.waitMs ?? 6e4), onLog, () => died !== void 0);
|
|
2358
|
+
if (app) await new Promise((resolve) => setTimeout(resolve, 250));
|
|
2359
|
+
if (died === void 0 && app?.exitCode !== null && app?.exitCode !== void 0) died = app.exitCode;
|
|
2360
|
+
if (died !== void 0) {
|
|
2361
|
+
stop(app);
|
|
2362
|
+
throw new Error(
|
|
2363
|
+
`The start command exited (${died}) before the product was ready. Its output is above.` + (up ? `
|
|
2364
|
+
|
|
2365
|
+
Something else is already answering on ${options.appUrl}, which 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.` : "")
|
|
2366
|
+
);
|
|
2367
|
+
}
|
|
2368
|
+
if (!up) {
|
|
2369
|
+
stop(app);
|
|
2370
|
+
throw new Error(
|
|
2371
|
+
`${options.appUrl} never answered. Check the start command above, and that your product listens on that address.`
|
|
2372
|
+
);
|
|
2373
|
+
}
|
|
2374
|
+
return {
|
|
2375
|
+
appUrl: options.appUrl,
|
|
2376
|
+
...sessionPath ? { sessionPath } : {},
|
|
2377
|
+
close: async () => {
|
|
2378
|
+
stop(app);
|
|
2379
|
+
}
|
|
2380
|
+
};
|
|
2381
|
+
}
|
|
2382
|
+
async function runToCompletion(command, timeoutMs, env, onLog) {
|
|
2383
|
+
return new Promise((resolve) => {
|
|
2384
|
+
const child = spawn(command, {
|
|
2385
|
+
shell: true,
|
|
2386
|
+
detached: true,
|
|
2387
|
+
env: { ...process.env, ...env },
|
|
2388
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
2389
|
+
});
|
|
2390
|
+
const timer = setTimeout(() => {
|
|
2391
|
+
stop(child);
|
|
2392
|
+
resolve({ ok: false, timedOut: true });
|
|
2393
|
+
}, timeoutMs);
|
|
2394
|
+
child.stdout?.on("data", (chunk) => onLog?.(` [seed] ${chunk.toString().trimEnd()}`));
|
|
2395
|
+
child.stderr?.on("data", (chunk) => onLog?.(` [seed] ${chunk.toString().trimEnd()}`));
|
|
2396
|
+
child.on("error", (error) => {
|
|
2397
|
+
clearTimeout(timer);
|
|
2398
|
+
onLog?.(` [seed] ${error.message}`);
|
|
2399
|
+
resolve({ ok: false, code: 1, timedOut: false });
|
|
2400
|
+
});
|
|
2401
|
+
child.on("exit", (code) => {
|
|
2402
|
+
clearTimeout(timer);
|
|
2403
|
+
resolve({ ok: code === 0, code: code ?? 1, timedOut: false });
|
|
2404
|
+
});
|
|
2405
|
+
});
|
|
2406
|
+
}
|
|
2407
|
+
function stop(app) {
|
|
2408
|
+
if (!app?.pid) return;
|
|
2409
|
+
try {
|
|
2410
|
+
process.kill(-app.pid, "SIGTERM");
|
|
2411
|
+
} catch {
|
|
2412
|
+
try {
|
|
2413
|
+
process.kill(app.pid, "SIGTERM");
|
|
2414
|
+
} catch {
|
|
2415
|
+
}
|
|
2416
|
+
}
|
|
2417
|
+
}
|
|
2418
|
+
|
|
2419
|
+
// core/seed/gaps.ts
|
|
2420
|
+
function gapsFrom(misses, cassette) {
|
|
2421
|
+
const known = cassette?.interactions.map((interaction) => interaction.path) ?? [];
|
|
2422
|
+
const templates = templatise([...known, ...misses.map((miss) => miss.path)]);
|
|
2423
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
2424
|
+
for (const miss of misses) {
|
|
2425
|
+
const path2 = templates.get(miss.path) ?? miss.path;
|
|
2426
|
+
const key = interactionKey({ method: miss.method, path: path2, ...miss.query ? { query: miss.query } : {} });
|
|
2427
|
+
const existing = grouped.get(key);
|
|
2428
|
+
if (existing) {
|
|
2429
|
+
existing.asked += 1;
|
|
2430
|
+
if (miss.from && !existing.broke.includes(miss.from)) existing.broke.push(miss.from);
|
|
2431
|
+
continue;
|
|
2432
|
+
}
|
|
2433
|
+
grouped.set(key, {
|
|
2434
|
+
method: miss.method.toUpperCase(),
|
|
2435
|
+
path: path2,
|
|
2436
|
+
asked: 1,
|
|
2437
|
+
broke: miss.from ? [miss.from] : [],
|
|
2438
|
+
example: miss.path
|
|
2439
|
+
});
|
|
2440
|
+
}
|
|
2441
|
+
return [...grouped.values()].sort((a, b) => b.asked - a.asked || a.path.localeCompare(b.path));
|
|
2442
|
+
}
|
|
2443
|
+
function gapReport(gaps) {
|
|
2444
|
+
if (!gaps.length) return ["The recording answered everything this run asked for."];
|
|
2445
|
+
const lines = [
|
|
2446
|
+
`${gaps.length} endpoint(s) are missing from the recording. Until they are in it, the screens below have no data:`,
|
|
2447
|
+
""
|
|
2448
|
+
];
|
|
2449
|
+
for (const gap of gaps) {
|
|
2450
|
+
const broke = gap.broke.length ? ` \u2014 breaks ${gap.broke.join(", ")}` : "";
|
|
2451
|
+
lines.push(` ${gap.method} ${gap.path}${broke}`);
|
|
2452
|
+
if (gap.path !== gap.example) lines.push(` for example ${gap.example}`);
|
|
2453
|
+
}
|
|
2454
|
+
lines.push(
|
|
2455
|
+
"",
|
|
2456
|
+
"Record them by running the product against the recording proxy once more and visiting those screens."
|
|
2457
|
+
);
|
|
2458
|
+
return lines;
|
|
2459
|
+
}
|
|
2460
|
+
function coverage(routes, cassette) {
|
|
2461
|
+
const screens = cassette.screens ?? [];
|
|
2462
|
+
if (!screens.length) return { covered: [], missing: [], known: false };
|
|
2463
|
+
const covered = [];
|
|
2464
|
+
const missing = [];
|
|
2465
|
+
for (const route of routes) {
|
|
2466
|
+
(screens.some((screen) => sameScreen(route, screen)) ? covered : missing).push(route);
|
|
2467
|
+
}
|
|
2468
|
+
return { covered, missing, known: true };
|
|
2469
|
+
}
|
|
2470
|
+
function sameScreen(route, screen) {
|
|
2471
|
+
const wanted = route.split("/").filter(Boolean);
|
|
2472
|
+
const actual = screen.split("/").filter(Boolean);
|
|
2473
|
+
if (wanted.length !== actual.length) return false;
|
|
2474
|
+
return wanted.every(
|
|
2475
|
+
(segment, index) => /^[[:]/.test(segment) || segment.toLowerCase() === actual[index]?.toLowerCase()
|
|
2476
|
+
);
|
|
2477
|
+
}
|
|
2478
|
+
function coverageReport(found, total) {
|
|
2479
|
+
if (!found.known) {
|
|
2480
|
+
return [
|
|
2481
|
+
"This recording does not say which screens it was made from, so it cannot be checked",
|
|
2482
|
+
"against your plan before the run. Record again to find out what is missing before",
|
|
2483
|
+
"photographing rather than after."
|
|
2484
|
+
];
|
|
2485
|
+
}
|
|
2486
|
+
if (!found.missing.length) {
|
|
2487
|
+
return [`All ${total} screen(s) in your plan were visited while recording.`];
|
|
2488
|
+
}
|
|
2489
|
+
return [
|
|
2490
|
+
`Your plan needs ${total} screen(s). This recording was made while visiting ${found.covered.length}.`,
|
|
2491
|
+
"",
|
|
2492
|
+
`${found.missing.length} screen(s) have no data in the recording:`,
|
|
2493
|
+
"",
|
|
2494
|
+
...found.missing.map((route) => ` ${route}`),
|
|
2495
|
+
"",
|
|
2496
|
+
"They will not be photographed. Record again and visit them, or they stay out of the",
|
|
2497
|
+
"plan \u2014 an empty screen photographed from a gap in the recording looks exactly like an",
|
|
2498
|
+
"empty screen belonging to a customer with nothing, which is how a help article ends up",
|
|
2499
|
+
"showing a product with no content in it."
|
|
2500
|
+
];
|
|
2501
|
+
}
|
|
2502
|
+
|
|
2503
|
+
// adapters/capture-bin.ts
|
|
2504
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2505
|
+
import path from "node:path";
|
|
2506
|
+
|
|
2507
|
+
// adapters/map-cli.ts
|
|
2508
|
+
import { execFileSync } from "node:child_process";
|
|
2509
|
+
|
|
2510
|
+
// core/discovery/next-app-router.ts
|
|
2511
|
+
var PAGE_FILE = /(^|\/)page\.(tsx|ts|jsx|js)$/;
|
|
2512
|
+
var LAYOUT_FILE = /(^|\/)layout\.(tsx|ts|jsx|js)$/;
|
|
2513
|
+
var STATE_FILE = /(^|\/)(error|global-error|loading|not-found|template|default)\.(tsx|ts|jsx|js)$/;
|
|
2514
|
+
var ROUTE_GROUP = /^\((.+)\)$/;
|
|
2515
|
+
var DYNAMIC = /^\[{1,2}(\.{3})?(.+?)\]{1,2}$/;
|
|
2516
|
+
var PARALLEL = /^@/;
|
|
2517
|
+
var INTERCEPTING = /^\(\.+\)/;
|
|
2518
|
+
function classify(path2) {
|
|
2519
|
+
if (path2 === "/api" || path2.startsWith("/api/")) {
|
|
2520
|
+
return { skipped: "not-html", note: "an API route, not a screen" };
|
|
2521
|
+
}
|
|
2522
|
+
if (/(^|\/)(admin|internal|debug|_dev)(\/|$)/.test(path2)) {
|
|
2523
|
+
return { skipped: "internal-tooling", note: "internal tooling" };
|
|
2524
|
+
}
|
|
2525
|
+
if (/(^|\/)(login|logout|signin|sign-in|signup|sign-up|auth|verify|reset-password)(\/|$)/.test(path2)) {
|
|
2526
|
+
return { skipped: "unauthenticated-flow", note: "an authentication step" };
|
|
2527
|
+
}
|
|
2528
|
+
return void 0;
|
|
2529
|
+
}
|
|
2530
|
+
function detectRouterRoot(files) {
|
|
2531
|
+
for (const candidate of ["src/app", "app"]) {
|
|
2532
|
+
if (files.some((file) => file.startsWith(`${candidate}/`) && PAGE_FILE.test(file))) {
|
|
2533
|
+
return candidate;
|
|
2534
|
+
}
|
|
2535
|
+
}
|
|
2536
|
+
return void 0;
|
|
2537
|
+
}
|
|
2538
|
+
function readNextAppRouter(files, root = "src/app") {
|
|
2539
|
+
const prefix = root.endsWith("/") ? root : `${root}/`;
|
|
2540
|
+
const inRouter = [...new Set(files)].filter((file) => file.startsWith(prefix));
|
|
2541
|
+
const layouts = inRouter.filter((file) => LAYOUT_FILE.test(file));
|
|
2542
|
+
const routes = [];
|
|
2543
|
+
const ignored = [];
|
|
2544
|
+
for (const file of inRouter) {
|
|
2545
|
+
if (!PAGE_FILE.test(file)) continue;
|
|
2546
|
+
const segments = file.slice(prefix.length).split("/").slice(0, -1);
|
|
2547
|
+
const parsed = toPath(segments);
|
|
2548
|
+
if ("reason" in parsed) {
|
|
2549
|
+
ignored.push({ file, reason: parsed.reason });
|
|
2550
|
+
continue;
|
|
2551
|
+
}
|
|
2552
|
+
const classification = classify(parsed.path);
|
|
2553
|
+
routes.push({
|
|
2554
|
+
path: parsed.path,
|
|
2555
|
+
// The page, then what sits beside it, then the layouts that wrap it.
|
|
2556
|
+
//
|
|
2557
|
+
// Co-located files matter as much as the page: a route's server actions
|
|
2558
|
+
// and client components are where its behaviour actually lives, so an
|
|
2559
|
+
// article about the screen goes stale when THEY change. Taking only the
|
|
2560
|
+
// page would leave change detection blind to the part most likely to move.
|
|
2561
|
+
sourceFiles: [file, ...siblings(file, inRouter), ...enclosingLayouts(file, layouts)],
|
|
2562
|
+
params: parsed.params,
|
|
2563
|
+
groups: parsed.groups,
|
|
2564
|
+
states: declaredStates(file, inRouter, prefix),
|
|
2565
|
+
...classification ?? {}
|
|
2566
|
+
});
|
|
2567
|
+
}
|
|
2568
|
+
routes.sort((a, b) => depth(a.path) - depth(b.path) || a.path.localeCompare(b.path));
|
|
2569
|
+
return { framework: "next-app-router", routes, ignored };
|
|
2570
|
+
}
|
|
2571
|
+
function declaredStates(pageFile, files, prefix) {
|
|
2572
|
+
const directory = pageFile.slice(0, pageFile.lastIndexOf("/"));
|
|
2573
|
+
const own = new Set(
|
|
2574
|
+
files.filter((file) => file.slice(0, file.lastIndexOf("/")) === directory)
|
|
2575
|
+
);
|
|
2576
|
+
const found = /* @__PURE__ */ new Map();
|
|
2577
|
+
for (let at = directory; at.length >= prefix.length - 1; ) {
|
|
2578
|
+
for (const file of files) {
|
|
2579
|
+
if (file.slice(0, file.lastIndexOf("/")) !== at) continue;
|
|
2580
|
+
const kind = stateKindOf(file);
|
|
2581
|
+
if (!kind || found.has(kind)) continue;
|
|
2582
|
+
found.set(kind, {
|
|
2583
|
+
kind,
|
|
2584
|
+
file,
|
|
2585
|
+
segment: at.slice(prefix.length - 1).replace(/^\//, ""),
|
|
2586
|
+
inherited: !own.has(file)
|
|
2587
|
+
});
|
|
2588
|
+
}
|
|
2589
|
+
const up = at.lastIndexOf("/");
|
|
2590
|
+
if (up < 0 || at.length <= prefix.length - 1) break;
|
|
2591
|
+
at = at.slice(0, up);
|
|
2592
|
+
}
|
|
2593
|
+
return [...found.values()].sort((a, b) => a.kind.localeCompare(b.kind));
|
|
2594
|
+
}
|
|
2595
|
+
function stateKindOf(file) {
|
|
2596
|
+
const match = /(^|\/)(error|global-error|loading|not-found)\.(tsx|ts|jsx|js)$/.exec(file);
|
|
2597
|
+
return match ? match[2] : void 0;
|
|
2598
|
+
}
|
|
2599
|
+
function toPath(segments) {
|
|
2600
|
+
const parts = [];
|
|
2601
|
+
const params = [];
|
|
2602
|
+
const groups = [];
|
|
2603
|
+
for (const segment of segments) {
|
|
2604
|
+
if (INTERCEPTING.test(segment)) return { reason: "an intercepting route, not its own URL" };
|
|
2605
|
+
if (PARALLEL.test(segment)) return { reason: "a parallel route slot, not its own URL" };
|
|
2606
|
+
const group = ROUTE_GROUP.exec(segment);
|
|
2607
|
+
if (group) {
|
|
2608
|
+
groups.push(group[1]);
|
|
2609
|
+
continue;
|
|
2610
|
+
}
|
|
2611
|
+
const dynamic = DYNAMIC.exec(segment);
|
|
2612
|
+
if (dynamic) {
|
|
2613
|
+
params.push(dynamic[2]);
|
|
2614
|
+
parts.push(segment);
|
|
2615
|
+
continue;
|
|
2616
|
+
}
|
|
2617
|
+
parts.push(segment);
|
|
2618
|
+
}
|
|
2619
|
+
return { path: `/${parts.join("/")}`.replace(/\/+$/, "") || "/", params, groups };
|
|
2620
|
+
}
|
|
2621
|
+
function siblings(pageFile, files) {
|
|
2622
|
+
const dir = pageFile.slice(0, pageFile.lastIndexOf("/") + 1);
|
|
2623
|
+
return files.filter((file) => file !== pageFile && file.startsWith(dir) && !file.slice(dir.length).includes("/")).filter((file) => !/(^|\/)(opengraph-image|twitter-image|icon|apple-icon|sitemap|robots)\./.test(file)).filter((file) => !STATE_FILE.test(file)).filter((file) => !LAYOUT_FILE.test(file)).sort();
|
|
2624
|
+
}
|
|
2625
|
+
function enclosingLayouts(pageFile, layouts) {
|
|
2626
|
+
const dir = pageFile.slice(0, pageFile.lastIndexOf("/"));
|
|
2627
|
+
return layouts.filter((layout) => dir.startsWith(layout.slice(0, layout.lastIndexOf("/")))).sort((a, b) => a.length - b.length);
|
|
2628
|
+
}
|
|
2629
|
+
function depth(path2) {
|
|
2630
|
+
return path2 === "/" ? 0 : path2.split("/").filter(Boolean).length;
|
|
2631
|
+
}
|
|
2632
|
+
|
|
2633
|
+
// core/discovery/article-plan.ts
|
|
2634
|
+
var MONEY_OR_ACCESS = /* @__PURE__ */ new Set([
|
|
2635
|
+
"billing",
|
|
2636
|
+
"invoice",
|
|
2637
|
+
"invoices",
|
|
2638
|
+
"payment",
|
|
2639
|
+
"payments",
|
|
2640
|
+
"subscription",
|
|
2641
|
+
"subscriptions",
|
|
2642
|
+
"plan",
|
|
2643
|
+
"plans",
|
|
2644
|
+
"pricing",
|
|
2645
|
+
"seat",
|
|
2646
|
+
"seats",
|
|
2647
|
+
"permission",
|
|
2648
|
+
"permissions",
|
|
2649
|
+
"role",
|
|
2650
|
+
"roles",
|
|
2651
|
+
"member",
|
|
2652
|
+
"members"
|
|
2653
|
+
]);
|
|
2654
|
+
var ENTRY_POINT = /* @__PURE__ */ new Set([
|
|
2655
|
+
"onboard",
|
|
2656
|
+
"onboarding",
|
|
2657
|
+
"setup",
|
|
2658
|
+
"get-started",
|
|
2659
|
+
"getting-started",
|
|
2660
|
+
"welcome",
|
|
2661
|
+
"install",
|
|
2662
|
+
"import",
|
|
2663
|
+
"connect",
|
|
2664
|
+
"integration",
|
|
2665
|
+
"integrations"
|
|
2666
|
+
]);
|
|
2667
|
+
function hasSegment(path2, vocabulary) {
|
|
2668
|
+
return path2.split("/").filter(Boolean).some((segment) => vocabulary.has(segment.toLowerCase()));
|
|
2669
|
+
}
|
|
2670
|
+
function score(route, signals) {
|
|
2671
|
+
const because = [];
|
|
2672
|
+
const segments = route.path.split("/").filter(Boolean);
|
|
2673
|
+
let total = 0;
|
|
2674
|
+
const depthPenalty = Math.min(segments.length, 5) * 8;
|
|
2675
|
+
total += 40 - depthPenalty;
|
|
2676
|
+
if (segments.length <= 1) because.push("a top-level destination");
|
|
2677
|
+
if (route.params.length > 0) {
|
|
2678
|
+
total += 10;
|
|
2679
|
+
because.push("a record detail screen");
|
|
2680
|
+
}
|
|
2681
|
+
if (signals.writes && signals.writes > 0) {
|
|
2682
|
+
total += Math.min(signals.writes, 4) * 7;
|
|
2683
|
+
because.push(
|
|
2684
|
+
signals.writes === 1 ? "somewhere a user changes something" : `${signals.writes} actions a user can take here`
|
|
2685
|
+
);
|
|
2686
|
+
}
|
|
2687
|
+
if (signals.fanIn && signals.fanIn > 2) {
|
|
2688
|
+
total += Math.min(signals.fanIn, 8) * 2;
|
|
2689
|
+
because.push("linked to from across the app");
|
|
2690
|
+
}
|
|
2691
|
+
if (hasSegment(route.path, MONEY_OR_ACCESS)) {
|
|
2692
|
+
total += 18;
|
|
2693
|
+
because.push("money or access \u2014 asked about out of proportion to its traffic");
|
|
2694
|
+
}
|
|
2695
|
+
if (hasSegment(route.path, ENTRY_POINT)) {
|
|
2696
|
+
total += 16;
|
|
2697
|
+
because.push("one of the first things a new customer does");
|
|
2698
|
+
}
|
|
2699
|
+
return { score: total, because };
|
|
2700
|
+
}
|
|
2701
|
+
function tierFor(rank, count) {
|
|
2702
|
+
if (rank <= Math.max(3, Math.ceil(count * 0.1))) return "critical";
|
|
2703
|
+
if (rank <= Math.max(8, Math.ceil(count * 0.3))) return "core";
|
|
2704
|
+
if (rank <= Math.max(16, Math.ceil(count * 0.6))) return "supporting";
|
|
2705
|
+
return "minor";
|
|
2706
|
+
}
|
|
2707
|
+
function slugFor(path2) {
|
|
2708
|
+
const segments = path2.split("/").filter(Boolean).filter((segment) => !segment.startsWith("["));
|
|
2709
|
+
return segments.length ? segments.join("/") : "overview";
|
|
2710
|
+
}
|
|
2711
|
+
function titleFor(route) {
|
|
2712
|
+
const segments = route.path.split("/").filter(Boolean).filter((s) => !s.startsWith("["));
|
|
2713
|
+
if (!segments.length) return "Getting around";
|
|
2714
|
+
const words = segments[segments.length - 1].replace(/[-_]/g, " ");
|
|
2715
|
+
const subject = words.charAt(0).toUpperCase() + words.slice(1);
|
|
2716
|
+
if (!route.params.length) return subject;
|
|
2717
|
+
return `Working with ${article(singular2(subject.toLowerCase()))}`;
|
|
2718
|
+
}
|
|
2719
|
+
function singular2(word) {
|
|
2720
|
+
if (/(sis|ss|us|is)$/.test(word)) return word;
|
|
2721
|
+
if (/(ches|shes|xes|zes)$/.test(word)) return word.slice(0, -2);
|
|
2722
|
+
if (/[^aeiou]ies$/.test(word)) return `${word.slice(0, -3)}y`;
|
|
2723
|
+
if (/s$/.test(word)) return word.slice(0, -1);
|
|
2724
|
+
return word;
|
|
2725
|
+
}
|
|
2726
|
+
function article(word) {
|
|
2727
|
+
return /^[aeiou]/.test(word) ? `an ${word}` : `a ${word}`;
|
|
2728
|
+
}
|
|
2729
|
+
function buildArticlePlan(options) {
|
|
2730
|
+
const existing = new Set(options.existingSlugs ?? []);
|
|
2731
|
+
const excluded = [];
|
|
2732
|
+
const scored = [];
|
|
2733
|
+
for (const route of options.routes) {
|
|
2734
|
+
if (route.skipped) {
|
|
2735
|
+
excluded.push({ route: route.path, reason: route.note ?? route.skipped });
|
|
2736
|
+
continue;
|
|
2737
|
+
}
|
|
2738
|
+
scored.push({ route, ...score(route, options.signals?.[route.path] ?? {}) });
|
|
2739
|
+
}
|
|
2740
|
+
scored.sort((a, b) => b.score - a.score || a.route.path.localeCompare(b.route.path));
|
|
2741
|
+
const merged = /* @__PURE__ */ new Map();
|
|
2742
|
+
for (const entry of scored) {
|
|
2743
|
+
const slug = slugFor(entry.route.path);
|
|
2744
|
+
const existingNeed = merged.get(slug);
|
|
2745
|
+
if (!existingNeed) {
|
|
2746
|
+
merged.set(slug, {
|
|
2747
|
+
entry,
|
|
2748
|
+
routes: [entry.route.path],
|
|
2749
|
+
files: [...entry.route.sourceFiles],
|
|
2750
|
+
because: [...entry.because],
|
|
2751
|
+
...entry.route.title ? { title: entry.route.title } : {}
|
|
2752
|
+
});
|
|
2753
|
+
continue;
|
|
2754
|
+
}
|
|
2755
|
+
if (!existingNeed.title && entry.route.title) existingNeed.title = entry.route.title;
|
|
2756
|
+
existingNeed.routes.push(entry.route.path);
|
|
2757
|
+
for (const file of entry.route.sourceFiles) {
|
|
2758
|
+
if (!existingNeed.files.includes(file)) existingNeed.files.push(file);
|
|
2759
|
+
}
|
|
2760
|
+
for (const reason of entry.because) {
|
|
2761
|
+
if (!existingNeed.because.includes(reason)) existingNeed.because.push(reason);
|
|
2762
|
+
}
|
|
2763
|
+
}
|
|
2764
|
+
let covered = 0;
|
|
2765
|
+
const needs = [...merged.entries()].map(([slug, group], index) => {
|
|
2766
|
+
const rank = index + 1;
|
|
2767
|
+
const claimed = [group.entry.route.path, ...group.routes.slice(1) ?? []].map((path2) => options.documented?.[path2]).find(Boolean);
|
|
2768
|
+
const already = existing.has(slug) || Boolean(claimed);
|
|
2769
|
+
if (already) covered += 1;
|
|
2770
|
+
return {
|
|
2771
|
+
slug,
|
|
2772
|
+
title: group.title ?? titleFor(group.entry.route),
|
|
2773
|
+
route: group.entry.route.path,
|
|
2774
|
+
...group.routes.length > 1 ? { alsoCovers: group.routes.slice(1) } : {},
|
|
2775
|
+
tier: tierFor(rank, merged.size),
|
|
2776
|
+
rank,
|
|
2777
|
+
sourceRefs: group.files,
|
|
2778
|
+
because: group.because,
|
|
2779
|
+
...already ? { covered: claimed ?? slug } : {}
|
|
2780
|
+
};
|
|
2781
|
+
});
|
|
2782
|
+
return { needs, excluded, covered };
|
|
2783
|
+
}
|
|
2784
|
+
|
|
2785
|
+
// adapters/map-cli.ts
|
|
2786
|
+
function repositoryFiles(cwd) {
|
|
2787
|
+
return execFileSync("git", ["ls-files"], { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }).split("\n").filter(Boolean);
|
|
2788
|
+
}
|
|
2789
|
+
|
|
2790
|
+
// core/discovery/storyboard.ts
|
|
2791
|
+
function shotIdFor(route) {
|
|
2792
|
+
const parts = route.split("/").filter(Boolean).map((segment) => segment.replace(/^\[+\.{0,3}|\]+$/g, ""));
|
|
2793
|
+
return parts.length ? parts.join("-").toLowerCase() : "overview";
|
|
2794
|
+
}
|
|
2795
|
+
function describe(route, isEntry) {
|
|
2796
|
+
const segments = route.path.split("/").filter(Boolean);
|
|
2797
|
+
const last = segments[segments.length - 1] ?? "";
|
|
2798
|
+
const readable = route.title ?? (last.startsWith("[") ? `a single ${(segments[segments.length - 2] ?? "record").replace(/s$/, "")}` : last.replace(/[-_]/g, " ") || "the home screen");
|
|
2799
|
+
return isEntry ? `The ${readable} screen as someone arrives on it` : `The ${readable} screen, reached from ${segments.slice(0, -1).join("/") || "the top level"}`;
|
|
2800
|
+
}
|
|
2801
|
+
function kindFor(_route, _isEntry) {
|
|
2802
|
+
return "still";
|
|
2803
|
+
}
|
|
2804
|
+
function buildStoryboard(options) {
|
|
2805
|
+
const { need } = options;
|
|
2806
|
+
const covered = /* @__PURE__ */ new Set([need.route, ...need.alsoCovers ?? []]);
|
|
2807
|
+
const byPath = new Map(options.routes.map((route) => [route.path, route]));
|
|
2808
|
+
const beneath = options.routes.filter(
|
|
2809
|
+
(route) => !covered.has(route.path) && !route.skipped && [...covered].some((base) => base !== "/" && route.path.startsWith(`${base}/`))
|
|
2810
|
+
);
|
|
2811
|
+
const ordered = [...covered].map((path2) => byPath.get(path2)).filter((route) => Boolean(route) && !route.skipped).sort((a, b) => a.path.length - b.path.length).concat(beneath.sort((a, b) => a.path.length - b.path.length));
|
|
2812
|
+
const limit = options.maxSteps ?? 5;
|
|
2813
|
+
const steps = ordered.slice(0, limit).map((route, index) => ({
|
|
2814
|
+
route: route.path,
|
|
2815
|
+
shotId: shotIdFor(route.path),
|
|
2816
|
+
depicts: describe(route, index === 0),
|
|
2817
|
+
kind: kindFor(route, index === 0),
|
|
2818
|
+
needsExampleFor: route.params
|
|
2819
|
+
}));
|
|
2820
|
+
const dropped = ordered.length - steps.length;
|
|
2821
|
+
return {
|
|
2822
|
+
slug: need.slug,
|
|
2823
|
+
title: need.title,
|
|
2824
|
+
steps,
|
|
2825
|
+
// Named, because an article that quietly stops halfway is worse than one
|
|
2826
|
+
// that says where its edge is.
|
|
2827
|
+
scope: dropped > 0 ? `Covers ${steps.length} screens. ${dropped} deeper screen${dropped === 1 ? "" : "s"} below this belong in their own article rather than making this one longer than anybody reads.` : `Covers ${steps.length} screen${steps.length === 1 ? "" : "s"}, which is the whole of this area.`
|
|
2828
|
+
};
|
|
2829
|
+
}
|
|
2830
|
+
|
|
2831
|
+
// adapters/capture-bin.ts
|
|
2832
|
+
function parseArgs(argv) {
|
|
2833
|
+
const args = {
|
|
2834
|
+
brands: [],
|
|
2835
|
+
only: [],
|
|
2836
|
+
liveClock: false,
|
|
2837
|
+
allowProduction: false
|
|
2838
|
+
};
|
|
2839
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
2840
|
+
const flag = argv[index];
|
|
2841
|
+
const value = argv[index + 1];
|
|
2842
|
+
if (flag === "--key" && value) args.key = value, index += 1;
|
|
2843
|
+
else if (flag === "--to" && value) args.to = value.replace(/\/+$/, ""), index += 1;
|
|
2844
|
+
else if (flag === "--base-url" && value) args.baseUrl = value.replace(/\/+$/, ""), index += 1;
|
|
2845
|
+
else if (flag === "--brand" && value) args.brands.push(value), index += 1;
|
|
2846
|
+
else if (flag === "--only" && value) args.only.push(value), index += 1;
|
|
2847
|
+
else if (flag === "--session" && value) args.session = value, index += 1;
|
|
2848
|
+
else if (flag === "--rebaseline" && value) args.rebaseline = value, index += 1;
|
|
2849
|
+
else if (flag === "--cassette" && value) args.cassette = value, index += 1;
|
|
2850
|
+
else if (flag === "--app-command" && value) args.appCommand = value, index += 1;
|
|
2851
|
+
else if (flag === "--seed-command" && value) args.seedCommand = value, index += 1;
|
|
2852
|
+
else if (flag === "--app-env" && value) args.appEnv = value, index += 1;
|
|
2853
|
+
else if (flag === "--replay-port" && value) args.replayPort = Number(value), index += 1;
|
|
2854
|
+
else if (flag === "--out" && value) args.out = value, index += 1;
|
|
2855
|
+
else if (flag === "--repo" && value) args.repo = value, index += 1;
|
|
2856
|
+
else if (flag === "--live-clock") args.liveClock = true;
|
|
2857
|
+
else if (flag === "--allow-production") args.allowProduction = true;
|
|
2858
|
+
else if (flag === "--rebaseline" || flag === "--force") {
|
|
2859
|
+
return '--rebaseline needs a reason: `--rebaseline "new brand colours shipped"`. Replacing every stored frame moves every checksum at once, and a re-baseline nobody wrote down is indistinguishable from the product changing.';
|
|
2860
|
+
}
|
|
2861
|
+
}
|
|
2862
|
+
if (args.out) {
|
|
2863
|
+
args.key ??= "";
|
|
2864
|
+
args.to ??= "";
|
|
2865
|
+
if (!args.baseUrl) return "Missing --base-url. That is where your product is running.";
|
|
2866
|
+
return args;
|
|
2867
|
+
}
|
|
2868
|
+
if (!args.key) return "Missing --key, or --out <dir> to keep the frames here instead.";
|
|
2869
|
+
if (!args.to) return "Missing --to. That is the address of your help desk, or use --out <dir>.";
|
|
2870
|
+
return args;
|
|
2871
|
+
}
|
|
2872
|
+
function shotsFromRepo(repo, previous) {
|
|
2873
|
+
const files = repositoryFiles(repo);
|
|
2874
|
+
const routes = readNextAppRouter(files, detectRouterRoot(files)).routes;
|
|
2875
|
+
return buildArticlePlan({ routes }).needs.flatMap((need) => buildStoryboard({ need, routes }).steps).filter((step) => step.kind === "still").map((step) => ({
|
|
2876
|
+
id: step.shotId,
|
|
2877
|
+
route: step.route,
|
|
2878
|
+
depicts: step.depicts,
|
|
2879
|
+
// Carried so a second run can recognise a frame it already has, which is
|
|
2880
|
+
// what makes "nothing changed" a sentence this can say offline too.
|
|
2881
|
+
...previous.has(step.shotId) ? { sha256: previous.get(step.shotId) } : {}
|
|
2882
|
+
}));
|
|
2883
|
+
}
|
|
2884
|
+
async function previousFrames(out) {
|
|
2885
|
+
try {
|
|
2886
|
+
const manifest = JSON.parse(await readFile(path.join(out, "manifest.json"), "utf8"));
|
|
2887
|
+
return new Map((manifest.frames ?? []).map((frame) => [frame.id, frame.sha256]));
|
|
2888
|
+
} catch {
|
|
2889
|
+
return /* @__PURE__ */ new Map();
|
|
2890
|
+
}
|
|
2891
|
+
}
|
|
2892
|
+
async function main(argv) {
|
|
2893
|
+
if (argv[0] === "sign-in") return signIn(argv.slice(1));
|
|
2894
|
+
const parsed = parseArgs(argv);
|
|
2895
|
+
if (typeof parsed === "string") {
|
|
2896
|
+
console.error(parsed);
|
|
2897
|
+
return 2;
|
|
2898
|
+
}
|
|
2899
|
+
const headers = { authorization: `Bearer ${parsed.key}` };
|
|
2900
|
+
const local = parsed.out;
|
|
2901
|
+
const frames = [];
|
|
2902
|
+
let orders;
|
|
2903
|
+
if (local) {
|
|
2904
|
+
const repo = parsed.repo ?? process.cwd();
|
|
2905
|
+
await mkdir(local, { recursive: true });
|
|
2906
|
+
try {
|
|
2907
|
+
orders = { baseUrl: null, shots: shotsFromRepo(repo, await previousFrames(local)) };
|
|
2908
|
+
} catch (error) {
|
|
2909
|
+
console.error(
|
|
2910
|
+
`Could not read a plan from ${repo}: ${error.message}
|
|
2911
|
+
Run this from your repository, or pass --repo <dir>.`
|
|
2912
|
+
);
|
|
2913
|
+
return 1;
|
|
2914
|
+
}
|
|
2915
|
+
console.log(`Read ${orders.shots.length} screen(s) from ${repo}. Frames will be written to ${local}.
|
|
2916
|
+
`);
|
|
2917
|
+
} else {
|
|
2918
|
+
const listed = await fetch(`${parsed.to}/api/helpdesk/frames`, { headers });
|
|
2919
|
+
if (!listed.ok) {
|
|
2920
|
+
console.error(`The help desk refused the key (${listed.status}).`);
|
|
2921
|
+
return 1;
|
|
2922
|
+
}
|
|
2923
|
+
orders = await listed.json();
|
|
2924
|
+
if (!orders.capture) {
|
|
2925
|
+
console.error(
|
|
2926
|
+
`This workspace has approved no capture subject, so the help desk will refuse every frame.
|
|
2927
|
+
${orders.captureRefused ?? ""}
|
|
2928
|
+
Approve one on the plan page, then run this again.`
|
|
2929
|
+
);
|
|
2930
|
+
return 1;
|
|
2931
|
+
}
|
|
2932
|
+
}
|
|
2933
|
+
const baseUrl = parsed.cassette || parsed.seedCommand ? parsed.baseUrl ?? "http://127.0.0.1:3000" : parsed.baseUrl ?? orders.baseUrl;
|
|
2934
|
+
if (!baseUrl) {
|
|
2935
|
+
console.error("No product address. Pass --base-url, or set one on the help desk's plan page.");
|
|
2936
|
+
return 2;
|
|
2937
|
+
}
|
|
2938
|
+
const shots = parsed.only.length ? orders.shots.filter((shot) => parsed.only.includes(shot.id)) : orders.shots;
|
|
2939
|
+
if (!shots.length) {
|
|
2940
|
+
console.log("Nothing to photograph.");
|
|
2941
|
+
return 0;
|
|
2942
|
+
}
|
|
2943
|
+
let stage;
|
|
2944
|
+
let seeded;
|
|
2945
|
+
if (parsed.seedCommand) {
|
|
2946
|
+
try {
|
|
2947
|
+
seeded = await stageSeeded({
|
|
2948
|
+
appUrl: baseUrl,
|
|
2949
|
+
seedCommand: parsed.seedCommand,
|
|
2950
|
+
...parsed.appCommand ? { appCommand: parsed.appCommand } : {},
|
|
2951
|
+
sessionOut: parsed.session ?? path.join(process.cwd(), "helpdesk-demo-session.json"),
|
|
2952
|
+
onLog: (line) => console.log(line)
|
|
2953
|
+
});
|
|
2954
|
+
if (seeded.sessionPath) parsed.session = seeded.sessionPath;
|
|
2955
|
+
else delete parsed.session;
|
|
2956
|
+
console.log("");
|
|
2957
|
+
} catch (error) {
|
|
2958
|
+
console.error(String(error.message));
|
|
2959
|
+
return 1;
|
|
2960
|
+
}
|
|
2961
|
+
}
|
|
2962
|
+
if (parsed.cassette) {
|
|
2963
|
+
try {
|
|
2964
|
+
stage = await stageAgainstRecording({
|
|
2965
|
+
cassette: parseCassette(await readFile(parsed.cassette, "utf8")),
|
|
2966
|
+
appUrl: baseUrl,
|
|
2967
|
+
...parsed.appCommand ? { appCommand: parsed.appCommand } : {},
|
|
2968
|
+
...parsed.appEnv ? { appEnv: parsed.appEnv } : {},
|
|
2969
|
+
...parsed.replayPort ? { replayPort: parsed.replayPort } : {},
|
|
2970
|
+
onLog: (line) => console.log(line)
|
|
2971
|
+
});
|
|
2972
|
+
console.log("");
|
|
2973
|
+
} catch (error) {
|
|
2974
|
+
console.error(String(error.message));
|
|
2975
|
+
return 1;
|
|
2976
|
+
}
|
|
2977
|
+
}
|
|
2978
|
+
const browser = await playwrightBrowser({
|
|
2979
|
+
...parsed.session ? { sessionPath: parsed.session } : {},
|
|
2980
|
+
...parsed.liveClock ? { liveClock: true } : {}
|
|
2981
|
+
});
|
|
2982
|
+
const checks = preflight({
|
|
2983
|
+
baseUrl,
|
|
2984
|
+
browser: await browser.build?.().catch(() => void 0),
|
|
2985
|
+
clockPinned: !parsed.liveClock,
|
|
2986
|
+
locale: "en-GB",
|
|
2987
|
+
timezone: "UTC",
|
|
2988
|
+
session: Boolean(parsed.session),
|
|
2989
|
+
...stage ? { replaying: parsed.cassette } : {},
|
|
2990
|
+
...parsed.allowProduction ? { productionGranted: true } : {},
|
|
2991
|
+
...parsed.rebaseline ? { rebaselineReason: parsed.rebaseline } : {}
|
|
2992
|
+
});
|
|
2993
|
+
console.log(checks.lines.join("\n"));
|
|
2994
|
+
console.log("");
|
|
2995
|
+
if (!checks.ok) {
|
|
2996
|
+
await browser.close();
|
|
2997
|
+
await stage?.close();
|
|
2998
|
+
await seeded?.close();
|
|
2999
|
+
return 2;
|
|
3000
|
+
}
|
|
3001
|
+
if (stage) {
|
|
3002
|
+
const found = coverage(
|
|
3003
|
+
shots.map((shot) => shot.route),
|
|
3004
|
+
stage.cassette
|
|
3005
|
+
);
|
|
3006
|
+
console.log(`${coverageReport(found, shots.length).join("\n")}
|
|
3007
|
+
`);
|
|
3008
|
+
}
|
|
3009
|
+
console.log(
|
|
3010
|
+
stage ? `Photographing ${shots.length} screen(s) of ${baseUrl}, served from ${parsed.cassette}.
|
|
3011
|
+
` : `Photographing ${shots.length} screen(s) of ${baseUrl}.
|
|
3012
|
+
`
|
|
3013
|
+
);
|
|
3014
|
+
const runId = local ? void 0 : await openRun(parsed.to, headers, shots.length);
|
|
3015
|
+
try {
|
|
3016
|
+
const report = await runCapture({
|
|
3017
|
+
baseUrl,
|
|
3018
|
+
shots,
|
|
3019
|
+
browser,
|
|
3020
|
+
...orders.capture ? {
|
|
3021
|
+
clearance: {
|
|
3022
|
+
subject: orders.capture.subject,
|
|
3023
|
+
routes: orders.capture.routes,
|
|
3024
|
+
regions: orders.capture.regions,
|
|
3025
|
+
// A cover is where to put a rectangle; an approval is where not
|
|
3026
|
+
// to. Both are read off the clearance the help desk holds, and
|
|
3027
|
+
// the second is checked again against that same record when the
|
|
3028
|
+
// frame arrives, so this run cannot approve anything by saying so.
|
|
3029
|
+
covers: (orders.capture.surfaces ?? []).filter((surface) => surface.treatment?.treat === "cover").map((surface) => surface.where),
|
|
3030
|
+
approved: (orders.capture.surfaces ?? []).filter((surface) => surface.treatment?.treat === "approve").map((surface) => surface.where),
|
|
3031
|
+
coverUnnamed: orders.capture.coverUnnamed === true,
|
|
3032
|
+
expectedBrands: orders.capture.expectedBrands ?? []
|
|
3033
|
+
}
|
|
3034
|
+
} : {},
|
|
3035
|
+
brands: parsed.brands,
|
|
3036
|
+
force: Boolean(parsed.rebaseline),
|
|
3037
|
+
/*
|
|
3038
|
+
* Said rather than inferred. A run with no saved session is signed out,
|
|
3039
|
+
* which is a fact about the evidence worth recording; a run with one is
|
|
3040
|
+
* signed in as somebody nobody has told us about, which is a different
|
|
3041
|
+
* fact and not a defect. Neither is an empty string.
|
|
3042
|
+
*/
|
|
3043
|
+
session: parsed.session ? "saved-session" : "signed-out",
|
|
3044
|
+
role: parsed.session ? unknownRole() : noRole("this run was signed out"),
|
|
3045
|
+
onProgress: (outcome, index, total) => {
|
|
3046
|
+
const mark = { captured: "\u2713", unchanged: "=", skipped: "\u2013", refused: "\u2717", failed: "!" }[outcome.status];
|
|
3047
|
+
console.log(`${mark} ${index}/${total} ${outcome.route}${outcome.reason ? ` \u2014 ${outcome.reason}` : ""}`);
|
|
3048
|
+
if (runId) {
|
|
3049
|
+
void tellHelpDesk(parsed.to, headers, {
|
|
3050
|
+
action: "progress",
|
|
3051
|
+
jobId: runId,
|
|
3052
|
+
item: outcome.route,
|
|
3053
|
+
status: outcome.status === "captured" || outcome.status === "unchanged" ? "ok" : outcome.status === "failed" ? "failed" : "skipped",
|
|
3054
|
+
...outcome.reason ? { note: outcome.reason } : {}
|
|
3055
|
+
});
|
|
3056
|
+
}
|
|
3057
|
+
},
|
|
3058
|
+
async send(frame) {
|
|
3059
|
+
if (local) {
|
|
3060
|
+
const file = `${frame.shot.id}.png`;
|
|
3061
|
+
await writeFile(path.join(local, file), frame.image);
|
|
3062
|
+
frames.push({
|
|
3063
|
+
id: frame.shot.id,
|
|
3064
|
+
route: frame.shot.route,
|
|
3065
|
+
depicts: frame.shot.depicts,
|
|
3066
|
+
file,
|
|
3067
|
+
sha256: frame.sha256,
|
|
3068
|
+
settled: frame.settled,
|
|
3069
|
+
...frame.browser ? { browser: frame.browser } : {},
|
|
3070
|
+
...frame.width ? { width: frame.width } : {},
|
|
3071
|
+
...frame.height ? { height: frame.height } : {}
|
|
3072
|
+
});
|
|
3073
|
+
return { ok: true };
|
|
3074
|
+
}
|
|
3075
|
+
const form = new FormData();
|
|
3076
|
+
form.set(
|
|
3077
|
+
"meta",
|
|
3078
|
+
JSON.stringify({
|
|
3079
|
+
shotId: frame.shot.id,
|
|
3080
|
+
route: frame.shot.route,
|
|
3081
|
+
depicts: frame.shot.depicts,
|
|
3082
|
+
pageText: frame.pageText,
|
|
3083
|
+
capturedFrom: frame.capturedFrom,
|
|
3084
|
+
settled: frame.settled,
|
|
3085
|
+
// What this run read off the screen. Absent is a refusal at the
|
|
3086
|
+
// other end rather than a default, which is the point of sending it.
|
|
3087
|
+
...frame.report ? { report: frame.report } : {},
|
|
3088
|
+
...frame.browser ? { browser: frame.browser } : {},
|
|
3089
|
+
...frame.width ? { width: frame.width } : {},
|
|
3090
|
+
...frame.height ? { height: frame.height } : {}
|
|
3091
|
+
})
|
|
3092
|
+
);
|
|
3093
|
+
form.set("image", new Blob([frame.image], { type: "image/png" }), `${frame.shot.id}.png`);
|
|
3094
|
+
const response = await fetch(`${parsed.to}/api/helpdesk/frames`, {
|
|
3095
|
+
method: "POST",
|
|
3096
|
+
headers,
|
|
3097
|
+
body: form
|
|
3098
|
+
});
|
|
3099
|
+
if (response.ok) return { ok: true };
|
|
3100
|
+
const body = await response.json().catch(() => ({}));
|
|
3101
|
+
return { ok: false, error: body.error ?? `the help desk answered ${response.status}` };
|
|
3102
|
+
}
|
|
3103
|
+
});
|
|
3104
|
+
console.log(
|
|
3105
|
+
`
|
|
3106
|
+
${report.captured} photographed, ${report.unchanged} unchanged, ${report.skipped} skipped, ${report.refused} refused, ${report.failed} failed.`
|
|
3107
|
+
);
|
|
3108
|
+
if (local) {
|
|
3109
|
+
const kept = new Map(frames.map((frame) => [frame.id, frame]));
|
|
3110
|
+
for (const shot of shots) {
|
|
3111
|
+
if (kept.has(shot.id) || !shot.sha256) continue;
|
|
3112
|
+
const unchanged = report.outcomes.find((outcome) => outcome.shotId === shot.id);
|
|
3113
|
+
if (unchanged?.status !== "unchanged") continue;
|
|
3114
|
+
kept.set(shot.id, {
|
|
3115
|
+
id: shot.id,
|
|
3116
|
+
route: shot.route,
|
|
3117
|
+
depicts: shot.depicts,
|
|
3118
|
+
file: `${shot.id}.png`,
|
|
3119
|
+
sha256: shot.sha256,
|
|
3120
|
+
settled: unchanged.settled !== false
|
|
3121
|
+
});
|
|
3122
|
+
}
|
|
3123
|
+
await writeFile(
|
|
3124
|
+
path.join(local, "manifest.json"),
|
|
3125
|
+
`${JSON.stringify(
|
|
3126
|
+
{
|
|
3127
|
+
schema: "olomon.capture-manifest/1",
|
|
3128
|
+
capturedFrom: baseUrl,
|
|
3129
|
+
capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3130
|
+
...parsed.cassette ? { recording: parsed.cassette } : {},
|
|
3131
|
+
frames: [...kept.values()].sort((a, b) => a.id.localeCompare(b.id)),
|
|
3132
|
+
survey: report.survey
|
|
3133
|
+
},
|
|
3134
|
+
null,
|
|
3135
|
+
2
|
|
3136
|
+
)}
|
|
3137
|
+
`,
|
|
3138
|
+
"utf8"
|
|
3139
|
+
);
|
|
3140
|
+
console.log(`
|
|
3141
|
+
Written to ${local}: ${kept.size} frame(s) and manifest.json.`);
|
|
3142
|
+
}
|
|
3143
|
+
if (report.survey.length) {
|
|
3144
|
+
const survey = captureReport(report.survey);
|
|
3145
|
+
console.log(`
|
|
3146
|
+
${surveyLine(report.survey)}
|
|
3147
|
+
`);
|
|
3148
|
+
if (survey.needData) {
|
|
3149
|
+
console.log("What needs attention:\n");
|
|
3150
|
+
console.log(survey.lines.slice(2).join("\n"));
|
|
3151
|
+
}
|
|
3152
|
+
const unstable = report.outcomes.filter((outcome) => outcome.settled === false);
|
|
3153
|
+
if (unstable.length) {
|
|
3154
|
+
console.log(
|
|
3155
|
+
`
|
|
3156
|
+
${unstable.length} screen(s) never stopped moving: ${unstable.map((o) => o.route).join(", ")}.
|
|
3157
|
+
Their frames were kept, but they will hash differently every run, so nothing can tell you
|
|
3158
|
+
whether they changed. Usually a spinner that never resolves or a looping animation.`
|
|
3159
|
+
);
|
|
3160
|
+
}
|
|
3161
|
+
}
|
|
3162
|
+
const gated = report.outcomes.filter((outcome) => outcome.needsAccount);
|
|
3163
|
+
if (gated.length) {
|
|
3164
|
+
console.log(
|
|
3165
|
+
stage ? "\nSome screens still showed a sign-in. A recording answers the backend, but it cannot\ngive the product a session \u2014 record the sign-in itself, or save one against the replayed\nproduct and pass it with --session." : parsed.session ? "\nSome screens still showed a sign-in. The saved session may have expired \u2014 run `sign-in` again." : `
|
|
3166
|
+
Some screens are behind your sign-in. To photograph them, save a session against a
|
|
3167
|
+
DEMO tenant and pass it back:
|
|
3168
|
+
|
|
3169
|
+
${helpdeskCommand("helpdesk-capture", ["sign-in", `--base-url ${baseUrl}`, "--session ./demo-session.json"])}
|
|
3170
|
+
${helpdeskCommand("helpdesk-capture", [local ? `--out ${local} --base-url ${baseUrl}` : `--key <your key> --to ${parsed.to}`, "--session ./demo-session.json"])}
|
|
3171
|
+
|
|
3172
|
+
Never a real customer's account: a help article shows the typical state of a screen,
|
|
3173
|
+
not somebody's data. The session file stays on this machine and is sent nowhere.`
|
|
3174
|
+
);
|
|
3175
|
+
}
|
|
3176
|
+
console.log(
|
|
3177
|
+
`
|
|
3178
|
+
${attestation({
|
|
3179
|
+
captured: report.captured,
|
|
3180
|
+
unchanged: report.unchanged,
|
|
3181
|
+
refused: report.refused,
|
|
3182
|
+
baseUrl,
|
|
3183
|
+
...report.browser ? { browser: report.browser } : {},
|
|
3184
|
+
clockPinned: !parsed.liveClock,
|
|
3185
|
+
...stage ? { replaying: parsed.cassette } : {}
|
|
3186
|
+
})}`
|
|
3187
|
+
);
|
|
3188
|
+
if (stage?.replay.misses.length) {
|
|
3189
|
+
console.log(`
|
|
3190
|
+
${gapReport(gapsFrom([...stage.replay.misses])).join("\n")}`);
|
|
3191
|
+
}
|
|
3192
|
+
if (runId) {
|
|
3193
|
+
await tellHelpDesk(parsed.to, headers, {
|
|
3194
|
+
action: "finish",
|
|
3195
|
+
jobId: runId,
|
|
3196
|
+
...report.survey.length ? { survey: report.survey } : {}
|
|
3197
|
+
});
|
|
3198
|
+
}
|
|
3199
|
+
return report.failed ? 1 : 0;
|
|
3200
|
+
} catch (error) {
|
|
3201
|
+
if (runId) {
|
|
3202
|
+
await tellHelpDesk(parsed.to, headers, {
|
|
3203
|
+
action: "finish",
|
|
3204
|
+
jobId: runId,
|
|
3205
|
+
error: error instanceof Error ? error.message : String(error)
|
|
3206
|
+
});
|
|
3207
|
+
}
|
|
3208
|
+
throw error;
|
|
3209
|
+
} finally {
|
|
3210
|
+
await browser.close();
|
|
3211
|
+
await stage?.close();
|
|
3212
|
+
await seeded?.close();
|
|
3213
|
+
}
|
|
3214
|
+
}
|
|
3215
|
+
async function openRun(to, headers, total) {
|
|
3216
|
+
try {
|
|
3217
|
+
const response = await fetch(`${to}/api/helpdesk/captures`, {
|
|
3218
|
+
method: "POST",
|
|
3219
|
+
headers: { ...headers, "content-type": "application/json" },
|
|
3220
|
+
body: JSON.stringify({ action: "start", total })
|
|
3221
|
+
});
|
|
3222
|
+
if (!response.ok) return void 0;
|
|
3223
|
+
const body = await response.json();
|
|
3224
|
+
return body.jobId;
|
|
3225
|
+
} catch {
|
|
3226
|
+
return void 0;
|
|
3227
|
+
}
|
|
3228
|
+
}
|
|
3229
|
+
async function tellHelpDesk(to, headers, body) {
|
|
3230
|
+
await fetch(`${to}/api/helpdesk/captures`, {
|
|
3231
|
+
method: "POST",
|
|
3232
|
+
headers: { ...headers, "content-type": "application/json" },
|
|
3233
|
+
body: JSON.stringify(body)
|
|
3234
|
+
}).catch(() => void 0);
|
|
3235
|
+
}
|
|
3236
|
+
async function signIn(argv) {
|
|
3237
|
+
let baseUrl = "";
|
|
3238
|
+
let sessionPath = "./demo-session.json";
|
|
3239
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
3240
|
+
const flag = argv[index];
|
|
3241
|
+
const value = argv[index + 1];
|
|
3242
|
+
if (flag === "--base-url" && value) baseUrl = value.replace(/\/+$/, ""), index += 1;
|
|
3243
|
+
else if (flag === "--session" && value) sessionPath = value, index += 1;
|
|
3244
|
+
}
|
|
3245
|
+
if (!baseUrl) {
|
|
3246
|
+
console.error("Missing --base-url. That is where your product is deployed.");
|
|
3247
|
+
return 2;
|
|
3248
|
+
}
|
|
3249
|
+
console.log(
|
|
3250
|
+
`Opening ${baseUrl}. Sign in as your DEMO tenant \u2014 never a real customer.
|
|
3251
|
+
This window is yours: use SSO, a one-time code, whatever you normally do.
|
|
3252
|
+
The session will be saved to ${sessionPath} and sent nowhere.
|
|
3253
|
+
`
|
|
3254
|
+
);
|
|
3255
|
+
const outcome = await captureSignIn({ baseUrl, sessionPath });
|
|
3256
|
+
if (!outcome.ok) {
|
|
3257
|
+
console.error(outcome.reason ?? "Could not save a session.");
|
|
3258
|
+
return 1;
|
|
3259
|
+
}
|
|
3260
|
+
console.log(`
|
|
3261
|
+
Saved ${sessionPath}. Pass it to a capture run with --session ${sessionPath}.`);
|
|
3262
|
+
return 0;
|
|
3263
|
+
}
|
|
3264
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
3265
|
+
main(process.argv.slice(2)).then(
|
|
3266
|
+
(code) => process.exit(code),
|
|
3267
|
+
(error) => {
|
|
3268
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
3269
|
+
process.exit(1);
|
|
3270
|
+
}
|
|
3271
|
+
);
|
|
3272
|
+
}
|
|
3273
|
+
export {
|
|
3274
|
+
main
|
|
3275
|
+
};
|