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,80 @@
1
+ /** Resolve a step's role/name targets against the latest observation (never guessed) into a request. */
2
+ export function stepRequest(lab, step) {
3
+ const hasTarget = step.name !== undefined || step.nameContains !== undefined;
4
+ const ref = hasTarget ? lab.findRef({ role: step.role, name: step.name, nameContains: step.nameContains }) : undefined;
5
+ const toRef = step.to ? lab.findRef(step.to) : undefined;
6
+ const { do: action, value, key, values, direction, amount, dx, dy, files, tab, path } = step;
7
+ return Object.fromEntries(Object.entries({ action, ref, value, key, values, direction, amount, toRef, dx, dy, files, tab, path })
8
+ .filter(([, v]) => v !== undefined));
9
+ }
10
+ /** A step aimed at a field with one of these words in its name or role carries a secret: its value is never shown. */
11
+ export const SECRET_FIELD = /pass|secret|token|otp|pin|cvv|cvc/i;
12
+ export const isSecretStep = (s) => SECRET_FIELD.test(`${s.name ?? ''} ${s.nameContains ?? ''} ${s.role ?? ''}`);
13
+ export function describeStep(step) {
14
+ if (!step.do)
15
+ return 'check';
16
+ const has = step.name !== undefined || step.nameContains !== undefined;
17
+ const target = has ? `${step.role ?? 'control'} ${step.name !== undefined ? JSON.stringify(step.name) : `containing ${JSON.stringify(step.nameContains)}`}` : '';
18
+ switch (step.do) {
19
+ // The label is printed before the lab sees the field, so it cannot know a password field by its type:
20
+ // a fill's value never appears here. The action line that follows shows it when the field is not secret.
21
+ case 'fill': return `fill ${target}`;
22
+ // A single character may be part of a password: only named keys (Enter, Tab, Control+a) are shown.
23
+ case 'press': return `press ${step.key && [...step.key].length === 1 ? 'a character' : step.key}${target ? ` in ${target}` : ''}`;
24
+ case 'select': return `select ${(step.values ?? []).join(', ')} in ${target}`;
25
+ case 'scroll':
26
+ case 'swipe': return `${step.do} ${step.direction ?? 'to'}${target ? ` ${target}` : ''}`;
27
+ case 'upload': return `upload ${(step.files ?? []).join(', ')} to ${target}`;
28
+ case 'drag': return `drag ${target} ${step.to ? `onto ${step.to.name}` : `by (${step.dx ?? 0}, ${step.dy ?? 0})`}`;
29
+ case 'open_tab': return `open tab at ${step.path}`;
30
+ case 'switch_tab':
31
+ case 'close_tab': return `${step.do} ${step.tab ?? ''}`.trim();
32
+ default: return `${step.do}${target ? ` ${target}` : ''}`;
33
+ }
34
+ }
35
+ const matches = (o, q) => o.controls.filter((c) => (!q.role || c.role === q.role) && (q.name === undefined || c.name === q.name) && (q.nameContains === undefined || c.name.includes(q.nameContains)));
36
+ /** Evaluate expectations against the latest observation and action. Exported for tests. */
37
+ export function check(e, o, action, findings = []) {
38
+ const checks = [];
39
+ const add = (ok, text) => checks.push({ ok, text });
40
+ if (e.route !== undefined)
41
+ add(o.route === e.route, `route is ${e.route} (got ${o.route})`);
42
+ if (e.heading !== undefined)
43
+ add(o.headings.some((h) => h.replace(/^h\d /, '') === e.heading), `heading "${e.heading}" (got ${o.headings.join(' | ') || 'none'})`);
44
+ if (e.dialog !== undefined) {
45
+ add(e.dialog === null ? o.dialog === undefined : o.dialog === e.dialog, e.dialog === null ? `no dialog open (got ${o.dialog ?? 'none'})` : `dialog "${e.dialog}" open (got ${o.dialog ?? 'none'})`);
46
+ }
47
+ if (e.message !== undefined)
48
+ add(o.messages.some((m) => m.text.includes(e.message)), `message containing "${e.message}" (got ${o.messages.map((m) => m.text).join(' | ') || 'none'})`);
49
+ if (e.control) {
50
+ const { disabled, invalid, ...q } = e.control;
51
+ const found = matches(o, q).filter((c) => (disabled === undefined || !!c.disabled === disabled) && (invalid === undefined || !!c.invalid === invalid));
52
+ add(found.length > 0, `control ${JSON.stringify(e.control)} present`);
53
+ }
54
+ if (e.noControl)
55
+ add(matches(o, e.noControl).length === 0, `no control ${JSON.stringify(e.noControl)}`);
56
+ if (e.layout === 'clean')
57
+ add(o.layout.length === 0, `no layout flags (got ${o.layout.map((f) => f.kind).join(', ') || 'none'})`);
58
+ else if (Array.isArray(e.layout)) {
59
+ for (const want of e.layout) {
60
+ const hit = o.layout.some((f) => f.kind === want.kind && (!want.name || (!!f.ref && o.controls.some((c) => c.ref === f.ref && c.name === want.name))));
61
+ add(hit, `layout flag ${want.kind}${want.name ? ` on "${want.name}"` : ''} reported`);
62
+ }
63
+ }
64
+ if (e.note !== undefined)
65
+ add(!!action?.notes.some((n) => n.includes(e.note)), `action note containing "${e.note}"`);
66
+ if (e.navigated !== undefined)
67
+ add(action?.navigated === e.navigated, `navigated is ${e.navigated}`);
68
+ if (e.tab !== undefined)
69
+ add((o.tab ?? 't1') === e.tab, `active tab is ${e.tab} (got ${o.tab ?? 't1'})`);
70
+ if (e.findings === 'none')
71
+ add(findings.length === 0, `no findings in session (got ${findings.map((f) => `${f.id} ${f.kind}`).join(', ') || 'none'})`);
72
+ else if (Array.isArray(e.findings)) {
73
+ for (const want of e.findings) {
74
+ const hit = findings.find((f) => f.kind === want.kind && (!want.name || f.target?.name === want.name) && (!want.severity || f.severity === want.severity));
75
+ add(!!hit, `finding ${want.kind}${want.name ? ` on "${want.name}"` : ''}${want.severity ? ` (${want.severity})` : ''} recorded${hit ? ` as ${hit.id}` : ''}`);
76
+ }
77
+ }
78
+ return checks;
79
+ }
80
+ //# sourceMappingURL=steps.js.map
@@ -0,0 +1,138 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs';
2
+ import { redactUrlSecrets } from './url-redact.js';
3
+ import { join, relative } from 'node:path';
4
+ import { getDevice } from './devices.js';
5
+ import { measureState } from './checks.js';
6
+ import { installNavTracking, installTimerTracking, waitForDomQuiet } from './extract.js';
7
+ import { settleCauseText } from './format.js';
8
+ import { LabError, SCHEMA_VERSION } from './schema.js';
9
+ export async function runSweep(env, opts) {
10
+ const devices = opts.devices.map((id) => getDevice(id, env.devices));
11
+ if (!opts.route.startsWith('/'))
12
+ throw new LabError('invalid_request', `sweep route must be a path starting with "/", got "${opts.route}"`);
13
+ const dir = join(env.runDir, 'sweeps', opts.id);
14
+ mkdirSync(dir, { recursive: true });
15
+ const t0 = Date.now();
16
+ const startedAt = new Date().toISOString();
17
+ env.emit({ kind: 'sweep', phase: 'start', id: opts.id, route: redactUrlSecrets(opts.route), devices: devices.map((d) => ({ id: d.id, width: d.viewport.width, height: d.viewport.height })) });
18
+ const results = [];
19
+ let interrupted;
20
+ for (const [i, device] of devices.entries()) {
21
+ const stop = env.checkpoint?.();
22
+ if (stop) {
23
+ interrupted = { ...stop, skipped: devices.slice(i).map((d) => d.id) };
24
+ break;
25
+ }
26
+ env.emit({ kind: 'sweep', phase: 'device-start', id: opts.id, device: device.id });
27
+ const r = await sweepDevice(env, opts, device, dir);
28
+ results.push(r);
29
+ env.emit({ kind: 'sweep', phase: 'device-done', id: opts.id, result: r });
30
+ }
31
+ const ids = new Set(results.flatMap((r) => r.findings));
32
+ const findings = env.store.list().filter((f) => ids.has(f.id));
33
+ const report = join(dir, 'report.md');
34
+ const result = {
35
+ schemaVersion: SCHEMA_VERSION, id: opts.id, route: redactUrlSecrets(opts.route), startedAt, ms: Date.now() - t0, devices: results, findings, report,
36
+ ...(interrupted ? { interrupted } : {}),
37
+ };
38
+ writeFileSync(report, sweepReportMarkdown(result, dir));
39
+ writeFileSync(join(dir, 'result.json'), JSON.stringify(result, null, 2));
40
+ env.emit({ kind: 'sweep', phase: 'done', id: opts.id, result });
41
+ return result;
42
+ }
43
+ async function sweepDevice(env, opts, device, dir) {
44
+ const t0 = Date.now();
45
+ const url = env.origin + opts.route;
46
+ const base = {
47
+ device: device.id, label: device.label, width: device.viewport.width, height: device.viewport.height, route: redactUrlSecrets(opts.route),
48
+ status: 'ok', ms: 0, settled: 'quiet', documentWidth: 0, controls: 0, reachChecked: 0, findings: [],
49
+ };
50
+ const context = await env.browser.newContext({
51
+ viewport: device.viewport, deviceScaleFactor: device.deviceScaleFactor, isMobile: device.isMobile, hasTouch: device.hasTouch,
52
+ userAgent: device.userAgent, storageState: env.storageState,
53
+ });
54
+ let unwatch;
55
+ try {
56
+ if (env.settle.timerMaxMs > 0)
57
+ await context.addInitScript(installTimerTracking);
58
+ await context.addInitScript(installNavTracking);
59
+ const page = await context.newPage();
60
+ unwatch = await env.watch?.(page, device.viewport);
61
+ const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 });
62
+ // A scan fails a load error with its HTTP status; a sweep does the same instead of measuring an error page.
63
+ if (response)
64
+ base.httpStatus = response.status();
65
+ if (response && response.status() >= 400) {
66
+ throw new LabError('http_status', `GET ${redactUrlSecrets(opts.route)} answered HTTP ${response.status()} at ${device.id}; the page was not measured`, {
67
+ hint: 'Check the route, or that the app serves it when signed in with the session\'s state.', details: { status: response.status(), route: redactUrlSecrets(opts.route) },
68
+ });
69
+ }
70
+ await page.waitForLoadState('load', { timeout: env.settle.maxMs }).catch(() => undefined);
71
+ base.settled = await page.evaluate(waitForDomQuiet, { quietMs: env.settle.quietMs, maxMs: env.settle.maxMs, timerMaxMs: env.settle.timerMaxMs })
72
+ .catch(() => 'timeout');
73
+ if (base.settled !== 'quiet')
74
+ base.settleCause = base.settled === 'timeout' ? 'dom' : base.settled;
75
+ const open = `sweep ${opts.id}: open ${redactUrlSecrets(url)} in a new ${device.id} context (${device.viewport.width}x${device.viewport.height}) with the session's cookies and storage`;
76
+ // The same engine as a scan: the observation's flags, the layout detectors and the reach check.
77
+ const m = await measureState(page, {
78
+ store: env.store, sessionId: env.sessionId, gen: env.gen, ctx: { device: device.id }, reproduction: [...env.history, open],
79
+ detectors: env.detectors, reachLimit: opts.reachLimit ?? 40,
80
+ frameFile: (suffix) => join(dir, suffix === 'state' ? `${device.id}.jpg` : `${device.id}-${suffix}.jpg`),
81
+ onFindings: (findings, frame) => env.emit({ kind: 'findings', findings, ...(frame ? { frame } : {}) }),
82
+ });
83
+ base.route = m.observation.route;
84
+ base.documentWidth = m.observation.documentWidth;
85
+ base.controls = m.observation.controls.length;
86
+ base.reachChecked = m.reachChecked;
87
+ if (m.frame)
88
+ base.frame = m.frame;
89
+ base.findings = m.findings;
90
+ }
91
+ catch (err) {
92
+ base.status = 'error';
93
+ base.error = LabError.from(err).toJSON();
94
+ }
95
+ finally {
96
+ await unwatch?.().catch(() => undefined);
97
+ await context.close().catch(() => undefined);
98
+ }
99
+ base.ms = Date.now() - t0;
100
+ return base;
101
+ }
102
+ const cell = (s) => s.replace(/\|/g, '\\|').replace(/\n/g, ' ');
103
+ /** Compact Markdown report: one section per width, confirmed obstructions apart from heuristic warnings. */
104
+ export function sweepReportMarkdown(r, dir) {
105
+ const byId = new Map(r.findings.map((f) => [f.id, f]));
106
+ const lines = [
107
+ `# Responsive sweep ${r.id}: ${r.route}`,
108
+ '',
109
+ `${r.startedAt} · ${r.devices.length} widths, serial, one isolated browser context each · ${r.ms} ms`,
110
+ '',
111
+ '**Confirmed** findings were measured by the reach check (a sideways pan was needed, or the centre is covered after scrolling into view). **Heuristic** findings are layout warnings that often, but not always, hurt a person.',
112
+ '',
113
+ '| device | width | status | document width | controls | confirmed | heuristic | frame |',
114
+ '| --- | --- | --- | --- | --- | --- | --- | --- |',
115
+ ];
116
+ for (const d of r.devices) {
117
+ const fs = d.findings.map((id) => byId.get(id)).filter((f) => !!f);
118
+ const confirmed = fs.filter((f) => f.confidence === 'confirmed').length;
119
+ lines.push(`| ${d.device} | ${d.width} | ${d.status === 'ok' ? `ok (${d.settled === 'quiet' ? 'quiet' : cell(`settle timed out: ${settleCauseText(d.settleCause ?? 'dom')}`)})` : `error: ${cell(`${d.error?.code ?? ''} ${d.error?.message ?? ''}`.trim())}`} | ${d.documentWidth} | ${d.controls} (${d.reachChecked} reach-checked) | ${confirmed} | ${fs.length - confirmed} | ${d.frame ? `[${d.device}.jpg](${relative(dir, d.frame)})` : '–'} |`);
120
+ }
121
+ for (const d of r.devices) {
122
+ const fs = d.findings.map((id) => byId.get(id)).filter((f) => !!f);
123
+ lines.push('', `## ${d.device} (${d.width}×${d.height}) · ${d.route}`, '');
124
+ if (!fs.length) {
125
+ lines.push('No findings.');
126
+ continue;
127
+ }
128
+ lines.push('| id | confidence | severity | finding | measurements | reproduction |', '| --- | --- | --- | --- | --- | --- |');
129
+ for (const f of [...fs].sort((a, b) => (a.confidence === b.confidence ? 0 : a.confidence === 'confirmed' ? -1 : 1))) {
130
+ const evidence = Object.entries(f.evidence).map(([k, v]) => `${k}=${v}`).join(', ');
131
+ const repro = f.reproduction.slice(-3).map((s, i, a) => `${f.reproduction.length - a.length + i + 1}. ${s}`).join('<br>');
132
+ lines.push(`| ${f.id} | ${f.confidence} | ${f.severity} | ${cell(`${f.kind}: ${f.message}`)} | ${cell(evidence)} | ${cell(repro)} |`);
133
+ }
134
+ }
135
+ lines.push('', 'Full reproduction steps and evidence frames: `result.json` and the `.jpg` files next to this report.');
136
+ return lines.join('\n') + '\n';
137
+ }
138
+ //# sourceMappingURL=sweep.js.map