jev-cdp 0.1.3 → 0.1.4
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 +2 -0
- package/dist/cli.js +138 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -100,6 +100,8 @@ bun run run -- run \
|
|
|
100
100
|
|
|
101
101
|
`--final-state` adds an AI-oriented semantic snapshot to the final JSON on standard output. It includes the final URL, title, visible text, viewport, scroll state, actionable elements, accessible labels, and control state such as `pressed`, `checked`, `selected`, and `expanded`. Progress remains on standard error, so a coding agent can parse standard output as one JSON object and choose the next bounded goal without another browser observation.
|
|
102
102
|
|
|
103
|
+
The snapshot includes visible controls in direct child 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.
|
|
104
|
+
|
|
103
105
|
## Supply known field values without another LLM
|
|
104
106
|
|
|
105
107
|
For portable QA scenarios, let the planner provide exact test data instead of invoking the Luna fallback. Match a field by its observed accessible label:
|
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.4",
|
|
7
7
|
description: "A small Jev-powered bridge to Chrome through the Chrome DevTools Protocol.",
|
|
8
8
|
type: "module",
|
|
9
9
|
license: "MIT",
|
|
@@ -317,8 +317,15 @@ class Browser {
|
|
|
317
317
|
#pageLoadPromise = null;
|
|
318
318
|
#resolvePageLoad;
|
|
319
319
|
#pauseUntil = 0;
|
|
320
|
+
#frameSessions = new Map;
|
|
321
|
+
#frameContexts = new Map;
|
|
322
|
+
#knownTargets = new Set;
|
|
323
|
+
#openedTabs = [];
|
|
324
|
+
#cdpUrl;
|
|
325
|
+
#switchOnPopup = false;
|
|
320
326
|
constructor(cdp, sessionId, targetId, ownsTarget, browserContextId, options) {
|
|
321
327
|
this.#cdp = cdp;
|
|
328
|
+
this.#cdpUrl = options.cdpUrl;
|
|
322
329
|
this.#sessionId = sessionId;
|
|
323
330
|
this.#targetId = targetId;
|
|
324
331
|
this.#ownsTarget = ownsTarget;
|
|
@@ -376,7 +383,20 @@ class Browser {
|
|
|
376
383
|
flatten: true
|
|
377
384
|
});
|
|
378
385
|
const browser = new Browser(cdp, attached.sessionId, targetId, ownsTarget, browserContextId, options);
|
|
386
|
+
browser.#knownTargets = new Set((await listChromeTargets(options.cdpUrl)).map((target) => target.id));
|
|
379
387
|
try {
|
|
388
|
+
browser.#stopPageEvents.push(cdp.on("Target.attachedToTarget", browser.#sessionId, (params) => {
|
|
389
|
+
const info = params.targetInfo;
|
|
390
|
+
if (info?.type === "iframe" && info.targetId && typeof params.sessionId === "string")
|
|
391
|
+
browser.#frameSessions.set(info.targetId, params.sessionId);
|
|
392
|
+
}));
|
|
393
|
+
browser.#stopPageEvents.push(cdp.on("Page.frameNavigated", browser.#sessionId, (params) => {
|
|
394
|
+
const frame = params.frame;
|
|
395
|
+
if (frame?.id)
|
|
396
|
+
browser.#frameContexts.delete(frame.id);
|
|
397
|
+
}));
|
|
398
|
+
await browser.call("Target.setAutoAttach", { autoAttach: true, waitForDebuggerOnStart: false, flatten: true });
|
|
399
|
+
await browser.call("Page.enable");
|
|
380
400
|
await browser.call("Emulation.setDeviceMetricsOverride", {
|
|
381
401
|
width: 1120,
|
|
382
402
|
height: 780,
|
|
@@ -464,7 +484,9 @@ class Browser {
|
|
|
464
484
|
return;
|
|
465
485
|
if (!Bun.which("ffmpeg"))
|
|
466
486
|
throw new Error("--recording requires ffmpeg on PATH");
|
|
467
|
-
this.#recordingDirectory
|
|
487
|
+
const firstSegment = !this.#recordingDirectory;
|
|
488
|
+
if (firstSegment)
|
|
489
|
+
this.#recordingDirectory = await mkdtemp(join(tmpdir(), "jev-cdp-recording-"));
|
|
468
490
|
await this.call("Page.enable");
|
|
469
491
|
await this.call("Page.addScriptToEvaluateOnNewDocument", { source: RECORDING_CURSOR_INIT });
|
|
470
492
|
const viewport = await this.evaluate("({width: innerWidth, height: innerHeight})");
|
|
@@ -472,11 +494,11 @@ class Browser {
|
|
|
472
494
|
throw new Error("Could not read the recording viewport");
|
|
473
495
|
await this.animateCursor(viewport.width / 2, viewport.height / 2);
|
|
474
496
|
const initial = await this.call("Page.captureScreenshot", { format: "jpeg", quality: 70 });
|
|
475
|
-
const initialPath = join(this.#recordingDirectory, "
|
|
497
|
+
const initialPath = join(this.#recordingDirectory, `${String(this.#recordingSequence++).padStart(6, "0")}.jpg`);
|
|
476
498
|
await Bun.write(initialPath, Buffer.from(initial.data, "base64"));
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
this.#
|
|
499
|
+
if (firstSegment)
|
|
500
|
+
this.#recordingStartedAt = performance.now();
|
|
501
|
+
this.#recordingFrames.push({ path: initialPath, elapsedMs: firstSegment ? 0 : performance.now() - this.#recordingStartedAt });
|
|
480
502
|
this.#stopRecordingEvents = this.#cdp.on("Page.screencastFrame", this.#sessionId, (params) => {
|
|
481
503
|
const frame = params;
|
|
482
504
|
this.call("Page.screencastFrameAck", { sessionId: frame.sessionId }).catch(() => {
|
|
@@ -609,6 +631,64 @@ class Browser {
|
|
|
609
631
|
throw new StalePageError("Document changed during evaluation");
|
|
610
632
|
return response.result?.value;
|
|
611
633
|
}
|
|
634
|
+
async evaluateFrame(frameId, expression) {
|
|
635
|
+
const session = this.#frameSessions.get(frameId);
|
|
636
|
+
let contextId = this.#frameContexts.get(frameId);
|
|
637
|
+
if (!session && !contextId) {
|
|
638
|
+
const context = await this.call("Page.createIsolatedWorld", { frameId });
|
|
639
|
+
contextId = context.executionContextId;
|
|
640
|
+
this.#frameContexts.set(frameId, contextId);
|
|
641
|
+
}
|
|
642
|
+
const response = await this.#cdp.command("Runtime.evaluate", { expression, returnByValue: true, ...contextId ? { contextId } : {} }, session ?? this.#sessionId);
|
|
643
|
+
if (response.exceptionDetails)
|
|
644
|
+
throw new StalePageError("Frame changed during evaluation");
|
|
645
|
+
return response.result?.value;
|
|
646
|
+
}
|
|
647
|
+
async frameOffset(frameId) {
|
|
648
|
+
const owner = await this.call("DOM.getFrameOwner", { frameId });
|
|
649
|
+
const box = await this.call("DOM.getBoxModel", { backendNodeId: owner.backendNodeId });
|
|
650
|
+
return { x: box.model.content[0], y: box.model.content[1] };
|
|
651
|
+
}
|
|
652
|
+
async childFrames() {
|
|
653
|
+
const tree = await this.call("Page.getFrameTree");
|
|
654
|
+
return (tree.frameTree.childFrames ?? []).map((child) => child.frame.id);
|
|
655
|
+
}
|
|
656
|
+
async discoverTabs() {
|
|
657
|
+
if (!this.#switchOnPopup)
|
|
658
|
+
return;
|
|
659
|
+
for (const target of await listChromeTargets(this.#cdpUrl)) {
|
|
660
|
+
if (target.type !== "page" || this.#knownTargets.has(target.id))
|
|
661
|
+
continue;
|
|
662
|
+
this.#knownTargets.add(target.id);
|
|
663
|
+
const attached = await this.#cdp.command("Target.attachToTarget", { targetId: target.id, flatten: true });
|
|
664
|
+
try {
|
|
665
|
+
await this.#cdp.command("Emulation.setDeviceMetricsOverride", {
|
|
666
|
+
width: 1120,
|
|
667
|
+
height: 780,
|
|
668
|
+
deviceScaleFactor: 1,
|
|
669
|
+
mobile: false
|
|
670
|
+
}, attached.sessionId);
|
|
671
|
+
} finally {
|
|
672
|
+
if (this.#switchOnPopup) {
|
|
673
|
+
if (this.#recordingPath) {
|
|
674
|
+
await this.call("Page.stopScreencast").catch(() => {
|
|
675
|
+
return;
|
|
676
|
+
});
|
|
677
|
+
this.#stopRecordingEvents?.();
|
|
678
|
+
this.#stopRecordingEvents = undefined;
|
|
679
|
+
await this.#recordingWrites;
|
|
680
|
+
}
|
|
681
|
+
this.#sessionId = attached.sessionId;
|
|
682
|
+
this.#targetId = target.id;
|
|
683
|
+
this.#switchOnPopup = false;
|
|
684
|
+
await this.startRecording();
|
|
685
|
+
} else {
|
|
686
|
+
await this.#cdp.command("Target.detachFromTarget", { sessionId: attached.sessionId });
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
this.#openedTabs.push({ id: target.id, url: target.url, title: target.title });
|
|
690
|
+
}
|
|
691
|
+
}
|
|
612
692
|
async settleAfterInput() {
|
|
613
693
|
const action = this.#afterInput;
|
|
614
694
|
this.#afterInput = null;
|
|
@@ -644,6 +724,7 @@ class Browser {
|
|
|
644
724
|
}
|
|
645
725
|
async observe(screenshot = this.#screenshots) {
|
|
646
726
|
await this.settleAfterInput();
|
|
727
|
+
await this.discoverTabs();
|
|
647
728
|
let info;
|
|
648
729
|
for (let attempt = 0;attempt < 10; attempt++) {
|
|
649
730
|
try {
|
|
@@ -658,6 +739,36 @@ class Browser {
|
|
|
658
739
|
}
|
|
659
740
|
if (!info)
|
|
660
741
|
throw new StalePageError("Document is navigating");
|
|
742
|
+
if (this.#openedTabs.length) {
|
|
743
|
+
const targets = await listChromeTargets(this.#cdpUrl);
|
|
744
|
+
this.#openedTabs = this.#openedTabs.map((tab) => {
|
|
745
|
+
const current = targets.find((target) => target.id === tab.id);
|
|
746
|
+
return current ? { ...tab, url: current.url, title: current.title } : tab;
|
|
747
|
+
});
|
|
748
|
+
info.text = `${info.text}
|
|
749
|
+
${this.#openedTabs.map((tab) => `Opened new tab: ${tab.title} ${tab.url} (target ${tab.id})`).join(`
|
|
750
|
+
`)}`;
|
|
751
|
+
}
|
|
752
|
+
for (const frameId of await this.childFrames()) {
|
|
753
|
+
try {
|
|
754
|
+
const offset = await this.frameOffset(frameId);
|
|
755
|
+
const child = await this.evaluateFrame(frameId, snapshot_default);
|
|
756
|
+
if (!child)
|
|
757
|
+
continue;
|
|
758
|
+
info.text = `${info.text}
|
|
759
|
+
${child.text}`.slice(0, 6000);
|
|
760
|
+
for (const action of child.actions) {
|
|
761
|
+
if (!action.node || !action.rect)
|
|
762
|
+
continue;
|
|
763
|
+
const rect = { ...action.rect, x: action.rect.x + offset.x, y: action.rect.y + offset.y };
|
|
764
|
+
if (rect.x < 0 || rect.y < 0 || rect.x >= info.w || rect.y >= info.h)
|
|
765
|
+
continue;
|
|
766
|
+
info.actions.push({ ...action, frameId, rect, id: `e${info.actions.length + 1}` });
|
|
767
|
+
info.guards[`${frameId}:${action.node}`] = child.guards[String(action.node)];
|
|
768
|
+
}
|
|
769
|
+
info.marker = [info.marker, frameId, child.marker];
|
|
770
|
+
} catch {}
|
|
771
|
+
}
|
|
661
772
|
const page = { ...info, fingerprint: fingerprint(info) };
|
|
662
773
|
if (screenshot) {
|
|
663
774
|
const capture = await this.call("Page.captureScreenshot", { format: "jpeg", quality: 72 });
|
|
@@ -666,6 +777,12 @@ class Browser {
|
|
|
666
777
|
return page;
|
|
667
778
|
}
|
|
668
779
|
async fresh(page, action) {
|
|
780
|
+
if (action?.frameId && typeof action.node === "number") {
|
|
781
|
+
const current = await this.evaluateFrame(action.frameId, `(() => {
|
|
782
|
+
const c=window.__jevFast; return c ? c.guard(c.nodes.get(${action.node})) : null;
|
|
783
|
+
})()`);
|
|
784
|
+
return stableStringify(current) === stableStringify(page.guards[`${action.frameId}:${action.node}`]);
|
|
785
|
+
}
|
|
669
786
|
if (action && (action.kind === "click" || action.kind === "select")) {
|
|
670
787
|
if (typeof action.node !== "number")
|
|
671
788
|
return false;
|
|
@@ -698,7 +815,11 @@ class Browser {
|
|
|
698
815
|
}
|
|
699
816
|
if (typeof action.node !== "number")
|
|
700
817
|
throw new Error("Invalid observed node");
|
|
701
|
-
const target = await this.
|
|
818
|
+
const target = await (action.frameId ? this.evaluateFrame(action.frameId, `(action => {
|
|
819
|
+
const e=window.__jevFast?.nodes.get(action.node);
|
|
820
|
+
if (!e?.isConnected || !e.checkVisibility({checkOpacity:true,checkVisibilityCSS:true})) return null;
|
|
821
|
+
const r=e.getBoundingClientRect(); return {x:r.x+r.width/2,y:r.y+r.height/2};
|
|
822
|
+
})(${JSON.stringify(action)})`) : this.evaluate(`(action => {
|
|
702
823
|
const e=window.__jevFast?.nodes.get(action.node);
|
|
703
824
|
if (!e?.isConnected || e.matches(':disabled') || e.closest('[aria-disabled="true"],[inert]') ||
|
|
704
825
|
!e.checkVisibility({checkOpacity:true,checkVisibilityCSS:true})) return null;
|
|
@@ -714,12 +835,17 @@ class Browser {
|
|
|
714
835
|
e.dispatchEvent(new Event('change',{bubbles:true}));
|
|
715
836
|
}
|
|
716
837
|
return {x,y};
|
|
717
|
-
})(${JSON.stringify(action)})`);
|
|
838
|
+
})(${JSON.stringify(action)})`));
|
|
718
839
|
if (!target) {
|
|
719
840
|
if (action.kind === "select")
|
|
720
841
|
throw new Error("Dropdown execution was not confirmed");
|
|
721
842
|
throw new StalePageError("Target changed or is covered");
|
|
722
843
|
}
|
|
844
|
+
if (action.frameId) {
|
|
845
|
+
const offset = await this.frameOffset(action.frameId);
|
|
846
|
+
target.x += offset.x;
|
|
847
|
+
target.y += offset.y;
|
|
848
|
+
}
|
|
723
849
|
await this.animateCursor(target.x, target.y);
|
|
724
850
|
if (action.kind !== "select") {
|
|
725
851
|
if (action.kind === "click" && this.#interactionPauses > 0) {
|
|
@@ -760,6 +886,10 @@ class Browser {
|
|
|
760
886
|
}
|
|
761
887
|
}
|
|
762
888
|
this.#afterInput = action;
|
|
889
|
+
if (action.frameId && action.kind === "click") {
|
|
890
|
+
this.#switchOnPopup = true;
|
|
891
|
+
await Bun.sleep(650);
|
|
892
|
+
}
|
|
763
893
|
}
|
|
764
894
|
async close() {
|
|
765
895
|
if (this.#closed)
|