agent-device-lab 0.0.0-stage → 0.3.1

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.
Files changed (54) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +10 -0
  3. package/README.md +150 -2
  4. package/assets/dashboard/app.css +238 -0
  5. package/assets/dashboard/app.js +976 -0
  6. package/assets/dashboard/index.html +120 -0
  7. package/bin/agentlab.js +8 -0
  8. package/dist/cli/ci.js +443 -0
  9. package/dist/cli/flow.js +127 -0
  10. package/dist/cli/main.js +855 -0
  11. package/dist/cli/signals.js +38 -0
  12. package/dist/core/action-log.js +125 -0
  13. package/dist/core/auth.js +175 -0
  14. package/dist/core/bundle.js +432 -0
  15. package/dist/core/checks.js +137 -0
  16. package/dist/core/ci.js +389 -0
  17. package/dist/core/commands.js +366 -0
  18. package/dist/core/control.js +217 -0
  19. package/dist/core/detectors.js +392 -0
  20. package/dist/core/devices.js +83 -0
  21. package/dist/core/doctor.js +203 -0
  22. package/dist/core/explore-safety.js +110 -0
  23. package/dist/core/extract.js +1445 -0
  24. package/dist/core/feed.js +493 -0
  25. package/dist/core/findings.js +179 -0
  26. package/dist/core/format.js +311 -0
  27. package/dist/core/init.js +607 -0
  28. package/dist/core/lab.js +1979 -0
  29. package/dist/core/migrate.js +35 -0
  30. package/dist/core/observation.js +161 -0
  31. package/dist/core/ownership.js +142 -0
  32. package/dist/core/process-group.js +206 -0
  33. package/dist/core/process-identity.js +56 -0
  34. package/dist/core/profile.js +816 -0
  35. package/dist/core/project-runner.js +359 -0
  36. package/dist/core/replay.js +303 -0
  37. package/dist/core/reproduction.js +36 -0
  38. package/dist/core/scan-policy.js +156 -0
  39. package/dist/core/scan-report.js +145 -0
  40. package/dist/core/scan.js +455 -0
  41. package/dist/core/schema.js +39 -0
  42. package/dist/core/services.js +134 -0
  43. package/dist/core/steps.js +80 -0
  44. package/dist/core/sweep.js +138 -0
  45. package/dist/core/trace-sanitize.js +641 -0
  46. package/dist/core/url-redact.js +25 -0
  47. package/dist/core/versions.js +29 -0
  48. package/dist/core/zip.js +264 -0
  49. package/dist/daemon/main.js +160 -0
  50. package/dist/daemon/state.js +93 -0
  51. package/dist/dashboard/open.js +25 -0
  52. package/dist/dashboard/server.js +366 -0
  53. package/dist/mcp/server.js +89 -0
  54. package/package.json +63 -4
