jev-cdp 0.1.4 → 0.1.6
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 -5
- package/dist/cli.js +720 -140
- package/package.json +1 -1
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.6",
|
|
7
7
|
description: "A small Jev-powered bridge to Chrome through the Chrome DevTools Protocol.",
|
|
8
8
|
type: "module",
|
|
9
9
|
license: "MIT",
|
|
@@ -38,9 +38,9 @@ var package_default = {
|
|
|
38
38
|
|
|
39
39
|
// src/browser.ts
|
|
40
40
|
import { createHash } from "crypto";
|
|
41
|
-
import { mkdir, mkdtemp, rm } from "fs/promises";
|
|
42
|
-
import { tmpdir } from "os";
|
|
43
|
-
import { dirname, join, resolve } from "path";
|
|
41
|
+
import { mkdir, mkdtemp as mkdtemp2, rm as rm2 } from "fs/promises";
|
|
42
|
+
import { tmpdir as tmpdir2 } from "os";
|
|
43
|
+
import { dirname, join as join2, resolve } from "path";
|
|
44
44
|
|
|
45
45
|
// src/cdp.ts
|
|
46
46
|
async function listChromeTargets(cdpUrl) {
|
|
@@ -143,6 +143,88 @@ class CdpClient {
|
|
|
143
143
|
}
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
+
// src/ffmpeg.ts
|
|
147
|
+
import { mkdtemp, rm } from "fs/promises";
|
|
148
|
+
import { tmpdir } from "os";
|
|
149
|
+
import { join } from "path";
|
|
150
|
+
function selectFrameSyncOption(help) {
|
|
151
|
+
if (/^\s*-fps_mode(?:\[:<stream_spec>\])?\s/m.test(help))
|
|
152
|
+
return "-fps_mode";
|
|
153
|
+
if (/^\s*-vsync\s/m.test(help))
|
|
154
|
+
return "-vsync";
|
|
155
|
+
throw new Error("FFmpeg supports neither -fps_mode nor -vsync");
|
|
156
|
+
}
|
|
157
|
+
async function run(command) {
|
|
158
|
+
const process2 = Bun.spawn(command, { stdout: "ignore", stderr: "pipe" });
|
|
159
|
+
const stderr = await new Response(process2.stderr).text();
|
|
160
|
+
return { code: await process2.exited, stderr };
|
|
161
|
+
}
|
|
162
|
+
async function recordingEncoder(ffmpeg = Bun.which("ffmpeg")) {
|
|
163
|
+
if (!ffmpeg)
|
|
164
|
+
throw new Error("FFmpeg not found on PATH");
|
|
165
|
+
const process2 = Bun.spawn([ffmpeg, "-hide_banner", "-h", "full"], { stdout: "pipe", stderr: "pipe" });
|
|
166
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
167
|
+
new Response(process2.stdout).text(),
|
|
168
|
+
new Response(process2.stderr).text(),
|
|
169
|
+
process2.exited
|
|
170
|
+
]);
|
|
171
|
+
if (code !== 0)
|
|
172
|
+
throw new Error(`Could not inspect FFmpeg options: ${stderr.slice(-500)}`);
|
|
173
|
+
return { path: ffmpeg, sync: selectFrameSyncOption(stdout + stderr) };
|
|
174
|
+
}
|
|
175
|
+
function recordingCommand(encoder, manifest, output) {
|
|
176
|
+
return [
|
|
177
|
+
encoder.path,
|
|
178
|
+
"-y",
|
|
179
|
+
"-f",
|
|
180
|
+
"concat",
|
|
181
|
+
"-safe",
|
|
182
|
+
"0",
|
|
183
|
+
"-i",
|
|
184
|
+
manifest,
|
|
185
|
+
encoder.sync,
|
|
186
|
+
"vfr",
|
|
187
|
+
"-c:v",
|
|
188
|
+
"libx264",
|
|
189
|
+
"-pix_fmt",
|
|
190
|
+
"yuv420p",
|
|
191
|
+
"-movflags",
|
|
192
|
+
"+faststart",
|
|
193
|
+
output
|
|
194
|
+
];
|
|
195
|
+
}
|
|
196
|
+
async function renderRecording(encoder, manifest, output) {
|
|
197
|
+
const result = await run(recordingCommand(encoder, manifest, output));
|
|
198
|
+
if (result.code !== 0)
|
|
199
|
+
throw new Error(`Could not render recording: ${result.stderr.slice(-800)}`);
|
|
200
|
+
}
|
|
201
|
+
async function checkRecordingEncoder() {
|
|
202
|
+
const encoder = await recordingEncoder();
|
|
203
|
+
const directory = await mkdtemp(join(tmpdir(), "jev-cdp-ffmpeg-check-"));
|
|
204
|
+
try {
|
|
205
|
+
const frame = join(directory, "frame.ppm");
|
|
206
|
+
const manifest = join(directory, "frames.ffconcat");
|
|
207
|
+
const output = join(directory, "check.mp4");
|
|
208
|
+
await Bun.write(frame, Buffer.concat([Buffer.from(`P6
|
|
209
|
+
16 16
|
|
210
|
+
255
|
|
211
|
+
`), Buffer.alloc(16 * 16 * 3, 90)]));
|
|
212
|
+
const quoted = frame.replaceAll("'", "'\\''");
|
|
213
|
+
await Bun.write(manifest, `ffconcat version 1.0
|
|
214
|
+
file '${quoted}'
|
|
215
|
+
duration 0.12
|
|
216
|
+
file '${quoted}'
|
|
217
|
+
`);
|
|
218
|
+
await renderRecording(encoder, manifest, output);
|
|
219
|
+
const decoded = await run([encoder.path, "-v", "error", "-i", output, "-f", "null", "-"]);
|
|
220
|
+
if (decoded.code !== 0)
|
|
221
|
+
throw new Error(`Could not decode recording: ${decoded.stderr.slice(-500)}`);
|
|
222
|
+
return `FFmpeg at ${encoder.path}; ${encoder.sync} vfr and libx264 encode/decode passed`;
|
|
223
|
+
} finally {
|
|
224
|
+
await rm(directory, { recursive: true, force: true });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
146
228
|
// src/snapshot.js
|
|
147
229
|
var snapshot_default = `// Ported from browser-use/jev-ultrafast at commit 452c1ad2 under the MIT License.
|
|
148
230
|
(() => {
|
|
@@ -207,8 +289,15 @@ var snapshot_default = `// Ported from browser-use/jev-ultrafast at commit 452c1
|
|
|
207
289
|
const r=e.getBoundingClientRect(), x=r.x+r.width/2, y=r.y+r.height/2, rname=role(e);
|
|
208
290
|
if (!rname || r.width<=0 || r.height<=0 || x<0 || y<0 || x>=innerWidth || y>=innerHeight) continue;
|
|
209
291
|
if (rname==='gridcell' && e.querySelector('button,[role="button"]')) continue;
|
|
292
|
+
const hit=document.elementFromPoint(x,y);
|
|
293
|
+
const coveredBy=hit && !e.contains(hit) && !hit.contains(e) ?
|
|
294
|
+
{tag:hit.tagName.toLowerCase(),text:(hit.innerText||hit.getAttribute('aria-label')||'').trim().slice(0,160),role:hit.getAttribute('role')} : null;
|
|
295
|
+
const region=e.closest('form,dialog,[role="dialog"],article,section,li,tr,[role="row"],main,nav,header,footer');
|
|
296
|
+
const nearbyText=(region?.innerText||e.parentElement?.innerText||'').trim().replace(/\\s+/g,' ').slice(0,240);
|
|
210
297
|
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}
|
|
298
|
+
rect:{x:r.x,y:r.y,w:r.width,h:r.height},frameUrl:location.href,
|
|
299
|
+
nearbyText,region:region?.getAttribute('aria-label')||region?.getAttribute('role')||region?.tagName.toLowerCase()||'',
|
|
300
|
+
clickable:!coveredBy,coveredBy};
|
|
212
301
|
for (const key of ['checked','selected','expanded','pressed']) {
|
|
213
302
|
const value=e.getAttribute('aria-'+key);
|
|
214
303
|
if (value!==null) base[key]=value;
|
|
@@ -228,14 +317,20 @@ var snapshot_default = `// Ported from browser-use/jev-ultrafast at commit 452c1
|
|
|
228
317
|
if (editable) actions.push({...base,kind:'click',value,label:'Open '+base.label});
|
|
229
318
|
}
|
|
230
319
|
}
|
|
231
|
-
const
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
320
|
+
const roots=[document.body], shadowRoots=[];
|
|
321
|
+
for (const root of roots) for (const e of root.querySelectorAll('*')) {
|
|
322
|
+
if (e.shadowRoot) { roots.push(e.shadowRoot); shadowRoots.push(e.shadowRoot); }
|
|
323
|
+
}
|
|
324
|
+
const words=[], range=document.createRange(); let length=0;
|
|
325
|
+
for (const root of [...shadowRoots,document.body]) {
|
|
326
|
+
const walker=document.createTreeWalker(root,NodeFilter.SHOW_TEXT); let node;
|
|
327
|
+
while ((node=walker.nextNode()) && length<6000) {
|
|
328
|
+
const value=node.textContent.trim(), parent=node.parentElement;
|
|
329
|
+
if (!value || !parent || parent.closest('script,style,noscript,template') || !visible(parent)) continue;
|
|
330
|
+
range.selectNodeContents(node); const r=range.getBoundingClientRect();
|
|
331
|
+
if (r.width>0 && r.height>0 && r.bottom>0 && r.top<innerHeight && r.right>0 && r.left<innerWidth) {
|
|
332
|
+
words.push(value); length+=value.length;
|
|
333
|
+
}
|
|
239
334
|
}
|
|
240
335
|
}
|
|
241
336
|
const text=words.join('\\n').slice(0,6000), height=document.documentElement.scrollHeight;
|
|
@@ -249,7 +344,6 @@ var snapshot_default = `// Ported from browser-use/jev-ultrafast at commit 452c1
|
|
|
249
344
|
actions.forEach((a,i)=>a.id='e'+(i+1));
|
|
250
345
|
if (scrollY+innerHeight<height-2) actions.push({id:'scroll_down',kind:'scroll',label:'Scroll down',delta:560});
|
|
251
346
|
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
347
|
return {url:location.href,title:document.title,w:innerWidth,h:innerHeight,text,
|
|
254
348
|
scroll:{y:scrollY,height},actions,marker,page_key,guards,omitted_actions};
|
|
255
349
|
})()
|
|
@@ -278,6 +372,18 @@ var RECORDING_CURSOR_INIT = `(() => {
|
|
|
278
372
|
|
|
279
373
|
class StalePageError extends Error {
|
|
280
374
|
}
|
|
375
|
+
|
|
376
|
+
class WaitTimeoutError extends Error {
|
|
377
|
+
elapsedMs;
|
|
378
|
+
pendingCondition;
|
|
379
|
+
state;
|
|
380
|
+
constructor(elapsedMs, pendingCondition, state) {
|
|
381
|
+
super(`Wait timed out after ${elapsedMs}ms: ${pendingCondition}`);
|
|
382
|
+
this.elapsedMs = elapsedMs;
|
|
383
|
+
this.pendingCondition = pendingCondition;
|
|
384
|
+
this.state = state;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
281
387
|
function stableValue(value) {
|
|
282
388
|
if (Array.isArray(value))
|
|
283
389
|
return value.map(stableValue);
|
|
@@ -323,6 +429,17 @@ class Browser {
|
|
|
323
429
|
#openedTabs = [];
|
|
324
430
|
#cdpUrl;
|
|
325
431
|
#switchOnPopup = false;
|
|
432
|
+
#loadingFrames = new Set;
|
|
433
|
+
#waitBudgetMs;
|
|
434
|
+
#waitSpentMs = 0;
|
|
435
|
+
#transitions = [];
|
|
436
|
+
#origin = null;
|
|
437
|
+
#changeListeners = new Set;
|
|
438
|
+
#targetUrls = new Map;
|
|
439
|
+
#newTargets = new Set;
|
|
440
|
+
#popupListeners = new Set;
|
|
441
|
+
#consoleErrors = [];
|
|
442
|
+
#watchedConsoleSessions = new Set;
|
|
326
443
|
constructor(cdp, sessionId, targetId, ownsTarget, browserContextId, options) {
|
|
327
444
|
this.#cdp = cdp;
|
|
328
445
|
this.#cdpUrl = options.cdpUrl;
|
|
@@ -333,6 +450,7 @@ class Browser {
|
|
|
333
450
|
this.#keepOpen = options.keepOpen ?? false;
|
|
334
451
|
this.#screenshots = options.screenshots ?? false;
|
|
335
452
|
this.#interactionPauses = options.interactionPauses ?? 0;
|
|
453
|
+
this.#waitBudgetMs = options.waitBudgetMs ?? 15000;
|
|
336
454
|
this.#recordingPath = options.recordingPath ? resolve(options.recordingPath) : undefined;
|
|
337
455
|
this.#screenshotPath = options.screenshotPath ? resolve(options.screenshotPath) : undefined;
|
|
338
456
|
}
|
|
@@ -385,10 +503,30 @@ class Browser {
|
|
|
385
503
|
const browser = new Browser(cdp, attached.sessionId, targetId, ownsTarget, browserContextId, options);
|
|
386
504
|
browser.#knownTargets = new Set((await listChromeTargets(options.cdpUrl)).map((target) => target.id));
|
|
387
505
|
try {
|
|
506
|
+
const recordTargetUrl = (params) => {
|
|
507
|
+
const info = params.targetInfo;
|
|
508
|
+
if (info?.type === "page" && info.targetId && !browser.#knownTargets.has(info.targetId)) {
|
|
509
|
+
browser.#newTargets.add(info.targetId);
|
|
510
|
+
for (const listener of browser.#popupListeners)
|
|
511
|
+
listener();
|
|
512
|
+
}
|
|
513
|
+
if (!info?.targetId || !info.url || info.url === "about:blank")
|
|
514
|
+
return;
|
|
515
|
+
const urls = browser.#targetUrls.get(info.targetId) ?? [];
|
|
516
|
+
if (urls.at(-1) !== info.url)
|
|
517
|
+
urls.push(info.url);
|
|
518
|
+
browser.#targetUrls.set(info.targetId, urls);
|
|
519
|
+
};
|
|
520
|
+
browser.#stopPageEvents.push(cdp.on("Target.targetCreated", "", recordTargetUrl), cdp.on("Target.targetInfoChanged", "", recordTargetUrl));
|
|
521
|
+
await cdp.command("Target.setDiscoverTargets", { discover: true });
|
|
388
522
|
browser.#stopPageEvents.push(cdp.on("Target.attachedToTarget", browser.#sessionId, (params) => {
|
|
389
523
|
const info = params.targetInfo;
|
|
390
|
-
if (info?.type === "iframe" && info.targetId && typeof params.sessionId === "string")
|
|
524
|
+
if (info?.type === "iframe" && info.targetId && typeof params.sessionId === "string") {
|
|
391
525
|
browser.#frameSessions.set(info.targetId, params.sessionId);
|
|
526
|
+
browser.watchConsole(params.sessionId, info.targetId).catch(() => {
|
|
527
|
+
return;
|
|
528
|
+
});
|
|
529
|
+
}
|
|
392
530
|
}));
|
|
393
531
|
browser.#stopPageEvents.push(cdp.on("Page.frameNavigated", browser.#sessionId, (params) => {
|
|
394
532
|
const frame = params.frame;
|
|
@@ -396,6 +534,7 @@ class Browser {
|
|
|
396
534
|
browser.#frameContexts.delete(frame.id);
|
|
397
535
|
}));
|
|
398
536
|
await browser.call("Target.setAutoAttach", { autoAttach: true, waitForDebuggerOnStart: false, flatten: true });
|
|
537
|
+
await browser.watchConsole();
|
|
399
538
|
await browser.call("Page.enable");
|
|
400
539
|
await browser.call("Emulation.setDeviceMetricsOverride", {
|
|
401
540
|
width: 1120,
|
|
@@ -412,7 +551,9 @@ class Browser {
|
|
|
412
551
|
await browser.startRecording();
|
|
413
552
|
return browser;
|
|
414
553
|
} catch (error) {
|
|
415
|
-
await browser.close()
|
|
554
|
+
await browser.close().catch(() => {
|
|
555
|
+
return;
|
|
556
|
+
});
|
|
416
557
|
throw error;
|
|
417
558
|
}
|
|
418
559
|
}
|
|
@@ -422,13 +563,65 @@ class Browser {
|
|
|
422
563
|
get targetId() {
|
|
423
564
|
return this.#targetId;
|
|
424
565
|
}
|
|
425
|
-
|
|
426
|
-
|
|
566
|
+
takeConsoleErrors() {
|
|
567
|
+
return this.#consoleErrors.splice(0);
|
|
568
|
+
}
|
|
569
|
+
pendingConsoleErrors() {
|
|
570
|
+
return [...this.#consoleErrors];
|
|
571
|
+
}
|
|
572
|
+
async watchConsole(sessionId = this.#sessionId, targetId = this.#targetId) {
|
|
573
|
+
if (this.#watchedConsoleSessions.has(sessionId))
|
|
427
574
|
return;
|
|
575
|
+
this.#watchedConsoleSessions.add(sessionId);
|
|
576
|
+
const record = (source, message, url, timestamp) => {
|
|
577
|
+
if (!message.trim())
|
|
578
|
+
return;
|
|
579
|
+
this.#consoleErrors.push({
|
|
580
|
+
source,
|
|
581
|
+
message: message.slice(0, 4000),
|
|
582
|
+
...url ? { url } : {},
|
|
583
|
+
...timestamp !== undefined ? { timestamp } : {},
|
|
584
|
+
targetId
|
|
585
|
+
});
|
|
586
|
+
};
|
|
587
|
+
this.#stopPageEvents.push(this.#cdp.on("Runtime.consoleAPICalled", sessionId, (params) => {
|
|
588
|
+
if (params.type !== "error")
|
|
589
|
+
return;
|
|
590
|
+
const args = params.args;
|
|
591
|
+
const message = args?.map((arg) => String(arg.value ?? arg.description ?? "")).join(" ") ?? "";
|
|
592
|
+
const frame = params.stackTrace?.callFrames?.[0];
|
|
593
|
+
record("console", message, frame?.url, typeof params.timestamp === "number" ? params.timestamp : undefined);
|
|
594
|
+
}), this.#cdp.on("Runtime.exceptionThrown", sessionId, (params) => {
|
|
595
|
+
const details = params.exceptionDetails;
|
|
596
|
+
record("exception", details?.exception?.description ?? details?.text ?? "Uncaught exception", details?.url, typeof params.timestamp === "number" ? params.timestamp : undefined);
|
|
597
|
+
}), this.#cdp.on("Log.entryAdded", sessionId, (params) => {
|
|
598
|
+
const entry = params.entry;
|
|
599
|
+
if (entry?.level === "error")
|
|
600
|
+
record("log", entry.text ?? "", entry.url, entry.timestamp);
|
|
601
|
+
}));
|
|
602
|
+
await this.#cdp.command("Runtime.enable", {}, sessionId);
|
|
603
|
+
await this.#cdp.command("Log.enable", {}, sessionId);
|
|
604
|
+
}
|
|
605
|
+
async watchPageLoads() {
|
|
428
606
|
await this.call("Page.enable");
|
|
429
607
|
const tree = await this.call("Page.getFrameTree");
|
|
430
608
|
this.#mainFrameId = tree.frameTree.frame.id;
|
|
609
|
+
await this.call("Runtime.addBinding", { name: "__jevDomChanged" });
|
|
610
|
+
const observeDom = `(() => {
|
|
611
|
+
if (window.__jevDomWatching) return;
|
|
612
|
+
window.__jevDomWatching = true;
|
|
613
|
+
const start = () => new MutationObserver(() => window.__jevDomChanged?.('change'))
|
|
614
|
+
.observe(document, {subtree:true, childList:true, attributes:true, characterData:true});
|
|
615
|
+
if (document.documentElement) start(); else document.addEventListener('DOMContentLoaded', start, {once:true});
|
|
616
|
+
})()`;
|
|
617
|
+
await this.call("Page.addScriptToEvaluateOnNewDocument", { source: observeDom });
|
|
618
|
+
await this.evaluate(observeDom).catch(() => {
|
|
619
|
+
return;
|
|
620
|
+
});
|
|
431
621
|
const loading = (params) => {
|
|
622
|
+
if (typeof params.frameId === "string")
|
|
623
|
+
this.#loadingFrames.add(params.frameId);
|
|
624
|
+
this.signalChange();
|
|
432
625
|
if (params.frameId !== this.#mainFrameId || this.#pageLoadPromise)
|
|
433
626
|
return;
|
|
434
627
|
this.#pageLoadPromise = new Promise((resolve2) => {
|
|
@@ -436,6 +629,9 @@ class Browser {
|
|
|
436
629
|
});
|
|
437
630
|
};
|
|
438
631
|
const loaded = () => {
|
|
632
|
+
if (this.#mainFrameId)
|
|
633
|
+
this.#loadingFrames.delete(this.#mainFrameId);
|
|
634
|
+
this.signalChange();
|
|
439
635
|
if (!this.#pageLoadPromise)
|
|
440
636
|
return;
|
|
441
637
|
this.#pauseUntil = performance.now() + this.#interactionPauses;
|
|
@@ -445,34 +641,69 @@ class Browser {
|
|
|
445
641
|
};
|
|
446
642
|
this.#stopPageEvents.push(this.#cdp.on("Page.frameStartedLoading", this.#sessionId, loading), this.#cdp.on("Page.frameNavigated", this.#sessionId, (params) => {
|
|
447
643
|
const frame = params.frame;
|
|
644
|
+
this.signalChange();
|
|
448
645
|
if (frame && frame.id === this.#mainFrameId && !frame.parentId)
|
|
449
646
|
loading({ frameId: frame.id });
|
|
450
647
|
}), this.#cdp.on("Page.loadEventFired", this.#sessionId, loaded), this.#cdp.on("Page.frameStoppedLoading", this.#sessionId, (params) => {
|
|
648
|
+
if (typeof params.frameId === "string")
|
|
649
|
+
this.#loadingFrames.delete(params.frameId);
|
|
650
|
+
this.signalChange();
|
|
451
651
|
if (params.frameId === this.#mainFrameId)
|
|
452
652
|
loaded();
|
|
453
653
|
}), this.#cdp.on("Page.navigatedWithinDocument", this.#sessionId, (params) => {
|
|
654
|
+
this.signalChange();
|
|
454
655
|
if (params.frameId === this.#mainFrameId) {
|
|
455
656
|
this.#pauseUntil = performance.now() + this.#interactionPauses;
|
|
456
657
|
this.resetRecordingCursor().catch(() => {
|
|
457
658
|
return;
|
|
458
659
|
});
|
|
459
660
|
}
|
|
460
|
-
}));
|
|
661
|
+
}), 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()));
|
|
662
|
+
}
|
|
663
|
+
signalChange() {
|
|
664
|
+
for (const listener of this.#changeListeners)
|
|
665
|
+
listener();
|
|
666
|
+
}
|
|
667
|
+
async waitForChange(ms) {
|
|
668
|
+
await new Promise((resolve2) => {
|
|
669
|
+
const done = () => {
|
|
670
|
+
clearTimeout(timer);
|
|
671
|
+
this.#changeListeners.delete(done);
|
|
672
|
+
resolve2();
|
|
673
|
+
};
|
|
674
|
+
const timer = setTimeout(done, Math.min(ms, 150));
|
|
675
|
+
this.#changeListeners.add(done);
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
async waitForNewTarget(ms) {
|
|
679
|
+
if (this.#newTargets.size)
|
|
680
|
+
return;
|
|
681
|
+
await new Promise((resolve2) => {
|
|
682
|
+
const done = () => {
|
|
683
|
+
clearTimeout(timer);
|
|
684
|
+
this.#popupListeners.delete(done);
|
|
685
|
+
resolve2();
|
|
686
|
+
};
|
|
687
|
+
const timer = setTimeout(done, ms);
|
|
688
|
+
this.#popupListeners.add(done);
|
|
689
|
+
});
|
|
461
690
|
}
|
|
462
691
|
async waitForInteractionPause() {
|
|
463
692
|
if (!this.#interactionPauses)
|
|
464
693
|
return;
|
|
694
|
+
const waitStarted = performance.now();
|
|
465
695
|
while (true) {
|
|
466
696
|
const pageLoad = this.#pageLoadPromise;
|
|
467
697
|
if (pageLoad) {
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
]);
|
|
698
|
+
const remainingBudget = this.#waitBudgetMs - this.#waitSpentMs - (performance.now() - waitStarted);
|
|
699
|
+
if (remainingBudget <= 0)
|
|
700
|
+
throw new WaitTimeoutError(Math.round(this.#waitSpentMs + performance.now() - waitStarted), "main document loading", null);
|
|
701
|
+
await Promise.race([pageLoad, Bun.sleep(remainingBudget).then(() => {
|
|
702
|
+
throw new WaitTimeoutError(Math.round(this.#waitSpentMs + performance.now() - waitStarted), "main document loading", null);
|
|
703
|
+
})]);
|
|
474
704
|
continue;
|
|
475
705
|
}
|
|
706
|
+
this.#waitSpentMs += performance.now() - waitStarted;
|
|
476
707
|
const remaining = this.#pauseUntil - performance.now();
|
|
477
708
|
if (remaining <= 0)
|
|
478
709
|
return;
|
|
@@ -482,11 +713,10 @@ class Browser {
|
|
|
482
713
|
async startRecording() {
|
|
483
714
|
if (!this.#recordingPath)
|
|
484
715
|
return;
|
|
485
|
-
|
|
486
|
-
throw new Error("--recording requires ffmpeg on PATH");
|
|
716
|
+
await recordingEncoder();
|
|
487
717
|
const firstSegment = !this.#recordingDirectory;
|
|
488
718
|
if (firstSegment)
|
|
489
|
-
this.#recordingDirectory = await
|
|
719
|
+
this.#recordingDirectory = await mkdtemp2(join2(tmpdir2(), "jev-cdp-recording-"));
|
|
490
720
|
await this.call("Page.enable");
|
|
491
721
|
await this.call("Page.addScriptToEvaluateOnNewDocument", { source: RECORDING_CURSOR_INIT });
|
|
492
722
|
const viewport = await this.evaluate("({width: innerWidth, height: innerHeight})");
|
|
@@ -494,7 +724,7 @@ class Browser {
|
|
|
494
724
|
throw new Error("Could not read the recording viewport");
|
|
495
725
|
await this.animateCursor(viewport.width / 2, viewport.height / 2);
|
|
496
726
|
const initial = await this.call("Page.captureScreenshot", { format: "jpeg", quality: 70 });
|
|
497
|
-
const initialPath =
|
|
727
|
+
const initialPath = join2(this.#recordingDirectory, `${String(this.#recordingSequence++).padStart(6, "0")}.jpg`);
|
|
498
728
|
await Bun.write(initialPath, Buffer.from(initial.data, "base64"));
|
|
499
729
|
if (firstSegment)
|
|
500
730
|
this.#recordingStartedAt = performance.now();
|
|
@@ -505,7 +735,7 @@ class Browser {
|
|
|
505
735
|
return;
|
|
506
736
|
});
|
|
507
737
|
const sequence = this.#recordingSequence++;
|
|
508
|
-
const path =
|
|
738
|
+
const path = join2(this.#recordingDirectory, `${String(sequence).padStart(6, "0")}.jpg`);
|
|
509
739
|
const elapsedMs = sequence ? performance.now() - this.#recordingStartedAt : 0;
|
|
510
740
|
this.#recordingWrites = this.#recordingWrites.then(async () => {
|
|
511
741
|
await Bun.write(path, Buffer.from(frame.data, "base64"));
|
|
@@ -543,33 +773,12 @@ class Browser {
|
|
|
543
773
|
lines.push(`file '${quoted(frame.path)}'`, `duration ${duration.toFixed(4)}`);
|
|
544
774
|
}
|
|
545
775
|
lines.push(`file '${quoted(this.#recordingFrames.at(-1).path)}'`);
|
|
546
|
-
const manifest =
|
|
776
|
+
const manifest = join2(this.#recordingDirectory, "frames.ffconcat");
|
|
547
777
|
await Bun.write(manifest, `${lines.join(`
|
|
548
778
|
`)}
|
|
549
779
|
`);
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
"-y",
|
|
553
|
-
"-f",
|
|
554
|
-
"concat",
|
|
555
|
-
"-safe",
|
|
556
|
-
"0",
|
|
557
|
-
"-i",
|
|
558
|
-
manifest,
|
|
559
|
-
"-vsync",
|
|
560
|
-
"vfr",
|
|
561
|
-
"-c:v",
|
|
562
|
-
"libx264",
|
|
563
|
-
"-pix_fmt",
|
|
564
|
-
"yuv420p",
|
|
565
|
-
"-movflags",
|
|
566
|
-
"+faststart",
|
|
567
|
-
this.#recordingPath
|
|
568
|
-
], { stdout: "ignore", stderr: "pipe" });
|
|
569
|
-
const stderr = await new Response(process2.stderr).text();
|
|
570
|
-
if (await process2.exited !== 0)
|
|
571
|
-
throw new Error(`Could not render recording: ${stderr.slice(-800)}`);
|
|
572
|
-
await rm(this.#recordingDirectory, { recursive: true, force: true });
|
|
780
|
+
await renderRecording(await recordingEncoder(), manifest, this.#recordingPath);
|
|
781
|
+
await rm2(this.#recordingDirectory, { recursive: true, force: true });
|
|
573
782
|
this.#recordingDirectory = undefined;
|
|
574
783
|
}
|
|
575
784
|
async saveFinalScreenshot() {
|
|
@@ -612,18 +821,52 @@ class Browser {
|
|
|
612
821
|
})()`);
|
|
613
822
|
}
|
|
614
823
|
async waitForReady() {
|
|
615
|
-
const
|
|
616
|
-
|
|
824
|
+
const started = performance.now();
|
|
825
|
+
const deadline = started + this.#waitBudgetMs - this.#waitSpentMs;
|
|
826
|
+
while (performance.now() < deadline) {
|
|
617
827
|
try {
|
|
618
|
-
if (!this.#pageLoadPromise && await this.evaluate("document.readyState") === "complete")
|
|
828
|
+
if (!this.#pageLoadPromise && await this.evaluate("document.readyState") === "complete") {
|
|
829
|
+
this.#waitSpentMs += performance.now() - started;
|
|
619
830
|
return;
|
|
831
|
+
}
|
|
832
|
+
} catch (error) {
|
|
833
|
+
if (!(error instanceof StalePageError))
|
|
834
|
+
throw error;
|
|
835
|
+
}
|
|
836
|
+
await this.waitForChange(Math.max(1, deadline - performance.now()));
|
|
837
|
+
}
|
|
838
|
+
this.#waitSpentMs += performance.now() - started;
|
|
839
|
+
const state = await this.observe(false).catch(() => null);
|
|
840
|
+
throw new WaitTimeoutError(Math.round(this.#waitSpentMs), "main document loading", state);
|
|
841
|
+
}
|
|
842
|
+
async waitForSemanticReady(initial, screenshot = this.#screenshots) {
|
|
843
|
+
const started = performance.now();
|
|
844
|
+
const spentAtStart = this.#waitSpentMs;
|
|
845
|
+
const deadline = started + Math.max(0, this.#waitBudgetMs - this.#waitSpentMs);
|
|
846
|
+
const accountWait = () => {
|
|
847
|
+
this.#waitSpentMs += Math.max(0, performance.now() - started - (this.#waitSpentMs - spentAtStart));
|
|
848
|
+
};
|
|
849
|
+
let state = initial;
|
|
850
|
+
while (true) {
|
|
851
|
+
const pending = state.frames.find((frame) => frame.loading || frame.readyState !== "complete");
|
|
852
|
+
const loadingText = state.frames.length > 1 && !state.actions.some((action) => action.frameId) && /(?:^|\n)loading(?:\.{0,3}|\s)/i.test(state.text);
|
|
853
|
+
if (!pending && !loadingText) {
|
|
854
|
+
accountWait();
|
|
855
|
+
return state;
|
|
856
|
+
}
|
|
857
|
+
const condition = pending ? `frame ${pending.url || pending.id} loading` : "embedded content loading";
|
|
858
|
+
if (performance.now() >= deadline) {
|
|
859
|
+
accountWait();
|
|
860
|
+
throw new WaitTimeoutError(Math.round(this.#waitSpentMs), condition, state);
|
|
861
|
+
}
|
|
862
|
+
await this.waitForChange(Math.max(1, deadline - performance.now()));
|
|
863
|
+
try {
|
|
864
|
+
state = await this.observe(screenshot);
|
|
620
865
|
} catch (error) {
|
|
621
866
|
if (!(error instanceof StalePageError))
|
|
622
867
|
throw error;
|
|
623
868
|
}
|
|
624
|
-
await Bun.sleep(20);
|
|
625
869
|
}
|
|
626
|
-
throw new Error("Page did not finish loading within 15 seconds");
|
|
627
870
|
}
|
|
628
871
|
async evaluate(expression, awaitPromise = false) {
|
|
629
872
|
const response = await this.call("Runtime.evaluate", { expression, returnByValue: true, awaitPromise }, awaitPromise ? 15000 : undefined);
|
|
@@ -644,14 +887,48 @@ class Browser {
|
|
|
644
887
|
throw new StalePageError("Frame changed during evaluation");
|
|
645
888
|
return response.result?.value;
|
|
646
889
|
}
|
|
647
|
-
async frameOffset(frameId) {
|
|
648
|
-
const
|
|
649
|
-
const
|
|
890
|
+
async frameOffset(frameId, parentId = null) {
|
|
891
|
+
const session = parentId ? this.#frameSessions.get(parentId) ?? this.#sessionId : this.#sessionId;
|
|
892
|
+
const owner = await this.#cdp.command("DOM.getFrameOwner", { frameId }, session);
|
|
893
|
+
const box = await this.#cdp.command("DOM.getBoxModel", { backendNodeId: owner.backendNodeId }, session);
|
|
650
894
|
return { x: box.model.content[0], y: box.model.content[1] };
|
|
651
895
|
}
|
|
652
|
-
async
|
|
896
|
+
async frameScreenOffset(frameId, frames) {
|
|
897
|
+
const states = frames ?? await this.frameStates();
|
|
898
|
+
let id = frameId;
|
|
899
|
+
let x = 0, y = 0;
|
|
900
|
+
while (id) {
|
|
901
|
+
const frame = states.find((item) => item.id === id);
|
|
902
|
+
if (!frame?.parentId)
|
|
903
|
+
break;
|
|
904
|
+
const offset = await this.frameOffset(id, frame.parentId);
|
|
905
|
+
x += offset.x;
|
|
906
|
+
y += offset.y;
|
|
907
|
+
id = frame.parentId;
|
|
908
|
+
}
|
|
909
|
+
return { x, y };
|
|
910
|
+
}
|
|
911
|
+
async frameStates() {
|
|
653
912
|
const tree = await this.call("Page.getFrameTree");
|
|
654
|
-
|
|
913
|
+
const frames = [];
|
|
914
|
+
const visit = async (node, parentId) => {
|
|
915
|
+
const { id, url = "" } = node.frame;
|
|
916
|
+
let readyState = null;
|
|
917
|
+
try {
|
|
918
|
+
readyState = id === this.#mainFrameId ? await this.evaluate("document.readyState") ?? null : await this.evaluateFrame(id, "document.readyState") ?? null;
|
|
919
|
+
} catch {}
|
|
920
|
+
frames.push({
|
|
921
|
+
id,
|
|
922
|
+
parentId,
|
|
923
|
+
url,
|
|
924
|
+
readyState,
|
|
925
|
+
loading: this.#loadingFrames.has(id) || readyState !== "complete"
|
|
926
|
+
});
|
|
927
|
+
for (const child of node.childFrames ?? [])
|
|
928
|
+
await visit(child, id);
|
|
929
|
+
};
|
|
930
|
+
await visit(tree.frameTree, null);
|
|
931
|
+
return frames;
|
|
655
932
|
}
|
|
656
933
|
async discoverTabs() {
|
|
657
934
|
if (!this.#switchOnPopup)
|
|
@@ -660,6 +937,7 @@ class Browser {
|
|
|
660
937
|
if (target.type !== "page" || this.#knownTargets.has(target.id))
|
|
661
938
|
continue;
|
|
662
939
|
this.#knownTargets.add(target.id);
|
|
940
|
+
this.#newTargets.delete(target.id);
|
|
663
941
|
const attached = await this.#cdp.command("Target.attachToTarget", { targetId: target.id, flatten: true });
|
|
664
942
|
try {
|
|
665
943
|
await this.#cdp.command("Emulation.setDeviceMetricsOverride", {
|
|
@@ -680,6 +958,9 @@ class Browser {
|
|
|
680
958
|
}
|
|
681
959
|
this.#sessionId = attached.sessionId;
|
|
682
960
|
this.#targetId = target.id;
|
|
961
|
+
this.#loadingFrames.clear();
|
|
962
|
+
await this.watchConsole();
|
|
963
|
+
await this.watchPageLoads();
|
|
683
964
|
this.#switchOnPopup = false;
|
|
684
965
|
await this.startRecording();
|
|
685
966
|
} else {
|
|
@@ -687,7 +968,30 @@ class Browser {
|
|
|
687
968
|
}
|
|
688
969
|
}
|
|
689
970
|
this.#openedTabs.push({ id: target.id, url: target.url, title: target.title });
|
|
971
|
+
if (this.#origin)
|
|
972
|
+
this.#transitions.push({
|
|
973
|
+
kind: "new_tab",
|
|
974
|
+
control: this.#origin.control,
|
|
975
|
+
fromTargetId: this.#origin.targetId,
|
|
976
|
+
targetId: target.id,
|
|
977
|
+
fromUrl: this.#origin.url,
|
|
978
|
+
destinationUrl: target.url,
|
|
979
|
+
settled: false
|
|
980
|
+
});
|
|
981
|
+
const urls = this.#targetUrls.get(target.id) ?? [];
|
|
982
|
+
const requested = this.#origin?.href ?? urls[0];
|
|
983
|
+
if (this.#origin && requested && requested !== target.url)
|
|
984
|
+
this.#transitions.push({
|
|
985
|
+
kind: "redirect",
|
|
986
|
+
control: this.#origin.control,
|
|
987
|
+
fromTargetId: this.#origin.targetId,
|
|
988
|
+
targetId: target.id,
|
|
989
|
+
fromUrl: requested,
|
|
990
|
+
destinationUrl: target.url,
|
|
991
|
+
settled: false
|
|
992
|
+
});
|
|
690
993
|
}
|
|
994
|
+
this.#switchOnPopup = false;
|
|
691
995
|
}
|
|
692
996
|
async settleAfterInput() {
|
|
693
997
|
const action = this.#afterInput;
|
|
@@ -726,32 +1030,42 @@ class Browser {
|
|
|
726
1030
|
await this.settleAfterInput();
|
|
727
1031
|
await this.discoverTabs();
|
|
728
1032
|
let info;
|
|
729
|
-
|
|
1033
|
+
const waitStarted = performance.now();
|
|
1034
|
+
const deadline = waitStarted + Math.max(0, this.#waitBudgetMs - this.#waitSpentMs);
|
|
1035
|
+
let waited = false;
|
|
1036
|
+
while (true) {
|
|
730
1037
|
try {
|
|
731
1038
|
info = await this.evaluate(snapshot_default);
|
|
732
1039
|
if (info)
|
|
733
1040
|
break;
|
|
734
1041
|
} catch (error) {
|
|
735
|
-
if (!(error instanceof StalePageError)
|
|
1042
|
+
if (!(error instanceof StalePageError))
|
|
736
1043
|
throw error;
|
|
737
1044
|
}
|
|
738
|
-
|
|
1045
|
+
if (performance.now() >= deadline) {
|
|
1046
|
+
this.#waitSpentMs += performance.now() - waitStarted;
|
|
1047
|
+
throw new WaitTimeoutError(Math.round(this.#waitSpentMs), "document navigating", null);
|
|
1048
|
+
}
|
|
1049
|
+
waited = true;
|
|
1050
|
+
await this.waitForChange(Math.max(1, deadline - performance.now()));
|
|
739
1051
|
}
|
|
740
|
-
if (
|
|
741
|
-
|
|
1052
|
+
if (waited)
|
|
1053
|
+
this.#waitSpentMs += performance.now() - waitStarted;
|
|
742
1054
|
if (this.#openedTabs.length) {
|
|
743
|
-
const
|
|
1055
|
+
const targets2 = await listChromeTargets(this.#cdpUrl);
|
|
744
1056
|
this.#openedTabs = this.#openedTabs.map((tab) => {
|
|
745
|
-
const current =
|
|
1057
|
+
const current = targets2.find((target) => target.id === tab.id);
|
|
746
1058
|
return current ? { ...tab, url: current.url, title: current.title } : tab;
|
|
747
1059
|
});
|
|
748
1060
|
info.text = `${info.text}
|
|
749
1061
|
${this.#openedTabs.map((tab) => `Opened new tab: ${tab.title} ${tab.url} (target ${tab.id})`).join(`
|
|
750
1062
|
`)}`;
|
|
751
1063
|
}
|
|
752
|
-
|
|
1064
|
+
const frames = await this.frameStates();
|
|
1065
|
+
for (const frame of frames.filter((frame2) => frame2.parentId !== null)) {
|
|
1066
|
+
const frameId = frame.id;
|
|
753
1067
|
try {
|
|
754
|
-
const offset = await this.
|
|
1068
|
+
const offset = await this.frameScreenOffset(frameId, frames);
|
|
755
1069
|
const child = await this.evaluateFrame(frameId, snapshot_default);
|
|
756
1070
|
if (!child)
|
|
757
1071
|
continue;
|
|
@@ -763,13 +1077,67 @@ ${child.text}`.slice(0, 6000);
|
|
|
763
1077
|
const rect = { ...action.rect, x: action.rect.x + offset.x, y: action.rect.y + offset.y };
|
|
764
1078
|
if (rect.x < 0 || rect.y < 0 || rect.x >= info.w || rect.y >= info.h)
|
|
765
1079
|
continue;
|
|
766
|
-
|
|
1080
|
+
const covering = await this.evaluate(`(() => {
|
|
1081
|
+
const e=document.elementFromPoint(${rect.x + rect.w / 2},${rect.y + rect.h / 2});
|
|
1082
|
+
if (!e || e.tagName==='IFRAME') return null;
|
|
1083
|
+
return {tag:e.tagName.toLowerCase(),text:(e.innerText||e.getAttribute('aria-label')||'').trim().slice(0,160),role:e.getAttribute('role')};
|
|
1084
|
+
})()`);
|
|
1085
|
+
info.actions.push({
|
|
1086
|
+
...action,
|
|
1087
|
+
frameId,
|
|
1088
|
+
rect,
|
|
1089
|
+
id: `e${info.actions.length + 1}`,
|
|
1090
|
+
clickable: action.clickable && !covering,
|
|
1091
|
+
coveredBy: covering ?? action.coveredBy
|
|
1092
|
+
});
|
|
767
1093
|
info.guards[`${frameId}:${action.node}`] = child.guards[String(action.node)];
|
|
768
1094
|
}
|
|
769
1095
|
info.marker = [info.marker, frameId, child.marker];
|
|
770
1096
|
} catch {}
|
|
771
1097
|
}
|
|
772
|
-
|
|
1098
|
+
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)) {
|
|
1099
|
+
this.#transitions.push({
|
|
1100
|
+
kind: "navigation",
|
|
1101
|
+
control: this.#origin.control,
|
|
1102
|
+
fromTargetId: this.#origin.targetId,
|
|
1103
|
+
targetId: this.#targetId,
|
|
1104
|
+
fromUrl: this.#origin.url,
|
|
1105
|
+
destinationUrl: info.url,
|
|
1106
|
+
settled: false
|
|
1107
|
+
});
|
|
1108
|
+
if (this.#origin.href && this.#origin.href !== info.url)
|
|
1109
|
+
this.#transitions.push({
|
|
1110
|
+
kind: "redirect",
|
|
1111
|
+
control: this.#origin.control,
|
|
1112
|
+
fromTargetId: this.#origin.targetId,
|
|
1113
|
+
targetId: this.#targetId,
|
|
1114
|
+
fromUrl: this.#origin.href,
|
|
1115
|
+
destinationUrl: info.url,
|
|
1116
|
+
settled: false
|
|
1117
|
+
});
|
|
1118
|
+
}
|
|
1119
|
+
const targets = this.#transitions.length ? await listChromeTargets(this.#cdpUrl) : [];
|
|
1120
|
+
for (const transition of this.#transitions) {
|
|
1121
|
+
const target = targets.find((item) => item.id === transition.targetId);
|
|
1122
|
+
if (target)
|
|
1123
|
+
transition.destinationUrl = target.url;
|
|
1124
|
+
transition.settled = frames.every((frame) => !frame.loading);
|
|
1125
|
+
}
|
|
1126
|
+
if (this.#origin?.href)
|
|
1127
|
+
for (const transition of this.#transitions.filter((item) => item.kind === "new_tab" && item.control === this.#origin.control)) {
|
|
1128
|
+
if (transition.destinationUrl !== this.#origin.href && !this.#transitions.some((item) => item.kind === "redirect" && item.targetId === transition.targetId && item.fromUrl === this.#origin.href)) {
|
|
1129
|
+
this.#transitions.push({
|
|
1130
|
+
kind: "redirect",
|
|
1131
|
+
control: transition.control,
|
|
1132
|
+
fromTargetId: transition.fromTargetId,
|
|
1133
|
+
targetId: transition.targetId,
|
|
1134
|
+
fromUrl: this.#origin.href,
|
|
1135
|
+
destinationUrl: transition.destinationUrl,
|
|
1136
|
+
settled: transition.settled
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
const page = { ...info, frames, transitions: [...this.#transitions], fingerprint: fingerprint(info) };
|
|
773
1141
|
if (screenshot) {
|
|
774
1142
|
const capture = await this.call("Page.captureScreenshot", { format: "jpeg", quality: 72 });
|
|
775
1143
|
page.screenshot = capture.data;
|
|
@@ -792,15 +1160,49 @@ ${child.text}`.slice(0, 6000);
|
|
|
792
1160
|
})()`);
|
|
793
1161
|
return stableStringify(current) === stableStringify([page.page_key, page.guards[String(action.node)]]);
|
|
794
1162
|
}
|
|
795
|
-
|
|
1163
|
+
let marker = await this.evaluate(MARKER);
|
|
1164
|
+
for (const frame of (await this.frameStates()).filter((item) => item.parentId !== null)) {
|
|
1165
|
+
try {
|
|
1166
|
+
const childMarker = await this.evaluateFrame(frame.id, MARKER);
|
|
1167
|
+
if (childMarker !== undefined)
|
|
1168
|
+
marker = [marker ?? null, frame.id, childMarker];
|
|
1169
|
+
} catch {
|
|
1170
|
+
return false;
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
796
1173
|
return stableStringify(marker) === stableStringify(page.marker);
|
|
797
1174
|
}
|
|
1175
|
+
async describeElement(action) {
|
|
1176
|
+
const expression = `(node => {
|
|
1177
|
+
const e = window.__jevFast?.nodes.get(node);
|
|
1178
|
+
if (!e?.isConnected) return null;
|
|
1179
|
+
const path = [];
|
|
1180
|
+
for (let current = e; current; current = current.parentElement) {
|
|
1181
|
+
if (current.id && document.querySelectorAll('#' + CSS.escape(current.id)).length === 1) {
|
|
1182
|
+
path.unshift('#' + CSS.escape(current.id));
|
|
1183
|
+
break;
|
|
1184
|
+
}
|
|
1185
|
+
let position = 1;
|
|
1186
|
+
for (let sibling = current.previousElementSibling; sibling; sibling = sibling.previousElementSibling) {
|
|
1187
|
+
if (sibling.tagName === current.tagName) position++;
|
|
1188
|
+
}
|
|
1189
|
+
path.unshift(current.tagName.toLowerCase() + ':nth-of-type(' + position + ')');
|
|
1190
|
+
}
|
|
1191
|
+
return {css:path.join(' > '),tag:e.tagName.toLowerCase(),
|
|
1192
|
+
href:typeof e.href === 'string' ? e.href : e.getAttribute('href'),
|
|
1193
|
+
inputType:e.getAttribute('type'),frameUrl:location.href,target:e.getAttribute('target')};
|
|
1194
|
+
})(${action.node})`;
|
|
1195
|
+
const details = action.frameId ? await this.evaluateFrame(action.frameId, expression) : await this.evaluate(expression);
|
|
1196
|
+
if (!details)
|
|
1197
|
+
throw new StalePageError("Target changed before replay details were captured");
|
|
1198
|
+
return details;
|
|
1199
|
+
}
|
|
798
1200
|
async act(action, page, text) {
|
|
799
1201
|
if (!await this.fresh(page, action))
|
|
800
1202
|
throw new StalePageError("Page changed since this decision");
|
|
801
1203
|
if (action.kind === "wait") {
|
|
802
1204
|
await Bun.sleep(100);
|
|
803
|
-
return;
|
|
1205
|
+
return { element: null, performedAt: performance.now() };
|
|
804
1206
|
}
|
|
805
1207
|
if (action.kind === "scroll") {
|
|
806
1208
|
await this.animateCursor(550, 650);
|
|
@@ -811,14 +1213,24 @@ ${child.text}`.slice(0, 6000);
|
|
|
811
1213
|
deltaX: 0,
|
|
812
1214
|
deltaY: action.delta ?? 0
|
|
813
1215
|
});
|
|
814
|
-
return;
|
|
1216
|
+
return { element: null, performedAt: performance.now() };
|
|
815
1217
|
}
|
|
816
1218
|
if (typeof action.node !== "number")
|
|
817
1219
|
throw new Error("Invalid observed node");
|
|
1220
|
+
const details = await this.describeElement(action);
|
|
1221
|
+
if (action.kind === "click")
|
|
1222
|
+
this.#origin = {
|
|
1223
|
+
control: action.label,
|
|
1224
|
+
targetId: this.#targetId,
|
|
1225
|
+
url: page.url,
|
|
1226
|
+
href: details.href
|
|
1227
|
+
};
|
|
818
1228
|
const target = await (action.frameId ? this.evaluateFrame(action.frameId, `(action => {
|
|
819
1229
|
const e=window.__jevFast?.nodes.get(action.node);
|
|
820
1230
|
if (!e?.isConnected || !e.checkVisibility({checkOpacity:true,checkVisibilityCSS:true})) return null;
|
|
821
|
-
const r=e.getBoundingClientRect()
|
|
1231
|
+
const r=e.getBoundingClientRect(), x=r.x+r.width/2, y=r.y+r.height/2;
|
|
1232
|
+
if (!e.contains(document.elementFromPoint(x,y))) return null;
|
|
1233
|
+
return {x,y};
|
|
822
1234
|
})(${JSON.stringify(action)})`) : this.evaluate(`(action => {
|
|
823
1235
|
const e=window.__jevFast?.nodes.get(action.node);
|
|
824
1236
|
if (!e?.isConnected || e.matches(':disabled') || e.closest('[aria-disabled="true"],[inert]') ||
|
|
@@ -841,11 +1253,37 @@ ${child.text}`.slice(0, 6000);
|
|
|
841
1253
|
throw new Error("Dropdown execution was not confirmed");
|
|
842
1254
|
throw new StalePageError("Target changed or is covered");
|
|
843
1255
|
}
|
|
1256
|
+
let performedAt = action.kind === "select" ? performance.now() : 0;
|
|
844
1257
|
if (action.frameId) {
|
|
845
|
-
const offset = await this.
|
|
1258
|
+
const offset = await this.frameScreenOffset(action.frameId);
|
|
846
1259
|
target.x += offset.x;
|
|
847
1260
|
target.y += offset.y;
|
|
1261
|
+
const covered = await this.evaluate(`(() => {
|
|
1262
|
+
const e=document.elementFromPoint(${target.x},${target.y});
|
|
1263
|
+
return !e || e.tagName!=='IFRAME';
|
|
1264
|
+
})()`);
|
|
1265
|
+
if (covered)
|
|
1266
|
+
throw new StalePageError("Target is covered in the parent page");
|
|
1267
|
+
}
|
|
1268
|
+
let frame = null;
|
|
1269
|
+
if (action.frameId) {
|
|
1270
|
+
const frames = await this.frameStates();
|
|
1271
|
+
const parentId = frames.find((item) => item.id === action.frameId)?.parentId ?? null;
|
|
1272
|
+
const index = frames.filter((item) => item.parentId === parentId).findIndex((item) => item.id === action.frameId);
|
|
1273
|
+
if (index < 0)
|
|
1274
|
+
throw new StalePageError("Target frame changed before input");
|
|
1275
|
+
frame = { id: action.frameId, parentId, url: details.frameUrl, index };
|
|
848
1276
|
}
|
|
1277
|
+
const element = {
|
|
1278
|
+
css: details.css,
|
|
1279
|
+
role: action.role ?? null,
|
|
1280
|
+
name: action.label,
|
|
1281
|
+
tag: details.tag,
|
|
1282
|
+
href: details.href,
|
|
1283
|
+
inputType: details.inputType,
|
|
1284
|
+
point: { x: target.x, y: target.y },
|
|
1285
|
+
frame
|
|
1286
|
+
};
|
|
849
1287
|
await this.animateCursor(target.x, target.y);
|
|
850
1288
|
if (action.kind !== "select") {
|
|
851
1289
|
if (action.kind === "click" && this.#interactionPauses > 0) {
|
|
@@ -867,6 +1305,7 @@ ${child.text}`.slice(0, 6000);
|
|
|
867
1305
|
if (type === "mousePressed")
|
|
868
1306
|
await this.animateCursor(target.x, target.y, true);
|
|
869
1307
|
}
|
|
1308
|
+
performedAt = performance.now();
|
|
870
1309
|
if (action.kind === "fill") {
|
|
871
1310
|
const modifiers = process.platform === "darwin" ? 4 : 2;
|
|
872
1311
|
await this.call("Input.dispatchKeyEvent", {
|
|
@@ -883,13 +1322,15 @@ ${child.text}`.slice(0, 6000);
|
|
|
883
1322
|
modifiers
|
|
884
1323
|
});
|
|
885
1324
|
await this.call("Input.insertText", { text: text ?? "" });
|
|
1325
|
+
performedAt = performance.now();
|
|
886
1326
|
}
|
|
887
1327
|
}
|
|
888
1328
|
this.#afterInput = action;
|
|
889
|
-
if (action.
|
|
1329
|
+
if (action.kind === "click") {
|
|
890
1330
|
this.#switchOnPopup = true;
|
|
891
|
-
await
|
|
1331
|
+
await this.waitForNewTarget(details.target === "_blank" ? 650 : 100);
|
|
892
1332
|
}
|
|
1333
|
+
return { element, performedAt };
|
|
893
1334
|
}
|
|
894
1335
|
async close() {
|
|
895
1336
|
if (this.#closed)
|
|
@@ -926,9 +1367,9 @@ ${child.text}`.slice(0, 6000);
|
|
|
926
1367
|
}
|
|
927
1368
|
|
|
928
1369
|
// src/model.ts
|
|
929
|
-
import { mkdtemp as
|
|
930
|
-
import { tmpdir as
|
|
931
|
-
import { join as
|
|
1370
|
+
import { mkdtemp as mkdtemp3, readFile, rm as rm3 } from "fs/promises";
|
|
1371
|
+
import { tmpdir as tmpdir3 } from "os";
|
|
1372
|
+
import { join as join3 } from "path";
|
|
932
1373
|
|
|
933
1374
|
// src/questions.ts
|
|
934
1375
|
var NEXT_ACTION = `Advance the user's entire goal from the CURRENT page using one operation.
|
|
@@ -938,9 +1379,9 @@ its matching autocomplete suggestion selected. For date pickers, CLICK the field
|
|
|
938
1379
|
Set every requested filter/control; a matching result alone does not prove a requested filter was set.
|
|
939
1380
|
Do not toggle a checkbox, switch, or radio already in the requested state.
|
|
940
1381
|
Submit populated search fields before opening a result; a populated field alone is not an applied search.
|
|
941
|
-
WAIT only when the needed control is absent/disabled, or submitted results are still loading.
|
|
942
1382
|
If Search/Submit is visible and the required fields are ready, CLICK it immediately.
|
|
943
|
-
|
|
1383
|
+
Loading and frame readiness are handled by the browser adapter before this decision.
|
|
1384
|
+
Use frame URL, nearby text, bounds, and clickability to distinguish repeated labels.
|
|
944
1385
|
DONE requires visible evidence that ALL requirements are satisfied. If asked to open a result,
|
|
945
1386
|
a matching link is not enough. BLOCKED means no supported operation can make progress.`;
|
|
946
1387
|
var TARGET = `Choose the best observed target if the next operation is the one specified in this question.
|
|
@@ -1012,13 +1453,21 @@ function actionSpace(actions) {
|
|
|
1012
1453
|
}
|
|
1013
1454
|
if (typeof action.node !== "number")
|
|
1014
1455
|
continue;
|
|
1015
|
-
|
|
1456
|
+
const identity = `${action.frameId ?? "main"}:${action.node}`;
|
|
1457
|
+
if (!indices.has(identity)) {
|
|
1016
1458
|
const index2 = String(elements.length + 1);
|
|
1017
|
-
indices.set(
|
|
1459
|
+
indices.set(identity, index2);
|
|
1018
1460
|
const element2 = {
|
|
1019
1461
|
index: index2,
|
|
1020
1462
|
label: action.label.split(" \u2192 ")[0] ?? action.label,
|
|
1021
|
-
operations: []
|
|
1463
|
+
operations: [],
|
|
1464
|
+
frameId: action.frameId ?? null,
|
|
1465
|
+
frameUrl: action.frameUrl ?? null,
|
|
1466
|
+
bounds: action.rect ?? null,
|
|
1467
|
+
nearbyText: action.nearbyText ?? "",
|
|
1468
|
+
region: action.region ?? "",
|
|
1469
|
+
clickable: action.clickable ?? true,
|
|
1470
|
+
coveredBy: action.coveredBy ?? null
|
|
1022
1471
|
};
|
|
1023
1472
|
for (const key of ["role", "value", "checked", "selected", "expanded", "pressed", "sensitive"]) {
|
|
1024
1473
|
if (action[key] !== undefined)
|
|
@@ -1030,7 +1479,9 @@ function actionSpace(actions) {
|
|
|
1030
1479
|
}
|
|
1031
1480
|
elements.push(element2);
|
|
1032
1481
|
}
|
|
1033
|
-
const index = indices.get(
|
|
1482
|
+
const index = indices.get(identity);
|
|
1483
|
+
if (action.clickable === false)
|
|
1484
|
+
continue;
|
|
1034
1485
|
const group = targets[operation] ??= {};
|
|
1035
1486
|
const element = elements[Number(index) - 1];
|
|
1036
1487
|
if (!element.operations.includes(operation))
|
|
@@ -1069,6 +1520,13 @@ async function choose(page, goal, history, providedFields = []) {
|
|
|
1069
1520
|
type: "choice",
|
|
1070
1521
|
criteria: Object.fromEntries(Object.entries(candidates).map(([index, action]) => [index, {
|
|
1071
1522
|
element: `[${index}] ${action.label}`,
|
|
1523
|
+
frame_id: action.frameId ?? null,
|
|
1524
|
+
frame: action.frameUrl ?? page.url,
|
|
1525
|
+
region: action.region ?? "",
|
|
1526
|
+
nearby_text: action.nearbyText ?? "",
|
|
1527
|
+
bounds: action.rect ?? null,
|
|
1528
|
+
clickable: action.clickable ?? true,
|
|
1529
|
+
covered_by: action.coveredBy ?? null,
|
|
1072
1530
|
current_value: action.current_value ?? action.value ?? "",
|
|
1073
1531
|
...Object.fromEntries(["role", "checked", "selected", "expanded", "pressed", "sensitive"].filter((name) => action[name] !== undefined).map((name) => [name, action[name]]))
|
|
1074
1532
|
}])),
|
|
@@ -1151,9 +1609,9 @@ async function codexFieldText(context) {
|
|
|
1151
1609
|
if (!["none", "low", "medium", "high", "xhigh", "max"].includes(reasoning)) {
|
|
1152
1610
|
throw new Error("Unsupported Codex reasoning effort");
|
|
1153
1611
|
}
|
|
1154
|
-
const folder = await
|
|
1155
|
-
const outputPath =
|
|
1156
|
-
const schemaPath =
|
|
1612
|
+
const folder = await mkdtemp3(join3(tmpdir3(), "jev-codex-text-"));
|
|
1613
|
+
const outputPath = join3(folder, "output.json");
|
|
1614
|
+
const schemaPath = join3(folder, "schema.json");
|
|
1157
1615
|
await Bun.write(schemaPath, JSON.stringify(text_value_schema_default));
|
|
1158
1616
|
const childEnvironment = Object.fromEntries(Object.entries(process.env).filter(([name, value]) => value !== undefined && !["TYPESAFE_API_KEY", "TEXT_MODEL_API_KEY"].includes(name)).map(([name, value]) => [name, value]));
|
|
1159
1617
|
const command = [
|
|
@@ -1216,7 +1674,7 @@ ${JSON.stringify(context)}`;
|
|
|
1216
1674
|
usage: {}
|
|
1217
1675
|
}];
|
|
1218
1676
|
} finally {
|
|
1219
|
-
await
|
|
1677
|
+
await rm3(folder, { recursive: true, force: true });
|
|
1220
1678
|
}
|
|
1221
1679
|
}
|
|
1222
1680
|
async function apiFieldText(context) {
|
|
@@ -1248,12 +1706,23 @@ function fieldText(context) {
|
|
|
1248
1706
|
}
|
|
1249
1707
|
|
|
1250
1708
|
// src/agent.ts
|
|
1709
|
+
function redactConsoleErrors(errors, secrets) {
|
|
1710
|
+
const redact = (text) => secrets.reduce((value, secret) => value.replaceAll(secret, "[redacted]"), text);
|
|
1711
|
+
return errors.map((error) => ({
|
|
1712
|
+
...error,
|
|
1713
|
+
message: redact(error.message),
|
|
1714
|
+
...error.url ? { url: redact(error.url) } : {}
|
|
1715
|
+
}));
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1251
1718
|
class Agent {
|
|
1252
1719
|
#browser;
|
|
1253
1720
|
#goal;
|
|
1254
1721
|
#maxSteps;
|
|
1255
1722
|
#screenshots;
|
|
1256
1723
|
#fieldValues;
|
|
1724
|
+
#sensitiveFieldLabels;
|
|
1725
|
+
#secretValues;
|
|
1257
1726
|
#page;
|
|
1258
1727
|
#decision = null;
|
|
1259
1728
|
#history = [];
|
|
@@ -1262,6 +1731,8 @@ class Agent {
|
|
|
1262
1731
|
#status = "ready";
|
|
1263
1732
|
#startedAt = null;
|
|
1264
1733
|
#pendingText = null;
|
|
1734
|
+
#waitTimeout;
|
|
1735
|
+
#initialConsoleErrors = [];
|
|
1265
1736
|
constructor(browser, page, options) {
|
|
1266
1737
|
this.#browser = browser;
|
|
1267
1738
|
this.#page = page;
|
|
@@ -1269,6 +1740,9 @@ class Agent {
|
|
|
1269
1740
|
this.#maxSteps = options.maxSteps;
|
|
1270
1741
|
this.#screenshots = options.screenshots ?? false;
|
|
1271
1742
|
this.#fieldValues = options.fieldValues ?? {};
|
|
1743
|
+
this.#sensitiveFieldLabels = new Set(options.sensitiveFieldLabels ?? []);
|
|
1744
|
+
this.#secretValues = Object.entries(this.#fieldValues).filter(([label, value]) => this.#sensitiveFieldLabels.has(label) && value.length > 0).map(([, value]) => value);
|
|
1745
|
+
this.#startedAt = performance.now();
|
|
1272
1746
|
}
|
|
1273
1747
|
static async create(options) {
|
|
1274
1748
|
if (!options.goal.trim())
|
|
@@ -1286,12 +1760,24 @@ class Agent {
|
|
|
1286
1760
|
recordingPath: options.recordingPath,
|
|
1287
1761
|
screenshotPath: options.screenshotPath,
|
|
1288
1762
|
freshContext: options.freshContext,
|
|
1289
|
-
interactionPauses: options.interactionPauses
|
|
1763
|
+
interactionPauses: options.interactionPauses,
|
|
1764
|
+
waitBudgetMs: options.waitBudgetMs
|
|
1290
1765
|
});
|
|
1291
1766
|
try {
|
|
1292
|
-
|
|
1767
|
+
const agent = new Agent(browser, await browser.observe(options.screenshots), options);
|
|
1768
|
+
try {
|
|
1769
|
+
await agent.waitForReadiness();
|
|
1770
|
+
} catch (error) {
|
|
1771
|
+
if (!(error instanceof WaitTimeoutError))
|
|
1772
|
+
throw error;
|
|
1773
|
+
agent.recordWaitTimeout(error);
|
|
1774
|
+
}
|
|
1775
|
+
agent.#initialConsoleErrors = redactConsoleErrors(browser.takeConsoleErrors(), agent.#secretValues);
|
|
1776
|
+
return agent;
|
|
1293
1777
|
} catch (error) {
|
|
1294
|
-
await browser.close()
|
|
1778
|
+
await browser.close().catch(() => {
|
|
1779
|
+
return;
|
|
1780
|
+
});
|
|
1295
1781
|
throw error;
|
|
1296
1782
|
}
|
|
1297
1783
|
}
|
|
@@ -1306,7 +1792,14 @@ class Agent {
|
|
|
1306
1792
|
status: this.#status,
|
|
1307
1793
|
elapsedMs: this.elapsedMs(),
|
|
1308
1794
|
maxSteps: this.#maxSteps,
|
|
1309
|
-
elements: actionSpace(this.#page.actions).elements
|
|
1795
|
+
elements: actionSpace(this.#page.actions).elements,
|
|
1796
|
+
initialConsoleErrors: [...this.#initialConsoleErrors],
|
|
1797
|
+
consoleErrors: [
|
|
1798
|
+
...this.#initialConsoleErrors,
|
|
1799
|
+
...this.#history.flatMap((entry) => entry.consoleErrors),
|
|
1800
|
+
...redactConsoleErrors(this.#browser.pendingConsoleErrors(), this.#secretValues)
|
|
1801
|
+
],
|
|
1802
|
+
...this.#waitTimeout ? { waitTimeout: this.#waitTimeout } : {}
|
|
1310
1803
|
};
|
|
1311
1804
|
}
|
|
1312
1805
|
get targetId() {
|
|
@@ -1320,15 +1813,12 @@ class Agent {
|
|
|
1320
1813
|
this.#startedAt = performance.now();
|
|
1321
1814
|
if (!await this.#browser.fresh(this.#page)) {
|
|
1322
1815
|
this.#page = await this.#browser.observe(this.#screenshots);
|
|
1816
|
+
await this.waitForReadiness();
|
|
1323
1817
|
}
|
|
1324
1818
|
this.#decision = null;
|
|
1325
|
-
if (["done", "blocked", "budget_exhausted"].includes(this.#status)) {
|
|
1819
|
+
if (["done", "blocked", "budget_exhausted", "wait_timeout"].includes(this.#status)) {
|
|
1326
1820
|
throw new Error("This run has stopped");
|
|
1327
1821
|
}
|
|
1328
|
-
if (this.#decisions.length >= this.#maxSteps * 2) {
|
|
1329
|
-
this.#status = "budget_exhausted";
|
|
1330
|
-
return;
|
|
1331
|
-
}
|
|
1332
1822
|
this.#decision = await choose(this.#page, this.#goal, this.#history, Object.keys(this.#fieldValues));
|
|
1333
1823
|
this.#decisions.push(this.#decision);
|
|
1334
1824
|
this.#status = "predicted";
|
|
@@ -1369,7 +1859,7 @@ class Agent {
|
|
|
1369
1859
|
if (provided !== undefined) {
|
|
1370
1860
|
text = provided;
|
|
1371
1861
|
helper = { model: "provided-field-value", provider: "caller", latency_ms: 0, usage: {} };
|
|
1372
|
-
this.#textCalls.push({ ...helper, field: action.label, value: action.sensitive ? "[redacted]" : text });
|
|
1862
|
+
this.#textCalls.push({ ...helper, field: action.label, value: action.sensitive || this.#sensitiveFieldLabels.has(action.label) ? "[redacted]" : text });
|
|
1373
1863
|
} else if (this.#pendingText?.contextKey === contextKey) {
|
|
1374
1864
|
({ text, helper } = this.#pendingText);
|
|
1375
1865
|
} else {
|
|
@@ -1379,7 +1869,8 @@ class Agent {
|
|
|
1379
1869
|
}
|
|
1380
1870
|
}
|
|
1381
1871
|
await this.#browser.waitForInteractionPause();
|
|
1382
|
-
|
|
1872
|
+
const fromTargetId = this.#browser.targetId;
|
|
1873
|
+
const { element, performedAt } = await this.#browser.act(action, page, text ?? undefined);
|
|
1383
1874
|
this.#pendingText = null;
|
|
1384
1875
|
const entry = {
|
|
1385
1876
|
step: this.#history.length + 1,
|
|
@@ -1389,21 +1880,33 @@ class Agent {
|
|
|
1389
1880
|
probability: decision.probabilities[selected] ?? 0,
|
|
1390
1881
|
confidence: decision.confidence,
|
|
1391
1882
|
latency_ms: decision.latency_ms,
|
|
1392
|
-
text: action.sensitive && text !== null ? "[redacted]" : text,
|
|
1883
|
+
text: (action.sensitive || this.#sensitiveFieldLabels.has(action.label)) && text !== null ? "[redacted]" : text,
|
|
1393
1884
|
text_helper: helper?.model ?? null,
|
|
1394
1885
|
text_latency_ms: helper?.latency_ms ?? 0,
|
|
1395
1886
|
operation: decision.operation,
|
|
1396
1887
|
target: decision.target,
|
|
1397
1888
|
page_changed: null,
|
|
1889
|
+
from_url: page.url,
|
|
1398
1890
|
url: page.url,
|
|
1891
|
+
viewport: { width: page.w, height: page.h },
|
|
1892
|
+
from_target_id: fromTargetId,
|
|
1893
|
+
target_id: this.#browser.targetId,
|
|
1894
|
+
element,
|
|
1895
|
+
value: action.kind === "select" ? action.value ?? null : null,
|
|
1896
|
+
delta_y: action.kind === "scroll" ? action.delta ?? 0 : null,
|
|
1897
|
+
redacted: action.kind === "fill" && (Boolean(action.sensitive) || this.#sensitiveFieldLabels.has(action.label)),
|
|
1399
1898
|
usage: decision.usage,
|
|
1400
|
-
executed_ms:
|
|
1401
|
-
elapsed_ms: this.elapsedMs()
|
|
1899
|
+
executed_ms: Math.round(performedAt - this.#startedAt),
|
|
1900
|
+
elapsed_ms: this.elapsedMs(),
|
|
1901
|
+
consoleErrors: []
|
|
1402
1902
|
};
|
|
1403
1903
|
this.#history.push(entry);
|
|
1404
1904
|
this.#page = await this.#browser.observe(this.#screenshots);
|
|
1905
|
+
await this.waitForReadiness();
|
|
1906
|
+
entry.consoleErrors = redactConsoleErrors(this.#browser.takeConsoleErrors(), this.#secretValues);
|
|
1405
1907
|
entry.page_changed = this.#page.fingerprint !== page.fingerprint;
|
|
1406
1908
|
entry.url = this.#page.url;
|
|
1909
|
+
entry.target_id = this.#browser.targetId;
|
|
1407
1910
|
entry.elapsed_ms = this.elapsedMs();
|
|
1408
1911
|
this.#status = "ready";
|
|
1409
1912
|
}
|
|
@@ -1412,21 +1915,41 @@ class Agent {
|
|
|
1412
1915
|
await this.predict();
|
|
1413
1916
|
await this.act();
|
|
1414
1917
|
} catch (error) {
|
|
1918
|
+
if (error instanceof WaitTimeoutError) {
|
|
1919
|
+
this.recordWaitTimeout(error);
|
|
1920
|
+
return this.snapshot();
|
|
1921
|
+
}
|
|
1415
1922
|
if (!(error instanceof StalePageError))
|
|
1416
1923
|
throw error;
|
|
1417
1924
|
this.#decision = null;
|
|
1418
1925
|
this.#status = "ready";
|
|
1419
1926
|
this.#page = await this.#browser.observe(this.#screenshots);
|
|
1927
|
+
try {
|
|
1928
|
+
await this.waitForReadiness();
|
|
1929
|
+
} catch (waitError) {
|
|
1930
|
+
if (!(waitError instanceof WaitTimeoutError))
|
|
1931
|
+
throw waitError;
|
|
1932
|
+
this.recordWaitTimeout(waitError);
|
|
1933
|
+
}
|
|
1420
1934
|
}
|
|
1421
1935
|
return this.snapshot();
|
|
1422
1936
|
}
|
|
1423
1937
|
async run(onStep) {
|
|
1424
|
-
while (!["done", "blocked", "budget_exhausted"].includes(this.#status)) {
|
|
1938
|
+
while (!["done", "blocked", "budget_exhausted", "wait_timeout"].includes(this.#status)) {
|
|
1425
1939
|
const state = await this.tick();
|
|
1426
1940
|
onStep?.(state);
|
|
1427
1941
|
}
|
|
1428
1942
|
return this.snapshot();
|
|
1429
1943
|
}
|
|
1944
|
+
async waitForReadiness() {
|
|
1945
|
+
this.#page = await this.#browser.waitForSemanticReady(this.#page, this.#screenshots);
|
|
1946
|
+
}
|
|
1947
|
+
recordWaitTimeout(error) {
|
|
1948
|
+
this.#status = "wait_timeout";
|
|
1949
|
+
this.#waitTimeout = { elapsedMs: error.elapsedMs, pendingCondition: error.pendingCondition };
|
|
1950
|
+
if (error.state)
|
|
1951
|
+
this.#page = error.state;
|
|
1952
|
+
}
|
|
1430
1953
|
close() {
|
|
1431
1954
|
return this.#browser.close();
|
|
1432
1955
|
}
|
|
@@ -1502,6 +2025,8 @@ Goal control:
|
|
|
1502
2025
|
--goal <text> One bounded browser goal. Required.
|
|
1503
2026
|
--max-steps <number> Maximum executed browser actions.
|
|
1504
2027
|
[env: JEV_MAX_STEPS] [default: 12]
|
|
2028
|
+
--wait-budget-ms <number> Total wall-clock budget for page and frame readiness.
|
|
2029
|
+
[default: 15000]
|
|
1505
2030
|
|
|
1506
2031
|
Browser behavior:
|
|
1507
2032
|
--visible Activate the controlled tab.
|
|
@@ -1525,12 +2050,15 @@ Evidence and output:
|
|
|
1525
2050
|
-h, --help Show this help and exit.
|
|
1526
2051
|
|
|
1527
2052
|
Output:
|
|
1528
|
-
|
|
2053
|
+
Stdout is JSON Lines: one object per executed action, then one result object.
|
|
2054
|
+
Each action includes new console errors observed during that step. The result
|
|
2055
|
+
includes initial errors and all errors observed during the run.
|
|
2056
|
+
Errors and diagnostics use stderr.
|
|
1529
2057
|
|
|
1530
2058
|
Exit codes:
|
|
1531
2059
|
0 Jev reported the goal complete.
|
|
1532
2060
|
1 Invalid configuration or runtime failure.
|
|
1533
|
-
2 Jev
|
|
2061
|
+
2 Jev was blocked or the wait budget timed out.
|
|
1534
2062
|
3 The maximum browser-step budget was exhausted.
|
|
1535
2063
|
|
|
1536
2064
|
Examples:
|
|
@@ -1613,6 +2141,7 @@ function addFieldValue(options, assignment, fromEnvironment) {
|
|
|
1613
2141
|
if (!supplied)
|
|
1614
2142
|
throw new CliError(`Environment variable is missing or empty: ${environmentName}`);
|
|
1615
2143
|
options.fieldValues[label] = supplied;
|
|
2144
|
+
options.sensitiveFieldLabels.push(label);
|
|
1616
2145
|
} else {
|
|
1617
2146
|
options.fieldValues[label] = assignment.slice(separator + 1);
|
|
1618
2147
|
}
|
|
@@ -1622,10 +2151,12 @@ function parseRunOptions(args) {
|
|
|
1622
2151
|
cdpUrl: process.env.CHROME_CDP_URL ?? DEFAULT_CDP_URL,
|
|
1623
2152
|
maxSteps: parsePositiveInteger(process.env.JEV_MAX_STEPS ?? "12", "JEV_MAX_STEPS"),
|
|
1624
2153
|
interactionPauses: 0,
|
|
2154
|
+
waitBudgetMs: 15000,
|
|
1625
2155
|
visible: enabled(process.env.JEV_BROWSER_VISIBLE),
|
|
1626
2156
|
keepOpen: enabled(process.env.JEV_BROWSER_KEEP_OPEN),
|
|
1627
2157
|
finalState: false,
|
|
1628
2158
|
fieldValues: {},
|
|
2159
|
+
sensitiveFieldLabels: [],
|
|
1629
2160
|
freshContext: enabled(process.env.JEV_BROWSER_FRESH_CONTEXT)
|
|
1630
2161
|
};
|
|
1631
2162
|
for (let index = 0;index < args.length; index++) {
|
|
@@ -1642,6 +2173,8 @@ function parseRunOptions(args) {
|
|
|
1642
2173
|
options.maxSteps = parsePositiveInteger(nextValue(args, index++, argument), argument);
|
|
1643
2174
|
else if (argument === "--interaction-pauses")
|
|
1644
2175
|
options.interactionPauses = parseNonNegativeInteger(nextValue(args, index++, argument), argument);
|
|
2176
|
+
else if (argument === "--wait-budget-ms")
|
|
2177
|
+
options.waitBudgetMs = parsePositiveInteger(nextValue(args, index++, argument), argument);
|
|
1645
2178
|
else if (argument === "--recording")
|
|
1646
2179
|
options.recordingPath = nextValue(args, index++, argument);
|
|
1647
2180
|
else if (argument === "--screenshot")
|
|
@@ -1689,17 +2222,45 @@ function parseCommonOptions(args, help) {
|
|
|
1689
2222
|
}
|
|
1690
2223
|
return options;
|
|
1691
2224
|
}
|
|
1692
|
-
function
|
|
2225
|
+
function actionEvent(entry, maxSteps) {
|
|
2226
|
+
return {
|
|
2227
|
+
type: "action",
|
|
2228
|
+
status: "executed",
|
|
2229
|
+
step: entry.step,
|
|
2230
|
+
elapsedMs: entry.executed_ms,
|
|
2231
|
+
budget: { used: entry.step, max: maxSteps, remaining: maxSteps - entry.step },
|
|
2232
|
+
page: { before: entry.from_url, after: entry.url, changed: entry.page_changed, viewport: entry.viewport },
|
|
2233
|
+
tab: { before: entry.from_target_id, after: entry.target_id },
|
|
2234
|
+
consoleErrors: entry.consoleErrors,
|
|
2235
|
+
action: {
|
|
2236
|
+
kind: entry.kind,
|
|
2237
|
+
label: entry.action,
|
|
2238
|
+
element: entry.element,
|
|
2239
|
+
...entry.kind === "fill" ? { text: entry.text, redacted: entry.redacted } : {},
|
|
2240
|
+
...entry.kind === "select" ? { optionValue: entry.value } : {},
|
|
2241
|
+
...entry.kind === "scroll" ? { deltaY: entry.delta_y, point: { x: 550, y: 650 } } : {},
|
|
2242
|
+
...entry.kind === "wait" ? { durationMs: 100 } : {}
|
|
2243
|
+
}
|
|
2244
|
+
};
|
|
2245
|
+
}
|
|
2246
|
+
function semanticState(page, elements) {
|
|
2247
|
+
const frameTree = (parentId) => page.frames.filter((frame) => frame.parentId === parentId).map((frame) => ({ ...frame, children: frameTree(frame.id) }));
|
|
1693
2248
|
return {
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
2249
|
+
url: page.url,
|
|
2250
|
+
title: page.title,
|
|
2251
|
+
text: page.text,
|
|
2252
|
+
viewport: { width: page.w, height: page.h },
|
|
2253
|
+
scroll: page.scroll,
|
|
2254
|
+
elements,
|
|
2255
|
+
frameTree: frameTree(null),
|
|
2256
|
+
transitions: page.transitions,
|
|
2257
|
+
omittedActions: page.omitted_actions
|
|
1698
2258
|
};
|
|
1699
2259
|
}
|
|
1700
2260
|
async function runGoal(args) {
|
|
1701
2261
|
const options = parseRunOptions(args);
|
|
1702
2262
|
let agent;
|
|
2263
|
+
let reportedActions = 0;
|
|
1703
2264
|
try {
|
|
1704
2265
|
agent = await Agent.create({
|
|
1705
2266
|
url: options.url,
|
|
@@ -1708,50 +2269,65 @@ async function runGoal(args) {
|
|
|
1708
2269
|
cdpUrl: options.cdpUrl,
|
|
1709
2270
|
maxSteps: options.maxSteps,
|
|
1710
2271
|
interactionPauses: options.interactionPauses,
|
|
2272
|
+
waitBudgetMs: options.waitBudgetMs,
|
|
1711
2273
|
visible: options.visible,
|
|
1712
2274
|
keepOpen: options.keepOpen,
|
|
1713
2275
|
recordingPath: options.recordingPath,
|
|
1714
2276
|
screenshotPath: options.screenshotPath,
|
|
1715
2277
|
fieldValues: options.fieldValues,
|
|
2278
|
+
sensitiveFieldLabels: options.sensitiveFieldLabels,
|
|
1716
2279
|
freshContext: options.freshContext
|
|
1717
2280
|
});
|
|
1718
|
-
|
|
1719
|
-
const result = await agent.run((state) => {
|
|
2281
|
+
await agent.run((state) => {
|
|
1720
2282
|
const action = state.history.length > reportedActions ? state.history.at(-1) : undefined;
|
|
1721
|
-
const decision = state.decisions.at(-1);
|
|
1722
2283
|
reportedActions = state.history.length;
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
const helper = action?.text_helper ? ` text=${action.text_helper}:${action.text_latency_ms}ms` : "";
|
|
1726
|
-
console.error(`elapsed=${state.elapsedMs}ms actions=${state.history.length}/${state.maxSteps} status=${state.status} operation=${operation} jev=${jevLatency ?? 0}ms${helper}`);
|
|
2284
|
+
if (action)
|
|
2285
|
+
console.log(JSON.stringify(actionEvent(action, state.maxSteps)));
|
|
1727
2286
|
});
|
|
2287
|
+
await agent.close();
|
|
2288
|
+
const result = agent.snapshot();
|
|
1728
2289
|
console.log(JSON.stringify({
|
|
2290
|
+
type: "result",
|
|
1729
2291
|
status: result.status,
|
|
1730
2292
|
targetId: agent.targetId,
|
|
1731
2293
|
url: result.page.url,
|
|
1732
2294
|
actions: result.history.length,
|
|
1733
2295
|
maxSteps: result.maxSteps,
|
|
2296
|
+
budget: { used: result.history.length, max: result.maxSteps, remaining: result.maxSteps - result.history.length },
|
|
1734
2297
|
elapsedMs: result.elapsedMs,
|
|
1735
2298
|
textCalls: result.textCalls.length,
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
},
|
|
2299
|
+
initialConsoleErrors: result.initialConsoleErrors,
|
|
2300
|
+
consoleErrors: result.consoleErrors,
|
|
2301
|
+
...result.waitTimeout ? { waitTimeout: result.waitTimeout } : {},
|
|
1740
2302
|
...options.recordingPath ? { recording: options.recordingPath } : {},
|
|
1741
2303
|
...options.screenshotPath ? { screenshot: options.screenshotPath } : {},
|
|
1742
|
-
...options.finalState ? {
|
|
1743
|
-
finalState:
|
|
1744
|
-
url: result.page.url,
|
|
1745
|
-
title: result.page.title,
|
|
1746
|
-
text: result.page.text,
|
|
1747
|
-
viewport: { width: result.page.w, height: result.page.h },
|
|
1748
|
-
scroll: result.page.scroll,
|
|
1749
|
-
elements: result.elements,
|
|
1750
|
-
omittedActions: result.page.omitted_actions
|
|
1751
|
-
}
|
|
2304
|
+
...options.finalState || result.status === "wait_timeout" ? {
|
|
2305
|
+
finalState: semanticState(result.page, result.elements)
|
|
1752
2306
|
} : {}
|
|
1753
2307
|
}));
|
|
1754
2308
|
return result.status === "done" ? 0 : result.status === "budget_exhausted" ? 3 : 2;
|
|
2309
|
+
} catch (error) {
|
|
2310
|
+
const state = agent?.snapshot();
|
|
2311
|
+
for (const action of state?.history.slice(reportedActions) ?? []) {
|
|
2312
|
+
console.log(JSON.stringify(actionEvent(action, options.maxSteps)));
|
|
2313
|
+
}
|
|
2314
|
+
console.log(JSON.stringify({
|
|
2315
|
+
type: "result",
|
|
2316
|
+
status: error instanceof WaitTimeoutError ? "wait_timeout" : "error",
|
|
2317
|
+
targetId: agent?.targetId ?? null,
|
|
2318
|
+
url: state?.page.url ?? (error instanceof WaitTimeoutError ? error.state?.url ?? null : null),
|
|
2319
|
+
actions: state?.history.length ?? 0,
|
|
2320
|
+
maxSteps: options.maxSteps,
|
|
2321
|
+
budget: { used: state?.history.length ?? 0, max: options.maxSteps, remaining: options.maxSteps - (state?.history.length ?? 0) },
|
|
2322
|
+
elapsedMs: state?.elapsedMs ?? 0,
|
|
2323
|
+
initialConsoleErrors: state?.initialConsoleErrors ?? [],
|
|
2324
|
+
consoleErrors: state?.consoleErrors ?? [],
|
|
2325
|
+
...error instanceof WaitTimeoutError ? { waitTimeout: { elapsedMs: error.elapsedMs, pendingCondition: error.pendingCondition } } : {},
|
|
2326
|
+
...state ? { finalState: semanticState(state.page, state.elements) } : error instanceof WaitTimeoutError && error.state ? { finalState: semanticState(error.state, actionSpace(error.state.actions).elements) } : {}
|
|
2327
|
+
}));
|
|
2328
|
+
if (error instanceof WaitTimeoutError)
|
|
2329
|
+
return 2;
|
|
2330
|
+
throw error;
|
|
1755
2331
|
} finally {
|
|
1756
2332
|
await agent?.close();
|
|
1757
2333
|
}
|
|
@@ -1791,8 +2367,11 @@ async function doctor(args) {
|
|
|
1791
2367
|
required: true
|
|
1792
2368
|
});
|
|
1793
2369
|
}
|
|
1794
|
-
|
|
1795
|
-
|
|
2370
|
+
try {
|
|
2371
|
+
checks.push({ name: "recording", status: "ok", detail: await checkRecordingEncoder(), required: false });
|
|
2372
|
+
} catch (error) {
|
|
2373
|
+
checks.push({ name: "recording", status: "warning", detail: `${error instanceof Error ? error.message : String(error)}; --recording will be unavailable`, required: false });
|
|
2374
|
+
}
|
|
1796
2375
|
if (process.env.TEXT_MODEL_PROVIDER === "api") {
|
|
1797
2376
|
checks.push(process.env.TEXT_MODEL_API_KEY ? { name: "text-helper", status: "ok", detail: `API helper configured (${process.env.TEXT_MODEL ?? "deepseek-chat"})`, required: false } : { name: "text-helper", status: "warning", detail: "TEXT_MODEL_PROVIDER=api but TEXT_MODEL_API_KEY is not set", required: false });
|
|
1798
2377
|
} else {
|
|
@@ -1859,5 +2438,6 @@ async function main(args = Bun.argv.slice(2)) {
|
|
|
1859
2438
|
if (import.meta.main)
|
|
1860
2439
|
process.exitCode = await main();
|
|
1861
2440
|
export {
|
|
2441
|
+
actionEvent,
|
|
1862
2442
|
main
|
|
1863
2443
|
};
|