jev-cdp 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -3
- package/dist/cli.js +738 -91
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -27,8 +27,8 @@ This is an early experimental port. See [NOTICE.md](NOTICE.md) for source attrib
|
|
|
27
27
|
Run the published CLI without adding it to a project. Bun must be installed for either command:
|
|
28
28
|
|
|
29
29
|
```bash
|
|
30
|
-
bunx jev-cdp@0.1.
|
|
31
|
-
npx -y jev-cdp@0.1.
|
|
30
|
+
bunx jev-cdp@0.1.5 help run
|
|
31
|
+
npx -y jev-cdp@0.1.5 help run
|
|
32
32
|
```
|
|
33
33
|
|
|
34
34
|
Chrome with a CDP endpoint and `TYPESAFE_API_KEY` are required for browser runs. FFmpeg is required for `--recording`.
|
|
@@ -98,7 +98,11 @@ bun run run -- run \
|
|
|
98
98
|
|
|
99
99
|
`--recording` captures Chrome's compositor screencast stream for the full goal and renders an H.264 MP4. Because Chrome's native pointer is not part of that stream, the adapter draws a high-contrast cursor that starts at the viewport center, glides to each target, and pulses on clicks. `--interaction-pauses` adds a deterministic delay after opening or loading a page, switching to an attached tab, or changing the URL within a page. Jev chooses during that delay, and the adapter waits only for any time left before acting. The same flag also pauses after moving to a click target and before pressing the mouse. Jev does not choose or observe these delays. `--screenshot` saves the final viewport after the goal stops; when recording is also enabled, it reuses the final screencast frame.
|
|
100
100
|
|
|
101
|
-
|
|
101
|
+
`jev-cdp run` writes JSON Lines to standard output: one `type:"action"` object per executed action, followed by one `type:"result"` object with the final status and action budget. Action objects include elapsed execution time, the operation, page and tab URLs, the viewport, and a CSS selector, role, name, frame, and coordinates for the element. Fill actions include the entered text; password fields and `--field-value-env` values are redacted and must be supplied separately for replay. Select and scroll actions include their option value or wheel delta. The CSS selector and page URL can be used as Playwright replay targets, with the coordinates as a fallback at the recorded viewport size. `--final-state` adds the final semantic page snapshot: a frame tree with URLs and loading state, actionable elements with frame identity, screen bounds, nearby text, region, clickability, and any element covering the click point, plus new-tab and redirect transitions. Automatic navigation and embedded-content waits use `--wait-budget-ms` (15 seconds by default) and do not consume `--max-steps`; a timeout returns `wait_timeout`, its pending condition and elapsed wait time, and the latest semantic state. Runtime failures emit a `type:"result"` object with `status:"error"`; diagnostic text uses standard error.
|
|
102
|
+
|
|
103
|
+
Each action object includes `consoleErrors` observed during that step. The result object includes `initialConsoleErrors` already present when attaching to the tab and `consoleErrors` for the full run. These fields include browser console errors, uncaught exceptions, and error-level DevTools log entries from the page and its frames. Review them alongside the semantic state; a console error does not by itself establish that the goal failed.
|
|
104
|
+
|
|
105
|
+
The snapshot includes visible controls in nested iframes, including cross-origin frames. Clicking a link there uses its frame coordinates and checks that the observed control is still current. If that click opens a new tab, Jev switches to it, returns its target ID for the next goal, and keeps the recording and 1120×780 viewport consistent across the switch.
|
|
102
106
|
|
|
103
107
|
## Supply known field values without another LLM
|
|
104
108
|
|
package/dist/cli.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// package.json
|
|
4
4
|
var package_default = {
|
|
5
5
|
name: "jev-cdp",
|
|
6
|
-
version: "0.1.
|
|
6
|
+
version: "0.1.5",
|
|
7
7
|
description: "A small Jev-powered bridge to Chrome through the Chrome DevTools Protocol.",
|
|
8
8
|
type: "module",
|
|
9
9
|
license: "MIT",
|
|
@@ -207,8 +207,15 @@ var snapshot_default = `// Ported from browser-use/jev-ultrafast at commit 452c1
|
|
|
207
207
|
const r=e.getBoundingClientRect(), x=r.x+r.width/2, y=r.y+r.height/2, rname=role(e);
|
|
208
208
|
if (!rname || r.width<=0 || r.height<=0 || x<0 || y<0 || x>=innerWidth || y>=innerHeight) continue;
|
|
209
209
|
if (rname==='gridcell' && e.querySelector('button,[role="button"]')) continue;
|
|
210
|
+
const hit=document.elementFromPoint(x,y);
|
|
211
|
+
const coveredBy=hit && !e.contains(hit) && !hit.contains(e) ?
|
|
212
|
+
{tag:hit.tagName.toLowerCase(),text:(hit.innerText||hit.getAttribute('aria-label')||'').trim().slice(0,160),role:hit.getAttribute('role')} : null;
|
|
213
|
+
const region=e.closest('form,dialog,[role="dialog"],article,section,li,tr,[role="row"],main,nav,header,footer');
|
|
214
|
+
const nearbyText=(region?.innerText||e.parentElement?.innerText||'').trim().replace(/\\s+/g,' ').slice(0,240);
|
|
210
215
|
const base={node:identity(e),role:rname,label:name(e)||rname,...(sensitive(e)?{sensitive:true}:{}),
|
|
211
|
-
rect:{x:r.x,y:r.y,w:r.width,h:r.height}
|
|
216
|
+
rect:{x:r.x,y:r.y,w:r.width,h:r.height},frameUrl:location.href,
|
|
217
|
+
nearbyText,region:region?.getAttribute('aria-label')||region?.getAttribute('role')||region?.tagName.toLowerCase()||'',
|
|
218
|
+
clickable:!coveredBy,coveredBy};
|
|
212
219
|
for (const key of ['checked','selected','expanded','pressed']) {
|
|
213
220
|
const value=e.getAttribute('aria-'+key);
|
|
214
221
|
if (value!==null) base[key]=value;
|
|
@@ -228,14 +235,20 @@ var snapshot_default = `// Ported from browser-use/jev-ultrafast at commit 452c1
|
|
|
228
235
|
if (editable) actions.push({...base,kind:'click',value,label:'Open '+base.label});
|
|
229
236
|
}
|
|
230
237
|
}
|
|
231
|
-
const
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
238
|
+
const roots=[document.body], shadowRoots=[];
|
|
239
|
+
for (const root of roots) for (const e of root.querySelectorAll('*')) {
|
|
240
|
+
if (e.shadowRoot) { roots.push(e.shadowRoot); shadowRoots.push(e.shadowRoot); }
|
|
241
|
+
}
|
|
242
|
+
const words=[], range=document.createRange(); let length=0;
|
|
243
|
+
for (const root of [...shadowRoots,document.body]) {
|
|
244
|
+
const walker=document.createTreeWalker(root,NodeFilter.SHOW_TEXT); let node;
|
|
245
|
+
while ((node=walker.nextNode()) && length<6000) {
|
|
246
|
+
const value=node.textContent.trim(), parent=node.parentElement;
|
|
247
|
+
if (!value || !parent || parent.closest('script,style,noscript,template') || !visible(parent)) continue;
|
|
248
|
+
range.selectNodeContents(node); const r=range.getBoundingClientRect();
|
|
249
|
+
if (r.width>0 && r.height>0 && r.bottom>0 && r.top<innerHeight && r.right>0 && r.left<innerWidth) {
|
|
250
|
+
words.push(value); length+=value.length;
|
|
251
|
+
}
|
|
239
252
|
}
|
|
240
253
|
}
|
|
241
254
|
const text=words.join('\\n').slice(0,6000), height=document.documentElement.scrollHeight;
|
|
@@ -249,7 +262,6 @@ var snapshot_default = `// Ported from browser-use/jev-ultrafast at commit 452c1
|
|
|
249
262
|
actions.forEach((a,i)=>a.id='e'+(i+1));
|
|
250
263
|
if (scrollY+innerHeight<height-2) actions.push({id:'scroll_down',kind:'scroll',label:'Scroll down',delta:560});
|
|
251
264
|
if (scrollY>0) actions.push({id:'scroll_up',kind:'scroll',label:'Scroll up',delta:-560});
|
|
252
|
-
actions.push({id:'wait',kind:'wait',label:'Wait for the page to update'});
|
|
253
265
|
return {url:location.href,title:document.title,w:innerWidth,h:innerHeight,text,
|
|
254
266
|
scroll:{y:scrollY,height},actions,marker,page_key,guards,omitted_actions};
|
|
255
267
|
})()
|
|
@@ -278,6 +290,18 @@ var RECORDING_CURSOR_INIT = `(() => {
|
|
|
278
290
|
|
|
279
291
|
class StalePageError extends Error {
|
|
280
292
|
}
|
|
293
|
+
|
|
294
|
+
class WaitTimeoutError extends Error {
|
|
295
|
+
elapsedMs;
|
|
296
|
+
pendingCondition;
|
|
297
|
+
state;
|
|
298
|
+
constructor(elapsedMs, pendingCondition, state) {
|
|
299
|
+
super(`Wait timed out after ${elapsedMs}ms: ${pendingCondition}`);
|
|
300
|
+
this.elapsedMs = elapsedMs;
|
|
301
|
+
this.pendingCondition = pendingCondition;
|
|
302
|
+
this.state = state;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
281
305
|
function stableValue(value) {
|
|
282
306
|
if (Array.isArray(value))
|
|
283
307
|
return value.map(stableValue);
|
|
@@ -317,8 +341,26 @@ class Browser {
|
|
|
317
341
|
#pageLoadPromise = null;
|
|
318
342
|
#resolvePageLoad;
|
|
319
343
|
#pauseUntil = 0;
|
|
344
|
+
#frameSessions = new Map;
|
|
345
|
+
#frameContexts = new Map;
|
|
346
|
+
#knownTargets = new Set;
|
|
347
|
+
#openedTabs = [];
|
|
348
|
+
#cdpUrl;
|
|
349
|
+
#switchOnPopup = false;
|
|
350
|
+
#loadingFrames = new Set;
|
|
351
|
+
#waitBudgetMs;
|
|
352
|
+
#waitSpentMs = 0;
|
|
353
|
+
#transitions = [];
|
|
354
|
+
#origin = null;
|
|
355
|
+
#changeListeners = new Set;
|
|
356
|
+
#targetUrls = new Map;
|
|
357
|
+
#newTargets = new Set;
|
|
358
|
+
#popupListeners = new Set;
|
|
359
|
+
#consoleErrors = [];
|
|
360
|
+
#watchedConsoleSessions = new Set;
|
|
320
361
|
constructor(cdp, sessionId, targetId, ownsTarget, browserContextId, options) {
|
|
321
362
|
this.#cdp = cdp;
|
|
363
|
+
this.#cdpUrl = options.cdpUrl;
|
|
322
364
|
this.#sessionId = sessionId;
|
|
323
365
|
this.#targetId = targetId;
|
|
324
366
|
this.#ownsTarget = ownsTarget;
|
|
@@ -326,6 +368,7 @@ class Browser {
|
|
|
326
368
|
this.#keepOpen = options.keepOpen ?? false;
|
|
327
369
|
this.#screenshots = options.screenshots ?? false;
|
|
328
370
|
this.#interactionPauses = options.interactionPauses ?? 0;
|
|
371
|
+
this.#waitBudgetMs = options.waitBudgetMs ?? 15000;
|
|
329
372
|
this.#recordingPath = options.recordingPath ? resolve(options.recordingPath) : undefined;
|
|
330
373
|
this.#screenshotPath = options.screenshotPath ? resolve(options.screenshotPath) : undefined;
|
|
331
374
|
}
|
|
@@ -376,7 +419,41 @@ class Browser {
|
|
|
376
419
|
flatten: true
|
|
377
420
|
});
|
|
378
421
|
const browser = new Browser(cdp, attached.sessionId, targetId, ownsTarget, browserContextId, options);
|
|
422
|
+
browser.#knownTargets = new Set((await listChromeTargets(options.cdpUrl)).map((target) => target.id));
|
|
379
423
|
try {
|
|
424
|
+
const recordTargetUrl = (params) => {
|
|
425
|
+
const info = params.targetInfo;
|
|
426
|
+
if (info?.type === "page" && info.targetId && !browser.#knownTargets.has(info.targetId)) {
|
|
427
|
+
browser.#newTargets.add(info.targetId);
|
|
428
|
+
for (const listener of browser.#popupListeners)
|
|
429
|
+
listener();
|
|
430
|
+
}
|
|
431
|
+
if (!info?.targetId || !info.url || info.url === "about:blank")
|
|
432
|
+
return;
|
|
433
|
+
const urls = browser.#targetUrls.get(info.targetId) ?? [];
|
|
434
|
+
if (urls.at(-1) !== info.url)
|
|
435
|
+
urls.push(info.url);
|
|
436
|
+
browser.#targetUrls.set(info.targetId, urls);
|
|
437
|
+
};
|
|
438
|
+
browser.#stopPageEvents.push(cdp.on("Target.targetCreated", "", recordTargetUrl), cdp.on("Target.targetInfoChanged", "", recordTargetUrl));
|
|
439
|
+
await cdp.command("Target.setDiscoverTargets", { discover: true });
|
|
440
|
+
browser.#stopPageEvents.push(cdp.on("Target.attachedToTarget", browser.#sessionId, (params) => {
|
|
441
|
+
const info = params.targetInfo;
|
|
442
|
+
if (info?.type === "iframe" && info.targetId && typeof params.sessionId === "string") {
|
|
443
|
+
browser.#frameSessions.set(info.targetId, params.sessionId);
|
|
444
|
+
browser.watchConsole(params.sessionId, info.targetId).catch(() => {
|
|
445
|
+
return;
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
}));
|
|
449
|
+
browser.#stopPageEvents.push(cdp.on("Page.frameNavigated", browser.#sessionId, (params) => {
|
|
450
|
+
const frame = params.frame;
|
|
451
|
+
if (frame?.id)
|
|
452
|
+
browser.#frameContexts.delete(frame.id);
|
|
453
|
+
}));
|
|
454
|
+
await browser.call("Target.setAutoAttach", { autoAttach: true, waitForDebuggerOnStart: false, flatten: true });
|
|
455
|
+
await browser.watchConsole();
|
|
456
|
+
await browser.call("Page.enable");
|
|
380
457
|
await browser.call("Emulation.setDeviceMetricsOverride", {
|
|
381
458
|
width: 1120,
|
|
382
459
|
height: 780,
|
|
@@ -392,7 +469,9 @@ class Browser {
|
|
|
392
469
|
await browser.startRecording();
|
|
393
470
|
return browser;
|
|
394
471
|
} catch (error) {
|
|
395
|
-
await browser.close()
|
|
472
|
+
await browser.close().catch(() => {
|
|
473
|
+
return;
|
|
474
|
+
});
|
|
396
475
|
throw error;
|
|
397
476
|
}
|
|
398
477
|
}
|
|
@@ -402,13 +481,65 @@ class Browser {
|
|
|
402
481
|
get targetId() {
|
|
403
482
|
return this.#targetId;
|
|
404
483
|
}
|
|
405
|
-
|
|
406
|
-
|
|
484
|
+
takeConsoleErrors() {
|
|
485
|
+
return this.#consoleErrors.splice(0);
|
|
486
|
+
}
|
|
487
|
+
pendingConsoleErrors() {
|
|
488
|
+
return [...this.#consoleErrors];
|
|
489
|
+
}
|
|
490
|
+
async watchConsole(sessionId = this.#sessionId, targetId = this.#targetId) {
|
|
491
|
+
if (this.#watchedConsoleSessions.has(sessionId))
|
|
407
492
|
return;
|
|
493
|
+
this.#watchedConsoleSessions.add(sessionId);
|
|
494
|
+
const record = (source, message, url, timestamp) => {
|
|
495
|
+
if (!message.trim())
|
|
496
|
+
return;
|
|
497
|
+
this.#consoleErrors.push({
|
|
498
|
+
source,
|
|
499
|
+
message: message.slice(0, 4000),
|
|
500
|
+
...url ? { url } : {},
|
|
501
|
+
...timestamp !== undefined ? { timestamp } : {},
|
|
502
|
+
targetId
|
|
503
|
+
});
|
|
504
|
+
};
|
|
505
|
+
this.#stopPageEvents.push(this.#cdp.on("Runtime.consoleAPICalled", sessionId, (params) => {
|
|
506
|
+
if (params.type !== "error")
|
|
507
|
+
return;
|
|
508
|
+
const args = params.args;
|
|
509
|
+
const message = args?.map((arg) => String(arg.value ?? arg.description ?? "")).join(" ") ?? "";
|
|
510
|
+
const frame = params.stackTrace?.callFrames?.[0];
|
|
511
|
+
record("console", message, frame?.url, typeof params.timestamp === "number" ? params.timestamp : undefined);
|
|
512
|
+
}), this.#cdp.on("Runtime.exceptionThrown", sessionId, (params) => {
|
|
513
|
+
const details = params.exceptionDetails;
|
|
514
|
+
record("exception", details?.exception?.description ?? details?.text ?? "Uncaught exception", details?.url, typeof params.timestamp === "number" ? params.timestamp : undefined);
|
|
515
|
+
}), this.#cdp.on("Log.entryAdded", sessionId, (params) => {
|
|
516
|
+
const entry = params.entry;
|
|
517
|
+
if (entry?.level === "error")
|
|
518
|
+
record("log", entry.text ?? "", entry.url, entry.timestamp);
|
|
519
|
+
}));
|
|
520
|
+
await this.#cdp.command("Runtime.enable", {}, sessionId);
|
|
521
|
+
await this.#cdp.command("Log.enable", {}, sessionId);
|
|
522
|
+
}
|
|
523
|
+
async watchPageLoads() {
|
|
408
524
|
await this.call("Page.enable");
|
|
409
525
|
const tree = await this.call("Page.getFrameTree");
|
|
410
526
|
this.#mainFrameId = tree.frameTree.frame.id;
|
|
527
|
+
await this.call("Runtime.addBinding", { name: "__jevDomChanged" });
|
|
528
|
+
const observeDom = `(() => {
|
|
529
|
+
if (window.__jevDomWatching) return;
|
|
530
|
+
window.__jevDomWatching = true;
|
|
531
|
+
const start = () => new MutationObserver(() => window.__jevDomChanged?.('change'))
|
|
532
|
+
.observe(document, {subtree:true, childList:true, attributes:true, characterData:true});
|
|
533
|
+
if (document.documentElement) start(); else document.addEventListener('DOMContentLoaded', start, {once:true});
|
|
534
|
+
})()`;
|
|
535
|
+
await this.call("Page.addScriptToEvaluateOnNewDocument", { source: observeDom });
|
|
536
|
+
await this.evaluate(observeDom).catch(() => {
|
|
537
|
+
return;
|
|
538
|
+
});
|
|
411
539
|
const loading = (params) => {
|
|
540
|
+
if (typeof params.frameId === "string")
|
|
541
|
+
this.#loadingFrames.add(params.frameId);
|
|
542
|
+
this.signalChange();
|
|
412
543
|
if (params.frameId !== this.#mainFrameId || this.#pageLoadPromise)
|
|
413
544
|
return;
|
|
414
545
|
this.#pageLoadPromise = new Promise((resolve2) => {
|
|
@@ -416,6 +547,9 @@ class Browser {
|
|
|
416
547
|
});
|
|
417
548
|
};
|
|
418
549
|
const loaded = () => {
|
|
550
|
+
if (this.#mainFrameId)
|
|
551
|
+
this.#loadingFrames.delete(this.#mainFrameId);
|
|
552
|
+
this.signalChange();
|
|
419
553
|
if (!this.#pageLoadPromise)
|
|
420
554
|
return;
|
|
421
555
|
this.#pauseUntil = performance.now() + this.#interactionPauses;
|
|
@@ -425,34 +559,69 @@ class Browser {
|
|
|
425
559
|
};
|
|
426
560
|
this.#stopPageEvents.push(this.#cdp.on("Page.frameStartedLoading", this.#sessionId, loading), this.#cdp.on("Page.frameNavigated", this.#sessionId, (params) => {
|
|
427
561
|
const frame = params.frame;
|
|
562
|
+
this.signalChange();
|
|
428
563
|
if (frame && frame.id === this.#mainFrameId && !frame.parentId)
|
|
429
564
|
loading({ frameId: frame.id });
|
|
430
565
|
}), this.#cdp.on("Page.loadEventFired", this.#sessionId, loaded), this.#cdp.on("Page.frameStoppedLoading", this.#sessionId, (params) => {
|
|
566
|
+
if (typeof params.frameId === "string")
|
|
567
|
+
this.#loadingFrames.delete(params.frameId);
|
|
568
|
+
this.signalChange();
|
|
431
569
|
if (params.frameId === this.#mainFrameId)
|
|
432
570
|
loaded();
|
|
433
571
|
}), this.#cdp.on("Page.navigatedWithinDocument", this.#sessionId, (params) => {
|
|
572
|
+
this.signalChange();
|
|
434
573
|
if (params.frameId === this.#mainFrameId) {
|
|
435
574
|
this.#pauseUntil = performance.now() + this.#interactionPauses;
|
|
436
575
|
this.resetRecordingCursor().catch(() => {
|
|
437
576
|
return;
|
|
438
577
|
});
|
|
439
578
|
}
|
|
440
|
-
}));
|
|
579
|
+
}), this.#cdp.on("Page.frameAttached", this.#sessionId, () => this.signalChange()), this.#cdp.on("Page.frameDetached", this.#sessionId, () => this.signalChange()), this.#cdp.on("Runtime.bindingCalled", this.#sessionId, () => this.signalChange()));
|
|
580
|
+
}
|
|
581
|
+
signalChange() {
|
|
582
|
+
for (const listener of this.#changeListeners)
|
|
583
|
+
listener();
|
|
584
|
+
}
|
|
585
|
+
async waitForChange(ms) {
|
|
586
|
+
await new Promise((resolve2) => {
|
|
587
|
+
const done = () => {
|
|
588
|
+
clearTimeout(timer);
|
|
589
|
+
this.#changeListeners.delete(done);
|
|
590
|
+
resolve2();
|
|
591
|
+
};
|
|
592
|
+
const timer = setTimeout(done, Math.min(ms, 150));
|
|
593
|
+
this.#changeListeners.add(done);
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
async waitForNewTarget(ms) {
|
|
597
|
+
if (this.#newTargets.size)
|
|
598
|
+
return;
|
|
599
|
+
await new Promise((resolve2) => {
|
|
600
|
+
const done = () => {
|
|
601
|
+
clearTimeout(timer);
|
|
602
|
+
this.#popupListeners.delete(done);
|
|
603
|
+
resolve2();
|
|
604
|
+
};
|
|
605
|
+
const timer = setTimeout(done, ms);
|
|
606
|
+
this.#popupListeners.add(done);
|
|
607
|
+
});
|
|
441
608
|
}
|
|
442
609
|
async waitForInteractionPause() {
|
|
443
610
|
if (!this.#interactionPauses)
|
|
444
611
|
return;
|
|
612
|
+
const waitStarted = performance.now();
|
|
445
613
|
while (true) {
|
|
446
614
|
const pageLoad = this.#pageLoadPromise;
|
|
447
615
|
if (pageLoad) {
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
]);
|
|
616
|
+
const remainingBudget = this.#waitBudgetMs - this.#waitSpentMs - (performance.now() - waitStarted);
|
|
617
|
+
if (remainingBudget <= 0)
|
|
618
|
+
throw new WaitTimeoutError(Math.round(this.#waitSpentMs + performance.now() - waitStarted), "main document loading", null);
|
|
619
|
+
await Promise.race([pageLoad, Bun.sleep(remainingBudget).then(() => {
|
|
620
|
+
throw new WaitTimeoutError(Math.round(this.#waitSpentMs + performance.now() - waitStarted), "main document loading", null);
|
|
621
|
+
})]);
|
|
454
622
|
continue;
|
|
455
623
|
}
|
|
624
|
+
this.#waitSpentMs += performance.now() - waitStarted;
|
|
456
625
|
const remaining = this.#pauseUntil - performance.now();
|
|
457
626
|
if (remaining <= 0)
|
|
458
627
|
return;
|
|
@@ -464,7 +633,9 @@ class Browser {
|
|
|
464
633
|
return;
|
|
465
634
|
if (!Bun.which("ffmpeg"))
|
|
466
635
|
throw new Error("--recording requires ffmpeg on PATH");
|
|
467
|
-
this.#recordingDirectory
|
|
636
|
+
const firstSegment = !this.#recordingDirectory;
|
|
637
|
+
if (firstSegment)
|
|
638
|
+
this.#recordingDirectory = await mkdtemp(join(tmpdir(), "jev-cdp-recording-"));
|
|
468
639
|
await this.call("Page.enable");
|
|
469
640
|
await this.call("Page.addScriptToEvaluateOnNewDocument", { source: RECORDING_CURSOR_INIT });
|
|
470
641
|
const viewport = await this.evaluate("({width: innerWidth, height: innerHeight})");
|
|
@@ -472,11 +643,11 @@ class Browser {
|
|
|
472
643
|
throw new Error("Could not read the recording viewport");
|
|
473
644
|
await this.animateCursor(viewport.width / 2, viewport.height / 2);
|
|
474
645
|
const initial = await this.call("Page.captureScreenshot", { format: "jpeg", quality: 70 });
|
|
475
|
-
const initialPath = join(this.#recordingDirectory, "
|
|
646
|
+
const initialPath = join(this.#recordingDirectory, `${String(this.#recordingSequence++).padStart(6, "0")}.jpg`);
|
|
476
647
|
await Bun.write(initialPath, Buffer.from(initial.data, "base64"));
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
this.#
|
|
648
|
+
if (firstSegment)
|
|
649
|
+
this.#recordingStartedAt = performance.now();
|
|
650
|
+
this.#recordingFrames.push({ path: initialPath, elapsedMs: firstSegment ? 0 : performance.now() - this.#recordingStartedAt });
|
|
480
651
|
this.#stopRecordingEvents = this.#cdp.on("Page.screencastFrame", this.#sessionId, (params) => {
|
|
481
652
|
const frame = params;
|
|
482
653
|
this.call("Page.screencastFrameAck", { sessionId: frame.sessionId }).catch(() => {
|
|
@@ -590,18 +761,52 @@ class Browser {
|
|
|
590
761
|
})()`);
|
|
591
762
|
}
|
|
592
763
|
async waitForReady() {
|
|
593
|
-
const
|
|
594
|
-
|
|
764
|
+
const started = performance.now();
|
|
765
|
+
const deadline = started + this.#waitBudgetMs - this.#waitSpentMs;
|
|
766
|
+
while (performance.now() < deadline) {
|
|
595
767
|
try {
|
|
596
|
-
if (!this.#pageLoadPromise && await this.evaluate("document.readyState") === "complete")
|
|
768
|
+
if (!this.#pageLoadPromise && await this.evaluate("document.readyState") === "complete") {
|
|
769
|
+
this.#waitSpentMs += performance.now() - started;
|
|
597
770
|
return;
|
|
771
|
+
}
|
|
772
|
+
} catch (error) {
|
|
773
|
+
if (!(error instanceof StalePageError))
|
|
774
|
+
throw error;
|
|
775
|
+
}
|
|
776
|
+
await this.waitForChange(Math.max(1, deadline - performance.now()));
|
|
777
|
+
}
|
|
778
|
+
this.#waitSpentMs += performance.now() - started;
|
|
779
|
+
const state = await this.observe(false).catch(() => null);
|
|
780
|
+
throw new WaitTimeoutError(Math.round(this.#waitSpentMs), "main document loading", state);
|
|
781
|
+
}
|
|
782
|
+
async waitForSemanticReady(initial, screenshot = this.#screenshots) {
|
|
783
|
+
const started = performance.now();
|
|
784
|
+
const spentAtStart = this.#waitSpentMs;
|
|
785
|
+
const deadline = started + Math.max(0, this.#waitBudgetMs - this.#waitSpentMs);
|
|
786
|
+
const accountWait = () => {
|
|
787
|
+
this.#waitSpentMs += Math.max(0, performance.now() - started - (this.#waitSpentMs - spentAtStart));
|
|
788
|
+
};
|
|
789
|
+
let state = initial;
|
|
790
|
+
while (true) {
|
|
791
|
+
const pending = state.frames.find((frame) => frame.loading || frame.readyState !== "complete");
|
|
792
|
+
const loadingText = state.frames.length > 1 && !state.actions.some((action) => action.frameId) && /(?:^|\n)loading(?:\.{0,3}|\s)/i.test(state.text);
|
|
793
|
+
if (!pending && !loadingText) {
|
|
794
|
+
accountWait();
|
|
795
|
+
return state;
|
|
796
|
+
}
|
|
797
|
+
const condition = pending ? `frame ${pending.url || pending.id} loading` : "embedded content loading";
|
|
798
|
+
if (performance.now() >= deadline) {
|
|
799
|
+
accountWait();
|
|
800
|
+
throw new WaitTimeoutError(Math.round(this.#waitSpentMs), condition, state);
|
|
801
|
+
}
|
|
802
|
+
await this.waitForChange(Math.max(1, deadline - performance.now()));
|
|
803
|
+
try {
|
|
804
|
+
state = await this.observe(screenshot);
|
|
598
805
|
} catch (error) {
|
|
599
806
|
if (!(error instanceof StalePageError))
|
|
600
807
|
throw error;
|
|
601
808
|
}
|
|
602
|
-
await Bun.sleep(20);
|
|
603
809
|
}
|
|
604
|
-
throw new Error("Page did not finish loading within 15 seconds");
|
|
605
810
|
}
|
|
606
811
|
async evaluate(expression, awaitPromise = false) {
|
|
607
812
|
const response = await this.call("Runtime.evaluate", { expression, returnByValue: true, awaitPromise }, awaitPromise ? 15000 : undefined);
|
|
@@ -609,6 +814,125 @@ class Browser {
|
|
|
609
814
|
throw new StalePageError("Document changed during evaluation");
|
|
610
815
|
return response.result?.value;
|
|
611
816
|
}
|
|
817
|
+
async evaluateFrame(frameId, expression) {
|
|
818
|
+
const session = this.#frameSessions.get(frameId);
|
|
819
|
+
let contextId = this.#frameContexts.get(frameId);
|
|
820
|
+
if (!session && !contextId) {
|
|
821
|
+
const context = await this.call("Page.createIsolatedWorld", { frameId });
|
|
822
|
+
contextId = context.executionContextId;
|
|
823
|
+
this.#frameContexts.set(frameId, contextId);
|
|
824
|
+
}
|
|
825
|
+
const response = await this.#cdp.command("Runtime.evaluate", { expression, returnByValue: true, ...contextId ? { contextId } : {} }, session ?? this.#sessionId);
|
|
826
|
+
if (response.exceptionDetails)
|
|
827
|
+
throw new StalePageError("Frame changed during evaluation");
|
|
828
|
+
return response.result?.value;
|
|
829
|
+
}
|
|
830
|
+
async frameOffset(frameId, parentId = null) {
|
|
831
|
+
const session = parentId ? this.#frameSessions.get(parentId) ?? this.#sessionId : this.#sessionId;
|
|
832
|
+
const owner = await this.#cdp.command("DOM.getFrameOwner", { frameId }, session);
|
|
833
|
+
const box = await this.#cdp.command("DOM.getBoxModel", { backendNodeId: owner.backendNodeId }, session);
|
|
834
|
+
return { x: box.model.content[0], y: box.model.content[1] };
|
|
835
|
+
}
|
|
836
|
+
async frameScreenOffset(frameId, frames) {
|
|
837
|
+
const states = frames ?? await this.frameStates();
|
|
838
|
+
let id = frameId;
|
|
839
|
+
let x = 0, y = 0;
|
|
840
|
+
while (id) {
|
|
841
|
+
const frame = states.find((item) => item.id === id);
|
|
842
|
+
if (!frame?.parentId)
|
|
843
|
+
break;
|
|
844
|
+
const offset = await this.frameOffset(id, frame.parentId);
|
|
845
|
+
x += offset.x;
|
|
846
|
+
y += offset.y;
|
|
847
|
+
id = frame.parentId;
|
|
848
|
+
}
|
|
849
|
+
return { x, y };
|
|
850
|
+
}
|
|
851
|
+
async frameStates() {
|
|
852
|
+
const tree = await this.call("Page.getFrameTree");
|
|
853
|
+
const frames = [];
|
|
854
|
+
const visit = async (node, parentId) => {
|
|
855
|
+
const { id, url = "" } = node.frame;
|
|
856
|
+
let readyState = null;
|
|
857
|
+
try {
|
|
858
|
+
readyState = id === this.#mainFrameId ? await this.evaluate("document.readyState") ?? null : await this.evaluateFrame(id, "document.readyState") ?? null;
|
|
859
|
+
} catch {}
|
|
860
|
+
frames.push({
|
|
861
|
+
id,
|
|
862
|
+
parentId,
|
|
863
|
+
url,
|
|
864
|
+
readyState,
|
|
865
|
+
loading: this.#loadingFrames.has(id) || readyState !== "complete"
|
|
866
|
+
});
|
|
867
|
+
for (const child of node.childFrames ?? [])
|
|
868
|
+
await visit(child, id);
|
|
869
|
+
};
|
|
870
|
+
await visit(tree.frameTree, null);
|
|
871
|
+
return frames;
|
|
872
|
+
}
|
|
873
|
+
async discoverTabs() {
|
|
874
|
+
if (!this.#switchOnPopup)
|
|
875
|
+
return;
|
|
876
|
+
for (const target of await listChromeTargets(this.#cdpUrl)) {
|
|
877
|
+
if (target.type !== "page" || this.#knownTargets.has(target.id))
|
|
878
|
+
continue;
|
|
879
|
+
this.#knownTargets.add(target.id);
|
|
880
|
+
this.#newTargets.delete(target.id);
|
|
881
|
+
const attached = await this.#cdp.command("Target.attachToTarget", { targetId: target.id, flatten: true });
|
|
882
|
+
try {
|
|
883
|
+
await this.#cdp.command("Emulation.setDeviceMetricsOverride", {
|
|
884
|
+
width: 1120,
|
|
885
|
+
height: 780,
|
|
886
|
+
deviceScaleFactor: 1,
|
|
887
|
+
mobile: false
|
|
888
|
+
}, attached.sessionId);
|
|
889
|
+
} finally {
|
|
890
|
+
if (this.#switchOnPopup) {
|
|
891
|
+
if (this.#recordingPath) {
|
|
892
|
+
await this.call("Page.stopScreencast").catch(() => {
|
|
893
|
+
return;
|
|
894
|
+
});
|
|
895
|
+
this.#stopRecordingEvents?.();
|
|
896
|
+
this.#stopRecordingEvents = undefined;
|
|
897
|
+
await this.#recordingWrites;
|
|
898
|
+
}
|
|
899
|
+
this.#sessionId = attached.sessionId;
|
|
900
|
+
this.#targetId = target.id;
|
|
901
|
+
this.#loadingFrames.clear();
|
|
902
|
+
await this.watchConsole();
|
|
903
|
+
await this.watchPageLoads();
|
|
904
|
+
this.#switchOnPopup = false;
|
|
905
|
+
await this.startRecording();
|
|
906
|
+
} else {
|
|
907
|
+
await this.#cdp.command("Target.detachFromTarget", { sessionId: attached.sessionId });
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
this.#openedTabs.push({ id: target.id, url: target.url, title: target.title });
|
|
911
|
+
if (this.#origin)
|
|
912
|
+
this.#transitions.push({
|
|
913
|
+
kind: "new_tab",
|
|
914
|
+
control: this.#origin.control,
|
|
915
|
+
fromTargetId: this.#origin.targetId,
|
|
916
|
+
targetId: target.id,
|
|
917
|
+
fromUrl: this.#origin.url,
|
|
918
|
+
destinationUrl: target.url,
|
|
919
|
+
settled: false
|
|
920
|
+
});
|
|
921
|
+
const urls = this.#targetUrls.get(target.id) ?? [];
|
|
922
|
+
const requested = this.#origin?.href ?? urls[0];
|
|
923
|
+
if (this.#origin && requested && requested !== target.url)
|
|
924
|
+
this.#transitions.push({
|
|
925
|
+
kind: "redirect",
|
|
926
|
+
control: this.#origin.control,
|
|
927
|
+
fromTargetId: this.#origin.targetId,
|
|
928
|
+
targetId: target.id,
|
|
929
|
+
fromUrl: requested,
|
|
930
|
+
destinationUrl: target.url,
|
|
931
|
+
settled: false
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
this.#switchOnPopup = false;
|
|
935
|
+
}
|
|
612
936
|
async settleAfterInput() {
|
|
613
937
|
const action = this.#afterInput;
|
|
614
938
|
this.#afterInput = null;
|
|
@@ -644,21 +968,116 @@ class Browser {
|
|
|
644
968
|
}
|
|
645
969
|
async observe(screenshot = this.#screenshots) {
|
|
646
970
|
await this.settleAfterInput();
|
|
971
|
+
await this.discoverTabs();
|
|
647
972
|
let info;
|
|
648
|
-
|
|
973
|
+
const waitStarted = performance.now();
|
|
974
|
+
const deadline = waitStarted + Math.max(0, this.#waitBudgetMs - this.#waitSpentMs);
|
|
975
|
+
let waited = false;
|
|
976
|
+
while (true) {
|
|
649
977
|
try {
|
|
650
978
|
info = await this.evaluate(snapshot_default);
|
|
651
979
|
if (info)
|
|
652
980
|
break;
|
|
653
981
|
} catch (error) {
|
|
654
|
-
if (!(error instanceof StalePageError)
|
|
982
|
+
if (!(error instanceof StalePageError))
|
|
655
983
|
throw error;
|
|
656
984
|
}
|
|
657
|
-
|
|
985
|
+
if (performance.now() >= deadline) {
|
|
986
|
+
this.#waitSpentMs += performance.now() - waitStarted;
|
|
987
|
+
throw new WaitTimeoutError(Math.round(this.#waitSpentMs), "document navigating", null);
|
|
988
|
+
}
|
|
989
|
+
waited = true;
|
|
990
|
+
await this.waitForChange(Math.max(1, deadline - performance.now()));
|
|
991
|
+
}
|
|
992
|
+
if (waited)
|
|
993
|
+
this.#waitSpentMs += performance.now() - waitStarted;
|
|
994
|
+
if (this.#openedTabs.length) {
|
|
995
|
+
const targets2 = await listChromeTargets(this.#cdpUrl);
|
|
996
|
+
this.#openedTabs = this.#openedTabs.map((tab) => {
|
|
997
|
+
const current = targets2.find((target) => target.id === tab.id);
|
|
998
|
+
return current ? { ...tab, url: current.url, title: current.title } : tab;
|
|
999
|
+
});
|
|
1000
|
+
info.text = `${info.text}
|
|
1001
|
+
${this.#openedTabs.map((tab) => `Opened new tab: ${tab.title} ${tab.url} (target ${tab.id})`).join(`
|
|
1002
|
+
`)}`;
|
|
1003
|
+
}
|
|
1004
|
+
const frames = await this.frameStates();
|
|
1005
|
+
for (const frame of frames.filter((frame2) => frame2.parentId !== null)) {
|
|
1006
|
+
const frameId = frame.id;
|
|
1007
|
+
try {
|
|
1008
|
+
const offset = await this.frameScreenOffset(frameId, frames);
|
|
1009
|
+
const child = await this.evaluateFrame(frameId, snapshot_default);
|
|
1010
|
+
if (!child)
|
|
1011
|
+
continue;
|
|
1012
|
+
info.text = `${info.text}
|
|
1013
|
+
${child.text}`.slice(0, 6000);
|
|
1014
|
+
for (const action of child.actions) {
|
|
1015
|
+
if (!action.node || !action.rect)
|
|
1016
|
+
continue;
|
|
1017
|
+
const rect = { ...action.rect, x: action.rect.x + offset.x, y: action.rect.y + offset.y };
|
|
1018
|
+
if (rect.x < 0 || rect.y < 0 || rect.x >= info.w || rect.y >= info.h)
|
|
1019
|
+
continue;
|
|
1020
|
+
const covering = await this.evaluate(`(() => {
|
|
1021
|
+
const e=document.elementFromPoint(${rect.x + rect.w / 2},${rect.y + rect.h / 2});
|
|
1022
|
+
if (!e || e.tagName==='IFRAME') return null;
|
|
1023
|
+
return {tag:e.tagName.toLowerCase(),text:(e.innerText||e.getAttribute('aria-label')||'').trim().slice(0,160),role:e.getAttribute('role')};
|
|
1024
|
+
})()`);
|
|
1025
|
+
info.actions.push({
|
|
1026
|
+
...action,
|
|
1027
|
+
frameId,
|
|
1028
|
+
rect,
|
|
1029
|
+
id: `e${info.actions.length + 1}`,
|
|
1030
|
+
clickable: action.clickable && !covering,
|
|
1031
|
+
coveredBy: covering ?? action.coveredBy
|
|
1032
|
+
});
|
|
1033
|
+
info.guards[`${frameId}:${action.node}`] = child.guards[String(action.node)];
|
|
1034
|
+
}
|
|
1035
|
+
info.marker = [info.marker, frameId, child.marker];
|
|
1036
|
+
} catch {}
|
|
658
1037
|
}
|
|
659
|
-
if (!
|
|
660
|
-
|
|
661
|
-
|
|
1038
|
+
if (this.#origin && this.#targetId === this.#origin.targetId && info.url !== this.#origin.url && !this.#transitions.some((transition) => transition.control === this.#origin.control && transition.fromUrl === this.#origin.url && transition.targetId === this.#targetId)) {
|
|
1039
|
+
this.#transitions.push({
|
|
1040
|
+
kind: "navigation",
|
|
1041
|
+
control: this.#origin.control,
|
|
1042
|
+
fromTargetId: this.#origin.targetId,
|
|
1043
|
+
targetId: this.#targetId,
|
|
1044
|
+
fromUrl: this.#origin.url,
|
|
1045
|
+
destinationUrl: info.url,
|
|
1046
|
+
settled: false
|
|
1047
|
+
});
|
|
1048
|
+
if (this.#origin.href && this.#origin.href !== info.url)
|
|
1049
|
+
this.#transitions.push({
|
|
1050
|
+
kind: "redirect",
|
|
1051
|
+
control: this.#origin.control,
|
|
1052
|
+
fromTargetId: this.#origin.targetId,
|
|
1053
|
+
targetId: this.#targetId,
|
|
1054
|
+
fromUrl: this.#origin.href,
|
|
1055
|
+
destinationUrl: info.url,
|
|
1056
|
+
settled: false
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
const targets = this.#transitions.length ? await listChromeTargets(this.#cdpUrl) : [];
|
|
1060
|
+
for (const transition of this.#transitions) {
|
|
1061
|
+
const target = targets.find((item) => item.id === transition.targetId);
|
|
1062
|
+
if (target)
|
|
1063
|
+
transition.destinationUrl = target.url;
|
|
1064
|
+
transition.settled = frames.every((frame) => !frame.loading);
|
|
1065
|
+
}
|
|
1066
|
+
if (this.#origin?.href)
|
|
1067
|
+
for (const transition of this.#transitions.filter((item) => item.kind === "new_tab" && item.control === this.#origin.control)) {
|
|
1068
|
+
if (transition.destinationUrl !== this.#origin.href && !this.#transitions.some((item) => item.kind === "redirect" && item.targetId === transition.targetId && item.fromUrl === this.#origin.href)) {
|
|
1069
|
+
this.#transitions.push({
|
|
1070
|
+
kind: "redirect",
|
|
1071
|
+
control: transition.control,
|
|
1072
|
+
fromTargetId: transition.fromTargetId,
|
|
1073
|
+
targetId: transition.targetId,
|
|
1074
|
+
fromUrl: this.#origin.href,
|
|
1075
|
+
destinationUrl: transition.destinationUrl,
|
|
1076
|
+
settled: transition.settled
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
const page = { ...info, frames, transitions: [...this.#transitions], fingerprint: fingerprint(info) };
|
|
662
1081
|
if (screenshot) {
|
|
663
1082
|
const capture = await this.call("Page.captureScreenshot", { format: "jpeg", quality: 72 });
|
|
664
1083
|
page.screenshot = capture.data;
|
|
@@ -666,6 +1085,12 @@ class Browser {
|
|
|
666
1085
|
return page;
|
|
667
1086
|
}
|
|
668
1087
|
async fresh(page, action) {
|
|
1088
|
+
if (action?.frameId && typeof action.node === "number") {
|
|
1089
|
+
const current = await this.evaluateFrame(action.frameId, `(() => {
|
|
1090
|
+
const c=window.__jevFast; return c ? c.guard(c.nodes.get(${action.node})) : null;
|
|
1091
|
+
})()`);
|
|
1092
|
+
return stableStringify(current) === stableStringify(page.guards[`${action.frameId}:${action.node}`]);
|
|
1093
|
+
}
|
|
669
1094
|
if (action && (action.kind === "click" || action.kind === "select")) {
|
|
670
1095
|
if (typeof action.node !== "number")
|
|
671
1096
|
return false;
|
|
@@ -675,15 +1100,49 @@ class Browser {
|
|
|
675
1100
|
})()`);
|
|
676
1101
|
return stableStringify(current) === stableStringify([page.page_key, page.guards[String(action.node)]]);
|
|
677
1102
|
}
|
|
678
|
-
|
|
1103
|
+
let marker = await this.evaluate(MARKER);
|
|
1104
|
+
for (const frame of (await this.frameStates()).filter((item) => item.parentId !== null)) {
|
|
1105
|
+
try {
|
|
1106
|
+
const childMarker = await this.evaluateFrame(frame.id, MARKER);
|
|
1107
|
+
if (childMarker !== undefined)
|
|
1108
|
+
marker = [marker ?? null, frame.id, childMarker];
|
|
1109
|
+
} catch {
|
|
1110
|
+
return false;
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
679
1113
|
return stableStringify(marker) === stableStringify(page.marker);
|
|
680
1114
|
}
|
|
1115
|
+
async describeElement(action) {
|
|
1116
|
+
const expression = `(node => {
|
|
1117
|
+
const e = window.__jevFast?.nodes.get(node);
|
|
1118
|
+
if (!e?.isConnected) return null;
|
|
1119
|
+
const path = [];
|
|
1120
|
+
for (let current = e; current; current = current.parentElement) {
|
|
1121
|
+
if (current.id && document.querySelectorAll('#' + CSS.escape(current.id)).length === 1) {
|
|
1122
|
+
path.unshift('#' + CSS.escape(current.id));
|
|
1123
|
+
break;
|
|
1124
|
+
}
|
|
1125
|
+
let position = 1;
|
|
1126
|
+
for (let sibling = current.previousElementSibling; sibling; sibling = sibling.previousElementSibling) {
|
|
1127
|
+
if (sibling.tagName === current.tagName) position++;
|
|
1128
|
+
}
|
|
1129
|
+
path.unshift(current.tagName.toLowerCase() + ':nth-of-type(' + position + ')');
|
|
1130
|
+
}
|
|
1131
|
+
return {css:path.join(' > '),tag:e.tagName.toLowerCase(),
|
|
1132
|
+
href:typeof e.href === 'string' ? e.href : e.getAttribute('href'),
|
|
1133
|
+
inputType:e.getAttribute('type'),frameUrl:location.href,target:e.getAttribute('target')};
|
|
1134
|
+
})(${action.node})`;
|
|
1135
|
+
const details = action.frameId ? await this.evaluateFrame(action.frameId, expression) : await this.evaluate(expression);
|
|
1136
|
+
if (!details)
|
|
1137
|
+
throw new StalePageError("Target changed before replay details were captured");
|
|
1138
|
+
return details;
|
|
1139
|
+
}
|
|
681
1140
|
async act(action, page, text) {
|
|
682
1141
|
if (!await this.fresh(page, action))
|
|
683
1142
|
throw new StalePageError("Page changed since this decision");
|
|
684
1143
|
if (action.kind === "wait") {
|
|
685
1144
|
await Bun.sleep(100);
|
|
686
|
-
return;
|
|
1145
|
+
return { element: null, performedAt: performance.now() };
|
|
687
1146
|
}
|
|
688
1147
|
if (action.kind === "scroll") {
|
|
689
1148
|
await this.animateCursor(550, 650);
|
|
@@ -694,11 +1153,25 @@ class Browser {
|
|
|
694
1153
|
deltaX: 0,
|
|
695
1154
|
deltaY: action.delta ?? 0
|
|
696
1155
|
});
|
|
697
|
-
return;
|
|
1156
|
+
return { element: null, performedAt: performance.now() };
|
|
698
1157
|
}
|
|
699
1158
|
if (typeof action.node !== "number")
|
|
700
1159
|
throw new Error("Invalid observed node");
|
|
701
|
-
const
|
|
1160
|
+
const details = await this.describeElement(action);
|
|
1161
|
+
if (action.kind === "click")
|
|
1162
|
+
this.#origin = {
|
|
1163
|
+
control: action.label,
|
|
1164
|
+
targetId: this.#targetId,
|
|
1165
|
+
url: page.url,
|
|
1166
|
+
href: details.href
|
|
1167
|
+
};
|
|
1168
|
+
const target = await (action.frameId ? this.evaluateFrame(action.frameId, `(action => {
|
|
1169
|
+
const e=window.__jevFast?.nodes.get(action.node);
|
|
1170
|
+
if (!e?.isConnected || !e.checkVisibility({checkOpacity:true,checkVisibilityCSS:true})) return null;
|
|
1171
|
+
const r=e.getBoundingClientRect(), x=r.x+r.width/2, y=r.y+r.height/2;
|
|
1172
|
+
if (!e.contains(document.elementFromPoint(x,y))) return null;
|
|
1173
|
+
return {x,y};
|
|
1174
|
+
})(${JSON.stringify(action)})`) : this.evaluate(`(action => {
|
|
702
1175
|
const e=window.__jevFast?.nodes.get(action.node);
|
|
703
1176
|
if (!e?.isConnected || e.matches(':disabled') || e.closest('[aria-disabled="true"],[inert]') ||
|
|
704
1177
|
!e.checkVisibility({checkOpacity:true,checkVisibilityCSS:true})) return null;
|
|
@@ -714,12 +1187,43 @@ class Browser {
|
|
|
714
1187
|
e.dispatchEvent(new Event('change',{bubbles:true}));
|
|
715
1188
|
}
|
|
716
1189
|
return {x,y};
|
|
717
|
-
})(${JSON.stringify(action)})`);
|
|
1190
|
+
})(${JSON.stringify(action)})`));
|
|
718
1191
|
if (!target) {
|
|
719
1192
|
if (action.kind === "select")
|
|
720
1193
|
throw new Error("Dropdown execution was not confirmed");
|
|
721
1194
|
throw new StalePageError("Target changed or is covered");
|
|
722
1195
|
}
|
|
1196
|
+
let performedAt = action.kind === "select" ? performance.now() : 0;
|
|
1197
|
+
if (action.frameId) {
|
|
1198
|
+
const offset = await this.frameScreenOffset(action.frameId);
|
|
1199
|
+
target.x += offset.x;
|
|
1200
|
+
target.y += offset.y;
|
|
1201
|
+
const covered = await this.evaluate(`(() => {
|
|
1202
|
+
const e=document.elementFromPoint(${target.x},${target.y});
|
|
1203
|
+
return !e || e.tagName!=='IFRAME';
|
|
1204
|
+
})()`);
|
|
1205
|
+
if (covered)
|
|
1206
|
+
throw new StalePageError("Target is covered in the parent page");
|
|
1207
|
+
}
|
|
1208
|
+
let frame = null;
|
|
1209
|
+
if (action.frameId) {
|
|
1210
|
+
const frames = await this.frameStates();
|
|
1211
|
+
const parentId = frames.find((item) => item.id === action.frameId)?.parentId ?? null;
|
|
1212
|
+
const index = frames.filter((item) => item.parentId === parentId).findIndex((item) => item.id === action.frameId);
|
|
1213
|
+
if (index < 0)
|
|
1214
|
+
throw new StalePageError("Target frame changed before input");
|
|
1215
|
+
frame = { id: action.frameId, parentId, url: details.frameUrl, index };
|
|
1216
|
+
}
|
|
1217
|
+
const element = {
|
|
1218
|
+
css: details.css,
|
|
1219
|
+
role: action.role ?? null,
|
|
1220
|
+
name: action.label,
|
|
1221
|
+
tag: details.tag,
|
|
1222
|
+
href: details.href,
|
|
1223
|
+
inputType: details.inputType,
|
|
1224
|
+
point: { x: target.x, y: target.y },
|
|
1225
|
+
frame
|
|
1226
|
+
};
|
|
723
1227
|
await this.animateCursor(target.x, target.y);
|
|
724
1228
|
if (action.kind !== "select") {
|
|
725
1229
|
if (action.kind === "click" && this.#interactionPauses > 0) {
|
|
@@ -741,6 +1245,7 @@ class Browser {
|
|
|
741
1245
|
if (type === "mousePressed")
|
|
742
1246
|
await this.animateCursor(target.x, target.y, true);
|
|
743
1247
|
}
|
|
1248
|
+
performedAt = performance.now();
|
|
744
1249
|
if (action.kind === "fill") {
|
|
745
1250
|
const modifiers = process.platform === "darwin" ? 4 : 2;
|
|
746
1251
|
await this.call("Input.dispatchKeyEvent", {
|
|
@@ -757,9 +1262,15 @@ class Browser {
|
|
|
757
1262
|
modifiers
|
|
758
1263
|
});
|
|
759
1264
|
await this.call("Input.insertText", { text: text ?? "" });
|
|
1265
|
+
performedAt = performance.now();
|
|
760
1266
|
}
|
|
761
1267
|
}
|
|
762
1268
|
this.#afterInput = action;
|
|
1269
|
+
if (action.kind === "click") {
|
|
1270
|
+
this.#switchOnPopup = true;
|
|
1271
|
+
await this.waitForNewTarget(details.target === "_blank" ? 650 : 100);
|
|
1272
|
+
}
|
|
1273
|
+
return { element, performedAt };
|
|
763
1274
|
}
|
|
764
1275
|
async close() {
|
|
765
1276
|
if (this.#closed)
|
|
@@ -808,9 +1319,9 @@ its matching autocomplete suggestion selected. For date pickers, CLICK the field
|
|
|
808
1319
|
Set every requested filter/control; a matching result alone does not prove a requested filter was set.
|
|
809
1320
|
Do not toggle a checkbox, switch, or radio already in the requested state.
|
|
810
1321
|
Submit populated search fields before opening a result; a populated field alone is not an applied search.
|
|
811
|
-
WAIT only when the needed control is absent/disabled, or submitted results are still loading.
|
|
812
1322
|
If Search/Submit is visible and the required fields are ready, CLICK it immediately.
|
|
813
|
-
|
|
1323
|
+
Loading and frame readiness are handled by the browser adapter before this decision.
|
|
1324
|
+
Use frame URL, nearby text, bounds, and clickability to distinguish repeated labels.
|
|
814
1325
|
DONE requires visible evidence that ALL requirements are satisfied. If asked to open a result,
|
|
815
1326
|
a matching link is not enough. BLOCKED means no supported operation can make progress.`;
|
|
816
1327
|
var TARGET = `Choose the best observed target if the next operation is the one specified in this question.
|
|
@@ -882,13 +1393,21 @@ function actionSpace(actions) {
|
|
|
882
1393
|
}
|
|
883
1394
|
if (typeof action.node !== "number")
|
|
884
1395
|
continue;
|
|
885
|
-
|
|
1396
|
+
const identity = `${action.frameId ?? "main"}:${action.node}`;
|
|
1397
|
+
if (!indices.has(identity)) {
|
|
886
1398
|
const index2 = String(elements.length + 1);
|
|
887
|
-
indices.set(
|
|
1399
|
+
indices.set(identity, index2);
|
|
888
1400
|
const element2 = {
|
|
889
1401
|
index: index2,
|
|
890
1402
|
label: action.label.split(" \u2192 ")[0] ?? action.label,
|
|
891
|
-
operations: []
|
|
1403
|
+
operations: [],
|
|
1404
|
+
frameId: action.frameId ?? null,
|
|
1405
|
+
frameUrl: action.frameUrl ?? null,
|
|
1406
|
+
bounds: action.rect ?? null,
|
|
1407
|
+
nearbyText: action.nearbyText ?? "",
|
|
1408
|
+
region: action.region ?? "",
|
|
1409
|
+
clickable: action.clickable ?? true,
|
|
1410
|
+
coveredBy: action.coveredBy ?? null
|
|
892
1411
|
};
|
|
893
1412
|
for (const key of ["role", "value", "checked", "selected", "expanded", "pressed", "sensitive"]) {
|
|
894
1413
|
if (action[key] !== undefined)
|
|
@@ -900,7 +1419,9 @@ function actionSpace(actions) {
|
|
|
900
1419
|
}
|
|
901
1420
|
elements.push(element2);
|
|
902
1421
|
}
|
|
903
|
-
const index = indices.get(
|
|
1422
|
+
const index = indices.get(identity);
|
|
1423
|
+
if (action.clickable === false)
|
|
1424
|
+
continue;
|
|
904
1425
|
const group = targets[operation] ??= {};
|
|
905
1426
|
const element = elements[Number(index) - 1];
|
|
906
1427
|
if (!element.operations.includes(operation))
|
|
@@ -939,6 +1460,13 @@ async function choose(page, goal, history, providedFields = []) {
|
|
|
939
1460
|
type: "choice",
|
|
940
1461
|
criteria: Object.fromEntries(Object.entries(candidates).map(([index, action]) => [index, {
|
|
941
1462
|
element: `[${index}] ${action.label}`,
|
|
1463
|
+
frame_id: action.frameId ?? null,
|
|
1464
|
+
frame: action.frameUrl ?? page.url,
|
|
1465
|
+
region: action.region ?? "",
|
|
1466
|
+
nearby_text: action.nearbyText ?? "",
|
|
1467
|
+
bounds: action.rect ?? null,
|
|
1468
|
+
clickable: action.clickable ?? true,
|
|
1469
|
+
covered_by: action.coveredBy ?? null,
|
|
942
1470
|
current_value: action.current_value ?? action.value ?? "",
|
|
943
1471
|
...Object.fromEntries(["role", "checked", "selected", "expanded", "pressed", "sensitive"].filter((name) => action[name] !== undefined).map((name) => [name, action[name]]))
|
|
944
1472
|
}])),
|
|
@@ -1118,12 +1646,23 @@ function fieldText(context) {
|
|
|
1118
1646
|
}
|
|
1119
1647
|
|
|
1120
1648
|
// src/agent.ts
|
|
1649
|
+
function redactConsoleErrors(errors, secrets) {
|
|
1650
|
+
const redact = (text) => secrets.reduce((value, secret) => value.replaceAll(secret, "[redacted]"), text);
|
|
1651
|
+
return errors.map((error) => ({
|
|
1652
|
+
...error,
|
|
1653
|
+
message: redact(error.message),
|
|
1654
|
+
...error.url ? { url: redact(error.url) } : {}
|
|
1655
|
+
}));
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1121
1658
|
class Agent {
|
|
1122
1659
|
#browser;
|
|
1123
1660
|
#goal;
|
|
1124
1661
|
#maxSteps;
|
|
1125
1662
|
#screenshots;
|
|
1126
1663
|
#fieldValues;
|
|
1664
|
+
#sensitiveFieldLabels;
|
|
1665
|
+
#secretValues;
|
|
1127
1666
|
#page;
|
|
1128
1667
|
#decision = null;
|
|
1129
1668
|
#history = [];
|
|
@@ -1132,6 +1671,8 @@ class Agent {
|
|
|
1132
1671
|
#status = "ready";
|
|
1133
1672
|
#startedAt = null;
|
|
1134
1673
|
#pendingText = null;
|
|
1674
|
+
#waitTimeout;
|
|
1675
|
+
#initialConsoleErrors = [];
|
|
1135
1676
|
constructor(browser, page, options) {
|
|
1136
1677
|
this.#browser = browser;
|
|
1137
1678
|
this.#page = page;
|
|
@@ -1139,6 +1680,9 @@ class Agent {
|
|
|
1139
1680
|
this.#maxSteps = options.maxSteps;
|
|
1140
1681
|
this.#screenshots = options.screenshots ?? false;
|
|
1141
1682
|
this.#fieldValues = options.fieldValues ?? {};
|
|
1683
|
+
this.#sensitiveFieldLabels = new Set(options.sensitiveFieldLabels ?? []);
|
|
1684
|
+
this.#secretValues = Object.entries(this.#fieldValues).filter(([label, value]) => this.#sensitiveFieldLabels.has(label) && value.length > 0).map(([, value]) => value);
|
|
1685
|
+
this.#startedAt = performance.now();
|
|
1142
1686
|
}
|
|
1143
1687
|
static async create(options) {
|
|
1144
1688
|
if (!options.goal.trim())
|
|
@@ -1156,12 +1700,24 @@ class Agent {
|
|
|
1156
1700
|
recordingPath: options.recordingPath,
|
|
1157
1701
|
screenshotPath: options.screenshotPath,
|
|
1158
1702
|
freshContext: options.freshContext,
|
|
1159
|
-
interactionPauses: options.interactionPauses
|
|
1703
|
+
interactionPauses: options.interactionPauses,
|
|
1704
|
+
waitBudgetMs: options.waitBudgetMs
|
|
1160
1705
|
});
|
|
1161
1706
|
try {
|
|
1162
|
-
|
|
1707
|
+
const agent = new Agent(browser, await browser.observe(options.screenshots), options);
|
|
1708
|
+
try {
|
|
1709
|
+
await agent.waitForReadiness();
|
|
1710
|
+
} catch (error) {
|
|
1711
|
+
if (!(error instanceof WaitTimeoutError))
|
|
1712
|
+
throw error;
|
|
1713
|
+
agent.recordWaitTimeout(error);
|
|
1714
|
+
}
|
|
1715
|
+
agent.#initialConsoleErrors = redactConsoleErrors(browser.takeConsoleErrors(), agent.#secretValues);
|
|
1716
|
+
return agent;
|
|
1163
1717
|
} catch (error) {
|
|
1164
|
-
await browser.close()
|
|
1718
|
+
await browser.close().catch(() => {
|
|
1719
|
+
return;
|
|
1720
|
+
});
|
|
1165
1721
|
throw error;
|
|
1166
1722
|
}
|
|
1167
1723
|
}
|
|
@@ -1176,7 +1732,14 @@ class Agent {
|
|
|
1176
1732
|
status: this.#status,
|
|
1177
1733
|
elapsedMs: this.elapsedMs(),
|
|
1178
1734
|
maxSteps: this.#maxSteps,
|
|
1179
|
-
elements: actionSpace(this.#page.actions).elements
|
|
1735
|
+
elements: actionSpace(this.#page.actions).elements,
|
|
1736
|
+
initialConsoleErrors: [...this.#initialConsoleErrors],
|
|
1737
|
+
consoleErrors: [
|
|
1738
|
+
...this.#initialConsoleErrors,
|
|
1739
|
+
...this.#history.flatMap((entry) => entry.consoleErrors),
|
|
1740
|
+
...redactConsoleErrors(this.#browser.pendingConsoleErrors(), this.#secretValues)
|
|
1741
|
+
],
|
|
1742
|
+
...this.#waitTimeout ? { waitTimeout: this.#waitTimeout } : {}
|
|
1180
1743
|
};
|
|
1181
1744
|
}
|
|
1182
1745
|
get targetId() {
|
|
@@ -1190,15 +1753,12 @@ class Agent {
|
|
|
1190
1753
|
this.#startedAt = performance.now();
|
|
1191
1754
|
if (!await this.#browser.fresh(this.#page)) {
|
|
1192
1755
|
this.#page = await this.#browser.observe(this.#screenshots);
|
|
1756
|
+
await this.waitForReadiness();
|
|
1193
1757
|
}
|
|
1194
1758
|
this.#decision = null;
|
|
1195
|
-
if (["done", "blocked", "budget_exhausted"].includes(this.#status)) {
|
|
1759
|
+
if (["done", "blocked", "budget_exhausted", "wait_timeout"].includes(this.#status)) {
|
|
1196
1760
|
throw new Error("This run has stopped");
|
|
1197
1761
|
}
|
|
1198
|
-
if (this.#decisions.length >= this.#maxSteps * 2) {
|
|
1199
|
-
this.#status = "budget_exhausted";
|
|
1200
|
-
return;
|
|
1201
|
-
}
|
|
1202
1762
|
this.#decision = await choose(this.#page, this.#goal, this.#history, Object.keys(this.#fieldValues));
|
|
1203
1763
|
this.#decisions.push(this.#decision);
|
|
1204
1764
|
this.#status = "predicted";
|
|
@@ -1239,7 +1799,7 @@ class Agent {
|
|
|
1239
1799
|
if (provided !== undefined) {
|
|
1240
1800
|
text = provided;
|
|
1241
1801
|
helper = { model: "provided-field-value", provider: "caller", latency_ms: 0, usage: {} };
|
|
1242
|
-
this.#textCalls.push({ ...helper, field: action.label, value: action.sensitive ? "[redacted]" : text });
|
|
1802
|
+
this.#textCalls.push({ ...helper, field: action.label, value: action.sensitive || this.#sensitiveFieldLabels.has(action.label) ? "[redacted]" : text });
|
|
1243
1803
|
} else if (this.#pendingText?.contextKey === contextKey) {
|
|
1244
1804
|
({ text, helper } = this.#pendingText);
|
|
1245
1805
|
} else {
|
|
@@ -1249,7 +1809,8 @@ class Agent {
|
|
|
1249
1809
|
}
|
|
1250
1810
|
}
|
|
1251
1811
|
await this.#browser.waitForInteractionPause();
|
|
1252
|
-
|
|
1812
|
+
const fromTargetId = this.#browser.targetId;
|
|
1813
|
+
const { element, performedAt } = await this.#browser.act(action, page, text ?? undefined);
|
|
1253
1814
|
this.#pendingText = null;
|
|
1254
1815
|
const entry = {
|
|
1255
1816
|
step: this.#history.length + 1,
|
|
@@ -1259,21 +1820,33 @@ class Agent {
|
|
|
1259
1820
|
probability: decision.probabilities[selected] ?? 0,
|
|
1260
1821
|
confidence: decision.confidence,
|
|
1261
1822
|
latency_ms: decision.latency_ms,
|
|
1262
|
-
text: action.sensitive && text !== null ? "[redacted]" : text,
|
|
1823
|
+
text: (action.sensitive || this.#sensitiveFieldLabels.has(action.label)) && text !== null ? "[redacted]" : text,
|
|
1263
1824
|
text_helper: helper?.model ?? null,
|
|
1264
1825
|
text_latency_ms: helper?.latency_ms ?? 0,
|
|
1265
1826
|
operation: decision.operation,
|
|
1266
1827
|
target: decision.target,
|
|
1267
1828
|
page_changed: null,
|
|
1829
|
+
from_url: page.url,
|
|
1268
1830
|
url: page.url,
|
|
1831
|
+
viewport: { width: page.w, height: page.h },
|
|
1832
|
+
from_target_id: fromTargetId,
|
|
1833
|
+
target_id: this.#browser.targetId,
|
|
1834
|
+
element,
|
|
1835
|
+
value: action.kind === "select" ? action.value ?? null : null,
|
|
1836
|
+
delta_y: action.kind === "scroll" ? action.delta ?? 0 : null,
|
|
1837
|
+
redacted: action.kind === "fill" && (Boolean(action.sensitive) || this.#sensitiveFieldLabels.has(action.label)),
|
|
1269
1838
|
usage: decision.usage,
|
|
1270
|
-
executed_ms:
|
|
1271
|
-
elapsed_ms: this.elapsedMs()
|
|
1839
|
+
executed_ms: Math.round(performedAt - this.#startedAt),
|
|
1840
|
+
elapsed_ms: this.elapsedMs(),
|
|
1841
|
+
consoleErrors: []
|
|
1272
1842
|
};
|
|
1273
1843
|
this.#history.push(entry);
|
|
1274
1844
|
this.#page = await this.#browser.observe(this.#screenshots);
|
|
1845
|
+
await this.waitForReadiness();
|
|
1846
|
+
entry.consoleErrors = redactConsoleErrors(this.#browser.takeConsoleErrors(), this.#secretValues);
|
|
1275
1847
|
entry.page_changed = this.#page.fingerprint !== page.fingerprint;
|
|
1276
1848
|
entry.url = this.#page.url;
|
|
1849
|
+
entry.target_id = this.#browser.targetId;
|
|
1277
1850
|
entry.elapsed_ms = this.elapsedMs();
|
|
1278
1851
|
this.#status = "ready";
|
|
1279
1852
|
}
|
|
@@ -1282,21 +1855,41 @@ class Agent {
|
|
|
1282
1855
|
await this.predict();
|
|
1283
1856
|
await this.act();
|
|
1284
1857
|
} catch (error) {
|
|
1858
|
+
if (error instanceof WaitTimeoutError) {
|
|
1859
|
+
this.recordWaitTimeout(error);
|
|
1860
|
+
return this.snapshot();
|
|
1861
|
+
}
|
|
1285
1862
|
if (!(error instanceof StalePageError))
|
|
1286
1863
|
throw error;
|
|
1287
1864
|
this.#decision = null;
|
|
1288
1865
|
this.#status = "ready";
|
|
1289
1866
|
this.#page = await this.#browser.observe(this.#screenshots);
|
|
1867
|
+
try {
|
|
1868
|
+
await this.waitForReadiness();
|
|
1869
|
+
} catch (waitError) {
|
|
1870
|
+
if (!(waitError instanceof WaitTimeoutError))
|
|
1871
|
+
throw waitError;
|
|
1872
|
+
this.recordWaitTimeout(waitError);
|
|
1873
|
+
}
|
|
1290
1874
|
}
|
|
1291
1875
|
return this.snapshot();
|
|
1292
1876
|
}
|
|
1293
1877
|
async run(onStep) {
|
|
1294
|
-
while (!["done", "blocked", "budget_exhausted"].includes(this.#status)) {
|
|
1878
|
+
while (!["done", "blocked", "budget_exhausted", "wait_timeout"].includes(this.#status)) {
|
|
1295
1879
|
const state = await this.tick();
|
|
1296
1880
|
onStep?.(state);
|
|
1297
1881
|
}
|
|
1298
1882
|
return this.snapshot();
|
|
1299
1883
|
}
|
|
1884
|
+
async waitForReadiness() {
|
|
1885
|
+
this.#page = await this.#browser.waitForSemanticReady(this.#page, this.#screenshots);
|
|
1886
|
+
}
|
|
1887
|
+
recordWaitTimeout(error) {
|
|
1888
|
+
this.#status = "wait_timeout";
|
|
1889
|
+
this.#waitTimeout = { elapsedMs: error.elapsedMs, pendingCondition: error.pendingCondition };
|
|
1890
|
+
if (error.state)
|
|
1891
|
+
this.#page = error.state;
|
|
1892
|
+
}
|
|
1300
1893
|
close() {
|
|
1301
1894
|
return this.#browser.close();
|
|
1302
1895
|
}
|
|
@@ -1372,6 +1965,8 @@ Goal control:
|
|
|
1372
1965
|
--goal <text> One bounded browser goal. Required.
|
|
1373
1966
|
--max-steps <number> Maximum executed browser actions.
|
|
1374
1967
|
[env: JEV_MAX_STEPS] [default: 12]
|
|
1968
|
+
--wait-budget-ms <number> Total wall-clock budget for page and frame readiness.
|
|
1969
|
+
[default: 15000]
|
|
1375
1970
|
|
|
1376
1971
|
Browser behavior:
|
|
1377
1972
|
--visible Activate the controlled tab.
|
|
@@ -1395,12 +1990,15 @@ Evidence and output:
|
|
|
1395
1990
|
-h, --help Show this help and exit.
|
|
1396
1991
|
|
|
1397
1992
|
Output:
|
|
1398
|
-
|
|
1993
|
+
Stdout is JSON Lines: one object per executed action, then one result object.
|
|
1994
|
+
Each action includes new console errors observed during that step. The result
|
|
1995
|
+
includes initial errors and all errors observed during the run.
|
|
1996
|
+
Errors and diagnostics use stderr.
|
|
1399
1997
|
|
|
1400
1998
|
Exit codes:
|
|
1401
1999
|
0 Jev reported the goal complete.
|
|
1402
2000
|
1 Invalid configuration or runtime failure.
|
|
1403
|
-
2 Jev
|
|
2001
|
+
2 Jev was blocked or the wait budget timed out.
|
|
1404
2002
|
3 The maximum browser-step budget was exhausted.
|
|
1405
2003
|
|
|
1406
2004
|
Examples:
|
|
@@ -1483,6 +2081,7 @@ function addFieldValue(options, assignment, fromEnvironment) {
|
|
|
1483
2081
|
if (!supplied)
|
|
1484
2082
|
throw new CliError(`Environment variable is missing or empty: ${environmentName}`);
|
|
1485
2083
|
options.fieldValues[label] = supplied;
|
|
2084
|
+
options.sensitiveFieldLabels.push(label);
|
|
1486
2085
|
} else {
|
|
1487
2086
|
options.fieldValues[label] = assignment.slice(separator + 1);
|
|
1488
2087
|
}
|
|
@@ -1492,10 +2091,12 @@ function parseRunOptions(args) {
|
|
|
1492
2091
|
cdpUrl: process.env.CHROME_CDP_URL ?? DEFAULT_CDP_URL,
|
|
1493
2092
|
maxSteps: parsePositiveInteger(process.env.JEV_MAX_STEPS ?? "12", "JEV_MAX_STEPS"),
|
|
1494
2093
|
interactionPauses: 0,
|
|
2094
|
+
waitBudgetMs: 15000,
|
|
1495
2095
|
visible: enabled(process.env.JEV_BROWSER_VISIBLE),
|
|
1496
2096
|
keepOpen: enabled(process.env.JEV_BROWSER_KEEP_OPEN),
|
|
1497
2097
|
finalState: false,
|
|
1498
2098
|
fieldValues: {},
|
|
2099
|
+
sensitiveFieldLabels: [],
|
|
1499
2100
|
freshContext: enabled(process.env.JEV_BROWSER_FRESH_CONTEXT)
|
|
1500
2101
|
};
|
|
1501
2102
|
for (let index = 0;index < args.length; index++) {
|
|
@@ -1512,6 +2113,8 @@ function parseRunOptions(args) {
|
|
|
1512
2113
|
options.maxSteps = parsePositiveInteger(nextValue(args, index++, argument), argument);
|
|
1513
2114
|
else if (argument === "--interaction-pauses")
|
|
1514
2115
|
options.interactionPauses = parseNonNegativeInteger(nextValue(args, index++, argument), argument);
|
|
2116
|
+
else if (argument === "--wait-budget-ms")
|
|
2117
|
+
options.waitBudgetMs = parsePositiveInteger(nextValue(args, index++, argument), argument);
|
|
1515
2118
|
else if (argument === "--recording")
|
|
1516
2119
|
options.recordingPath = nextValue(args, index++, argument);
|
|
1517
2120
|
else if (argument === "--screenshot")
|
|
@@ -1559,17 +2162,45 @@ function parseCommonOptions(args, help) {
|
|
|
1559
2162
|
}
|
|
1560
2163
|
return options;
|
|
1561
2164
|
}
|
|
1562
|
-
function
|
|
2165
|
+
function actionEvent(entry, maxSteps) {
|
|
2166
|
+
return {
|
|
2167
|
+
type: "action",
|
|
2168
|
+
status: "executed",
|
|
2169
|
+
step: entry.step,
|
|
2170
|
+
elapsedMs: entry.executed_ms,
|
|
2171
|
+
budget: { used: entry.step, max: maxSteps, remaining: maxSteps - entry.step },
|
|
2172
|
+
page: { before: entry.from_url, after: entry.url, changed: entry.page_changed, viewport: entry.viewport },
|
|
2173
|
+
tab: { before: entry.from_target_id, after: entry.target_id },
|
|
2174
|
+
consoleErrors: entry.consoleErrors,
|
|
2175
|
+
action: {
|
|
2176
|
+
kind: entry.kind,
|
|
2177
|
+
label: entry.action,
|
|
2178
|
+
element: entry.element,
|
|
2179
|
+
...entry.kind === "fill" ? { text: entry.text, redacted: entry.redacted } : {},
|
|
2180
|
+
...entry.kind === "select" ? { optionValue: entry.value } : {},
|
|
2181
|
+
...entry.kind === "scroll" ? { deltaY: entry.delta_y, point: { x: 550, y: 650 } } : {},
|
|
2182
|
+
...entry.kind === "wait" ? { durationMs: 100 } : {}
|
|
2183
|
+
}
|
|
2184
|
+
};
|
|
2185
|
+
}
|
|
2186
|
+
function semanticState(page, elements) {
|
|
2187
|
+
const frameTree = (parentId) => page.frames.filter((frame) => frame.parentId === parentId).map((frame) => ({ ...frame, children: frameTree(frame.id) }));
|
|
1563
2188
|
return {
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
2189
|
+
url: page.url,
|
|
2190
|
+
title: page.title,
|
|
2191
|
+
text: page.text,
|
|
2192
|
+
viewport: { width: page.w, height: page.h },
|
|
2193
|
+
scroll: page.scroll,
|
|
2194
|
+
elements,
|
|
2195
|
+
frameTree: frameTree(null),
|
|
2196
|
+
transitions: page.transitions,
|
|
2197
|
+
omittedActions: page.omitted_actions
|
|
1568
2198
|
};
|
|
1569
2199
|
}
|
|
1570
2200
|
async function runGoal(args) {
|
|
1571
2201
|
const options = parseRunOptions(args);
|
|
1572
2202
|
let agent;
|
|
2203
|
+
let reportedActions = 0;
|
|
1573
2204
|
try {
|
|
1574
2205
|
agent = await Agent.create({
|
|
1575
2206
|
url: options.url,
|
|
@@ -1578,50 +2209,65 @@ async function runGoal(args) {
|
|
|
1578
2209
|
cdpUrl: options.cdpUrl,
|
|
1579
2210
|
maxSteps: options.maxSteps,
|
|
1580
2211
|
interactionPauses: options.interactionPauses,
|
|
2212
|
+
waitBudgetMs: options.waitBudgetMs,
|
|
1581
2213
|
visible: options.visible,
|
|
1582
2214
|
keepOpen: options.keepOpen,
|
|
1583
2215
|
recordingPath: options.recordingPath,
|
|
1584
2216
|
screenshotPath: options.screenshotPath,
|
|
1585
2217
|
fieldValues: options.fieldValues,
|
|
2218
|
+
sensitiveFieldLabels: options.sensitiveFieldLabels,
|
|
1586
2219
|
freshContext: options.freshContext
|
|
1587
2220
|
});
|
|
1588
|
-
|
|
1589
|
-
const result = await agent.run((state) => {
|
|
2221
|
+
await agent.run((state) => {
|
|
1590
2222
|
const action = state.history.length > reportedActions ? state.history.at(-1) : undefined;
|
|
1591
|
-
const decision = state.decisions.at(-1);
|
|
1592
2223
|
reportedActions = state.history.length;
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
const helper = action?.text_helper ? ` text=${action.text_helper}:${action.text_latency_ms}ms` : "";
|
|
1596
|
-
console.error(`elapsed=${state.elapsedMs}ms actions=${state.history.length}/${state.maxSteps} status=${state.status} operation=${operation} jev=${jevLatency ?? 0}ms${helper}`);
|
|
2224
|
+
if (action)
|
|
2225
|
+
console.log(JSON.stringify(actionEvent(action, state.maxSteps)));
|
|
1597
2226
|
});
|
|
2227
|
+
await agent.close();
|
|
2228
|
+
const result = agent.snapshot();
|
|
1598
2229
|
console.log(JSON.stringify({
|
|
2230
|
+
type: "result",
|
|
1599
2231
|
status: result.status,
|
|
1600
2232
|
targetId: agent.targetId,
|
|
1601
2233
|
url: result.page.url,
|
|
1602
2234
|
actions: result.history.length,
|
|
1603
2235
|
maxSteps: result.maxSteps,
|
|
2236
|
+
budget: { used: result.history.length, max: result.maxSteps, remaining: result.maxSteps - result.history.length },
|
|
1604
2237
|
elapsedMs: result.elapsedMs,
|
|
1605
2238
|
textCalls: result.textCalls.length,
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
},
|
|
2239
|
+
initialConsoleErrors: result.initialConsoleErrors,
|
|
2240
|
+
consoleErrors: result.consoleErrors,
|
|
2241
|
+
...result.waitTimeout ? { waitTimeout: result.waitTimeout } : {},
|
|
1610
2242
|
...options.recordingPath ? { recording: options.recordingPath } : {},
|
|
1611
2243
|
...options.screenshotPath ? { screenshot: options.screenshotPath } : {},
|
|
1612
|
-
...options.finalState ? {
|
|
1613
|
-
finalState:
|
|
1614
|
-
url: result.page.url,
|
|
1615
|
-
title: result.page.title,
|
|
1616
|
-
text: result.page.text,
|
|
1617
|
-
viewport: { width: result.page.w, height: result.page.h },
|
|
1618
|
-
scroll: result.page.scroll,
|
|
1619
|
-
elements: result.elements,
|
|
1620
|
-
omittedActions: result.page.omitted_actions
|
|
1621
|
-
}
|
|
2244
|
+
...options.finalState || result.status === "wait_timeout" ? {
|
|
2245
|
+
finalState: semanticState(result.page, result.elements)
|
|
1622
2246
|
} : {}
|
|
1623
2247
|
}));
|
|
1624
2248
|
return result.status === "done" ? 0 : result.status === "budget_exhausted" ? 3 : 2;
|
|
2249
|
+
} catch (error) {
|
|
2250
|
+
const state = agent?.snapshot();
|
|
2251
|
+
for (const action of state?.history.slice(reportedActions) ?? []) {
|
|
2252
|
+
console.log(JSON.stringify(actionEvent(action, options.maxSteps)));
|
|
2253
|
+
}
|
|
2254
|
+
console.log(JSON.stringify({
|
|
2255
|
+
type: "result",
|
|
2256
|
+
status: error instanceof WaitTimeoutError ? "wait_timeout" : "error",
|
|
2257
|
+
targetId: agent?.targetId ?? null,
|
|
2258
|
+
url: state?.page.url ?? (error instanceof WaitTimeoutError ? error.state?.url ?? null : null),
|
|
2259
|
+
actions: state?.history.length ?? 0,
|
|
2260
|
+
maxSteps: options.maxSteps,
|
|
2261
|
+
budget: { used: state?.history.length ?? 0, max: options.maxSteps, remaining: options.maxSteps - (state?.history.length ?? 0) },
|
|
2262
|
+
elapsedMs: state?.elapsedMs ?? 0,
|
|
2263
|
+
initialConsoleErrors: state?.initialConsoleErrors ?? [],
|
|
2264
|
+
consoleErrors: state?.consoleErrors ?? [],
|
|
2265
|
+
...error instanceof WaitTimeoutError ? { waitTimeout: { elapsedMs: error.elapsedMs, pendingCondition: error.pendingCondition } } : {},
|
|
2266
|
+
...state ? { finalState: semanticState(state.page, state.elements) } : error instanceof WaitTimeoutError && error.state ? { finalState: semanticState(error.state, actionSpace(error.state.actions).elements) } : {}
|
|
2267
|
+
}));
|
|
2268
|
+
if (error instanceof WaitTimeoutError)
|
|
2269
|
+
return 2;
|
|
2270
|
+
throw error;
|
|
1625
2271
|
} finally {
|
|
1626
2272
|
await agent?.close();
|
|
1627
2273
|
}
|
|
@@ -1729,5 +2375,6 @@ async function main(args = Bun.argv.slice(2)) {
|
|
|
1729
2375
|
if (import.meta.main)
|
|
1730
2376
|
process.exitCode = await main();
|
|
1731
2377
|
export {
|
|
2378
|
+
actionEvent,
|
|
1732
2379
|
main
|
|
1733
2380
|
};
|