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.
- package/LICENSE +202 -0
- package/NOTICE +10 -0
- package/README.md +150 -2
- package/assets/dashboard/app.css +238 -0
- package/assets/dashboard/app.js +976 -0
- package/assets/dashboard/index.html +120 -0
- package/bin/agentlab.js +8 -0
- package/dist/cli/ci.js +443 -0
- package/dist/cli/flow.js +127 -0
- package/dist/cli/main.js +855 -0
- package/dist/cli/signals.js +38 -0
- package/dist/core/action-log.js +125 -0
- package/dist/core/auth.js +175 -0
- package/dist/core/bundle.js +432 -0
- package/dist/core/checks.js +137 -0
- package/dist/core/ci.js +389 -0
- package/dist/core/commands.js +366 -0
- package/dist/core/control.js +217 -0
- package/dist/core/detectors.js +392 -0
- package/dist/core/devices.js +83 -0
- package/dist/core/doctor.js +203 -0
- package/dist/core/explore-safety.js +110 -0
- package/dist/core/extract.js +1445 -0
- package/dist/core/feed.js +493 -0
- package/dist/core/findings.js +179 -0
- package/dist/core/format.js +311 -0
- package/dist/core/init.js +607 -0
- package/dist/core/lab.js +1979 -0
- package/dist/core/migrate.js +35 -0
- package/dist/core/observation.js +161 -0
- package/dist/core/ownership.js +142 -0
- package/dist/core/process-group.js +206 -0
- package/dist/core/process-identity.js +56 -0
- package/dist/core/profile.js +816 -0
- package/dist/core/project-runner.js +359 -0
- package/dist/core/replay.js +303 -0
- package/dist/core/reproduction.js +36 -0
- package/dist/core/scan-policy.js +156 -0
- package/dist/core/scan-report.js +145 -0
- package/dist/core/scan.js +455 -0
- package/dist/core/schema.js +39 -0
- package/dist/core/services.js +134 -0
- package/dist/core/steps.js +80 -0
- package/dist/core/sweep.js +138 -0
- package/dist/core/trace-sanitize.js +641 -0
- package/dist/core/url-redact.js +25 -0
- package/dist/core/versions.js +29 -0
- package/dist/core/zip.js +264 -0
- package/dist/daemon/main.js +160 -0
- package/dist/daemon/state.js +93 -0
- package/dist/dashboard/open.js +25 -0
- package/dist/dashboard/server.js +366 -0
- package/dist/mcp/server.js +89 -0
- package/package.json +63 -4
package/dist/core/lab.js
ADDED
|
@@ -0,0 +1,1979 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync, realpathSync, rmSync, statSync } from 'node:fs';
|
|
2
|
+
import { redactUrlSecrets } from './url-redact.js';
|
|
3
|
+
import { basename, join, relative, resolve, sep } from 'node:path';
|
|
4
|
+
import { randomBytes } from 'node:crypto';
|
|
5
|
+
import { writeFileSync } from 'node:fs';
|
|
6
|
+
import { chromium } from 'playwright';
|
|
7
|
+
import { ActionLog } from './action-log.js';
|
|
8
|
+
import { authStateSecrets, invalidate, loadAuthState, saveAuthState } from './auth.js';
|
|
9
|
+
import { envSecrets, writeBundle } from './bundle.js';
|
|
10
|
+
import { sanitizeTrace } from './trace-sanitize.js';
|
|
11
|
+
import { EMULATION_LIMITATIONS, SWEEP_DEVICES, getDevice } from './devices.js';
|
|
12
|
+
import { armHitCapture, centreInView, checkability, currentDocId, extractPage, fillability, focusedIsSecret, installHumanRecorder, installNavTracking, installTimerTracking, lookupRef, needsHorizontalPan, panIntoView, scrollByAmount, scrollPositions, selectInfo, targetPoint, uploadInfo, waitForDomQuiet, } from './extract.js';
|
|
13
|
+
import { FindingStore } from './findings.js';
|
|
14
|
+
import { formatHuman } from './format.js';
|
|
15
|
+
import { CONTROL_OPS, Supervisor, stopReason } from './control.js';
|
|
16
|
+
import { runSweep } from './sweep.js';
|
|
17
|
+
import { runScan } from './scan.js';
|
|
18
|
+
import { checkFilter } from './checks.js';
|
|
19
|
+
import { buildObservation, diffObservations } from './observation.js';
|
|
20
|
+
import { DEFAULT_SETTLE, assertUrlAllowed, loadProfile } from './profile.js';
|
|
21
|
+
import { serverInfo } from './project-runner.js';
|
|
22
|
+
import { ServiceGroup } from './services.js';
|
|
23
|
+
import { keepRuns, liveSessionIds, pruneRuns, reapOrphans, recordOwnership, releaseOwnership } from './ownership.js';
|
|
24
|
+
import { appendStep, pushCapped, since } from './reproduction.js';
|
|
25
|
+
import { readState } from '../daemon/state.js';
|
|
26
|
+
import { LabError, SCHEMA_VERSION, } from './schema.js';
|
|
27
|
+
/** Keys the dashboard may send. Printable text goes through `text`. */
|
|
28
|
+
export const HUMAN_KEYS = [
|
|
29
|
+
'Enter', 'Tab', 'Shift+Tab', 'Escape', 'Backspace', 'Delete', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight',
|
|
30
|
+
'Home', 'End', 'PageUp', 'PageDown', 'Space',
|
|
31
|
+
];
|
|
32
|
+
const HUMAN_TEXT_MAX = 500;
|
|
33
|
+
const HUMAN_SCROLL_MAX = 5000;
|
|
34
|
+
/** A person can produce many events; beyond this rate they are counted, not recorded one by one. */
|
|
35
|
+
const HUMAN_EVENTS_PER_SECOND = 10;
|
|
36
|
+
/** How long a watched sweep or scan page stays in the live viewport at least. */
|
|
37
|
+
const WATCH_MIN_MS = 1200;
|
|
38
|
+
/** How long after a watched page closes the live viewport returns to the session's page. */
|
|
39
|
+
const WATCH_RETURN_MS = 600;
|
|
40
|
+
const OBSERVE_LIMIT = 40;
|
|
41
|
+
const BODY_IDLE_MS = 500;
|
|
42
|
+
/** How long to wait for a tab the page asked to open (a link with a target, window.open) to reach the lab. */
|
|
43
|
+
const NEW_TAB_WAIT_MS = 2000;
|
|
44
|
+
const ACTION_TIMEOUT_MS = 5000;
|
|
45
|
+
/**
|
|
46
|
+
* One session: an owned-or-reused web server, one Chromium context and page, observation history
|
|
47
|
+
* and an action log. This class is the typed core behind the CLI, the daemon and the future MCP adapter.
|
|
48
|
+
*/
|
|
49
|
+
export class Lab {
|
|
50
|
+
opts;
|
|
51
|
+
browser;
|
|
52
|
+
context;
|
|
53
|
+
/** The tab being observed and acted on. */
|
|
54
|
+
page;
|
|
55
|
+
profile;
|
|
56
|
+
services;
|
|
57
|
+
session;
|
|
58
|
+
last;
|
|
59
|
+
gen = 0;
|
|
60
|
+
nextRef = 1;
|
|
61
|
+
refDocs = new Map();
|
|
62
|
+
/** Tab each ref was issued in. */
|
|
63
|
+
refTabs = new Map();
|
|
64
|
+
/** old ref → new ref for controls the diff engine judged to be re-rendered. */
|
|
65
|
+
replacedBy = new Map();
|
|
66
|
+
/** The latest EVENTS_KEEP of each; the totals below stay exact and feed the observation counters. */
|
|
67
|
+
consoleErrors = [];
|
|
68
|
+
failedRequests = [];
|
|
69
|
+
consoleTotal = 0;
|
|
70
|
+
failedTotal = 0;
|
|
71
|
+
/** In-flight requests, for the settle policy. */
|
|
72
|
+
requests = new Map();
|
|
73
|
+
/**
|
|
74
|
+
* Last response or body data time of unfinished requests, by "METHOD url". A response whose body
|
|
75
|
+
* nobody reads (a POST result the page ignores, sent chunked) never finishes in Chromium; once its
|
|
76
|
+
* body has been silent for BODY_IDLE_MS it no longer holds settling. Streams keep sending, so they do.
|
|
77
|
+
*/
|
|
78
|
+
bodyActivity = new Map();
|
|
79
|
+
settlePolicy = { ...DEFAULT_SETTLE };
|
|
80
|
+
findings = new FindingStore('');
|
|
81
|
+
/** Human-readable steps from session start; copied into each finding as its reproduction. */
|
|
82
|
+
history = [];
|
|
83
|
+
actionPanX = 0;
|
|
84
|
+
lastFreshFindings = [];
|
|
85
|
+
hits = [];
|
|
86
|
+
/** Viewport captured just before an action panned sideways, kept as the pan finding's evidence. */
|
|
87
|
+
prePanFrame;
|
|
88
|
+
/** The field being filled or typed into is a password or similar; its value is kept out of history and logs. */
|
|
89
|
+
fillSecret = false;
|
|
90
|
+
closing;
|
|
91
|
+
/** Session id under which this Lab recorded ownership of its services, until they are stopped. */
|
|
92
|
+
ownedId;
|
|
93
|
+
/** Aborted by close() so services still starting are abandoned; `starting` is the start in flight. */
|
|
94
|
+
startAbort = new AbortController();
|
|
95
|
+
starting;
|
|
96
|
+
/** What the agent did, replay-ready, and the values it typed into password-like fields (memory only). */
|
|
97
|
+
log = new ActionLog();
|
|
98
|
+
tracing = false;
|
|
99
|
+
/** A sign-in state supplied in memory (see StartOptions.authState); scenarios with auth "saved" use it too. */
|
|
100
|
+
memoryAuth;
|
|
101
|
+
sweeps = 0;
|
|
102
|
+
scans = 0;
|
|
103
|
+
/** An isolated scenario context: shares the session's browser, owns only its own context. */
|
|
104
|
+
child = false;
|
|
105
|
+
/**
|
|
106
|
+
* Called after an action's input (or a load) and before settling: a scan uses it to capture what the
|
|
107
|
+
* state looked like before it settled, as evidence for layout shifts.
|
|
108
|
+
*/
|
|
109
|
+
onActed;
|
|
110
|
+
origin = '';
|
|
111
|
+
endedReason;
|
|
112
|
+
/** Pause, takeover and stop requests from a person watching the dashboard. */
|
|
113
|
+
control = new Supervisor();
|
|
114
|
+
/** Refs numbered below this were issued before a person used the browser: stale. */
|
|
115
|
+
refFloor = 0;
|
|
116
|
+
/** Refs below the floor that an observation since has listed again: the control is still there, so the ref is fresh. */
|
|
117
|
+
refsSinceFloor = new Set();
|
|
118
|
+
humanWindow = { start: 0, count: 0, dropped: 0 };
|
|
119
|
+
/** Until when typing flushed at the hand-back is still accepted as the person's (see flushHumanTyping). */
|
|
120
|
+
humanGraceUntil = 0;
|
|
121
|
+
// ---------- tabs ----------
|
|
122
|
+
tabs = new Map();
|
|
123
|
+
tabIds = new WeakMap();
|
|
124
|
+
openers = new Map();
|
|
125
|
+
cdp = new WeakMap();
|
|
126
|
+
nextTab = 1;
|
|
127
|
+
activeTab = '';
|
|
128
|
+
/** Tabs opened and closed since the current action began. */
|
|
129
|
+
tabEvents = { opened: [], closed: [] };
|
|
130
|
+
/** The live viewport stream, moved to whichever tab becomes active. */
|
|
131
|
+
/** An isolated context shown in the session's live viewport: hands the viewport back on close. */
|
|
132
|
+
unwatch;
|
|
133
|
+
unwatchTimer;
|
|
134
|
+
/** The current page's route as the browser has it, secrets included; never output. */
|
|
135
|
+
rawRoute;
|
|
136
|
+
cast;
|
|
137
|
+
constructor(opts) {
|
|
138
|
+
this.opts = { ...opts, stateDir: resolve(opts.stateDir) };
|
|
139
|
+
this.control.onChange((change) => this.onControl(change));
|
|
140
|
+
}
|
|
141
|
+
get active() {
|
|
142
|
+
return !!this.session && !this.endedReason;
|
|
143
|
+
}
|
|
144
|
+
async start(opts) {
|
|
145
|
+
if (this.session)
|
|
146
|
+
throw new LabError('session_exists', `Session ${this.session.id} is already running`, { hint: 'Close it first.' });
|
|
147
|
+
this.control.begin('start');
|
|
148
|
+
try {
|
|
149
|
+
this.starting = this.startSession(opts);
|
|
150
|
+
return await this.starting;
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
const error = LabError.from(err);
|
|
154
|
+
this.emit({ kind: 'start-failed', error: error.toJSON() });
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
157
|
+
finally {
|
|
158
|
+
this.control.end();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
async startSession(opts) {
|
|
162
|
+
const profile = await loadProfile(opts.project);
|
|
163
|
+
this.profile = profile;
|
|
164
|
+
const device = getDevice(opts.device ?? profile.device, profile.devices);
|
|
165
|
+
this.settlePolicy = profile.settle;
|
|
166
|
+
this.findings = new FindingStore(device.id);
|
|
167
|
+
const headed = opts.headed ?? false;
|
|
168
|
+
const appUrl = profile.app.url;
|
|
169
|
+
this.emit({ kind: 'starting', project: profile.name, url: appUrl + profile.startPath, device, headed });
|
|
170
|
+
if (headed && process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) {
|
|
171
|
+
throw new LabError('no_display', 'Headed mode needs a display but neither DISPLAY nor WAYLAND_DISPLAY is set', {
|
|
172
|
+
hint: 'Use --headless, or run under xvfb-run.',
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
// Checked before any service starts, so a bad auth request never leaves processes behind.
|
|
176
|
+
const authMode = opts.auth ?? 'auto';
|
|
177
|
+
let storageState;
|
|
178
|
+
if (opts.authState) {
|
|
179
|
+
storageState = opts.authState;
|
|
180
|
+
this.memoryAuth = opts.authState;
|
|
181
|
+
}
|
|
182
|
+
else if (authMode === 'saved' || (authMode === 'auto' && profile.auth.use === 'auto' && existsFile(profile.auth.file))) {
|
|
183
|
+
storageState = loadAuthState(profile.auth);
|
|
184
|
+
}
|
|
185
|
+
const id = `s-${new Date().toISOString().slice(0, 19).replace(/[-:T]/g, '')}-${randomBytes(2).toString('hex')}`;
|
|
186
|
+
await this.housekeeping(id);
|
|
187
|
+
const runDir = join(this.opts.stateDir, 'runs', id);
|
|
188
|
+
// Service logs can hold secrets the app prints: owner-only.
|
|
189
|
+
mkdirSync(runDir, { recursive: true, mode: 0o700 });
|
|
190
|
+
const multi = profile.services.length > 1;
|
|
191
|
+
this.services = await ServiceGroup.start(profile.services, {
|
|
192
|
+
runDir,
|
|
193
|
+
onLog: (name, line) => this.emit({ kind: 'server-log', line: multi ? `[${name}] ${line}` : line }),
|
|
194
|
+
onReady: (info) => this.emit({ kind: 'service', info }),
|
|
195
|
+
onUnexpectedExit: (exit) => this.onServiceExit(exit, multi),
|
|
196
|
+
signal: this.startAbort.signal,
|
|
197
|
+
});
|
|
198
|
+
try {
|
|
199
|
+
recordOwnership(this.opts.stateDir, id, this.serviceRecords());
|
|
200
|
+
this.ownedId = id;
|
|
201
|
+
}
|
|
202
|
+
catch (err) {
|
|
203
|
+
this.emit({ kind: 'server-log', line: `[agentlab] could not record ownership of the started services (${firstLine(err)}); a crash of this process would leave them running` });
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
// Playwright's own signal handlers close the browser and then exit the process, cutting off the lab's
|
|
207
|
+
// cleanup (services, ownership records, results). Every entry point handles signals itself.
|
|
208
|
+
this.browser = await chromium.launch({ headless: !headed, slowMo: opts.slowMoMs, handleSIGINT: false, handleSIGTERM: false, handleSIGHUP: false });
|
|
209
|
+
try {
|
|
210
|
+
await this.openContext(device, storageState);
|
|
211
|
+
}
|
|
212
|
+
catch (err) {
|
|
213
|
+
if (!storageState)
|
|
214
|
+
throw err;
|
|
215
|
+
if (this.memoryAuth)
|
|
216
|
+
throw new LabError('auth_invalid', 'The browser rejected the sign-in state supplied in memory', { hint: 'Check the state you exported (a Playwright storageState).' });
|
|
217
|
+
invalidate(profile.auth);
|
|
218
|
+
this.emit({ kind: 'auth', action: 'invalidated' });
|
|
219
|
+
throw new LabError('auth_invalid', `The browser rejected the saved sign-in state (${firstLine(err)}); it has been removed`, {
|
|
220
|
+
hint: 'Start a fresh session, sign in, then save the state again.',
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
if (storageState)
|
|
224
|
+
this.emit({ kind: 'auth', action: 'loaded', cookies: storageState.cookies.length, origins: storageState.origins.length });
|
|
225
|
+
this.session = {
|
|
226
|
+
schemaVersion: SCHEMA_VERSION,
|
|
227
|
+
id,
|
|
228
|
+
device,
|
|
229
|
+
browser: { engine: 'chromium', version: this.browser.version(), headed },
|
|
230
|
+
environment: `Chromium ${this.browser.version()} with ${device.isMobile ? 'mobile ' : ''}device emulation on ${process.platform}`,
|
|
231
|
+
limitations: EMULATION_LIMITATIONS,
|
|
232
|
+
runDir,
|
|
233
|
+
auth: storageState ? 'saved-state' : 'fresh',
|
|
234
|
+
};
|
|
235
|
+
this.browser.on('disconnected', () => void this.ended('browser disconnected or crashed'));
|
|
236
|
+
if (opts.trace)
|
|
237
|
+
await this.startTrace();
|
|
238
|
+
this.origin = new URL(appUrl).origin;
|
|
239
|
+
const since = Date.now();
|
|
240
|
+
appendStep(this.history, `open ${redactUrlSecrets(appUrl + profile.startPath)} in Chromium with device ${device.id} (${device.viewport.width}x${device.viewport.height})${storageState ? ' with the saved sign-in state' : ''}`);
|
|
241
|
+
await this.page.goto(appUrl + profile.startPath, { waitUntil: 'domcontentloaded', timeout: 30_000 });
|
|
242
|
+
await this.settle(since);
|
|
243
|
+
const observation = await this.snapshot();
|
|
244
|
+
this.log.startRoute = observation.route;
|
|
245
|
+
if (storageState && profile.auth.loginPath && observation.route.startsWith(profile.auth.loginPath)) {
|
|
246
|
+
if (this.memoryAuth) {
|
|
247
|
+
throw new LabError('auth_invalid', `The sign-in state supplied in memory no longer signs in: the app redirected to ${observation.route}`, {
|
|
248
|
+
hint: 'Export a fresh state (`agentlab auth save` after signing in) and update the CI secret.',
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
invalidate(profile.auth);
|
|
252
|
+
this.emit({ kind: 'auth', action: 'invalidated' });
|
|
253
|
+
throw new LabError('auth_invalid', `The saved sign-in state no longer signs in: the app redirected to ${observation.route}. It has been removed`, {
|
|
254
|
+
hint: 'Start again (a fresh session), sign in, then save the state again.',
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
const primary = this.services.get(profile.app.service);
|
|
258
|
+
const server = primary ? serverInfo(primary.info) : { url: appUrl, owned: false, reused: true, readyMs: 0 };
|
|
259
|
+
const result = { session: this.session, server, services: this.services.infos(), observation };
|
|
260
|
+
this.record('start', { session: this.session, server, services: result.services, route: observation.route });
|
|
261
|
+
this.emit({ kind: 'start', result });
|
|
262
|
+
return result;
|
|
263
|
+
}
|
|
264
|
+
catch (err) {
|
|
265
|
+
// A close already under way (a signal, a service exit) is waiting for this start to settle.
|
|
266
|
+
if (!this.closing)
|
|
267
|
+
await this.close('start failed');
|
|
268
|
+
throw LabError.from(err);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Before a session starts: stop what a crashed earlier session left running (see ownership.ts) and prune
|
|
273
|
+
* old runs. Both are reported as `server-log` lines; neither can fail the start.
|
|
274
|
+
*/
|
|
275
|
+
async housekeeping(currentId) {
|
|
276
|
+
const line = (text) => this.emit({ kind: 'server-log', line: `[agentlab] ${text}` });
|
|
277
|
+
try {
|
|
278
|
+
for (const note of await reapOrphans(this.opts.stateDir))
|
|
279
|
+
line(note);
|
|
280
|
+
}
|
|
281
|
+
catch (err) {
|
|
282
|
+
line(`could not check for services left by an earlier session: ${firstLine(err)}`);
|
|
283
|
+
}
|
|
284
|
+
try {
|
|
285
|
+
const protect = liveSessionIds(this.opts.stateDir).add(currentId);
|
|
286
|
+
const daemonSession = readState(this.opts.stateDir)?.sessionId;
|
|
287
|
+
if (daemonSession)
|
|
288
|
+
protect.add(daemonSession);
|
|
289
|
+
const removed = pruneRuns(this.opts.stateDir, keepRuns(), protect);
|
|
290
|
+
if (removed.length)
|
|
291
|
+
line(`pruned ${removed.length} old run${removed.length > 1 ? 's' : ''} (keeping the latest ${keepRuns()}; AGENTLAB_KEEP_RUNS changes this)`);
|
|
292
|
+
}
|
|
293
|
+
catch { /* retention must never break a start */ }
|
|
294
|
+
}
|
|
295
|
+
/** A started service exited by itself after it was ready: a required one ends the session. */
|
|
296
|
+
onServiceExit(exit, multi) {
|
|
297
|
+
if (this.closing)
|
|
298
|
+
return;
|
|
299
|
+
// The other services' leaders are still alive: snapshot their groups again for the ownership record.
|
|
300
|
+
if (this.ownedId) {
|
|
301
|
+
try {
|
|
302
|
+
recordOwnership(this.opts.stateDir, this.ownedId, this.serviceRecords());
|
|
303
|
+
}
|
|
304
|
+
catch { /* the record from start stays */ }
|
|
305
|
+
}
|
|
306
|
+
const how = exit.code !== null ? `code ${exit.code}` : `signal ${exit.signal}`;
|
|
307
|
+
this.emit({ kind: 'server-log', line: `[agentlab] service "${exit.name}" exited unexpectedly (${how})${exit.required ? '' : '; it is optional, so the session continues'}` });
|
|
308
|
+
for (const l of exit.logTail)
|
|
309
|
+
this.emit({ kind: 'server-log', line: multi ? `[${exit.name}] ${l}` : l });
|
|
310
|
+
if (exit.required)
|
|
311
|
+
void this.ended(`service "${exit.name}" exited unexpectedly (${how})`);
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* One browser context for a device: the timer tracker, the hit-capture binding, tabs for pop-ups, and
|
|
315
|
+
* the first page. Used by the session and by isolated scenario contexts alike.
|
|
316
|
+
*/
|
|
317
|
+
async openContext(device, storageState, extra = {}) {
|
|
318
|
+
this.context = await this.browser.newContext({
|
|
319
|
+
viewport: device.viewport,
|
|
320
|
+
deviceScaleFactor: device.deviceScaleFactor,
|
|
321
|
+
isMobile: device.isMobile,
|
|
322
|
+
hasTouch: device.hasTouch,
|
|
323
|
+
userAgent: device.userAgent,
|
|
324
|
+
...(storageState ? { storageState } : {}),
|
|
325
|
+
});
|
|
326
|
+
if (this.settlePolicy.timerMaxMs > 0)
|
|
327
|
+
await this.context.addInitScript(installTimerTracking);
|
|
328
|
+
await this.context.addInitScript(installNavTracking);
|
|
329
|
+
if (!this.child) {
|
|
330
|
+
await this.context.addInitScript(installHumanRecorder);
|
|
331
|
+
await this.context.exposeBinding('__agentDeviceLabHuman', (_source, info) => this.onHuman(info));
|
|
332
|
+
}
|
|
333
|
+
for (const script of extra.scripts ?? [])
|
|
334
|
+
await this.context.addInitScript(script);
|
|
335
|
+
if (extra.guard) {
|
|
336
|
+
const guard = extra.guard;
|
|
337
|
+
await this.context.route('**/*', (route) => {
|
|
338
|
+
const req = route.request();
|
|
339
|
+
const label = guard(req.method(), req.url(), req.isNavigationRequest());
|
|
340
|
+
return label ? route.abort('blockedbyclient') : route.fallback();
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
await this.context.exposeBinding('__agentDeviceLabHit', (_source, info) => {
|
|
344
|
+
// Page-supplied data: only accept the expected shape; it never drives decisions beyond this action.
|
|
345
|
+
const h = info;
|
|
346
|
+
if (typeof h?.token === 'string' && typeof h.type === 'string') {
|
|
347
|
+
this.hits.push({ token: h.token, type: h.type, onTarget: h.onTarget === true, target: String(h.target).slice(0, 80) });
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
// Pop-ups and target=_blank links become tabs the session can switch to.
|
|
351
|
+
this.context.on('page', (p) => {
|
|
352
|
+
if (this.tabIds.has(p))
|
|
353
|
+
return;
|
|
354
|
+
const id = this.registerTab(p, this.activeTab);
|
|
355
|
+
this.tabEvents.opened.push(id);
|
|
356
|
+
void p.opener().then((o) => { const by = o && this.tabIds.get(o); if (by)
|
|
357
|
+
this.openers.set(id, by); }).catch(() => undefined);
|
|
358
|
+
});
|
|
359
|
+
this.page = await this.context.newPage();
|
|
360
|
+
if (!this.tabIds.has(this.page))
|
|
361
|
+
this.registerTab(this.page);
|
|
362
|
+
this.activeTab = this.tabIds.get(this.page);
|
|
363
|
+
this.tabEvents = { opened: [], closed: [] };
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* A Lab on its own isolated browser context in an existing browser: a scenario's device. It shares
|
|
367
|
+
* nothing with the session's context (cookies and storage are copied in, never written back), runs
|
|
368
|
+
* actions through the same act(), and closing it closes only its context: never the browser, never a
|
|
369
|
+
* service. It writes no artifacts of its own.
|
|
370
|
+
*/
|
|
371
|
+
static async isolated(o) {
|
|
372
|
+
const lab = new Lab({ stateDir: o.runDir });
|
|
373
|
+
lab.child = true;
|
|
374
|
+
lab.browser = o.browser;
|
|
375
|
+
lab.profile = o.profile;
|
|
376
|
+
lab.settlePolicy = o.profile.settle;
|
|
377
|
+
lab.findings = new FindingStore(o.device.id);
|
|
378
|
+
lab.origin = new URL(o.profile.app.url).origin;
|
|
379
|
+
lab.session = {
|
|
380
|
+
schemaVersion: SCHEMA_VERSION, id: o.sessionId, device: o.device,
|
|
381
|
+
browser: { engine: 'chromium', version: o.browser.version(), headed: o.headed },
|
|
382
|
+
environment: `Chromium ${o.browser.version()} with ${o.device.isMobile ? 'mobile ' : ''}device emulation on ${process.platform}`,
|
|
383
|
+
limitations: EMULATION_LIMITATIONS, runDir: o.runDir, auth: o.storageState ? 'saved-state' : 'fresh',
|
|
384
|
+
};
|
|
385
|
+
try {
|
|
386
|
+
await lab.openContext(o.device, o.storageState, o.init);
|
|
387
|
+
lab.unwatch = await o.watch?.(lab.page, o.device.viewport);
|
|
388
|
+
}
|
|
389
|
+
catch (err) {
|
|
390
|
+
await lab.close('context failed');
|
|
391
|
+
throw LabError.from(err);
|
|
392
|
+
}
|
|
393
|
+
return lab;
|
|
394
|
+
}
|
|
395
|
+
/** Isolated contexts: open a path on the app's origin, wait for it to settle, and observe it. */
|
|
396
|
+
async load(path, describe) {
|
|
397
|
+
this.requireActive();
|
|
398
|
+
const url = this.origin + path;
|
|
399
|
+
assertUrlAllowed(url, this.profile.allowExternalUrl);
|
|
400
|
+
const since = Date.now();
|
|
401
|
+
appendStep(this.history, describe);
|
|
402
|
+
const response = await this.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 });
|
|
403
|
+
if (response && response.status() >= 400)
|
|
404
|
+
throw new LabError('action_failed', `GET ${path} returned HTTP ${response.status()}`, { hint: 'Check the scenario\'s route.' });
|
|
405
|
+
await this.onActed?.();
|
|
406
|
+
const settle = await this.settle(since);
|
|
407
|
+
return { observation: await this.snapshot(), settle };
|
|
408
|
+
}
|
|
409
|
+
/** The page being observed, for the checks engine (never exposed to agents). */
|
|
410
|
+
get activePage() {
|
|
411
|
+
return this.page;
|
|
412
|
+
}
|
|
413
|
+
/** Steps from the start of this Lab's history, as recorded for reproduction. */
|
|
414
|
+
get steps() {
|
|
415
|
+
return this.history;
|
|
416
|
+
}
|
|
417
|
+
async observe(opts = {}) {
|
|
418
|
+
this.requireActive();
|
|
419
|
+
this.admit('observe', 'observe');
|
|
420
|
+
return this.busy('observe', async () => {
|
|
421
|
+
const observation = await this.snapshot(opts.limit);
|
|
422
|
+
this.control.observed();
|
|
423
|
+
this.record('observe', { gen: observation.gen, route: observation.route, controls: observation.controls.length });
|
|
424
|
+
this.emit({ kind: 'observe', observation });
|
|
425
|
+
return observation;
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
/** Run a session operation as the one in flight, so a pause or takeover takes effect when it ends. */
|
|
429
|
+
async busy(label, fn) {
|
|
430
|
+
if (this.child)
|
|
431
|
+
return fn();
|
|
432
|
+
this.control.begin(label);
|
|
433
|
+
try {
|
|
434
|
+
return await fn();
|
|
435
|
+
}
|
|
436
|
+
finally {
|
|
437
|
+
this.control.end();
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
/** For scans and sweeps between units of work: why to stop now, if a person asked. */
|
|
441
|
+
interruption() {
|
|
442
|
+
if (!this.control.shouldInterrupt())
|
|
443
|
+
return undefined;
|
|
444
|
+
const s = this.control.state;
|
|
445
|
+
const reason = s.mode === 'stopping' || s.mode === 'stopped' ? 'stopped' : s.pending === 'human' ? 'takeover' : 'paused';
|
|
446
|
+
return { reason, ...(s.by ? { by: s.by } : {}) };
|
|
447
|
+
}
|
|
448
|
+
async act(request) {
|
|
449
|
+
this.requireActive();
|
|
450
|
+
if (!this.child)
|
|
451
|
+
this.admit(request.action, 'act');
|
|
452
|
+
return this.busy(request.action, () => this.actNow(request));
|
|
453
|
+
}
|
|
454
|
+
async actNow(request) {
|
|
455
|
+
const t0 = Date.now();
|
|
456
|
+
const consoleMark = this.consoleTotal;
|
|
457
|
+
const failedMark = this.failedTotal;
|
|
458
|
+
const before = this.last;
|
|
459
|
+
const target = request.ref ? before?.controls.find((c) => c.ref === request.ref) : undefined;
|
|
460
|
+
const notes = [];
|
|
461
|
+
let method;
|
|
462
|
+
let result;
|
|
463
|
+
this.actionPanX = 0;
|
|
464
|
+
this.prePanFrame = undefined;
|
|
465
|
+
this.fillSecret = false;
|
|
466
|
+
this.tabEvents = { opened: [], closed: [] };
|
|
467
|
+
const tabBefore = this.activeTab;
|
|
468
|
+
const head = { schemaVersion: SCHEMA_VERSION, action: request.action, ...(request.ref ? { ref: request.ref } : {}),
|
|
469
|
+
...(target ? { target: { ref: target.ref, role: target.role, name: target.name } } : {}) };
|
|
470
|
+
try {
|
|
471
|
+
validateAction(request);
|
|
472
|
+
method = await this.perform(request, notes);
|
|
473
|
+
// The active tab closed itself: wait for the switch to its opener before settling.
|
|
474
|
+
for (let i = 0; i < 40 && this.page?.isClosed() && this.tabs.size; i++)
|
|
475
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
476
|
+
appendStep(this.history, describeAction(request, target, before, this.fillSecret));
|
|
477
|
+
await this.onActed?.().catch(() => undefined);
|
|
478
|
+
let settle = await this.settle(t0, before ? pathOf(before.url) : undefined);
|
|
479
|
+
await this.awaitRequestedTab(t0);
|
|
480
|
+
if (await this.followNewTab(notes))
|
|
481
|
+
settle = await this.settle(Date.now());
|
|
482
|
+
const observation = await this.snapshot();
|
|
483
|
+
const changes = diffObservations(before, observation);
|
|
484
|
+
if (this.activeTab !== tabBefore) {
|
|
485
|
+
changes.reset = { reason: 'tab' };
|
|
486
|
+
changes.route = before ? { from: before.route, to: observation.route } : undefined;
|
|
487
|
+
}
|
|
488
|
+
if (this.tabEvents.opened.length || this.tabEvents.closed.length || this.activeTab !== tabBefore) {
|
|
489
|
+
changes.tabs = { opened: [...this.tabEvents.opened], closed: [...this.tabEvents.closed],
|
|
490
|
+
...(this.activeTab !== tabBefore ? { active: { from: tabBefore, to: this.activeTab } } : {}) };
|
|
491
|
+
changes.none = false;
|
|
492
|
+
}
|
|
493
|
+
for (const r of changes.rerendered)
|
|
494
|
+
this.replacedBy.set(r.from, r.to);
|
|
495
|
+
const newFindings = [...this.lastFreshFindings];
|
|
496
|
+
if (this.actionPanX && request.ref) {
|
|
497
|
+
// Recorded against the page the control was on, even if the action then navigated away.
|
|
498
|
+
const where = before ?? observation;
|
|
499
|
+
const control = where.controls.find((c) => c.ref === request.ref);
|
|
500
|
+
const pan = this.findings.recordHorizontalPan(where, control, {
|
|
501
|
+
role: target?.role ?? control?.role ?? 'control', name: target?.name ?? control?.name ?? request.ref, ref: request.ref,
|
|
502
|
+
}, Math.abs(this.actionPanX), this.history);
|
|
503
|
+
if (pan) {
|
|
504
|
+
newFindings.push(pan);
|
|
505
|
+
const frame = this.saveFrame(this.prePanFrame, `g${observation.gen}-before-pan`);
|
|
506
|
+
if (frame)
|
|
507
|
+
pan.frame = frame;
|
|
508
|
+
this.emit({ kind: 'findings', findings: [pan], frame });
|
|
509
|
+
}
|
|
510
|
+
observation.findings = this.findings.size;
|
|
511
|
+
this.saveFindings();
|
|
512
|
+
}
|
|
513
|
+
result = {
|
|
514
|
+
...head, outcome: 'success', method, elapsedMs: Date.now() - t0, settle,
|
|
515
|
+
navigated: changes.reset?.reason === 'navigation', notes, changes,
|
|
516
|
+
newConsoleErrors: since(this.consoleErrors, this.consoleTotal, consoleMark),
|
|
517
|
+
newFailedRequests: since(this.failedRequests, this.failedTotal, failedMark),
|
|
518
|
+
newFindings,
|
|
519
|
+
observation,
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
catch (err) {
|
|
523
|
+
// Stopped underneath the action (an emergency stop, or the browser went away): say so plainly.
|
|
524
|
+
const error = this.endedReason ? new LabError('browser_closed', `Session ended during the action: ${this.endedReason}`, { hint: 'Nothing more can run in this session.' }) : LabError.from(err);
|
|
525
|
+
result = {
|
|
526
|
+
...head, outcome: 'error', error: error.toJSON(), ...(method ? { method } : {}), elapsedMs: Date.now() - t0,
|
|
527
|
+
navigated: false, notes,
|
|
528
|
+
newConsoleErrors: since(this.consoleErrors, this.consoleTotal, consoleMark),
|
|
529
|
+
newFailedRequests: since(this.failedRequests, this.failedTotal, failedMark),
|
|
530
|
+
newFindings: [],
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
if (!this.child)
|
|
534
|
+
this.log.agent({ request, before, result, secret: this.fillSecret });
|
|
535
|
+
this.record('act', {
|
|
536
|
+
request: loggedRequest(request, this.fillSecret),
|
|
537
|
+
outcome: result.outcome, error: result.error?.code, elapsedMs: result.elapsedMs, notes: result.notes,
|
|
538
|
+
route: result.observation?.route, settle: result.settle, newFindings: result.newFindings.map((f) => f.id),
|
|
539
|
+
});
|
|
540
|
+
this.emit({ kind: 'act', request: this.fillSecret && request.key ? { ...request, key: SECRET_MASK } : request, result });
|
|
541
|
+
return result;
|
|
542
|
+
}
|
|
543
|
+
/** Resolve a role/name query to a ref in the latest observation. Never guesses between candidates. */
|
|
544
|
+
findRef(query) {
|
|
545
|
+
this.requireActive();
|
|
546
|
+
const controls = this.last?.controls ?? [];
|
|
547
|
+
const byRole = controls.filter((c) => !query.role || c.role === query.role);
|
|
548
|
+
let matches = byRole.filter((c) => (query.name === undefined || c.name === query.name) &&
|
|
549
|
+
(query.nameContains === undefined || c.name.includes(query.nameContains)));
|
|
550
|
+
if (!matches.length && query.name !== undefined) {
|
|
551
|
+
matches = byRole.filter((c) => c.name.toLowerCase() === query.name.toLowerCase());
|
|
552
|
+
}
|
|
553
|
+
const describe = `${query.role ?? 'control'} ${query.name !== undefined ? `"${query.name}"` : `containing "${query.nameContains}"`}`;
|
|
554
|
+
if (matches.length === 1)
|
|
555
|
+
return matches[0].ref;
|
|
556
|
+
if (matches.length > 1) {
|
|
557
|
+
throw new LabError('ambiguous_target', `${matches.length} controls match ${describe}`, {
|
|
558
|
+
details: { candidates: matches.map((c) => ({ ref: c.ref, role: c.role, name: c.name })) },
|
|
559
|
+
hint: 'Use a ref, or a more specific name.',
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
throw new LabError('not_found', `No visible ${describe} in observation gen ${this.last?.gen ?? 0}`, {
|
|
563
|
+
details: { sameRole: byRole.slice(0, 8).map((c) => `${c.ref} ${c.role} "${c.name}"`) },
|
|
564
|
+
hint: this.last?.dialog ? `A dialog ("${this.last.dialog}") is open; only its controls are actionable.` : 'Observe again after the UI changes.',
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
/** Session findings: all of them, one by id, or those concerning a control ref. */
|
|
568
|
+
inspect(query = {}) {
|
|
569
|
+
this.requireSession();
|
|
570
|
+
const all = this.findings.list();
|
|
571
|
+
if (query.id !== undefined) {
|
|
572
|
+
const finding = this.findings.get(query.id);
|
|
573
|
+
if (!finding) {
|
|
574
|
+
throw new LabError('not_found', `No finding ${query.id} in this session`, { hint: all.length ? `Known: ${all.map((f) => f.id).join(', ')}` : 'No findings recorded yet.' });
|
|
575
|
+
}
|
|
576
|
+
return { findings: [finding] };
|
|
577
|
+
}
|
|
578
|
+
if (query.ref !== undefined) {
|
|
579
|
+
const control = this.last?.controls.find((c) => c.ref === query.ref);
|
|
580
|
+
if (!control) {
|
|
581
|
+
throw new LabError(this.refDocs.has(query.ref) ? 'stale_ref' : 'unknown_ref', `${query.ref} is not in the latest observation`, { hint: 'Observe again and use a current ref.' });
|
|
582
|
+
}
|
|
583
|
+
const route = this.last.route;
|
|
584
|
+
const device = this.session.device.id;
|
|
585
|
+
return { control, findings: all.filter((f) => f.device === device && f.route === route && f.target?.role === control.role && f.target.name === control.name) };
|
|
586
|
+
}
|
|
587
|
+
return { findings: all, ...(this.findings.omitted ? { omitted: this.findings.omitted } : {}) };
|
|
588
|
+
}
|
|
589
|
+
/** Open tabs, in the order they were opened. */
|
|
590
|
+
listTabs() {
|
|
591
|
+
this.requireActive();
|
|
592
|
+
return [...this.tabs].map(([id, page]) => ({
|
|
593
|
+
id, url: stripQuery(page.url()), title: '', active: id === this.activeTab,
|
|
594
|
+
...(this.openers.has(id) ? { opener: this.openers.get(id) } : {}),
|
|
595
|
+
}));
|
|
596
|
+
}
|
|
597
|
+
/** Tabs with their titles (reads each page, so async). */
|
|
598
|
+
async tabList() {
|
|
599
|
+
const tabs = this.listTabs();
|
|
600
|
+
for (const t of tabs)
|
|
601
|
+
t.title = (await this.tabs.get(t.id).title().catch(() => '')).slice(0, 80);
|
|
602
|
+
return tabs;
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Save the session's cookies and localStorage so later sessions can start signed in. The state is
|
|
606
|
+
* written owner-only and never returned; the result only counts what was saved.
|
|
607
|
+
*/
|
|
608
|
+
async saveAuth() {
|
|
609
|
+
this.requireActive();
|
|
610
|
+
this.admit('auth_save', 'act');
|
|
611
|
+
const state = await this.context.storageState();
|
|
612
|
+
const saved = saveAuthState(this.profile.auth, this.profile.root, state);
|
|
613
|
+
this.record('auth-save', { cookies: saved.cookies, origins: saved.origins });
|
|
614
|
+
this.emit({ kind: 'auth', action: 'saved', cookies: saved.cookies, origins: saved.origins });
|
|
615
|
+
return { saved: true, cookies: saved.cookies, origins: saved.origins, file: relative(this.profile.root, saved.file) || saved.file };
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* Stream JPEG frames of the visible viewport of the active tab until the returned stop function is
|
|
619
|
+
* called or the session ends. At most maxFps frames are delivered, and acknowledgements are delayed
|
|
620
|
+
* so Chromium also captures fewer. Frames go only to `onFrame`, never into command results.
|
|
621
|
+
*/
|
|
622
|
+
async screencast(onFrame, opts) {
|
|
623
|
+
this.requireActive();
|
|
624
|
+
const cast = { onFrame, opts, stopped: false };
|
|
625
|
+
const previous = this.cast;
|
|
626
|
+
this.cast = cast;
|
|
627
|
+
await previous?.stop?.();
|
|
628
|
+
cast.stop = await this.castPage(this.page, cast);
|
|
629
|
+
return async () => {
|
|
630
|
+
if (cast.stopped)
|
|
631
|
+
return;
|
|
632
|
+
cast.stopped = true;
|
|
633
|
+
if (this.cast === cast)
|
|
634
|
+
this.cast = undefined;
|
|
635
|
+
await cast.stop?.();
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* The live viewport follows a sweep's or scan's page while it is measured, so a person watching sees
|
|
640
|
+
* each width, then returns to the session's page. Frames still go only to the viewer. A page measured
|
|
641
|
+
* faster than a person can take in stays on screen for WATCH_MIN_MS after its last change; nothing waits without a viewer.
|
|
642
|
+
*/
|
|
643
|
+
watchPage = async (page, viewport) => {
|
|
644
|
+
const cast = this.cast;
|
|
645
|
+
if (!cast || cast.stopped)
|
|
646
|
+
return async () => undefined;
|
|
647
|
+
clearTimeout(this.unwatchTimer);
|
|
648
|
+
// The previous picture stays until this page has loaded: a blank page tells a person nothing.
|
|
649
|
+
let loaded = false;
|
|
650
|
+
page.once('load', () => { loaded = true; });
|
|
651
|
+
await cast.stop?.();
|
|
652
|
+
cast.stop = await this.castPage(page, cast, viewport, () => loaded).catch(() => undefined);
|
|
653
|
+
cast.lastFrameAt = Date.now();
|
|
654
|
+
return async () => {
|
|
655
|
+
if (this.cast !== cast || cast.stopped)
|
|
656
|
+
return;
|
|
657
|
+
// Counted from the last change on screen, so a page that rendered late is still seen.
|
|
658
|
+
await new Promise((r) => setTimeout(r, 1000 / cast.opts.maxFps));
|
|
659
|
+
const hold = WATCH_MIN_MS - (Date.now() - (cast.lastFrameAt ?? 0));
|
|
660
|
+
if (hold > 0)
|
|
661
|
+
await new Promise((r) => setTimeout(r, hold));
|
|
662
|
+
if (this.cast !== cast || cast.stopped)
|
|
663
|
+
return;
|
|
664
|
+
await cast.stop?.();
|
|
665
|
+
cast.stop = undefined;
|
|
666
|
+
// The next width usually follows at once; going back to the session's page in between would flicker.
|
|
667
|
+
clearTimeout(this.unwatchTimer);
|
|
668
|
+
this.unwatchTimer = setTimeout(() => {
|
|
669
|
+
if (this.cast !== cast || cast.stopped || cast.stop || !this.active || !this.page)
|
|
670
|
+
return;
|
|
671
|
+
void this.castPage(this.page, cast).then((stop) => { if (cast.stop || cast.stopped)
|
|
672
|
+
void stop();
|
|
673
|
+
else
|
|
674
|
+
cast.stop = stop; }).catch(() => undefined);
|
|
675
|
+
}, WATCH_RETURN_MS);
|
|
676
|
+
};
|
|
677
|
+
};
|
|
678
|
+
async castPage(page, cast, vp = this.session.device.viewport, ready = () => true) {
|
|
679
|
+
const { opts } = cast;
|
|
680
|
+
const scale = Math.min(1, opts.maxWidth / vp.width);
|
|
681
|
+
const size = { width: Math.round(vp.width * scale), height: Math.round(vp.height * scale) };
|
|
682
|
+
const interval = 1000 / opts.maxFps;
|
|
683
|
+
let stopped = false;
|
|
684
|
+
let wake;
|
|
685
|
+
let lastSent = 0;
|
|
686
|
+
let latest;
|
|
687
|
+
let trailing;
|
|
688
|
+
const send = () => {
|
|
689
|
+
trailing = undefined;
|
|
690
|
+
if (stopped || cast.stopped || !latest)
|
|
691
|
+
return;
|
|
692
|
+
const frame = latest;
|
|
693
|
+
latest = undefined;
|
|
694
|
+
lastSent = Date.now();
|
|
695
|
+
cast.lastFrameAt = lastSent;
|
|
696
|
+
cast.onFrame(frame);
|
|
697
|
+
};
|
|
698
|
+
await page.screencast.start({
|
|
699
|
+
size, quality: opts.quality,
|
|
700
|
+
onFrame: async ({ data }) => {
|
|
701
|
+
// A page that has not loaded anything yet (a sweep width just opened) has nothing to show.
|
|
702
|
+
if (stopped || page.url() === 'about:blank' || !ready())
|
|
703
|
+
return;
|
|
704
|
+
// Hard cap: at most one frame per interval. A frame arriving early replaces the pending one and
|
|
705
|
+
// goes out when the interval allows, so the last state after a change is always shown.
|
|
706
|
+
latest = data;
|
|
707
|
+
const wait = lastSent + interval - Date.now();
|
|
708
|
+
if (wait <= 0 && !trailing)
|
|
709
|
+
send();
|
|
710
|
+
else
|
|
711
|
+
trailing ??= setTimeout(send, Math.max(0, wait));
|
|
712
|
+
// Back-pressure: Playwright acknowledges the frame, and Chromium captures more, only when this
|
|
713
|
+
// resolves. Chromium keeps a few frames in flight, so this slows capture but is not the cap.
|
|
714
|
+
await new Promise((r) => { wake = r; setTimeout(r, interval); });
|
|
715
|
+
},
|
|
716
|
+
});
|
|
717
|
+
return async () => {
|
|
718
|
+
if (stopped)
|
|
719
|
+
return;
|
|
720
|
+
// The last state is always shown, even when the stream moves on within the frame interval.
|
|
721
|
+
if (trailing)
|
|
722
|
+
clearTimeout(trailing);
|
|
723
|
+
send();
|
|
724
|
+
stopped = true;
|
|
725
|
+
wake?.();
|
|
726
|
+
await page.screencast.stop().catch(() => undefined);
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* Responsive sweep of one route (default: the current one) across device widths, serially, each in
|
|
731
|
+
* its own browser context carrying the session's cookies and storage. The session's page is untouched.
|
|
732
|
+
*/
|
|
733
|
+
async sweep(opts = {}) {
|
|
734
|
+
this.requireActive();
|
|
735
|
+
this.admit('sweep', 'act');
|
|
736
|
+
return this.busy('sweep', () => this.sweepNow(opts));
|
|
737
|
+
}
|
|
738
|
+
async sweepNow(opts) {
|
|
739
|
+
const route = opts.route ?? this.rawRoute ?? this.last?.route ?? '/';
|
|
740
|
+
for (const d of opts.devices ?? [])
|
|
741
|
+
getDevice(d, this.profile?.devices);
|
|
742
|
+
const result = await runSweep({
|
|
743
|
+
browser: this.browser, storageState: await this.context.storageState(), origin: this.origin,
|
|
744
|
+
sessionId: this.session.id, gen: this.gen, settle: this.settlePolicy, store: this.findings,
|
|
745
|
+
history: this.history, runDir: this.session.runDir, emit: (e) => this.emit(e), devices: this.profile?.devices,
|
|
746
|
+
detectors: { tapTargets: this.profile.scan.tapTargets, layoutShiftMin: this.profile.scan.layoutShiftMin, enabled: checkFilter(this.profile.scan.checks) },
|
|
747
|
+
checkpoint: () => this.interruption(), watch: this.watchPage,
|
|
748
|
+
}, { id: `S${++this.sweeps}`, route, devices: opts.devices ?? SWEEP_DEVICES });
|
|
749
|
+
this.saveFindings();
|
|
750
|
+
this.record('sweep', { id: result.id, route: result.route, ms: result.ms, devices: result.devices.map((d) => ({ device: d.device, status: d.status, findings: d.findings })), report: result.report });
|
|
751
|
+
return result;
|
|
752
|
+
}
|
|
753
|
+
/**
|
|
754
|
+
* Stateful responsive scan: each declared scenario (or one route as loaded) on each of its devices,
|
|
755
|
+
* in its own isolated context on this session's browser, with optional safe exploration. The
|
|
756
|
+
* session's page is untouched. Writes result.json and report.html under runs/<session>/scans/<id>/.
|
|
757
|
+
*/
|
|
758
|
+
async scan(opts = {}) {
|
|
759
|
+
this.requireActive();
|
|
760
|
+
this.admit('scan', 'act');
|
|
761
|
+
return this.busy('scan', () => this.scanNow(opts));
|
|
762
|
+
}
|
|
763
|
+
async scanNow(opts) {
|
|
764
|
+
for (const d of opts.devices ?? [])
|
|
765
|
+
getDevice(d, this.profile?.devices);
|
|
766
|
+
const profile = this.profile;
|
|
767
|
+
const result = await runScan({
|
|
768
|
+
browser: this.browser, profile, headed: this.session.browser.headed, sessionId: this.session.id,
|
|
769
|
+
sessionState: () => this.context.storageState(),
|
|
770
|
+
savedState: () => (this.memoryAuth ?? loadAuthState(profile.auth)),
|
|
771
|
+
sessionHistory: this.history, gen: () => this.gen, store: this.findings, runDir: this.session.runDir, emit: (e) => this.emit(e),
|
|
772
|
+
checkpoint: () => this.interruption(), watch: this.watchPage,
|
|
773
|
+
}, { id: `R${++this.scans}`, ...opts });
|
|
774
|
+
this.saveFindings();
|
|
775
|
+
this.record('scan', {
|
|
776
|
+
id: result.id, ms: result.ms, verdict: result.verdict.result,
|
|
777
|
+
runs: result.runs.map((r) => ({ scenario: r.scenario, device: r.device, status: r.status, failedAt: r.failedAt, states: r.states.length, findings: r.findings })),
|
|
778
|
+
report: result.reports.html,
|
|
779
|
+
});
|
|
780
|
+
return result;
|
|
781
|
+
}
|
|
782
|
+
/**
|
|
783
|
+
* Every value this session must never write: what was typed into password-like fields, the project's
|
|
784
|
+
* required and secret-named environment values, and the values of an in-memory sign-in state. For redaction
|
|
785
|
+
* sets only; nothing returns it to a command result.
|
|
786
|
+
*/
|
|
787
|
+
secretValues() {
|
|
788
|
+
return [...new Set([...this.log.secretValues(), ...(this.profile ? envSecrets(this.profile) : []), ...(this.memoryAuth ? authStateSecrets(this.memoryAuth) : [])])];
|
|
789
|
+
}
|
|
790
|
+
/** Identity of the application's owned server process group, for crash-recovery records. */
|
|
791
|
+
serverIdentity() {
|
|
792
|
+
const primary = this.services?.get(this.profile?.app.service);
|
|
793
|
+
return primary?.info.owned ? primary.identity : undefined;
|
|
794
|
+
}
|
|
795
|
+
/** Per-service ownership and identity, so `agentlab stop` can clean up after the daemon dies. */
|
|
796
|
+
serviceRecords() {
|
|
797
|
+
this.services?.refreshMembers();
|
|
798
|
+
return (this.services?.services ?? []).map((s) => ({
|
|
799
|
+
name: s.spec.name, ...(s.info.url ? { url: s.info.url } : {}), owned: s.info.owned, mode: s.spec.mode,
|
|
800
|
+
...(s.info.pid !== undefined ? { pid: s.info.pid } : {}), ...(s.identity ? { identity: s.identity } : {}),
|
|
801
|
+
...(s.members.length ? { members: [...s.members] } : {}),
|
|
802
|
+
...(s.info.owned && s.spec.shutdown.command ? { stopCommand: s.spec.shutdown.command, cwd: s.spec.cwd } : {}),
|
|
803
|
+
}));
|
|
804
|
+
}
|
|
805
|
+
get lastObservation() {
|
|
806
|
+
return this.last;
|
|
807
|
+
}
|
|
808
|
+
status() {
|
|
809
|
+
const primary = this.services?.get(this.profile?.app.service);
|
|
810
|
+
return {
|
|
811
|
+
active: this.active,
|
|
812
|
+
...(this.endedReason ? { endedReason: this.endedReason } : {}),
|
|
813
|
+
session: this.session,
|
|
814
|
+
server: primary ? serverInfo(primary.info) : this.profile && this.session ? { url: this.profile.app.url, owned: false, reused: true, readyMs: 0 } : undefined,
|
|
815
|
+
services: this.services?.infos() ?? [],
|
|
816
|
+
tabs: this.active ? this.listTabs() : [],
|
|
817
|
+
gen: this.last?.gen ?? 0,
|
|
818
|
+
route: this.last?.route,
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
/** Close the browser and stop the services the lab started (dependents first). Idempotent. */
|
|
822
|
+
close(reason = 'requested') {
|
|
823
|
+
this.closing ??= (async () => {
|
|
824
|
+
let browserClosed = false;
|
|
825
|
+
clearTimeout(this.unwatchTimer);
|
|
826
|
+
await this.cast?.stop?.().catch(() => undefined);
|
|
827
|
+
// A trace that was never saved is discarded, never written.
|
|
828
|
+
if (this.tracing) {
|
|
829
|
+
this.tracing = false;
|
|
830
|
+
await this.context?.tracing.stop().catch(() => undefined);
|
|
831
|
+
}
|
|
832
|
+
if (this.child) {
|
|
833
|
+
// An isolated context: the browser and every service belong to the session.
|
|
834
|
+
await this.unwatch?.().catch(() => undefined);
|
|
835
|
+
await this.context?.close().catch(() => undefined);
|
|
836
|
+
this.endedReason ??= reason;
|
|
837
|
+
return { reason, browserClosed: false, server: { owned: false, stopped: false, detail: 'isolated context; the session owns the server' }, services: [] };
|
|
838
|
+
}
|
|
839
|
+
this.startAbort.abort();
|
|
840
|
+
if (this.starting && reason !== 'start failed')
|
|
841
|
+
await this.starting.catch(() => undefined);
|
|
842
|
+
try {
|
|
843
|
+
await this.context?.close();
|
|
844
|
+
await this.browser?.close();
|
|
845
|
+
browserClosed = !!this.browser;
|
|
846
|
+
}
|
|
847
|
+
catch { /* browser already gone */ }
|
|
848
|
+
const services = this.services ? await this.services.stop() : [];
|
|
849
|
+
if (this.ownedId) {
|
|
850
|
+
releaseOwnership(this.opts.stateDir, this.ownedId);
|
|
851
|
+
this.ownedId = undefined;
|
|
852
|
+
}
|
|
853
|
+
const primary = services.find((s) => s.name === this.profile?.app.service);
|
|
854
|
+
const server = primary
|
|
855
|
+
? { owned: primary.owned, stopped: primary.stopped, detail: primary.detail }
|
|
856
|
+
: { owned: false, stopped: false, detail: this.services ? 'the app URL is not a declared service; left running' : 'no server' };
|
|
857
|
+
const result = { reason, browserClosed, server, services };
|
|
858
|
+
this.endedReason ??= reason;
|
|
859
|
+
this.record('close', result);
|
|
860
|
+
this.emit({ kind: 'closed', result });
|
|
861
|
+
return result;
|
|
862
|
+
})();
|
|
863
|
+
return this.closing;
|
|
864
|
+
}
|
|
865
|
+
// ---------- failure bundles ----------
|
|
866
|
+
async startTrace() {
|
|
867
|
+
await this.context.tracing.start({ screenshots: true, snapshots: true });
|
|
868
|
+
this.tracing = true;
|
|
869
|
+
}
|
|
870
|
+
/**
|
|
871
|
+
* Stop the trace, write a sanitized copy to `dest` and delete the raw one, always. When the sanitizer cannot
|
|
872
|
+
* prove the trace clean (`trace_unsanitizable`) nothing is written and the reason is returned instead.
|
|
873
|
+
* Tracing then starts again, so each saved trace covers the time since the previous one.
|
|
874
|
+
*/
|
|
875
|
+
async saveTrace(dest) {
|
|
876
|
+
this.requireActive();
|
|
877
|
+
if (!this.tracing)
|
|
878
|
+
throw new LabError('invalid_request', 'This session is not recording a trace', { hint: 'Start it with trace: true.' });
|
|
879
|
+
const raw = join(this.session.runDir, `.trace-raw-${randomBytes(4).toString('hex')}.zip`);
|
|
880
|
+
try {
|
|
881
|
+
await this.context.tracing.stop({ path: raw });
|
|
882
|
+
this.tracing = false;
|
|
883
|
+
await sanitizeTrace(raw, dest, { secrets: this.secretValues() });
|
|
884
|
+
return { path: dest };
|
|
885
|
+
}
|
|
886
|
+
catch (err) {
|
|
887
|
+
const error = LabError.from(err);
|
|
888
|
+
if (error.code === 'trace_unsanitizable')
|
|
889
|
+
return { dropped: error.message };
|
|
890
|
+
throw error;
|
|
891
|
+
}
|
|
892
|
+
finally {
|
|
893
|
+
rmSync(raw, { force: true });
|
|
894
|
+
if (this.active && !this.tracing)
|
|
895
|
+
await this.startTrace().catch(() => undefined);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* Collect a failure bundle: the structured action log, recent observations, findings, console and network
|
|
900
|
+
* failures, evidence frames and (when recording) a sanitized trace, written owner-only under
|
|
901
|
+
* `<stateDir>/bundles` (or `dir`). Returns where it is and what it holds, never its contents.
|
|
902
|
+
*/
|
|
903
|
+
async bundle(opts) {
|
|
904
|
+
this.requireSession();
|
|
905
|
+
if (this.child)
|
|
906
|
+
throw new LabError('invalid_request', 'A bundle is written from the session, not from a scenario context');
|
|
907
|
+
const session = this.session;
|
|
908
|
+
const dir = resolve(opts.dir ?? join(this.opts.stateDir, 'bundles'));
|
|
909
|
+
if (this.active)
|
|
910
|
+
this.saveFrame(await this.captureFrame(), `bundle-g${this.gen}`);
|
|
911
|
+
let trace;
|
|
912
|
+
const staged = join(session.runDir, `.trace-${randomBytes(4).toString('hex')}.zip`);
|
|
913
|
+
try {
|
|
914
|
+
if (this.tracing && this.active && opts.trace !== false) {
|
|
915
|
+
const saved = await this.saveTrace(staged);
|
|
916
|
+
trace = 'path' in saved ? { file: saved.path } : saved;
|
|
917
|
+
}
|
|
918
|
+
const failure = opts.failure ?? { kind: 'manual' };
|
|
919
|
+
const scenario = opts.scenario ?? (failure.kind === 'scenario-error' ? { name: failure.scenario, device: failure.device } : undefined);
|
|
920
|
+
return writeBundle({
|
|
921
|
+
reason: opts.reason, failure, profile: this.profile, session, startRoute: this.log.startRoute, ...(scenario ? { scenario } : {}),
|
|
922
|
+
route: this.last?.route ?? '/', actions: this.log.actions, actionsOmitted: this.log.actionsOmitted, observations: this.log.observations,
|
|
923
|
+
findings: this.findings.list(), consoleErrors: this.consoleErrors, failedRequests: this.failedRequests, runDir: session.runDir,
|
|
924
|
+
secrets: this.secretValues(), ...(trace ? { trace } : {}),
|
|
925
|
+
}, dir);
|
|
926
|
+
}
|
|
927
|
+
finally {
|
|
928
|
+
rmSync(staged, { force: true });
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
// ---------- supervision ----------
|
|
932
|
+
/**
|
|
933
|
+
* Refuse an agent command that may not run now (paused, a person in control, stopped, or a fresh
|
|
934
|
+
* observe needed). The refusal is shown on the dashboard's timeline; nothing is queued.
|
|
935
|
+
*/
|
|
936
|
+
admit(command, kind) {
|
|
937
|
+
if (this.child || !this.session)
|
|
938
|
+
return;
|
|
939
|
+
try {
|
|
940
|
+
this.control.admit(command, kind);
|
|
941
|
+
}
|
|
942
|
+
catch (err) {
|
|
943
|
+
const error = LabError.from(err);
|
|
944
|
+
this.record('refused', { command, code: error.code });
|
|
945
|
+
this.emit({ kind: 'refused', command, error: error.toJSON() });
|
|
946
|
+
throw error;
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
/**
|
|
950
|
+
* A person's request from the dashboard: pause, resume, take over, return control, stop, or emergency
|
|
951
|
+
* stop. Stop closes the session once the operation in flight finishes; emergency stop closes it at once
|
|
952
|
+
* (the operation in flight fails with browser_closed). Both stop only services the lab started.
|
|
953
|
+
*/
|
|
954
|
+
supervise(op, by = 'dashboard') {
|
|
955
|
+
if (!CONTROL_OPS.includes(op))
|
|
956
|
+
throw new LabError('invalid_request', `Unknown control request "${String(op).slice(0, 40)}"`, { hint: `One of: ${CONTROL_OPS.join(', ')}` });
|
|
957
|
+
this.requireActive();
|
|
958
|
+
return this.control.request(op, by);
|
|
959
|
+
}
|
|
960
|
+
onControl(change) {
|
|
961
|
+
if (this.child)
|
|
962
|
+
return;
|
|
963
|
+
const s = change.state;
|
|
964
|
+
// Control is back with the agent after a person used the page: every earlier ref is stale.
|
|
965
|
+
if (s.mode === 'agent' && s.observeRequired && (change.op === 'return' || change.op === 'resume')) {
|
|
966
|
+
this.refFloor = this.nextRef;
|
|
967
|
+
this.refsSinceFloor.clear();
|
|
968
|
+
}
|
|
969
|
+
this.emit({ kind: 'control', change });
|
|
970
|
+
if (s.mode === 'agent' && (change.op === 'return' || change.op === 'resume'))
|
|
971
|
+
this.flushHumanTyping();
|
|
972
|
+
if (s.mode === 'stopped' && this.active) {
|
|
973
|
+
const reason = stopReason(change.op === 'emergency-stop' ? 'emergency-stop' : 'stop', s.by);
|
|
974
|
+
// Marked ended before the browser goes, so the operation in flight reports browser_closed (not a generic failure).
|
|
975
|
+
this.endedReason = reason;
|
|
976
|
+
void this.close(reason);
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
/**
|
|
980
|
+
* Control is going back to the agent: ask every page to report typing it still holds (it would otherwise
|
|
981
|
+
* be described only after a 700 ms pause, and be lost). Those reports arrive after the mode changed, so
|
|
982
|
+
* for one second a report that carries the flush marker is still accepted, labelled as late.
|
|
983
|
+
*/
|
|
984
|
+
flushHumanTyping() {
|
|
985
|
+
this.humanGraceUntil = Date.now() + 1000;
|
|
986
|
+
for (const page of this.context?.pages() ?? []) {
|
|
987
|
+
void page.evaluate(() => window.__agentDeviceLabHumanFlush?.()).catch(() => undefined);
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
/** A report from the in-page recorder (page-supplied, so only the expected shape is kept). */
|
|
991
|
+
onHuman(info) {
|
|
992
|
+
if (!this.active)
|
|
993
|
+
return;
|
|
994
|
+
const h = (info ?? {});
|
|
995
|
+
// Typing flushed at the hand-back arrives just after the mode changed; nothing else is accepted then.
|
|
996
|
+
const late = !this.control.recordingHuman && h.type === 'type' && h.flushed === true && Date.now() <= this.humanGraceUntil;
|
|
997
|
+
if (!this.control.recordingHuman && !late)
|
|
998
|
+
return;
|
|
999
|
+
const types = ['tap', 'type', 'key', 'select', 'check', 'upload', 'submit', 'navigate'];
|
|
1000
|
+
if (!types.includes(h.type))
|
|
1001
|
+
return;
|
|
1002
|
+
const str = (v, n) => (typeof v === 'string' ? v.replace(/\s+/g, ' ').trim().slice(0, n) : undefined);
|
|
1003
|
+
const t = h.target;
|
|
1004
|
+
const action = {
|
|
1005
|
+
type: h.type,
|
|
1006
|
+
...(t && typeof t === 'object' ? { target: { role: str(t.role, 30) ?? 'element', name: str(t.name, 60) ?? '' } } : {}),
|
|
1007
|
+
...(h.secret === true ? { secret: true } : typeof h.chars === 'number' && Number.isFinite(h.chars) ? { chars: Math.max(0, Math.floor(h.chars)) } : {}),
|
|
1008
|
+
...(str(h.detail, 80) ? { detail: str(h.detail, 80) } : {}),
|
|
1009
|
+
...(late ? { late: true } : {}),
|
|
1010
|
+
};
|
|
1011
|
+
this.control.humanInteraction();
|
|
1012
|
+
const now = Date.now();
|
|
1013
|
+
const w = this.humanWindow;
|
|
1014
|
+
if (now - w.start >= 1000) {
|
|
1015
|
+
if (w.dropped)
|
|
1016
|
+
this.emit({ kind: 'human', action: { type: 'input-dropped', detail: `${w.dropped} more interaction(s) not listed` } });
|
|
1017
|
+
this.humanWindow = { start: now, count: 0, dropped: 0 };
|
|
1018
|
+
}
|
|
1019
|
+
if (++this.humanWindow.count > HUMAN_EVENTS_PER_SECOND) {
|
|
1020
|
+
this.humanWindow.dropped++;
|
|
1021
|
+
return;
|
|
1022
|
+
}
|
|
1023
|
+
appendStep(this.history, `a person ${formatHuman(action)}`);
|
|
1024
|
+
this.record('human', action);
|
|
1025
|
+
this.log.person(formatHuman(action));
|
|
1026
|
+
this.emit({ kind: 'human', action });
|
|
1027
|
+
}
|
|
1028
|
+
/**
|
|
1029
|
+
* Input a person sends from the dashboard while they have taken control: a tap at a point of the
|
|
1030
|
+
* viewport frame, a named key, text typed into the focused field, or a scroll. The recorder reports
|
|
1031
|
+
* what it did; the text itself is never recorded.
|
|
1032
|
+
*/
|
|
1033
|
+
async humanInput(input) {
|
|
1034
|
+
this.requireActive();
|
|
1035
|
+
if (this.control.mode !== 'human') {
|
|
1036
|
+
throw new LabError('invalid_control', 'Dashboard input is accepted only while a person has taken control', { hint: 'Take over first.' });
|
|
1037
|
+
}
|
|
1038
|
+
const page = this.page;
|
|
1039
|
+
const i = (input ?? {});
|
|
1040
|
+
const finite = (v) => typeof v === 'number' && Number.isFinite(v);
|
|
1041
|
+
switch (i.type) {
|
|
1042
|
+
case 'tap': {
|
|
1043
|
+
if (!finite(i.x) || !finite(i.y) || i.x < 0 || i.x > 1 || i.y < 0 || i.y > 1)
|
|
1044
|
+
throw new LabError('invalid_request', 'tap needs x and y between 0 and 1');
|
|
1045
|
+
// The frame is the visual viewport; input coordinates are visual-viewport CSS px times its scale.
|
|
1046
|
+
const vv = await page.evaluate(() => {
|
|
1047
|
+
const v = window.visualViewport;
|
|
1048
|
+
return v ? { w: v.width * v.scale, h: v.height * v.scale } : { w: window.innerWidth, h: window.innerHeight };
|
|
1049
|
+
});
|
|
1050
|
+
const x = i.x * vv.w;
|
|
1051
|
+
const y = i.y * vv.h;
|
|
1052
|
+
if (this.session.device.hasTouch)
|
|
1053
|
+
await page.touchscreen.tap(x, y);
|
|
1054
|
+
else
|
|
1055
|
+
await page.mouse.click(x, y);
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
1058
|
+
case 'key':
|
|
1059
|
+
if (typeof i.key !== 'string' || !HUMAN_KEYS.includes(i.key))
|
|
1060
|
+
throw new LabError('invalid_request', `key must be one of ${HUMAN_KEYS.join(', ')}`);
|
|
1061
|
+
await page.keyboard.press(i.key === 'Space' ? ' ' : i.key);
|
|
1062
|
+
return;
|
|
1063
|
+
case 'text':
|
|
1064
|
+
if (typeof i.text !== 'string' || !i.text.length || i.text.length > HUMAN_TEXT_MAX)
|
|
1065
|
+
throw new LabError('invalid_request', `text must be 1–${HUMAN_TEXT_MAX} characters`);
|
|
1066
|
+
await page.keyboard.insertText(i.text);
|
|
1067
|
+
return;
|
|
1068
|
+
case 'scroll':
|
|
1069
|
+
if (!finite(i.dy) || Math.abs(i.dy) > HUMAN_SCROLL_MAX)
|
|
1070
|
+
throw new LabError('invalid_request', `scroll needs dy within ±${HUMAN_SCROLL_MAX}`);
|
|
1071
|
+
await page.mouse.wheel(0, i.dy);
|
|
1072
|
+
return;
|
|
1073
|
+
default:
|
|
1074
|
+
throw new LabError('invalid_request', 'input type must be tap, key, text or scroll');
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
// ---------- internals ----------
|
|
1078
|
+
async ended(reason) {
|
|
1079
|
+
if (this.closing)
|
|
1080
|
+
return;
|
|
1081
|
+
this.endedReason = reason;
|
|
1082
|
+
await this.close(reason);
|
|
1083
|
+
}
|
|
1084
|
+
requireSession() {
|
|
1085
|
+
if (!this.session)
|
|
1086
|
+
throw new LabError('no_session', 'No session is running', { hint: 'Start one first.' });
|
|
1087
|
+
}
|
|
1088
|
+
requireActive() {
|
|
1089
|
+
if (!this.session)
|
|
1090
|
+
throw new LabError('no_session', 'No session is running', { hint: 'Start one first.' });
|
|
1091
|
+
if (this.endedReason)
|
|
1092
|
+
throw new LabError('browser_closed', `Session ended: ${this.endedReason}`, { hint: 'Start a new session.' });
|
|
1093
|
+
}
|
|
1094
|
+
registerTab(page, opener) {
|
|
1095
|
+
const id = `t${this.nextTab++}`;
|
|
1096
|
+
this.tabs.set(id, page);
|
|
1097
|
+
this.tabIds.set(page, id);
|
|
1098
|
+
if (opener)
|
|
1099
|
+
this.openers.set(id, opener);
|
|
1100
|
+
this.attachListeners(page);
|
|
1101
|
+
void this.watchBodies(page);
|
|
1102
|
+
page.on('close', () => void this.tabClosed(id));
|
|
1103
|
+
return id;
|
|
1104
|
+
}
|
|
1105
|
+
/** Body activity per request from CDP, which Playwright does not expose (see bodyActivity). */
|
|
1106
|
+
async watchBodies(page) {
|
|
1107
|
+
const cdp = await this.cdpFor(page).catch(() => undefined);
|
|
1108
|
+
if (!cdp)
|
|
1109
|
+
return;
|
|
1110
|
+
const open = new Map();
|
|
1111
|
+
const touch = (e) => { const k = open.get(e.requestId); if (k)
|
|
1112
|
+
this.bodyActivity.set(k, Date.now()); };
|
|
1113
|
+
const done = (e) => {
|
|
1114
|
+
const k = open.get(e.requestId);
|
|
1115
|
+
open.delete(e.requestId);
|
|
1116
|
+
if (k && ![...open.values()].includes(k))
|
|
1117
|
+
this.bodyActivity.delete(k);
|
|
1118
|
+
};
|
|
1119
|
+
cdp.on('Network.requestWillBeSent', (e) => open.set(e.requestId, `${e.request.method} ${e.request.url}`));
|
|
1120
|
+
cdp.on('Network.responseReceived', touch);
|
|
1121
|
+
cdp.on('Network.dataReceived', touch);
|
|
1122
|
+
cdp.on('Network.loadingFinished', done);
|
|
1123
|
+
cdp.on('Network.loadingFailed', done);
|
|
1124
|
+
await cdp.send('Network.enable').catch(() => undefined);
|
|
1125
|
+
}
|
|
1126
|
+
/** A tab closed (by the app, the person, or close_tab). The session ends only when the last one goes. */
|
|
1127
|
+
async tabClosed(id) {
|
|
1128
|
+
if (!this.tabs.has(id) || this.closing)
|
|
1129
|
+
return;
|
|
1130
|
+
this.tabs.delete(id);
|
|
1131
|
+
this.tabEvents.closed.push(id);
|
|
1132
|
+
if (!this.tabs.size) {
|
|
1133
|
+
// A killed or crashed browser closes its pages before it reports the disconnect: tell the two apart.
|
|
1134
|
+
for (let i = 0; i < 20 && this.browser?.isConnected(); i++)
|
|
1135
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
1136
|
+
return this.ended(this.browser?.isConnected() === false ? 'browser disconnected or crashed' : 'browser page was closed');
|
|
1137
|
+
}
|
|
1138
|
+
if (id === this.activeTab) {
|
|
1139
|
+
const opener = this.openers.get(id);
|
|
1140
|
+
const next = opener && this.tabs.has(opener) ? opener : [...this.tabs.keys()].at(-1);
|
|
1141
|
+
await this.activate(next);
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
async activate(id) {
|
|
1145
|
+
const page = this.tabs.get(id);
|
|
1146
|
+
if (!page)
|
|
1147
|
+
throw new LabError('unknown_tab', `No open tab ${id}`, { hint: `Open tabs: ${[...this.tabs.keys()].join(', ')}` });
|
|
1148
|
+
this.activeTab = id;
|
|
1149
|
+
this.page = page;
|
|
1150
|
+
if (this.session?.browser.headed)
|
|
1151
|
+
await page.bringToFront().catch(() => undefined);
|
|
1152
|
+
this.emit({ kind: 'page', url: stripQuery(page.url()) });
|
|
1153
|
+
const cast = this.cast;
|
|
1154
|
+
if (cast && !cast.stopped) {
|
|
1155
|
+
await cast.stop?.();
|
|
1156
|
+
cast.stop = await this.castPage(page, cast).catch(() => undefined);
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
/**
|
|
1160
|
+
* The page asked for a new window during the action (a link or form with a target, `window.open`) but the
|
|
1161
|
+
* browser has not delivered the tab yet: wait for it, bounded, so the result reports the tab it opened.
|
|
1162
|
+
*/
|
|
1163
|
+
async awaitRequestedTab(since) {
|
|
1164
|
+
if (this.tabEvents.opened.length || !this.page || this.page.isClosed())
|
|
1165
|
+
return;
|
|
1166
|
+
const at = await this.page.evaluate(() => window.__agentDeviceLab_nav?.windowAt ?? 0).catch(() => 0);
|
|
1167
|
+
if (at < since)
|
|
1168
|
+
return;
|
|
1169
|
+
const deadline = Date.now() + Math.min(NEW_TAB_WAIT_MS, this.settlePolicy.maxMs);
|
|
1170
|
+
while (!this.tabEvents.opened.length && Date.now() < deadline)
|
|
1171
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
1172
|
+
}
|
|
1173
|
+
/** A tab opened by the action becomes the active one, as a mobile browser switches to it. */
|
|
1174
|
+
async followNewTab(notes) {
|
|
1175
|
+
const opened = this.tabEvents.opened.filter((id) => this.tabs.has(id));
|
|
1176
|
+
const id = opened.at(-1);
|
|
1177
|
+
if (!id || id === this.activeTab)
|
|
1178
|
+
return false;
|
|
1179
|
+
const page = this.tabs.get(id);
|
|
1180
|
+
await page.waitForLoadState('domcontentloaded', { timeout: this.settlePolicy.maxMs }).catch(() => undefined);
|
|
1181
|
+
await this.activate(id);
|
|
1182
|
+
notes.push(`opened tab ${id} (${stripQuery(page.url())}) and switched to it; switch_tab ${this.openers.get(id) ?? 't1'} returns`);
|
|
1183
|
+
return true;
|
|
1184
|
+
}
|
|
1185
|
+
attachListeners(page) {
|
|
1186
|
+
const counts = () => this.emit({ kind: 'counts', consoleErrors: this.consoleTotal, failedRequests: this.failedTotal });
|
|
1187
|
+
page.on('crash', () => { if (page === this.page)
|
|
1188
|
+
void this.ended('browser tab crashed'); });
|
|
1189
|
+
page.on('framenavigated', (frame) => {
|
|
1190
|
+
if (frame !== page.mainFrame() || page !== this.page)
|
|
1191
|
+
return;
|
|
1192
|
+
this.emit({ kind: 'page', url: stripQuery(frame.url()) });
|
|
1193
|
+
if (this.control.recordingHuman)
|
|
1194
|
+
this.onHuman({ type: 'navigate', detail: pathOf(frame.url()) });
|
|
1195
|
+
});
|
|
1196
|
+
page.on('console', (msg) => {
|
|
1197
|
+
if (msg.type() !== 'error')
|
|
1198
|
+
return;
|
|
1199
|
+
this.consoleTotal++;
|
|
1200
|
+
pushCapped(this.consoleErrors, { type: 'console.error', text: msg.text().slice(0, 300), at: Date.now() });
|
|
1201
|
+
counts();
|
|
1202
|
+
});
|
|
1203
|
+
page.on('pageerror', (err) => {
|
|
1204
|
+
this.consoleTotal++;
|
|
1205
|
+
pushCapped(this.consoleErrors, { type: 'pageerror', text: err.message.slice(0, 300), at: Date.now() });
|
|
1206
|
+
counts();
|
|
1207
|
+
});
|
|
1208
|
+
page.on('request', (req) => {
|
|
1209
|
+
const type = req.resourceType();
|
|
1210
|
+
const url = req.url();
|
|
1211
|
+
const background = type === 'eventsource' || type === 'websocket' || this.settlePolicy.backgroundRequests.some((p) => url.includes(p));
|
|
1212
|
+
this.requests.set(req, { started: Date.now(), label: `${req.method()} ${pathOf(url)}`, key: `${req.method()} ${url}`, background });
|
|
1213
|
+
});
|
|
1214
|
+
page.on('requestfinished', (req) => { this.requests.delete(req); });
|
|
1215
|
+
page.on('requestfailed', (req) => {
|
|
1216
|
+
this.requests.delete(req);
|
|
1217
|
+
const failure = req.failure()?.errorText ?? 'failed';
|
|
1218
|
+
// Requests cancelled by a navigation are routine, not failures.
|
|
1219
|
+
if (failure.includes('ERR_ABORTED'))
|
|
1220
|
+
return;
|
|
1221
|
+
this.failedTotal++;
|
|
1222
|
+
pushCapped(this.failedRequests, { method: req.method(), url: stripQuery(req.url()), failure, at: Date.now() });
|
|
1223
|
+
counts();
|
|
1224
|
+
});
|
|
1225
|
+
page.on('response', (res) => {
|
|
1226
|
+
const entry = this.requests.get(res.request());
|
|
1227
|
+
if (entry)
|
|
1228
|
+
entry.responded = true;
|
|
1229
|
+
if (res.status() >= 400) {
|
|
1230
|
+
this.failedTotal++;
|
|
1231
|
+
pushCapped(this.failedRequests, { method: res.request().method(), url: stripQuery(res.url()), status: res.status(), at: Date.now() });
|
|
1232
|
+
counts();
|
|
1233
|
+
}
|
|
1234
|
+
});
|
|
1235
|
+
}
|
|
1236
|
+
async snapshot(limit = OBSERVE_LIMIT) {
|
|
1237
|
+
const page = this.page;
|
|
1238
|
+
let raw;
|
|
1239
|
+
for (let attempt = 0;; attempt++) {
|
|
1240
|
+
try {
|
|
1241
|
+
raw = await page.evaluate(extractPage, { nextRef: this.nextRef, limit });
|
|
1242
|
+
break;
|
|
1243
|
+
}
|
|
1244
|
+
catch (err) {
|
|
1245
|
+
// A navigation can destroy the execution context mid-evaluate; wait for the new document once.
|
|
1246
|
+
if (attempt >= 2)
|
|
1247
|
+
throw err;
|
|
1248
|
+
await page.waitForLoadState('domcontentloaded').catch(() => undefined);
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
this.nextRef = raw.nextRef;
|
|
1252
|
+
// Kept only in memory, for a sweep of "this route": the observation carries the redacted one.
|
|
1253
|
+
try {
|
|
1254
|
+
const u = new URL(raw.url);
|
|
1255
|
+
this.rawRoute = u.pathname + u.search + u.hash;
|
|
1256
|
+
}
|
|
1257
|
+
catch {
|
|
1258
|
+
this.rawRoute = undefined;
|
|
1259
|
+
}
|
|
1260
|
+
const observation = buildObservation(raw, {
|
|
1261
|
+
sessionId: this.session.id, gen: ++this.gen,
|
|
1262
|
+
consoleErrors: this.consoleTotal, failedRequests: this.failedTotal,
|
|
1263
|
+
});
|
|
1264
|
+
if (this.tabs.size > 1)
|
|
1265
|
+
observation.tab = this.activeTab;
|
|
1266
|
+
for (const c of observation.controls) {
|
|
1267
|
+
this.refDocs.set(c.ref, observation.docId);
|
|
1268
|
+
this.refTabs.set(c.ref, this.activeTab);
|
|
1269
|
+
if (this.refFloor)
|
|
1270
|
+
this.refsSinceFloor.add(c.ref);
|
|
1271
|
+
}
|
|
1272
|
+
this.lastFreshFindings = this.findings.recordObservation(observation, this.history);
|
|
1273
|
+
if (this.lastFreshFindings.length) {
|
|
1274
|
+
this.saveFindings();
|
|
1275
|
+
const frame = this.opts.evidenceFrames ? this.saveFrame(await this.captureFrame(), `g${observation.gen}`) : undefined;
|
|
1276
|
+
if (frame)
|
|
1277
|
+
for (const f of this.lastFreshFindings)
|
|
1278
|
+
f.frame = frame;
|
|
1279
|
+
this.emit({ kind: 'findings', findings: this.lastFreshFindings, frame });
|
|
1280
|
+
}
|
|
1281
|
+
observation.findings = this.findings.size;
|
|
1282
|
+
this.last = observation;
|
|
1283
|
+
if (!this.child)
|
|
1284
|
+
this.log.observation(observation);
|
|
1285
|
+
return observation;
|
|
1286
|
+
}
|
|
1287
|
+
async resolveRef(ref) {
|
|
1288
|
+
if (!/^e\d+$/.test(ref))
|
|
1289
|
+
throw new LabError('invalid_request', `"${ref}" is not a ref (expected e.g. e12)`);
|
|
1290
|
+
const issuedIn = this.refDocs.get(ref);
|
|
1291
|
+
if (!issuedIn) {
|
|
1292
|
+
throw new LabError('unknown_ref', `${ref} was never issued in session ${this.session.id}`, { hint: 'Use a ref from the latest observation.' });
|
|
1293
|
+
}
|
|
1294
|
+
if (Number(ref.slice(1)) < this.refFloor && !this.refsSinceFloor.has(ref)) {
|
|
1295
|
+
throw new LabError('stale_ref', `${ref} was issued before a person used the browser`, { hint: 'Run observe and use a fresh ref.' });
|
|
1296
|
+
}
|
|
1297
|
+
const tab = this.refTabs.get(ref);
|
|
1298
|
+
if (tab && tab !== this.activeTab) {
|
|
1299
|
+
throw new LabError('stale_ref', `${ref} belongs to tab ${tab}, not the active tab ${this.activeTab}`, {
|
|
1300
|
+
hint: this.tabs.has(tab) ? `switch_tab ${tab} first, or observe this tab for fresh refs.` : `Tab ${tab} has closed; observe for fresh refs.`,
|
|
1301
|
+
});
|
|
1302
|
+
}
|
|
1303
|
+
const page = this.page;
|
|
1304
|
+
const handle = await page.evaluateHandle(lookupRef, ref);
|
|
1305
|
+
const el = handle.asElement();
|
|
1306
|
+
if (el)
|
|
1307
|
+
return el;
|
|
1308
|
+
await handle.dispose();
|
|
1309
|
+
const doc = await page.evaluate(currentDocId);
|
|
1310
|
+
const successor = this.replacedBy.get(ref);
|
|
1311
|
+
throw new LabError('stale_ref', doc !== issuedIn
|
|
1312
|
+
? `${ref} belonged to a previous page; the browser has navigated since it was issued`
|
|
1313
|
+
: `${ref} is no longer attached to the page`, {
|
|
1314
|
+
hint: successor && doc === issuedIn ? `The same control was re-rendered as ${successor}.` : 'Run observe and use a fresh ref.',
|
|
1315
|
+
...(successor && doc === issuedIn ? { details: { replacedBy: successor } } : {}),
|
|
1316
|
+
});
|
|
1317
|
+
}
|
|
1318
|
+
async withRef(ref, fn) {
|
|
1319
|
+
const handle = await this.resolveRef(ref);
|
|
1320
|
+
try {
|
|
1321
|
+
return await fn(handle);
|
|
1322
|
+
}
|
|
1323
|
+
finally {
|
|
1324
|
+
await handle.dispose().catch(() => undefined);
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
/** Run one action. Returns the input method; throws LabError for recoverable failures. */
|
|
1328
|
+
async perform(r, notes) {
|
|
1329
|
+
const ref = r.ref;
|
|
1330
|
+
switch (r.action) {
|
|
1331
|
+
case 'click': return this.withRef(ref, (h) => this.click(h, notes));
|
|
1332
|
+
case 'fill': return this.withRef(ref, (h) => this.fill(h, r.value, notes));
|
|
1333
|
+
case 'press': return this.press(r, notes);
|
|
1334
|
+
case 'select': return this.withRef(ref, (h) => this.select(h, r.values, notes));
|
|
1335
|
+
case 'check':
|
|
1336
|
+
case 'uncheck': return this.withRef(ref, (h) => this.setChecked(h, r.action === 'check', notes));
|
|
1337
|
+
case 'scroll': return this.scroll(r, notes);
|
|
1338
|
+
case 'swipe': return this.swipe(r, notes);
|
|
1339
|
+
case 'back':
|
|
1340
|
+
case 'forward': return this.history_(r.action, notes);
|
|
1341
|
+
case 'hover': return this.withRef(ref, (h) => this.hover(h, notes));
|
|
1342
|
+
case 'upload': return this.upload(ref, r.files, notes);
|
|
1343
|
+
case 'drag': return this.withRef(ref, (h) => this.drag(h, r, notes));
|
|
1344
|
+
case 'open_tab': return this.openTab(r.path, notes);
|
|
1345
|
+
case 'switch_tab': return this.switchTab(r.tab, notes);
|
|
1346
|
+
case 'close_tab': return this.closeTab(r.tab, notes);
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
/** Visible and enabled, scrolled into view as a person would, and not covered: where input should land. */
|
|
1350
|
+
async reach(handle, notes) {
|
|
1351
|
+
const state = await handle.evaluate((el) => ({
|
|
1352
|
+
visible: el.getBoundingClientRect().width > 0 && el.checkVisibility({ opacityProperty: true, visibilityProperty: true }),
|
|
1353
|
+
disabled: el.disabled === true || el.getAttribute('aria-disabled') === 'true',
|
|
1354
|
+
}));
|
|
1355
|
+
if (!state.visible)
|
|
1356
|
+
throw new LabError('not_visible', 'Target is attached but not visible', { hint: 'Observe again; it may have been hidden.' });
|
|
1357
|
+
if (state.disabled)
|
|
1358
|
+
throw new LabError('disabled', 'Target is disabled');
|
|
1359
|
+
await this.bringIntoView(handle, notes);
|
|
1360
|
+
let point = await this.stablePoint(handle);
|
|
1361
|
+
if (!point.hitOk && point.hitFixed) {
|
|
1362
|
+
// Partly visible but its centre is under a fixed/sticky bar: scroll on, as a person would.
|
|
1363
|
+
const { dx, dy } = await handle.evaluate(centreInView);
|
|
1364
|
+
this.actionPanX += dx;
|
|
1365
|
+
if (dy)
|
|
1366
|
+
notes.push(`scrolled ${Math.abs(dy)}px more so the target is not under a fixed bar (${point.hit})`);
|
|
1367
|
+
point = await this.stablePoint(handle);
|
|
1368
|
+
}
|
|
1369
|
+
if (!point.inView) {
|
|
1370
|
+
throw new LabError('obstructed', 'The target centre is still outside the visible area after panning', { hint: 'Inspect the layout around the control.' });
|
|
1371
|
+
}
|
|
1372
|
+
if (!point.hitOk) {
|
|
1373
|
+
throw new LabError('obstructed', `A pointer at the target's centre lands on ${point.hit ?? 'another element'}, not the target`, {
|
|
1374
|
+
details: { hit: point.hit }, hint: 'Something covers the control. Close overlays or dialogs, or inspect the layout.',
|
|
1375
|
+
});
|
|
1376
|
+
}
|
|
1377
|
+
return point;
|
|
1378
|
+
}
|
|
1379
|
+
/**
|
|
1380
|
+
* Physical click/tap with verification: pan the target into the visual viewport as a person would,
|
|
1381
|
+
* check what sits under its centre, dispatch input at visual-viewport coordinates, then confirm which
|
|
1382
|
+
* element actually received pointerdown and click.
|
|
1383
|
+
*/
|
|
1384
|
+
async click(handle, notes) {
|
|
1385
|
+
const page = this.page;
|
|
1386
|
+
const point = await this.reach(handle, notes);
|
|
1387
|
+
const method = this.session.device.hasTouch ? 'tap' : 'click';
|
|
1388
|
+
const token = randomBytes(4).toString('hex');
|
|
1389
|
+
this.hits = this.hits.filter((h) => h.token === token);
|
|
1390
|
+
await handle.evaluate(armHitCapture, token);
|
|
1391
|
+
try {
|
|
1392
|
+
if (method === 'tap')
|
|
1393
|
+
await page.touchscreen.tap(point.x, point.y);
|
|
1394
|
+
else
|
|
1395
|
+
await page.mouse.click(point.x, point.y);
|
|
1396
|
+
}
|
|
1397
|
+
catch (err) {
|
|
1398
|
+
if (page.isClosed()) {
|
|
1399
|
+
notes.push('the page closed itself in response (e.g. window.close())');
|
|
1400
|
+
return method;
|
|
1401
|
+
}
|
|
1402
|
+
throw new LabError('action_failed', `${method} failed: ${firstLine(err)}`);
|
|
1403
|
+
}
|
|
1404
|
+
// The synthesised click follows the touch sequence asynchronously; give it a short, bounded window.
|
|
1405
|
+
const deadline = Date.now() + 500;
|
|
1406
|
+
while (!this.hits.some((h) => h.token === token && h.type === 'click') && Date.now() < deadline) {
|
|
1407
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
1408
|
+
}
|
|
1409
|
+
const hits = this.hits.filter((h) => h.token === token);
|
|
1410
|
+
const down = hits.find((h) => h.type === 'pointerdown');
|
|
1411
|
+
const click = hits.find((h) => h.type === 'click');
|
|
1412
|
+
const miss = [down, click].find((h) => h && !h.onTarget);
|
|
1413
|
+
if (miss) {
|
|
1414
|
+
throw new LabError('obstructed', `The ${method} was delivered to ${miss.target}, not the target`, {
|
|
1415
|
+
details: { delivered: hits }, hint: 'The event may have triggered that other element. Observe before retrying.',
|
|
1416
|
+
});
|
|
1417
|
+
}
|
|
1418
|
+
if (!down && !click)
|
|
1419
|
+
notes.push('input was dispatched but no pointer event was observed (the page may have navigated immediately)');
|
|
1420
|
+
else if (!click)
|
|
1421
|
+
notes.push(`pointerdown reached the target but no click followed within 500ms`);
|
|
1422
|
+
return method;
|
|
1423
|
+
}
|
|
1424
|
+
async fill(handle, value, notes) {
|
|
1425
|
+
const f = await handle.evaluate(fillability);
|
|
1426
|
+
this.fillSecret = f.secret === true;
|
|
1427
|
+
if (!f.fillable)
|
|
1428
|
+
throw new LabError('not_fillable', `Target cannot be filled: ${f.reason ?? 'not a text field'}`);
|
|
1429
|
+
if (f.disabled)
|
|
1430
|
+
throw new LabError('disabled', 'Target is disabled');
|
|
1431
|
+
await this.bringIntoView(handle, notes);
|
|
1432
|
+
try {
|
|
1433
|
+
await handle.fill(value, { timeout: ACTION_TIMEOUT_MS });
|
|
1434
|
+
}
|
|
1435
|
+
catch (err) {
|
|
1436
|
+
throw new LabError('action_failed', `fill failed: ${firstLine(err)}`);
|
|
1437
|
+
}
|
|
1438
|
+
return 'fill';
|
|
1439
|
+
}
|
|
1440
|
+
/** A key or chord, on a control (focused first) or on whatever has focus. */
|
|
1441
|
+
async press(r, notes) {
|
|
1442
|
+
const page = this.page;
|
|
1443
|
+
if (r.ref) {
|
|
1444
|
+
await this.withRef(r.ref, async (h) => {
|
|
1445
|
+
await this.bringIntoView(h, notes);
|
|
1446
|
+
await h.focus();
|
|
1447
|
+
});
|
|
1448
|
+
}
|
|
1449
|
+
// A printable key typed into a password-like field is part of a secret.
|
|
1450
|
+
this.fillSecret = !isNamedKey(r.key) && await page.evaluate(focusedIsSecret).catch(() => false);
|
|
1451
|
+
try {
|
|
1452
|
+
await page.keyboard.press(r.key);
|
|
1453
|
+
}
|
|
1454
|
+
catch (err) {
|
|
1455
|
+
if (/Unknown key/i.test(firstLine(err))) {
|
|
1456
|
+
throw new LabError('invalid_request', `Unknown key "${r.key}"`, { hint: 'Use key names such as Enter, Escape, Tab, ArrowDown, Backspace, or chords like Shift+Tab and Control+a.' });
|
|
1457
|
+
}
|
|
1458
|
+
throw new LabError('action_failed', `press failed: ${firstLine(err)}`);
|
|
1459
|
+
}
|
|
1460
|
+
return 'keyboard';
|
|
1461
|
+
}
|
|
1462
|
+
async select(handle, values, notes) {
|
|
1463
|
+
const info = await handle.evaluate(selectInfo);
|
|
1464
|
+
if (!info.select) {
|
|
1465
|
+
throw new LabError('not_selectable', 'Target is not a native <select>', { hint: 'For a custom list box, click it to open the list, then click the option.' });
|
|
1466
|
+
}
|
|
1467
|
+
if (info.disabled)
|
|
1468
|
+
throw new LabError('disabled', 'Target is disabled');
|
|
1469
|
+
if (values.length > 1 && !info.multiple)
|
|
1470
|
+
throw new LabError('invalid_request', 'This list allows only one choice');
|
|
1471
|
+
const chosen = values.map((v) => info.options.find((o) => o.value === v) ?? info.options.find((o) => o.label === v)
|
|
1472
|
+
?? info.options.find((o) => o.label.toLowerCase() === v.toLowerCase()));
|
|
1473
|
+
const missing = values.filter((_, i) => !chosen[i]);
|
|
1474
|
+
if (missing.length) {
|
|
1475
|
+
throw new LabError('not_found', `No option ${missing.map((m) => JSON.stringify(m)).join(', ')} in this list`, {
|
|
1476
|
+
details: { options: info.options.slice(0, 20).map((o) => o.label) }, hint: 'Use an option label or value from details.options.',
|
|
1477
|
+
});
|
|
1478
|
+
}
|
|
1479
|
+
const disabled = chosen.find((o) => o.disabled);
|
|
1480
|
+
if (disabled)
|
|
1481
|
+
throw new LabError('disabled', `Option "${disabled.label}" is disabled`);
|
|
1482
|
+
await this.bringIntoView(handle, notes);
|
|
1483
|
+
try {
|
|
1484
|
+
await handle.selectOption(chosen.map((o) => ({ value: o.value })), { timeout: ACTION_TIMEOUT_MS });
|
|
1485
|
+
}
|
|
1486
|
+
catch (err) {
|
|
1487
|
+
throw new LabError('action_failed', `select failed: ${firstLine(err)}`);
|
|
1488
|
+
}
|
|
1489
|
+
return 'select';
|
|
1490
|
+
}
|
|
1491
|
+
/** check / uncheck through the same verified tap as click, then confirm the state actually changed. */
|
|
1492
|
+
async setChecked(handle, want, notes) {
|
|
1493
|
+
const s = await handle.evaluate(checkability);
|
|
1494
|
+
if (!s.kind)
|
|
1495
|
+
throw new LabError('not_checkable', 'Target is not a checkbox, radio button or switch');
|
|
1496
|
+
if (s.disabled)
|
|
1497
|
+
throw new LabError('disabled', 'Target is disabled');
|
|
1498
|
+
if (!want && s.kind === 'radio') {
|
|
1499
|
+
throw new LabError('invalid_request', 'A radio button cannot be unchecked directly', { hint: 'Check another option in the same group.' });
|
|
1500
|
+
}
|
|
1501
|
+
if (s.checked === want) {
|
|
1502
|
+
notes.push(`already ${want ? 'checked' : 'unchecked'}; nothing was done`);
|
|
1503
|
+
return 'none';
|
|
1504
|
+
}
|
|
1505
|
+
const method = await this.click(handle, notes);
|
|
1506
|
+
const deadline = Date.now() + 500;
|
|
1507
|
+
let now = s.checked;
|
|
1508
|
+
while (Date.now() < deadline) {
|
|
1509
|
+
now = await handle.evaluate(checkability).then((c) => c.checked).catch(() => want);
|
|
1510
|
+
if (now === want)
|
|
1511
|
+
return method;
|
|
1512
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
1513
|
+
}
|
|
1514
|
+
throw new LabError('action_failed', `The ${method} reached the control but it is still ${now ? 'checked' : 'unchecked'}`, {
|
|
1515
|
+
hint: 'The page may handle it differently (a confirmation, a label elsewhere). Observe to see what changed.',
|
|
1516
|
+
});
|
|
1517
|
+
}
|
|
1518
|
+
async scroll(r, notes) {
|
|
1519
|
+
const page = this.page;
|
|
1520
|
+
if (!r.direction) {
|
|
1521
|
+
// Bring a control into view, the way a person scrolls until they can see it.
|
|
1522
|
+
return this.withRef(r.ref, async (h) => {
|
|
1523
|
+
const { dx, dy } = await h.evaluate(centreInView);
|
|
1524
|
+
notes.push(dx || dy ? `scrolled ${dy >= 0 ? 'down' : 'up'} ${Math.abs(dy)}px${dx ? ` and ${Math.abs(dx)}px sideways` : ''} to bring the control into view` : 'the control was already in view; nothing moved');
|
|
1525
|
+
return 'scroll';
|
|
1526
|
+
});
|
|
1527
|
+
}
|
|
1528
|
+
const vp = this.session.device.viewport;
|
|
1529
|
+
const vertical = r.direction === 'up' || r.direction === 'down';
|
|
1530
|
+
const amount = Math.round(r.amount ?? (vertical ? vp.height : vp.width) * 0.8);
|
|
1531
|
+
const sign = r.direction === 'down' || r.direction === 'right' ? 1 : -1;
|
|
1532
|
+
const delta = { dx: vertical ? 0 : sign * amount, dy: vertical ? sign * amount : 0 };
|
|
1533
|
+
const run = async (el) => page.evaluate(scrollByAmount, { el: el ?? null, ...delta });
|
|
1534
|
+
const out = r.ref ? await this.withRef(r.ref, run) : await run();
|
|
1535
|
+
const moved = Math.abs(vertical ? out.moved.y : out.moved.x);
|
|
1536
|
+
notes.push(out.atEnd
|
|
1537
|
+
? `${out.target} did not move: it is already at the ${r.direction === 'down' ? 'bottom' : r.direction === 'up' ? 'top' : `${r.direction} edge`}`
|
|
1538
|
+
: `${out.target} scrolled ${r.direction} ${moved}px${moved < amount ? ` (reached the end; asked for ${amount}px)` : ''}`);
|
|
1539
|
+
return 'scroll';
|
|
1540
|
+
}
|
|
1541
|
+
/** A touch drag across the screen (swipe left = the finger moves left), as real touch input. */
|
|
1542
|
+
async swipe(r, notes) {
|
|
1543
|
+
const page = this.page;
|
|
1544
|
+
const device = this.session.device;
|
|
1545
|
+
if (!device.hasTouch) {
|
|
1546
|
+
throw new LabError('invalid_request', `swipe needs a touch device; ${device.id} has none`, { hint: 'Use scroll, or start with a touch profile such as mobile-390.' });
|
|
1547
|
+
}
|
|
1548
|
+
const vp = device.viewport;
|
|
1549
|
+
let start = { x: vp.width / 2, y: vp.height / 2 };
|
|
1550
|
+
if (r.ref)
|
|
1551
|
+
start = await this.withRef(r.ref, (h) => this.reach(h, notes));
|
|
1552
|
+
const horizontal = r.direction === 'left' || r.direction === 'right';
|
|
1553
|
+
const distance = Math.round(r.amount ?? (horizontal ? vp.width : vp.height) * 0.6);
|
|
1554
|
+
const sign = r.direction === 'right' || r.direction === 'down' ? 1 : -1;
|
|
1555
|
+
const clamp = (v, max) => Math.min(max - 4, Math.max(4, v));
|
|
1556
|
+
const end = horizontal ? { x: clamp(start.x + sign * distance, vp.width), y: start.y } : { x: start.x, y: clamp(start.y + sign * distance, vp.height) };
|
|
1557
|
+
const beforePos = await page.evaluate(scrollPositions);
|
|
1558
|
+
const cdp = await this.cdpFor(page);
|
|
1559
|
+
const steps = 12;
|
|
1560
|
+
const point = (x, y) => [{ x: Math.round(x), y: Math.round(y), id: 1, radiusX: 4, radiusY: 4, force: 1 }];
|
|
1561
|
+
try {
|
|
1562
|
+
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: point(start.x, start.y) });
|
|
1563
|
+
for (let i = 1; i <= steps; i++) {
|
|
1564
|
+
await new Promise((res) => setTimeout(res, 16));
|
|
1565
|
+
const t = i / steps;
|
|
1566
|
+
await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: point(start.x + (end.x - start.x) * t, start.y + (end.y - start.y) * t) });
|
|
1567
|
+
}
|
|
1568
|
+
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
|
1569
|
+
}
|
|
1570
|
+
catch (err) {
|
|
1571
|
+
throw new LabError('action_failed', `swipe failed: ${firstLine(err)}`);
|
|
1572
|
+
}
|
|
1573
|
+
// A swipe can leave a fling scrolling, and Chromium's gesture recogniser stays busy briefly after
|
|
1574
|
+
// the touch ends; a tap in that window only stops the gesture (for a person too). Wait at least
|
|
1575
|
+
// 300 ms, and until nothing has moved for 150 ms, bounded at 1.5 s.
|
|
1576
|
+
const ended = Date.now();
|
|
1577
|
+
await new Promise((res) => setTimeout(res, 300));
|
|
1578
|
+
let afterPos = await page.evaluate(scrollPositions).catch(() => beforePos);
|
|
1579
|
+
for (let still = 0; still < 3 && Date.now() - ended < 1500;) {
|
|
1580
|
+
await new Promise((res) => setTimeout(res, 50));
|
|
1581
|
+
const now = await page.evaluate(scrollPositions).catch(() => afterPos);
|
|
1582
|
+
still = JSON.stringify(now) === JSON.stringify(afterPos) ? still + 1 : 0;
|
|
1583
|
+
afterPos = now;
|
|
1584
|
+
}
|
|
1585
|
+
const moved = Object.keys(afterPos).filter((k) => afterPos[k] !== beforePos[k]).map((k) => `${k.replace(/\.[xy]$/, '')} ${k.endsWith('.x') ? 'x' : 'y'} ${afterPos[k] - (beforePos[k] ?? 0) > 0 ? '+' : ''}${afterPos[k] - (beforePos[k] ?? 0)}px`);
|
|
1586
|
+
const travel = Math.round(Math.hypot(end.x - start.x, end.y - start.y));
|
|
1587
|
+
notes.push(`swiped ${r.direction} ${travel}px from (${Math.round(start.x)}, ${Math.round(start.y)}); ${moved.length ? `scrolled: ${moved.slice(0, 3).join(', ')}` : 'nothing scrolled (the page may handle the gesture itself)'}`);
|
|
1588
|
+
return 'touch';
|
|
1589
|
+
}
|
|
1590
|
+
async history_(direction, notes) {
|
|
1591
|
+
const page = this.page;
|
|
1592
|
+
const cdp = await this.cdpFor(page);
|
|
1593
|
+
const nav = await cdp.send('Page.getNavigationHistory');
|
|
1594
|
+
const next = nav.entries[nav.currentIndex + (direction === 'back' ? -1 : 1)];
|
|
1595
|
+
// The blank page a new tab starts on is not part of the app's history.
|
|
1596
|
+
const canGo = !!next && next.url !== 'about:blank';
|
|
1597
|
+
if (!canGo) {
|
|
1598
|
+
throw new LabError('no_history', `There is no ${direction === 'back' ? 'earlier' : 'later'} page in this tab's history`, {
|
|
1599
|
+
hint: direction === 'back' && this.openers.has(this.activeTab) ? `This tab was opened by ${this.openers.get(this.activeTab)}; switch_tab to return.` : 'Nothing to do; observe the current page.',
|
|
1600
|
+
});
|
|
1601
|
+
}
|
|
1602
|
+
const entry = nav.entries[nav.currentIndex + (direction === 'back' ? -1 : 1)];
|
|
1603
|
+
try {
|
|
1604
|
+
await (direction === 'back' ? page.goBack({ waitUntil: 'commit', timeout: 10_000 }) : page.goForward({ waitUntil: 'commit', timeout: 10_000 }));
|
|
1605
|
+
}
|
|
1606
|
+
catch (err) {
|
|
1607
|
+
notes.push(`the ${direction} navigation had not committed after 10 s (${firstLine(err)})`);
|
|
1608
|
+
}
|
|
1609
|
+
if (entry)
|
|
1610
|
+
notes.push(`went ${direction} to ${pathOf(entry.url)}`);
|
|
1611
|
+
// What a person sees too: the browser puts the page back where it was scrolled, and whatever the app
|
|
1612
|
+
// kept only in memory (an applied filter, an open panel) may be gone. Check the observation's scroll line.
|
|
1613
|
+
notes.push('the browser restores the earlier scroll position, and page state kept only in memory (filters, open panels) may have been reset');
|
|
1614
|
+
return 'history';
|
|
1615
|
+
}
|
|
1616
|
+
async hover(handle, notes) {
|
|
1617
|
+
const point = await this.reach(handle, notes);
|
|
1618
|
+
await this.page.mouse.move(point.x, point.y, { steps: 2 });
|
|
1619
|
+
if (this.session.device.hasTouch)
|
|
1620
|
+
notes.push('hover has no touch equivalent: a person on this touch device cannot reveal what this showed');
|
|
1621
|
+
return 'mouse';
|
|
1622
|
+
}
|
|
1623
|
+
/** Files must resolve (following symlinks) inside the profile's uploads.allow entries. */
|
|
1624
|
+
resolveUploads(files) {
|
|
1625
|
+
const root = this.profile.root;
|
|
1626
|
+
const allowed = this.profile.uploads.allow.map((a) => { try {
|
|
1627
|
+
return realpathSync(a);
|
|
1628
|
+
}
|
|
1629
|
+
catch {
|
|
1630
|
+
return undefined;
|
|
1631
|
+
} }).filter((a) => !!a);
|
|
1632
|
+
if (!this.profile.uploads.allow.length) {
|
|
1633
|
+
throw new LabError('upload_not_allowed', 'This project allows no uploads', { hint: 'Add "uploads": {"allow": ["<dir>"]} to agentlab.json, listing project directories the lab may read.' });
|
|
1634
|
+
}
|
|
1635
|
+
return files.map((f) => {
|
|
1636
|
+
let real;
|
|
1637
|
+
try {
|
|
1638
|
+
real = realpathSync(resolve(root, f));
|
|
1639
|
+
}
|
|
1640
|
+
catch {
|
|
1641
|
+
throw new LabError('not_found', `No such file: ${f}`, { hint: 'Paths are relative to the project root.' });
|
|
1642
|
+
}
|
|
1643
|
+
if (!allowed.some((a) => real === a || real.startsWith(a + sep))) {
|
|
1644
|
+
throw new LabError('upload_not_allowed', `${f} is outside the directories this project allows for upload`, {
|
|
1645
|
+
hint: `Allowed: ${this.profile.uploads.allow.map((a) => relative(root, a) || '.').join(', ')} (uploads.allow in agentlab.json).`,
|
|
1646
|
+
});
|
|
1647
|
+
}
|
|
1648
|
+
if (!statSync(real).isFile())
|
|
1649
|
+
throw new LabError('invalid_request', `${f} is not a file`);
|
|
1650
|
+
return real;
|
|
1651
|
+
});
|
|
1652
|
+
}
|
|
1653
|
+
async upload(ref, files, notes) {
|
|
1654
|
+
const paths = this.resolveUploads(files);
|
|
1655
|
+
const page = this.page;
|
|
1656
|
+
return this.withRef(ref, async (h) => {
|
|
1657
|
+
const info = await h.evaluate(uploadInfo);
|
|
1658
|
+
if (info.fileInput) {
|
|
1659
|
+
if (info.disabled)
|
|
1660
|
+
throw new LabError('disabled', 'Target is disabled');
|
|
1661
|
+
if (paths.length > 1 && !info.multiple)
|
|
1662
|
+
throw new LabError('invalid_request', 'This file input accepts one file');
|
|
1663
|
+
await this.bringIntoView(h, notes);
|
|
1664
|
+
try {
|
|
1665
|
+
await h.setInputFiles(paths, { timeout: ACTION_TIMEOUT_MS });
|
|
1666
|
+
}
|
|
1667
|
+
catch (err) {
|
|
1668
|
+
throw new LabError('action_failed', `upload failed: ${firstLine(err)}`);
|
|
1669
|
+
}
|
|
1670
|
+
return 'set-files';
|
|
1671
|
+
}
|
|
1672
|
+
// A button or label that opens the file chooser: tap it as a person would, then choose the files.
|
|
1673
|
+
const chooser = page.waitForEvent('filechooser', { timeout: 3000 }).catch(() => undefined);
|
|
1674
|
+
const method = await this.click(h, notes);
|
|
1675
|
+
const fc = await chooser;
|
|
1676
|
+
if (!fc) {
|
|
1677
|
+
throw new LabError('not_uploadable', `The ${method} did not open a file chooser, and the target is not a file input`, {
|
|
1678
|
+
hint: 'Target the file input itself, or the button that opens the chooser.',
|
|
1679
|
+
});
|
|
1680
|
+
}
|
|
1681
|
+
if (paths.length > 1 && !fc.isMultiple())
|
|
1682
|
+
throw new LabError('invalid_request', 'This file chooser accepts one file');
|
|
1683
|
+
await fc.setFiles(paths, { timeout: ACTION_TIMEOUT_MS });
|
|
1684
|
+
notes.push(`the ${method} opened a file chooser; chose ${paths.map((p) => basename(p)).join(', ')}`);
|
|
1685
|
+
return method;
|
|
1686
|
+
});
|
|
1687
|
+
}
|
|
1688
|
+
/** Press on the source, move in steps to the drop target (or by dx/dy), release. */
|
|
1689
|
+
async drag(handle, r, notes) {
|
|
1690
|
+
const page = this.page;
|
|
1691
|
+
const from = await this.reach(handle, notes);
|
|
1692
|
+
let to;
|
|
1693
|
+
if (r.toRef) {
|
|
1694
|
+
to = await this.withRef(r.toRef, async (t) => {
|
|
1695
|
+
const p = await t.evaluate(targetPoint);
|
|
1696
|
+
if (!p.inView) {
|
|
1697
|
+
throw new LabError('obstructed', 'The drop target is not on screen while the source is', { hint: 'Scroll so both are visible, or drag by dx/dy.' });
|
|
1698
|
+
}
|
|
1699
|
+
if (!p.hitOk)
|
|
1700
|
+
throw new LabError('obstructed', `The drop target's centre is covered by ${p.hit ?? 'another element'}`);
|
|
1701
|
+
return p;
|
|
1702
|
+
});
|
|
1703
|
+
}
|
|
1704
|
+
else {
|
|
1705
|
+
to = { x: from.x + (r.dx ?? 0), y: from.y + (r.dy ?? 0) };
|
|
1706
|
+
const vp = this.session.device.viewport;
|
|
1707
|
+
if (to.x < 0 || to.y < 0 || to.x > vp.width || to.y > vp.height) {
|
|
1708
|
+
throw new LabError('invalid_request', `Dragging by (${r.dx ?? 0}, ${r.dy ?? 0}) would leave the ${vp.width}x${vp.height} view`);
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
try {
|
|
1712
|
+
await page.mouse.move(from.x, from.y);
|
|
1713
|
+
await page.mouse.down();
|
|
1714
|
+
await page.mouse.move(from.x + Math.sign(to.x - from.x) * 4, from.y + Math.sign(to.y - from.y) * 4, { steps: 2 });
|
|
1715
|
+
await page.mouse.move(to.x, to.y, { steps: 12 });
|
|
1716
|
+
await page.mouse.up();
|
|
1717
|
+
}
|
|
1718
|
+
catch (err) {
|
|
1719
|
+
throw new LabError('action_failed', `drag failed: ${firstLine(err)}`);
|
|
1720
|
+
}
|
|
1721
|
+
if (this.session.device.hasTouch)
|
|
1722
|
+
notes.push('dispatched as a mouse drag; touch drag gestures are not emulated');
|
|
1723
|
+
return 'mouse-drag';
|
|
1724
|
+
}
|
|
1725
|
+
async openTab(path, notes) {
|
|
1726
|
+
const url = this.origin + path;
|
|
1727
|
+
assertUrlAllowed(url, this.profile.allowExternalUrl);
|
|
1728
|
+
const page = await this.context.newPage();
|
|
1729
|
+
const id = this.tabIds.get(page) ?? this.registerTab(page, this.activeTab);
|
|
1730
|
+
if (!this.tabEvents.opened.includes(id))
|
|
1731
|
+
this.tabEvents.opened.push(id);
|
|
1732
|
+
try {
|
|
1733
|
+
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 });
|
|
1734
|
+
}
|
|
1735
|
+
catch (err) {
|
|
1736
|
+
notes.push(`the page had not loaded after 30 s (${firstLine(err)})`);
|
|
1737
|
+
}
|
|
1738
|
+
await this.activate(id);
|
|
1739
|
+
notes.push(`opened tab ${id} at ${path}`);
|
|
1740
|
+
return 'new-tab';
|
|
1741
|
+
}
|
|
1742
|
+
async switchTab(tab, notes) {
|
|
1743
|
+
if (!this.tabs.has(tab))
|
|
1744
|
+
throw new LabError('unknown_tab', `No open tab ${tab}`, { hint: `Open tabs: ${[...this.tabs.keys()].join(', ')}` });
|
|
1745
|
+
if (tab === this.activeTab)
|
|
1746
|
+
notes.push(`${tab} is already the active tab`);
|
|
1747
|
+
else
|
|
1748
|
+
await this.activate(tab);
|
|
1749
|
+
return 'tab';
|
|
1750
|
+
}
|
|
1751
|
+
async closeTab(tab, notes) {
|
|
1752
|
+
const id = tab ?? this.activeTab;
|
|
1753
|
+
const page = this.tabs.get(id);
|
|
1754
|
+
if (!page)
|
|
1755
|
+
throw new LabError('unknown_tab', `No open tab ${id}`, { hint: `Open tabs: ${[...this.tabs.keys()].join(', ')}` });
|
|
1756
|
+
if (this.tabs.size === 1)
|
|
1757
|
+
throw new LabError('invalid_request', `${id} is the only tab`, { hint: 'Use stop to end the session.' });
|
|
1758
|
+
await page.close({ runBeforeUnload: false });
|
|
1759
|
+
await this.tabClosed(id);
|
|
1760
|
+
notes.push(`closed tab ${id}; active tab is ${this.activeTab}`);
|
|
1761
|
+
return 'tab';
|
|
1762
|
+
}
|
|
1763
|
+
async cdpFor(page) {
|
|
1764
|
+
let s = this.cdp.get(page);
|
|
1765
|
+
if (!s) {
|
|
1766
|
+
s = await this.context.newCDPSession(page);
|
|
1767
|
+
this.cdp.set(page, s);
|
|
1768
|
+
}
|
|
1769
|
+
return s;
|
|
1770
|
+
}
|
|
1771
|
+
async bringIntoView(handle, notes) {
|
|
1772
|
+
// Evidence for a pan finding is what a person sees before panning: the target out of view.
|
|
1773
|
+
if (this.opts.evidenceFrames && await handle.evaluate(needsHorizontalPan))
|
|
1774
|
+
this.prePanFrame = await this.captureFrame();
|
|
1775
|
+
const { dx, dy } = await handle.evaluate(panIntoView);
|
|
1776
|
+
this.actionPanX += dx;
|
|
1777
|
+
if (dx) {
|
|
1778
|
+
notes.push(`view panned sideways ${Math.abs(dx)}px to reach the target: it starts outside the ${this.session.device.viewport.width}px-wide view, so a person must discover a horizontal pan`);
|
|
1779
|
+
}
|
|
1780
|
+
if (dy)
|
|
1781
|
+
notes.push(`scrolled ${dy > 0 ? 'down' : 'up'} ${Math.abs(dy)}px to bring the target into view`);
|
|
1782
|
+
}
|
|
1783
|
+
/** Target point, re-measured until the element stops moving (animations, smooth layout shifts). */
|
|
1784
|
+
async stablePoint(handle) {
|
|
1785
|
+
let prev = await handle.evaluate(targetPoint);
|
|
1786
|
+
for (let i = 0; i < 10; i++) {
|
|
1787
|
+
await new Promise((r) => setTimeout(r, 32));
|
|
1788
|
+
const next = await handle.evaluate(targetPoint);
|
|
1789
|
+
if (Math.abs(next.x - prev.x) < 1 && Math.abs(next.y - prev.y) < 1)
|
|
1790
|
+
return next;
|
|
1791
|
+
prev = next;
|
|
1792
|
+
}
|
|
1793
|
+
return prev;
|
|
1794
|
+
}
|
|
1795
|
+
/**
|
|
1796
|
+
* Wait until the document is loaded, the DOM has been quiet for policy.quietMs and no awaited
|
|
1797
|
+
* request is open, bounded by policy.maxMs. Requests already open before `since`, EventSource and
|
|
1798
|
+
* WebSocket connections, and policy.backgroundRequests are not awaited, so long-polling and
|
|
1799
|
+
* streaming pages do not stall every action. A timeout is reported, never thrown.
|
|
1800
|
+
*/
|
|
1801
|
+
async settle(since, fromPath) {
|
|
1802
|
+
const page = this.page;
|
|
1803
|
+
const { quietMs, maxMs } = this.settlePolicy;
|
|
1804
|
+
const t0 = Date.now();
|
|
1805
|
+
const remaining = () => maxMs - (Date.now() - t0);
|
|
1806
|
+
const idle = (r) => {
|
|
1807
|
+
const last = this.bodyActivity.get(r.key);
|
|
1808
|
+
return !!r.responded && last !== undefined && Date.now() - last > BODY_IDLE_MS;
|
|
1809
|
+
};
|
|
1810
|
+
const awaited = () => [...this.requests.values()].filter((r) => !r.background && r.started >= since && !idle(r));
|
|
1811
|
+
const ignored = () => [...this.requests.values()].filter((r) => r.background || r.started < since || idle(r)).length;
|
|
1812
|
+
let cause = 'dom';
|
|
1813
|
+
while (remaining() > 0) {
|
|
1814
|
+
if (page.isClosed())
|
|
1815
|
+
return { ms: Date.now() - t0, reason: 'quiet', ignored: ignored() };
|
|
1816
|
+
await page.waitForLoadState('domcontentloaded', { timeout: Math.max(1, remaining()) }).catch(() => undefined);
|
|
1817
|
+
let dom;
|
|
1818
|
+
try {
|
|
1819
|
+
dom = await page.evaluate(waitForDomQuiet, { quietMs, maxMs: Math.max(50, remaining()), timerMaxMs: this.settlePolicy.timerMaxMs, ...(fromPath ? { fromPath } : {}) });
|
|
1820
|
+
}
|
|
1821
|
+
catch {
|
|
1822
|
+
continue; // context destroyed by navigation; loop waits for the new document
|
|
1823
|
+
}
|
|
1824
|
+
if (dom !== 'quiet') {
|
|
1825
|
+
cause = dom === 'timeout' ? 'dom' : dom;
|
|
1826
|
+
break;
|
|
1827
|
+
}
|
|
1828
|
+
if (!awaited().length)
|
|
1829
|
+
return { ms: Date.now() - t0, reason: 'quiet', ignored: ignored() };
|
|
1830
|
+
cause = 'network';
|
|
1831
|
+
while (awaited().length && remaining() > 0)
|
|
1832
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
1833
|
+
}
|
|
1834
|
+
const pending = awaited().map((r) => r.label);
|
|
1835
|
+
if (pending.length)
|
|
1836
|
+
cause = 'network';
|
|
1837
|
+
return { ms: Date.now() - t0, reason: 'timeout', cause, ...(pending.length ? { pending: pending.slice(0, 5) } : {}), ignored: ignored() };
|
|
1838
|
+
}
|
|
1839
|
+
/** The visible (visual) viewport as a CSS-pixel JPEG, or undefined if the page cannot be captured. */
|
|
1840
|
+
async captureFrame() {
|
|
1841
|
+
try {
|
|
1842
|
+
return await this.page.screenshot({ type: 'jpeg', quality: 70, scale: 'css', caret: 'initial', animations: 'allow', timeout: 2000 });
|
|
1843
|
+
}
|
|
1844
|
+
catch {
|
|
1845
|
+
return undefined;
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
saveFrame(jpeg, name) {
|
|
1849
|
+
if (!jpeg)
|
|
1850
|
+
return undefined;
|
|
1851
|
+
try {
|
|
1852
|
+
const dir = join(this.session.runDir, 'frames');
|
|
1853
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
1854
|
+
const file = join(dir, `${name}.jpg`);
|
|
1855
|
+
writeFileSync(file, jpeg);
|
|
1856
|
+
return file;
|
|
1857
|
+
}
|
|
1858
|
+
catch {
|
|
1859
|
+
return undefined;
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
saveFindings() {
|
|
1863
|
+
if (this.child)
|
|
1864
|
+
return;
|
|
1865
|
+
try {
|
|
1866
|
+
writeFileSync(join(this.session.runDir, 'findings.json'), JSON.stringify(this.findings.list(), null, 2));
|
|
1867
|
+
}
|
|
1868
|
+
catch { /* artifacts must never break a session */ }
|
|
1869
|
+
}
|
|
1870
|
+
record(kind, data) {
|
|
1871
|
+
if (!this.session || this.child)
|
|
1872
|
+
return;
|
|
1873
|
+
try {
|
|
1874
|
+
appendFileSync(join(this.session.runDir, 'actions.jsonl'), JSON.stringify({ at: new Date().toISOString(), kind, data }) + '\n');
|
|
1875
|
+
}
|
|
1876
|
+
catch { /* logging must never break a session */ }
|
|
1877
|
+
}
|
|
1878
|
+
emit(event) {
|
|
1879
|
+
try {
|
|
1880
|
+
this.opts.onEvent?.(event);
|
|
1881
|
+
}
|
|
1882
|
+
catch { /* observer errors are not session errors */ }
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1885
|
+
function stripQuery(url) {
|
|
1886
|
+
try {
|
|
1887
|
+
const u = new URL(url);
|
|
1888
|
+
return u.origin + u.pathname;
|
|
1889
|
+
}
|
|
1890
|
+
catch {
|
|
1891
|
+
return url.split('?')[0] ?? url;
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
function pathOf(url) {
|
|
1895
|
+
try {
|
|
1896
|
+
return new URL(url).pathname;
|
|
1897
|
+
}
|
|
1898
|
+
catch {
|
|
1899
|
+
return url.split('?')[0] ?? url;
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
function existsFile(file) {
|
|
1903
|
+
try {
|
|
1904
|
+
return statSync(file).isFile();
|
|
1905
|
+
}
|
|
1906
|
+
catch {
|
|
1907
|
+
return false;
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
const SECRET_MASK = '‹secret›';
|
|
1911
|
+
/** Arguments each action needs, checked before anything touches the page. */
|
|
1912
|
+
function validateAction(r) {
|
|
1913
|
+
const need = (ok, message) => { if (!ok)
|
|
1914
|
+
throw new LabError('invalid_request', `${r.action}: ${message}`); };
|
|
1915
|
+
const targeted = ['click', 'fill', 'select', 'check', 'uncheck', 'hover', 'upload', 'drag'];
|
|
1916
|
+
if (targeted.includes(r.action))
|
|
1917
|
+
need(!!r.ref, 'needs a ref, or a name (optionally with role)');
|
|
1918
|
+
switch (r.action) {
|
|
1919
|
+
case 'click':
|
|
1920
|
+
case 'check':
|
|
1921
|
+
case 'uncheck':
|
|
1922
|
+
case 'hover':
|
|
1923
|
+
case 'back':
|
|
1924
|
+
case 'forward':
|
|
1925
|
+
case 'close_tab': return;
|
|
1926
|
+
case 'fill': return need(typeof r.value === 'string', 'needs the text to enter');
|
|
1927
|
+
case 'press': return need(typeof r.key === 'string' && r.key.length > 0, 'needs a key, e.g. Enter');
|
|
1928
|
+
case 'select': return need(Array.isArray(r.values) && r.values.length > 0, 'needs at least one option (label or value)');
|
|
1929
|
+
case 'scroll': return need(!!r.direction || !!r.ref, 'needs a direction, a ref to bring into view, or both');
|
|
1930
|
+
case 'swipe': return need(!!r.direction, 'needs a direction (left, right, up or down)');
|
|
1931
|
+
case 'upload': return need(Array.isArray(r.files) && r.files.length > 0, 'needs at least one file');
|
|
1932
|
+
case 'drag': return need(!!r.toRef || r.dx !== undefined || r.dy !== undefined, 'needs a toRef, or dx/dy');
|
|
1933
|
+
case 'open_tab': return need(typeof r.path === 'string' && r.path.startsWith('/'), 'needs a path on the app origin, starting with "/"');
|
|
1934
|
+
case 'switch_tab': return need(typeof r.tab === 'string', 'needs a tab id, e.g. t2');
|
|
1935
|
+
default: throw new LabError('invalid_request', `Unsupported action "${String(r.action)}"`);
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
/** Keys with names (Enter, Tab, Shift+Tab…) are not text; a single character is. */
|
|
1939
|
+
function isNamedKey(key) {
|
|
1940
|
+
const last = key.split('+').at(-1) ?? key;
|
|
1941
|
+
return last.length > 1;
|
|
1942
|
+
}
|
|
1943
|
+
function describeAction(r, target, before, secret = false) {
|
|
1944
|
+
const what = target ? `${target.role} "${target.name}"` : r.ref;
|
|
1945
|
+
const other = r.toRef ? before?.controls.find((c) => c.ref === r.toRef) : undefined;
|
|
1946
|
+
switch (r.action) {
|
|
1947
|
+
case 'click': return `click ${what}`;
|
|
1948
|
+
case 'fill': return secret ? `fill ${what} with ${SECRET_MASK} (${(r.value ?? '').length} characters)` : `fill ${what} with ${JSON.stringify(redactValue(r.value ?? ''))}`;
|
|
1949
|
+
case 'press': return `press ${secret ? SECRET_MASK : r.key}${what ? ` in ${what}` : ''}`;
|
|
1950
|
+
case 'select': return `select ${(r.values ?? []).map((v) => JSON.stringify(redactValue(v))).join(', ')} in ${what}`;
|
|
1951
|
+
case 'check':
|
|
1952
|
+
case 'uncheck':
|
|
1953
|
+
case 'hover': return `${r.action} ${what}`;
|
|
1954
|
+
case 'scroll': return r.direction ? `scroll ${r.direction}${r.amount ? ` ${r.amount}px` : ''}${what ? ` in ${what}` : ''}` : `scroll ${what} into view`;
|
|
1955
|
+
case 'swipe': return `swipe ${r.direction}${what ? ` on ${what}` : ''}`;
|
|
1956
|
+
case 'back':
|
|
1957
|
+
case 'forward': return `go ${r.action}`;
|
|
1958
|
+
case 'upload': return `upload ${(r.files ?? []).map((f) => basename(f)).join(', ')} to ${what}`;
|
|
1959
|
+
case 'drag': return `drag ${what} ${r.toRef ? `onto ${other ? `${other.role} "${other.name}"` : r.toRef}` : `by (${r.dx ?? 0}, ${r.dy ?? 0})px`}`;
|
|
1960
|
+
case 'open_tab': return `open a new tab at ${r.path}`;
|
|
1961
|
+
case 'switch_tab': return `switch to tab ${r.tab}`;
|
|
1962
|
+
case 'close_tab': return `close tab ${r.tab ?? '(current)'}`;
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
/** The request as written to actions.jsonl: long values shortened, secrets masked. */
|
|
1966
|
+
function loggedRequest(r, secret) {
|
|
1967
|
+
return {
|
|
1968
|
+
...r,
|
|
1969
|
+
...(r.value !== undefined ? { value: secret ? SECRET_MASK : redactValue(r.value) } : {}),
|
|
1970
|
+
...(r.key !== undefined && secret ? { key: SECRET_MASK } : {}),
|
|
1971
|
+
};
|
|
1972
|
+
}
|
|
1973
|
+
function redactValue(value) {
|
|
1974
|
+
return value.length > 40 ? `${value.slice(0, 12)}…(${value.length} chars)` : value;
|
|
1975
|
+
}
|
|
1976
|
+
function firstLine(err) {
|
|
1977
|
+
return redactUrlSecrets((err instanceof Error ? err.message : String(err)).split('\n')[0] ?? '');
|
|
1978
|
+
}
|
|
1979
|
+
//# sourceMappingURL=lab.js.map
|