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.
Files changed (3) hide show
  1. package/README.md +2 -0
  2. package/dist/cli.js +138 -8
  3. 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.3",
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 = await mkdtemp(join(tmpdir(), "jev-cdp-recording-"));
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, "000000.jpg");
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
- this.#recordingFrames.push({ path: initialPath, elapsedMs: 0 });
478
- this.#recordingSequence = 1;
479
- this.#recordingStartedAt = performance.now();
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.evaluate(`(action => {
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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jev-cdp",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "A small Jev-powered bridge to Chrome through the Chrome DevTools Protocol.",
5
5
  "type": "module",
6
6
  "license": "MIT",