@@ -0,0 +1,493 @@
1
+ import { formatChanges, formatClose, formatControlChange, formatHuman, formatSettle } from './format.js';
2
+ import { basename } from 'node:path';
3
+ const DEFAULT_LIMITS = { buffer: 1000, timeline: 200, serverLog: 200, countsIntervalMs: 250 };
4
+ const SECRET = /\b([A-Za-z0-9_.-]*(?:secret|token|passw(?:or)?d|api[_-]?key|auth(?:orization)?|cookie|session[_-]?id|private[_-]?key)[A-Za-z0-9_.-]*)(\s*[=:]\s*|\s+)("[^"]*"|'[^']*'|[^\s,;&]+)/gi;
5
+ const BEARER = /\b(bearer|basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi;
6
+ const URL_CREDENTIALS = /(\b[a-z][a-z0-9+.-]*:\/\/)[^\s/@:]+:[^\s/@]+@/gi;
7
+ /** What an action was asked to do beyond its target. Secret keys arrive already masked; files show names only. */
8
+ function actionDetail(r) {
9
+ switch (r.action) {
10
+ case 'press': return `key ${r.key}`;
11
+ case 'select': return `→ ${(r.values ?? []).map((v) => JSON.stringify(v.slice(0, 40))).join(', ')}`;
12
+ case 'scroll': return r.direction ? `${r.direction}${r.amount ? ` ${r.amount}px` : ''}` : 'into view';
13
+ case 'swipe': return `${r.direction}${r.amount ? ` ${r.amount}px` : ''}`;
14
+ case 'upload': return `files ${(r.files ?? []).map((f) => basename(f)).join(', ')}`;
15
+ case 'drag': return r.toRef ? `onto ${r.toRef}` : `by (${r.dx ?? 0}, ${r.dy ?? 0})`;
16
+ case 'open_tab': return `at ${r.path}`;
17
+ case 'switch_tab':
18
+ case 'close_tab': return r.tab ? `tab ${r.tab}` : '';
19
+ default: return '';
20
+ }
21
+ }
22
+ /** Mask things that look like credentials in free text (server output, commands). */
23
+ export function redactSecrets(text) {
24
+ return text
25
+ .replace(URL_CREDENTIALS, '$1‹redacted›@')
26
+ .replace(BEARER, '$1 ‹redacted›')
27
+ .replace(SECRET, (_m, key, sep) => `${key}${sep}‹redacted›`);
28
+ }
29
+ /** origin + path: query strings and fragments are dropped because they often carry tokens. */
30
+ export function displayUrl(url) {
31
+ try {
32
+ const u = new URL(url);
33
+ return u.origin + u.pathname;
34
+ }
35
+ catch {
36
+ return url.split(/[?#]/)[0] ?? url;
37
+ }
38
+ }
39
+ function errorText(e) {
40
+ return redactSecrets(`${e.code}: ${e.message}${e.hint ? ` Hint: ${e.hint}` : ''}`);
41
+ }
42
+ function stateView(s) {
43
+ const { frame, error, ...rest } = s;
44
+ return structuredClone({ ...rest, ...(error ? { error: errorText(error) } : {}), hasFrame: !!frame });
45
+ }
46
+ function redactError(e) {
47
+ const details = e.details ? { ...e.details } : undefined;
48
+ if (details && Array.isArray(details.logTail))
49
+ details.logTail = details.logTail.map((l) => redactSecrets(String(l)));
50
+ return { ...e, message: redactSecrets(e.message), ...(e.hint ? { hint: redactSecrets(e.hint) } : {}), ...(details ? { details } : {}) };
51
+ }
52
+ /**
53
+ * Show a typed value only when the page itself displays it back in the control (so it is already on
54
+ * screen); password fields read back as a mask. Otherwise report only its length.
55
+ */
56
+ function fillValue(result, value) {
57
+ const shown = result.observation?.controls.find((c) => c.ref === result.ref)?.value;
58
+ if (shown !== undefined && shown === value)
59
+ return value.length > 60 ? `${value.slice(0, 57)}…` : value;
60
+ if (shown === '••••')
61
+ return '••••';
62
+ return `‹${value.length} chars›`;
63
+ }
64
+ export class SessionFeed {
65
+ limits;
66
+ seq = 0;
67
+ buffer = [];
68
+ status = SessionFeed.idle();
69
+ timeline = [];
70
+ findings = new Map();
71
+ frames = new Map();
72
+ serverLog = [];
73
+ sweeps = [];
74
+ sweepFrames = new Map();
75
+ scans = [];
76
+ scanFrames = new Map();
77
+ extraFrames = new Map();
78
+ listeners = new Set();
79
+ startingAt = 0;
80
+ countsTimer;
81
+ lastCountsAt = 0;
82
+ constructor(limits = {}) {
83
+ this.limits = { ...DEFAULT_LIMITS, ...limits };
84
+ }
85
+ static idle() {
86
+ return { state: 'idle', gen: 0, consoleErrors: 0, failedRequests: 0, findings: 0 };
87
+ }
88
+ /** Subscribe to live messages. Returns an unsubscribe function. */
89
+ subscribe(fn) {
90
+ this.listeners.add(fn);
91
+ return () => { this.listeners.delete(fn); };
92
+ }
93
+ get state() {
94
+ return this.status.state;
95
+ }
96
+ get lastSeq() {
97
+ return this.seq;
98
+ }
99
+ snapshot() {
100
+ return {
101
+ seq: this.seq,
102
+ status: structuredClone(this.status),
103
+ timeline: this.timeline.slice(-this.limits.timeline),
104
+ findings: [...this.findings.values()],
105
+ serverLog: [...this.serverLog],
106
+ sweeps: structuredClone(this.sweeps),
107
+ scans: structuredClone(this.scans),
108
+ };
109
+ }
110
+ /** Messages after `seq`, or undefined when they are no longer buffered (the client needs a snapshot). */
111
+ since(seq) {
112
+ if (seq === this.seq)
113
+ return [];
114
+ if (seq > this.seq)
115
+ return undefined;
116
+ const first = this.buffer[0];
117
+ if (!first || first.seq > seq + 1)
118
+ return undefined;
119
+ return this.buffer.filter((m) => m.seq > seq);
120
+ }
121
+ /** Viewport frame captured for one width of a sweep. */
122
+ sweepFrameFile(sweepId, device) {
123
+ return this.sweepFrames.get(`${sweepId}/${device}`);
124
+ }
125
+ /** State frame of a scan run (`runIndex` is the run's position in the plan). */
126
+ scanFrameFile(scanId, runIndex, stateId) {
127
+ return this.scanFrames.get(`${scanId}/${runIndex}/${stateId}`);
128
+ }
129
+ /** The i-th extra evidence frame of a finding (the other width, or before/after a shift). */
130
+ extraFrameFile(id, index) {
131
+ return this.extraFrames.get(`${id}-${index}`);
132
+ }
133
+ /** Evidence frame file for a finding id, if one was captured. */
134
+ frameFile(id) {
135
+ return this.frames.get(id);
136
+ }
137
+ apply(e) {
138
+ switch (e.kind) {
139
+ case 'starting': {
140
+ if (this.status.state !== 'idle')
141
+ this.reset();
142
+ this.startingAt = Date.now();
143
+ this.status = {
144
+ ...SessionFeed.idle(), state: 'starting', project: e.project, device: e.device, headed: e.headed,
145
+ url: displayUrl(e.url), startedAt: new Date().toISOString(),
146
+ };
147
+ this.emitStatus();
148
+ break;
149
+ }
150
+ case 'service': {
151
+ const i = e.info;
152
+ const services = (this.status.services ??= []).filter((s) => s.name !== i.name);
153
+ services.push({ name: i.name, ...(i.url ? { url: i.url } : {}), owned: i.owned, ...(i.pid !== undefined ? { pid: i.pid } : {}), readyMs: i.readyMs, readiness: i.readiness });
154
+ this.status.services = services;
155
+ this.emitStatus();
156
+ break;
157
+ }
158
+ case 'auth': {
159
+ if (e.action === 'saved') {
160
+ this.entry({ kind: 'auth', outcome: 'success', summary: `saved sign-in state (${e.cookies} cookies, ${e.origins} origins with storage)`, changes: [], notes: [], findings: [] });
161
+ }
162
+ else if (e.action === 'invalidated') {
163
+ this.entry({ kind: 'auth', outcome: 'error', summary: 'saved sign-in state was unusable and has been removed', changes: [], notes: [], findings: [] });
164
+ }
165
+ break;
166
+ }
167
+ case 'start': {
168
+ const { session, server, observation: o } = e.result;
169
+ Object.assign(this.status, {
170
+ state: 'active', sessionId: session.id, device: session.device, headed: session.browser.headed,
171
+ browser: `Chromium ${session.browser.version}`, environment: session.environment, auth: session.auth ?? 'fresh',
172
+ server: {
173
+ url: server.url, owned: server.owned, readyMs: server.readyMs,
174
+ ...(server.pid !== undefined ? { pid: server.pid } : {}),
175
+ ...(server.command ? { command: redactSecrets(server.command) } : {}),
176
+ },
177
+ });
178
+ this.observed(o);
179
+ this.emitStatus();
180
+ this.entry({
181
+ kind: 'start', outcome: 'success', durationMs: this.startingAt ? Date.now() - this.startingAt : undefined,
182
+ summary: `session ${session.id} on ${o.route}; server ${server.owned ? `started (pid ${server.pid}, ready in ${server.readyMs}ms)` : 'reused (not owned)'}`,
183
+ changes: [], notes: [], findings: this.firstSeenIn(o.gen), route: o.route,
184
+ });
185
+ break;
186
+ }
187
+ case 'start-failed': {
188
+ const error = redactError(e.error);
189
+ if (this.status.state === 'idle')
190
+ this.status = { ...SessionFeed.idle(), startedAt: new Date().toISOString() };
191
+ this.entry({
192
+ kind: 'start-failed', outcome: 'error', durationMs: this.startingAt ? Date.now() - this.startingAt : undefined,
193
+ summary: `start failed: ${error.code}`, changes: [], notes: [], findings: [],
194
+ error: { code: error.code, message: error.message, ...(error.hint ? { hint: error.hint } : {}) },
195
+ });
196
+ Object.assign(this.status, { state: 'failed', startError: error, endedReason: 'start failed' });
197
+ this.emitStatus();
198
+ break;
199
+ }
200
+ case 'server-log': {
201
+ const line = redactSecrets(e.line).slice(0, 500);
202
+ this.serverLog.push(line);
203
+ if (this.serverLog.length > this.limits.serverLog)
204
+ this.serverLog.splice(0, this.serverLog.length - this.limits.serverLog);
205
+ this.publish({ type: 'server-log', line });
206
+ break;
207
+ }
208
+ case 'observe': {
209
+ this.observed(e.observation);
210
+ this.emitStatus();
211
+ const o = e.observation;
212
+ this.entry({
213
+ kind: 'observe', outcome: 'success', summary: `observe gen ${o.gen}: ${o.controls.length} controls${o.omitted ? ` (+${o.omitted} omitted)` : ''}, ${o.layout.length} layout flags`,
214
+ changes: [], notes: [], findings: this.firstSeenIn(o.gen), route: o.route,
215
+ });
216
+ break;
217
+ }
218
+ case 'act': {
219
+ const r = e.result;
220
+ if (r.observation)
221
+ this.observed(r.observation);
222
+ this.emitStatus();
223
+ const target = r.target ?? (r.ref ? { ref: r.ref } : undefined);
224
+ const detail = actionDetail(e.request);
225
+ const what = [r.target ? `${r.target.role} "${r.target.name}"` : r.ref, detail].filter(Boolean).join(' ');
226
+ if (r.observation)
227
+ this.status.tab = r.observation.tab;
228
+ const changes = !r.changes ? []
229
+ : r.changes.reset ? [`${r.changes.route ? `route ${r.changes.route.from} → ${r.changes.route.to}; ` : ''}new document, baseline reset`]
230
+ : r.changes.none ? ['no visible change'] : formatChanges(r.changes).map((l) => l.replace(' (use --json for all)', ''));
231
+ this.entry({
232
+ kind: r.action, ...(target ? { target } : {}), ...(detail ? { detail } : {}), outcome: r.outcome, durationMs: r.elapsedMs,
233
+ ...(r.action === 'fill' && e.request.value !== undefined ? { value: fillValue(r, e.request.value) } : {}),
234
+ summary: `${r.action}${r.ref ? ` ${r.ref}` : ''}${what ? ` ${what}` : ''}`, ...(r.method ? { method: r.method } : {}),
235
+ ...(r.settle ? { settle: r.settle, ...(r.settle.reason === 'timeout' ? { settleText: formatSettle(r.settle) } : {}) } : {}),
236
+ changes, notes: r.notes, findings: r.newFindings.map((f) => f.id),
237
+ ...(r.error ? { error: { code: r.error.code, message: r.error.message, ...(r.error.hint ? { hint: r.error.hint } : {}) } } : {}),
238
+ ...(r.observation ? { route: r.observation.route } : {}), navigated: r.navigated,
239
+ newConsoleErrors: r.newConsoleErrors.length, newFailedRequests: r.newFailedRequests.length,
240
+ });
241
+ break;
242
+ }
243
+ case 'control': {
244
+ this.status.control = structuredClone(e.change.state);
245
+ this.emitStatus();
246
+ this.entry({ kind: 'control', outcome: 'success', summary: formatControlChange(e.change), changes: [], notes: [], findings: [] });
247
+ break;
248
+ }
249
+ case 'human': {
250
+ const a = e.action;
251
+ this.entry({
252
+ kind: 'human', outcome: 'success', summary: a.type === 'input-dropped' ? `person: ${formatHuman(a)}` : `person ${formatHuman(a)}`,
253
+ ...(a.target ? { target: { role: a.target.role, name: a.target.name } } : {}), changes: [], notes: [], findings: [],
254
+ });
255
+ break;
256
+ }
257
+ case 'refused': {
258
+ const error = redactError(e.error);
259
+ this.entry({
260
+ kind: 'refused', outcome: 'error', summary: `agent's ${e.command} refused (${error.code}); nothing ran`, changes: [], notes: [], findings: [],
261
+ error: { code: error.code, message: error.message, ...(error.hint ? { hint: error.hint } : {}) },
262
+ });
263
+ break;
264
+ }
265
+ case 'findings': {
266
+ for (const f of e.findings) {
267
+ const main = e.frame ?? f.frame;
268
+ if (main)
269
+ this.frames.set(f.id, main);
270
+ (f.frames ?? []).forEach((x, i) => this.extraFrames.set(`${f.id}-${i}`, x.path));
271
+ const { frame: _frame, frames, ...rest } = structuredClone(f);
272
+ const view = { ...rest, hasFrame: !!main, frames: (frames ?? []).map((x) => ({ label: x.label, ...(x.device ? { device: x.device } : {}) })) };
273
+ this.findings.set(f.id, view);
274
+ this.publish({ type: 'finding', finding: view });
275
+ }
276
+ this.status.findings = this.findings.size;
277
+ this.emitStatus();
278
+ break;
279
+ }
280
+ case 'page': {
281
+ this.status.url = displayUrl(e.url);
282
+ try {
283
+ this.status.route = new URL(e.url).pathname;
284
+ }
285
+ catch { /* keep the last route */ }
286
+ this.emitStatus();
287
+ break;
288
+ }
289
+ case 'counts': {
290
+ this.status.consoleErrors = e.consoleErrors;
291
+ this.status.failedRequests = e.failedRequests;
292
+ this.emitCounts();
293
+ break;
294
+ }
295
+ case 'sweep': {
296
+ if (e.phase === 'start') {
297
+ this.sweeps.push({ id: e.id, route: e.route, state: 'running', devices: e.devices.map((d) => ({ ...d, state: 'pending', findings: [], confirmed: 0, hasFrame: false })) });
298
+ if (this.sweeps.length > 10)
299
+ this.sweeps.shift();
300
+ }
301
+ const sweep = this.sweeps.find((s) => s.id === e.id);
302
+ if (!sweep)
303
+ break;
304
+ if (e.phase === 'device-start') {
305
+ const d = sweep.devices.find((x) => x.id === e.device);
306
+ if (d)
307
+ d.state = 'running';
308
+ }
309
+ else if (e.phase === 'device-done') {
310
+ const r = e.result;
311
+ const d = sweep.devices.find((x) => x.id === r.device);
312
+ if (d) {
313
+ Object.assign(d, {
314
+ state: r.status === 'ok' ? 'done' : 'error', ms: r.ms, findings: r.findings, hasFrame: !!r.frame,
315
+ confirmed: r.findings.filter((id) => this.findings.get(id)?.confidence === 'confirmed').length,
316
+ ...(r.error ? { error: redactSecrets(r.error.message) } : {}),
317
+ });
318
+ }
319
+ if (r.frame)
320
+ this.sweepFrames.set(`${e.id}/${r.device}`, r.frame);
321
+ }
322
+ else if (e.phase === 'done') {
323
+ sweep.state = 'done';
324
+ sweep.ms = e.result.ms;
325
+ const ids = e.result.findings.map((f) => f.id);
326
+ const confirmed = e.result.findings.filter((f) => f.confidence === 'confirmed').length;
327
+ this.entry({
328
+ kind: 'sweep', outcome: e.result.devices.some((d) => d.status === 'error') ? 'error' : 'success', durationMs: e.result.ms,
329
+ summary: `sweep ${e.id} of ${e.result.route} at ${e.result.devices.map((d) => d.width).join(', ')} px`,
330
+ changes: e.result.devices.map((d) => `${d.device}: ${d.status === 'error' ? `error ${d.error?.code ?? ''}` : d.findings.length ? `${d.findings.length} finding(s)` : 'clean'}`),
331
+ notes: ids.length ? [`${confirmed} confirmed, ${ids.length - confirmed} heuristic`] : [], findings: ids, route: e.result.route,
332
+ });
333
+ }
334
+ this.publish({ type: 'sweep', sweep: structuredClone(sweep) });
335
+ break;
336
+ }
337
+ case 'scan': {
338
+ this.applyScan(e);
339
+ break;
340
+ }
341
+ case 'closed': {
342
+ if (this.status.state === 'failed')
343
+ break; // start failure already reported, with its cause
344
+ // The entry goes first: viewers stop listening once they see the final status.
345
+ this.entry({ kind: 'stop', outcome: 'success', summary: formatClose(e.result), changes: [], notes: [], findings: [] });
346
+ if (this.status.state === 'starting')
347
+ break; // cleanup after a failed start; start-failed follows with the cause
348
+ Object.assign(this.status, { state: 'ended', endedReason: e.result.reason });
349
+ this.emitStatus();
350
+ break;
351
+ }
352
+ }
353
+ }
354
+ applyScan(e) {
355
+ if (e.phase === 'start') {
356
+ this.scans.push({
357
+ id: e.id, state: 'running', explore: e.explore,
358
+ runs: e.runs.map((r, index) => ({ index, ...r, state: 'pending', states: [], decisions: [], decisionsOmitted: 0, limits: [], findings: [] })),
359
+ });
360
+ while (this.scans.length > 5) {
361
+ const old = this.scans.shift();
362
+ for (const key of [...this.scanFrames.keys()])
363
+ if (key.startsWith(`${old.id}/`))
364
+ this.scanFrames.delete(key);
365
+ }
366
+ }
367
+ const scan = this.scans.find((s) => s.id === e.id);
368
+ if (!scan)
369
+ return;
370
+ const find = (scenario, device, state) => scan.runs.find((r) => r.scenario === scenario && r.device === device && r.state === state)
371
+ ?? scan.runs.find((r) => r.scenario === scenario && r.device === device);
372
+ if (e.phase === 'run-start') {
373
+ const run = find(e.scenario, e.device, 'pending');
374
+ if (run)
375
+ run.state = 'running';
376
+ scan.current = { scenario: e.scenario, device: e.device };
377
+ }
378
+ else if (e.phase === 'state') {
379
+ const run = find(e.scenario, e.device, 'running');
380
+ if (run) {
381
+ const view = stateView(e.state);
382
+ const i = run.states.findIndex((s) => s.id === view.id);
383
+ if (i >= 0)
384
+ run.states[i] = view;
385
+ else
386
+ run.states.push(view);
387
+ if (e.state.frame)
388
+ this.scanFrames.set(`${scan.id}/${run.index}/${e.state.id}`, e.state.frame);
389
+ }
390
+ scan.current = { scenario: e.scenario, device: e.device, state: e.state.label };
391
+ }
392
+ else if (e.phase === 'run-done') {
393
+ const r = e.run;
394
+ const run = find(r.scenario, r.device, 'running');
395
+ if (run) {
396
+ Object.assign(run, {
397
+ state: r.status === 'ok' ? 'ok' : 'failed', ms: r.ms, states: r.states.map(stateView), decisions: structuredClone(r.decisions),
398
+ decisionsOmitted: r.decisionsOmitted, limits: [...r.limits], findings: [...r.findings],
399
+ ...(r.failedAt ? { failedAt: r.failedAt } : {}), ...(r.error ? { error: errorText(r.error) } : {}),
400
+ });
401
+ for (const s of r.states)
402
+ if (s.frame)
403
+ this.scanFrames.set(`${scan.id}/${run.index}/${s.id}`, s.frame);
404
+ }
405
+ if (scan.current?.scenario === r.scenario && scan.current.device === r.device)
406
+ delete scan.current;
407
+ }
408
+ else if (e.phase === 'done') {
409
+ const r = e.result;
410
+ Object.assign(scan, { state: 'done', groups: structuredClone(r.groups), verdict: structuredClone(r.verdict), suppressions: structuredClone(r.suppressions), ms: r.ms });
411
+ delete scan.current;
412
+ // Suppression is decided when the scan finishes: tell viewers about findings that changed.
413
+ for (const f of r.findings) {
414
+ const known = this.findings.get(f.id);
415
+ if (!known || JSON.stringify(known.suppressed) === JSON.stringify(f.suppressed))
416
+ continue;
417
+ const { suppressed: _old, ...rest } = known;
418
+ const view = { ...rest, ...(f.suppressed ? { suppressed: structuredClone(f.suppressed) } : {}) };
419
+ this.findings.set(f.id, view);
420
+ this.publish({ type: 'finding', finding: view });
421
+ }
422
+ const failed = r.runs.filter((x) => x.status === 'failed');
423
+ this.entry({
424
+ kind: 'scan', outcome: r.verdict.result === 'pass' ? 'success' : 'error', durationMs: r.ms,
425
+ summary: `scan ${e.id}: ${r.verdict.result.toUpperCase()} · ${r.runs.length} runs (${failed.length} failed) · ${r.groups.length} problems`,
426
+ changes: failed.map((x) => `${x.scenario} @ ${x.device}: failed at ${x.failedAt ?? 'unknown'}`),
427
+ notes: [], findings: r.findings.map((f) => f.id),
428
+ });
429
+ }
430
+ this.publish({ type: 'scan', scan: structuredClone(scan) });
431
+ }
432
+ observed(o) {
433
+ Object.assign(this.status, {
434
+ gen: o.gen, url: displayUrl(o.url), route: o.route, title: o.title.slice(0, 120),
435
+ consoleErrors: o.console.errors, failedRequests: o.network.failed,
436
+ });
437
+ }
438
+ firstSeenIn(gen) {
439
+ return [...this.findings.values()].filter((f) => f.firstSeen.gen === gen).map((f) => f.id);
440
+ }
441
+ reset() {
442
+ this.buffer = [];
443
+ this.timeline = [];
444
+ this.findings.clear();
445
+ this.frames.clear();
446
+ this.serverLog = [];
447
+ this.sweeps = [];
448
+ this.sweepFrames.clear();
449
+ this.scans = [];
450
+ this.scanFrames.clear();
451
+ this.extraFrames.clear();
452
+ if (this.countsTimer)
453
+ clearTimeout(this.countsTimer);
454
+ this.countsTimer = undefined;
455
+ this.publish({ type: 'reset' });
456
+ }
457
+ entry(e) {
458
+ const entry = { seq: this.seq + 1, at: new Date().toISOString(), ...e };
459
+ this.timeline.push(entry);
460
+ if (this.timeline.length > this.limits.timeline)
461
+ this.timeline.splice(0, this.timeline.length - this.limits.timeline);
462
+ this.publish({ type: 'timeline', entry });
463
+ }
464
+ emitStatus() {
465
+ if (this.countsTimer) {
466
+ clearTimeout(this.countsTimer);
467
+ this.countsTimer = undefined;
468
+ }
469
+ this.lastCountsAt = Date.now();
470
+ this.publish({ type: 'status', status: structuredClone(this.status) });
471
+ }
472
+ /** Counters can change many times a second on a noisy page; publish at most once per interval. */
473
+ emitCounts() {
474
+ const wait = this.limits.countsIntervalMs - (Date.now() - this.lastCountsAt);
475
+ if (wait <= 0)
476
+ return this.emitStatus();
477
+ this.countsTimer ??= setTimeout(() => { this.countsTimer = undefined; this.emitStatus(); }, wait);
478
+ this.countsTimer.unref?.();
479
+ }
480
+ publish(event) {
481
+ const message = { seq: ++this.seq, at: new Date().toISOString(), event };
482
+ this.buffer.push(message);
483
+ if (this.buffer.length > this.limits.buffer)
484
+ this.buffer.splice(0, this.buffer.length - this.limits.buffer);
485
+ for (const fn of this.listeners) {
486
+ try {
487
+ fn(message);
488
+ }
489
+ catch { /* a viewer must never break the session */ }
490
+ }
491
+ }
492
+ }
493
+ //# sourceMappingURL=feed.js.map
@@ -0,0 +1,179 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { DETECTORS } from './detectors.js';
3
+ /** Basis that counts as a deterministic measurement: only these may make a heuristic high severity. */
4
+ const DETERMINISTIC = new Set(['clipping', 'hit-test', 'interaction', 'standard', 'browser-metric']);
5
+ /**
6
+ * The same underlying problem, stable across runs: the detector family (so a control covered at its
7
+ * centre is one problem whether the reach check or the fixed-bar check found it), the route without
8
+ * its fragment, and the element's identity. Device, scenario and state are left out so a problem seen
9
+ * at several widths or in several scenarios groups together.
10
+ */
11
+ export function fingerprintOf(kind, route, target) {
12
+ const family = DETECTORS[kind].family;
13
+ const identity = target ? [target.role, target.name, target.context ?? ''].join('\u0000') : kind;
14
+ return createHash('sha1').update([family, route.split('#')[0], identity].join('\u0001')).digest('hex').slice(0, 12);
15
+ }
16
+ /** A session keeps at most this many distinct findings; later new ones are counted in `omitted`. */
17
+ export const MAX_FINDINGS = 2000;
18
+ /** Session-scoped finding store. Pure: no browser, so it is unit-tested directly. */
19
+ export class FindingStore {
20
+ device;
21
+ max;
22
+ byKey = new Map();
23
+ next = 1;
24
+ omittedCount = 0;
25
+ constructor(device, max = MAX_FINDINGS) {
26
+ this.device = device;
27
+ this.max = max;
28
+ }
29
+ /** New findings that arrived after the cap and were counted, not kept. Findings already kept are never dropped. */
30
+ get omitted() {
31
+ return this.omittedCount;
32
+ }
33
+ list() {
34
+ return [...this.byKey.values()];
35
+ }
36
+ get(id) {
37
+ return this.list().find((f) => f.id === id);
38
+ }
39
+ get size() {
40
+ return this.byKey.size;
41
+ }
42
+ /** Record every layout flag in an observation (heuristic). Returns findings seen for the first time. */
43
+ recordObservation(o, reproduction, ctx = {}) {
44
+ return this.observationResults(o, reproduction, ctx).filter((r) => r.fresh).map((r) => r.finding);
45
+ }
46
+ /** Like recordObservation, but returns every finding the observation shows, new or already known. */
47
+ observationResults(o, reproduction, ctx = {}) {
48
+ const out = [];
49
+ for (const flag of o.layout) {
50
+ const control = flag.ref ? o.controls.find((c) => c.ref === flag.ref) : undefined;
51
+ const f = this.upsert({
52
+ kind: flag.kind, severity: flag.severity, source: 'measured-layout', confidence: 'heuristic',
53
+ confidenceScore: 0.6, basis: flag.kind === 'control-clipped' ? ['clipping'] : ['geometry'],
54
+ route: o.route, viewportWidth: o.viewport.width, target: control ? controlTarget(control) : undefined,
55
+ message: `${flag.message} on ${o.route}`, evidence: flag.evidence, gen: o.gen, reproduction,
56
+ }, ctx);
57
+ out.push(f);
58
+ }
59
+ return out;
60
+ }
61
+ /**
62
+ * Reaching a control needed the view to pan sideways (confirmed by measuring the pan). An automated
63
+ * click still succeeds, but a person would first have to discover the hidden horizontal pan.
64
+ */
65
+ recordHorizontalPan(o, control, target, panPx, reproduction, ctx = {}) {
66
+ const r = this.panResult(o, control, target, panPx, reproduction, ctx);
67
+ return r.fresh ? r.finding : undefined;
68
+ }
69
+ panResult(o, control, target, panPx, reproduction, ctx = {}) {
70
+ const width = control?.rect.w ?? 0;
71
+ const outsidePx = control?.clip?.px ?? panPx;
72
+ const entirelyOutside = width > 0 && outsidePx >= width;
73
+ return this.upsert({
74
+ kind: 'horizontal-pan-required', severity: entirelyOutside ? 'high' : 'medium', source: ctx.source ?? 'interaction', confidence: 'confirmed',
75
+ confidenceScore: 0.95, basis: ['interaction'],
76
+ route: o.route, viewportWidth: o.viewport.width, target: control ? controlTarget(control) : { ...target },
77
+ message: `${target.role} "${target.name}" on ${o.route} could only be reached after panning ${panPx}px sideways; ` +
78
+ `on a ${o.viewport.width}px-wide screen a person must first discover the horizontal pan`,
79
+ evidence: {
80
+ panPx, viewportWidth: o.viewport.width, documentWidth: o.documentWidth,
81
+ ...(control ? { targetLeft: control.rect.x, targetRight: control.rect.x + control.rect.w, targetWidth: control.rect.w } : {}),
82
+ },
83
+ gen: o.gen, reproduction,
84
+ }, ctx);
85
+ }
86
+ /**
87
+ * After scrolling a control into view, a pointer at its centre lands on another element (confirmed
88
+ * by hit-testing, the same check a click makes before tapping).
89
+ */
90
+ recordObstruction(o, control, hit, reproduction, ctx = {}) {
91
+ const r = this.obstructionResult(o, control, hit, reproduction, ctx);
92
+ return r.fresh ? r.finding : undefined;
93
+ }
94
+ obstructionResult(o, control, hit, reproduction, ctx = {}) {
95
+ return this.upsert({
96
+ kind: 'control-obstructed', severity: 'high', source: ctx.source ?? 'sweep-reachability', confidence: 'confirmed',
97
+ confidenceScore: 0.95, basis: ['hit-test'],
98
+ route: o.route, viewportWidth: o.viewport.width, target: controlTarget(control),
99
+ message: `${control.role} "${control.name}" on ${o.route} is covered at its centre by ${hit} even after scrolling it into view`,
100
+ evidence: { coveredBy: hit.slice(0, 80), viewportWidth: o.viewport.width, targetLeft: control.rect.x, targetTop: control.rect.y, targetWidth: control.rect.w, targetHeight: control.rect.h },
101
+ gen: o.gen, reproduction,
102
+ }, ctx);
103
+ }
104
+ /** A scan detector's result. Returns the finding (new or the existing one it merged into) and whether it is new. */
105
+ recordHit(hit, where, reproduction, ctx = {}) {
106
+ return this.upsert({
107
+ kind: hit.kind, severity: hit.severity, source: ctx.source ?? 'scan', confidence: hit.confidence, confidenceScore: hit.confidenceScore,
108
+ basis: hit.basis, route: where.route, viewportWidth: where.viewport.width, target: hit.target,
109
+ message: `${hit.message} on ${where.route}`, evidence: hit.evidence, gen: where.gen, reproduction, frames: hit.frames,
110
+ }, ctx);
111
+ }
112
+ /**
113
+ * Copy a finding recorded elsewhere (a scenario's own context, e.g. a sideways pan during a setup
114
+ * step) into this store under the given context.
115
+ */
116
+ adopt(f, reproduction, ctx) {
117
+ return this.upsert({
118
+ kind: f.kind, severity: f.severity, source: f.source, confidence: f.confidence, confidenceScore: f.confidenceScore, basis: f.basis,
119
+ route: f.route, viewportWidth: f.viewportWidth, target: f.target, message: f.message, evidence: f.evidence, gen: f.firstSeen.gen,
120
+ reproduction, frames: f.frames,
121
+ }, ctx);
122
+ }
123
+ /** Mark or clear a scan suppression on a finding (the finding itself is always kept and reported). */
124
+ suppress(id, s) {
125
+ const f = this.get(id);
126
+ if (!f)
127
+ return;
128
+ if (s)
129
+ f.suppressed = s;
130
+ else
131
+ delete f.suppressed;
132
+ }
133
+ upsert(input, ctx) {
134
+ const device = ctx.device ?? this.device;
135
+ const keepRef = ctx.keepRefs ?? !ctx.device;
136
+ // Refs change across documents, so identity is device + scenario + kind + route + the element's
137
+ // role, name and context. The UI state is not part of it: the same problem seen in several states
138
+ // of one scenario is one finding, which lists every state.
139
+ const key = [device, ctx.scenario ?? '', input.kind, input.route, input.target?.role ?? '', input.target?.name ?? '', input.target?.context ?? ''].join('\u0000');
140
+ const { ref, ...rest } = input.target ?? { role: '', name: '' };
141
+ const target = input.target ? { ...rest, ...(keepRef && ref ? { ref } : {}) } : undefined;
142
+ const existing = this.byKey.get(key);
143
+ if (existing) {
144
+ existing.lastSeenGen = input.gen;
145
+ existing.occurrences++;
146
+ if (target)
147
+ existing.target = target;
148
+ if (ctx.state && !existing.states?.includes(ctx.state))
149
+ (existing.states ??= []).push(ctx.state);
150
+ return { finding: existing, fresh: false };
151
+ }
152
+ // A heuristic stays below high severity unless a deterministic measurement backs it.
153
+ const severity = input.confidence === 'heuristic' && input.severity === 'high' && !input.basis.some((b) => DETERMINISTIC.has(b)) ? 'medium' : input.severity;
154
+ const finding = {
155
+ id: `F${this.next++}`, kind: input.kind, detector: { name: input.kind, version: DETECTORS[input.kind].version },
156
+ severity, source: input.source, confidence: input.confidence, confidenceScore: input.confidenceScore, basis: [...input.basis],
157
+ route: input.route, device, viewportWidth: input.viewportWidth,
158
+ ...(ctx.scenario ? { scenario: ctx.scenario } : {}),
159
+ ...(ctx.state ? { state: ctx.state, states: [ctx.state] } : {}),
160
+ ...(target ? { target } : {}),
161
+ message: input.message, evidence: { ...input.evidence },
162
+ fingerprint: fingerprintOf(input.kind, input.route, target),
163
+ ...(ctx.frame ? { frame: ctx.frame } : {}),
164
+ ...(input.frames?.length ? { frames: input.frames.map((f) => ({ ...f })) } : {}),
165
+ firstSeen: { gen: input.gen, at: new Date().toISOString() }, lastSeenGen: input.gen, occurrences: 1,
166
+ reproduction: [...input.reproduction],
167
+ };
168
+ if (this.byKey.size >= this.max) {
169
+ this.omittedCount++;
170
+ return { finding, fresh: false };
171
+ }
172
+ this.byKey.set(key, finding);
173
+ return { finding, fresh: true };
174
+ }
175
+ }
176
+ function controlTarget(c) {
177
+ return { role: c.role, name: c.name, ...(c.ref ? { ref: c.ref } : {}), ...(c.context ? { context: c.context } : {}) };
178
+ }
179
+ //# sourceMappingURL=findings.js.map