@jobshimo/browser-link 0.34.1 → 0.36.0

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 (38) hide show
  1. package/dist/agent-instructions/content.js +19 -6
  2. package/dist/agent-instructions/content.js.map +1 -1
  3. package/dist/bridge/events.d.ts +1 -1
  4. package/dist/bridge/events.js +8 -1
  5. package/dist/bridge/events.js.map +1 -1
  6. package/dist/bridge/ws-bridge.d.ts +17 -0
  7. package/dist/bridge/ws-bridge.js +34 -0
  8. package/dist/bridge/ws-bridge.js.map +1 -1
  9. package/dist/cdp/inpage/builders.d.ts +70 -3
  10. package/dist/cdp/inpage/builders.js +197 -36
  11. package/dist/cdp/inpage/builders.js.map +1 -1
  12. package/dist/cdp/inpage/observer.d.ts +77 -0
  13. package/dist/cdp/inpage/observer.js +509 -0
  14. package/dist/cdp/inpage/observer.js.map +1 -0
  15. package/dist/cdp/observer-host.d.ts +137 -0
  16. package/dist/cdp/observer-host.js +188 -0
  17. package/dist/cdp/observer-host.js.map +1 -0
  18. package/dist/cdp/transport.js +161 -25
  19. package/dist/cdp/transport.js.map +1 -1
  20. package/dist/extension/background.js +183 -33
  21. package/dist/extension/background.js.map +1 -1
  22. package/dist/extension/inpage/builders.d.ts +70 -3
  23. package/dist/extension/inpage/builders.js +196 -35
  24. package/dist/extension/inpage/builders.js.map +1 -1
  25. package/dist/extension/inpage/observer.d.ts +88 -0
  26. package/dist/extension/inpage/observer.js +520 -0
  27. package/dist/extension/inpage/observer.js.map +1 -0
  28. package/dist/extension/manifest.json +1 -1
  29. package/dist/extension/observer-host.d.ts +84 -0
  30. package/dist/extension/observer-host.js +172 -0
  31. package/dist/extension/observer-host.js.map +1 -0
  32. package/dist/tools/browser-definitions.js +16 -6
  33. package/dist/tools/browser-definitions.js.map +1 -1
  34. package/dist/tools/browser-dispatch.js +14 -2
  35. package/dist/tools/browser-dispatch.js.map +1 -1
  36. package/dist/tools/server-instructions.js +19 -5
  37. package/dist/tools/server-instructions.js.map +1 -1
  38. package/package.json +1 -1
@@ -1,4 +1,6 @@
1
- import { buildSnapshotJs, buildFindJs, buildClickResolveJs, buildTypeResolveJs, buildFocusJs, buildStateJs, buildDragProbeJs, buildElementRectJs, } from './inpage/builders.js';
1
+ import { buildSnapshotJs, buildFindJs, buildClickResolveJs, buildTypeResolveJs, buildFocusJs, buildStateJs, buildDragProbeJs, buildElementRectJs, buildWaitForSelectorJs, } from './inpage/builders.js';
2
+ import { OBSERVER_BINDING, buildObserverChangesJs, buildObserverInstallJs, buildObserverMarkJs, buildObserverTeardownJs, } from './inpage/observer.js';
3
+ import { TOP_DOCUMENT_PROBE_JS, applyObserverMessage, createObserverMirror, isTopDocumentMessage, mergeSettleChanges, parseObserverMessage, readObserverMark, } from './observer-host.js';
2
4
  import { runScreenshot } from './screenshot.js';
3
5
  import { buildKeyEventSequence, resolveKey, modifiersToBitmask, MODIFIER_BITS, } from './keymap.js';
4
6
  import { resolveSettleParams, settleSafely } from './settle.js';
@@ -425,6 +427,171 @@ function sleep(ms) {
425
427
  function runSettle(tabId, settle) {
426
428
  return settleSafely((expression) => evaluateInTab(tabId, expression), settle);
427
429
  }
430
+ const OBSERVER_UNAVAILABLE = { ok: false, reason: 'observer-unavailable' };
431
+ const OBSERVER_DOC_CHANGED = { ok: false, reason: 'doc-changed' };
432
+ /** Ceiling for one observer plumbing evaluation: a page frozen by a native
433
+ * dialog never answers `Runtime.evaluate`, and `cleanup` must still be
434
+ * able to detach. */
435
+ const OBSERVER_EVAL_TIMEOUT_MS = 2_000;
436
+ /** Race a CDP call against `OBSERVER_EVAL_TIMEOUT_MS`; rejects on timeout. */
437
+ async function withObserverTimeout(work) {
438
+ let timer;
439
+ const timeout = new Promise((_resolve, reject) => {
440
+ timer = setTimeout(() => {
441
+ reject(new Error(`observer evaluation timed out after ${OBSERVER_EVAL_TIMEOUT_MS}ms`));
442
+ }, OBSERVER_EVAL_TIMEOUT_MS);
443
+ });
444
+ return Promise.race([work, timeout]).finally(() => {
445
+ clearTimeout(timer);
446
+ });
447
+ }
448
+ /**
449
+ * `Runtime.evaluate` for page-observer plumbing (mark, changes, probes,
450
+ * teardown). Unlike `evaluateInTab` it passes no `userGesture` — these
451
+ * reads must never hand the page transient user activation it did not
452
+ * earn — and it rejects after `OBSERVER_EVAL_TIMEOUT_MS`.
453
+ */
454
+ async function evaluateObserverJs(tabId, expression) {
455
+ const result = await withObserverTimeout(cdp(tabId, 'Runtime.evaluate', {
456
+ expression,
457
+ returnByValue: true,
458
+ }));
459
+ if (result.exceptionDetails) {
460
+ const ex = result.exceptionDetails;
461
+ throw new Error(ex.exception?.description ?? ex.text ?? 'Evaluation failed');
462
+ }
463
+ return result.result.value;
464
+ }
465
+ /**
466
+ * Take the page observer's action mark BEFORE an action dispatches (spec v4
467
+ * §5.4). `null` when settle is off for this call (no changes are reported
468
+ * then); an `observer-unavailable` marker when the observer was never
469
+ * installed on this tab or the mark could not be taken.
470
+ */
471
+ async function takeObserverMark(tabId, settle) {
472
+ if (!settle)
473
+ return null;
474
+ if (tabStates.get(tabId)?.observer?.installed !== true)
475
+ return OBSERVER_UNAVAILABLE;
476
+ try {
477
+ return await evaluateObserverJs(tabId, buildObserverMarkJs());
478
+ }
479
+ catch {
480
+ return OBSERVER_UNAVAILABLE;
481
+ }
482
+ }
483
+ /**
484
+ * Run the settle wait, then read what the page observer saw since `mark`
485
+ * and fold it into the settle result (`mergeSettleChanges`). Never throws:
486
+ * a changes read that rejects — typically because the action navigated and
487
+ * destroyed the context — is reported as `doc-changed`.
488
+ */
489
+ async function settleWithChanges(tabId, settle, mark) {
490
+ const settleResult = await runSettle(tabId, settle);
491
+ if (!settleResult)
492
+ return undefined;
493
+ const parsed = readObserverMark(mark);
494
+ let changes = mark ?? OBSERVER_UNAVAILABLE;
495
+ if (parsed) {
496
+ try {
497
+ changes = await evaluateObserverJs(tabId, buildObserverChangesJs(parsed.mark, parsed.doc_id));
498
+ }
499
+ catch {
500
+ changes = OBSERVER_DOC_CHANGED;
501
+ }
502
+ }
503
+ return mergeSettleChanges(settleResult, changes) ?? undefined;
504
+ }
505
+ /**
506
+ * Handle one page-observer binding message. Summaries only update the
507
+ * mirror. `doc_start` / `nav` are first checked against the top document
508
+ * (the install script also runs in same-process child frames, and the
509
+ * binding is page-visible), then reported to the server as a
510
+ * `tab-navigated` bridge event so it can keep the tab's URL and title
511
+ * current.
512
+ */
513
+ async function handleObserverMessage(state, payload) {
514
+ const mirror = state.observer;
515
+ if (!mirror)
516
+ return;
517
+ const message = parseObserverMessage(payload);
518
+ if (!message)
519
+ return;
520
+ if (message.type === 'summary') {
521
+ applyObserverMessage(mirror, message);
522
+ return;
523
+ }
524
+ let probe;
525
+ try {
526
+ probe = await evaluateObserverJs(state.tabId, TOP_DOCUMENT_PROBE_JS);
527
+ }
528
+ catch {
529
+ return;
530
+ }
531
+ if (!isTopDocumentMessage(probe, message.doc_id))
532
+ return;
533
+ const outcome = applyObserverMessage(mirror, message);
534
+ if (outcome.kind !== 'doc_start' && outcome.kind !== 'nav')
535
+ return;
536
+ let title = '';
537
+ try {
538
+ title = (await chrome.tabs.get(state.tabId)).title ?? '';
539
+ }
540
+ catch {
541
+ // Best effort — the tab may be closing; the URL is still worth sending.
542
+ }
543
+ if (state.ws && state.ws.readyState === WebSocket.OPEN && state.serverTabId) {
544
+ send(state.ws, {
545
+ kind: 'bridge.event',
546
+ eventKind: 'tab-navigated',
547
+ tabId: state.serverTabId,
548
+ data: { url: outcome.url, title },
549
+ });
550
+ }
551
+ }
552
+ /**
553
+ * Install the page observer on a freshly attached tab: the push binding,
554
+ * then the observer as a new-document script that also runs immediately in
555
+ * the current document. Non-fatal — on failure the tab connects as before
556
+ * and actions report `settle.changes: null` with `observer-unavailable`.
557
+ */
558
+ async function installObserver(state) {
559
+ const mirror = createObserverMirror();
560
+ state.observer = mirror;
561
+ try {
562
+ await cdp(state.tabId, 'Runtime.addBinding', { name: OBSERVER_BINDING });
563
+ const added = await cdp(state.tabId, 'Page.addScriptToEvaluateOnNewDocument', { source: buildObserverInstallJs(), runImmediately: true });
564
+ if (typeof added?.identifier === 'string')
565
+ state.observerScriptId = added.identifier;
566
+ mirror.installed = true;
567
+ }
568
+ catch (err) {
569
+ console.warn('browser-link: page observer not installed', err instanceof Error ? err.message : String(err));
570
+ }
571
+ }
572
+ /** Best-effort observer removal before the debugger detaches. */
573
+ async function uninstallObserver(state) {
574
+ if (!state.observer?.installed)
575
+ return;
576
+ state.observer.installed = false;
577
+ if (state.observerScriptId !== undefined) {
578
+ try {
579
+ await withObserverTimeout(cdp(state.tabId, 'Page.removeScriptToEvaluateOnNewDocument', {
580
+ identifier: state.observerScriptId,
581
+ }));
582
+ }
583
+ catch {
584
+ // Best effort — the tab may already be gone.
585
+ }
586
+ state.observerScriptId = undefined;
587
+ }
588
+ try {
589
+ await evaluateObserverJs(state.tabId, buildObserverTeardownJs());
590
+ }
591
+ catch {
592
+ // Best effort — the tab may already be gone.
593
+ }
594
+ }
428
595
  /**
429
596
  * Dispatch the CDP `Input.dispatchKeyEvent` sequence for one resolved key.
430
597
  * The event shapes come from `buildKeyEventSequence` (keymap.ts) — pure and
@@ -472,34 +639,6 @@ function waitForCdpEvent(tabId, method, timeoutMs) {
472
639
  chrome.debugger.onEvent.addListener(handler);
473
640
  });
474
641
  }
475
- /**
476
- * Build the JS expression that checks a single wait_for condition for a
477
- * selector. Returns a boolean. Used by `case 'wait_for'` in handleTool.
478
- *
479
- * - visible: element exists AND non-zero box AND opacity > 0
480
- * - hidden: element doesn't exist OR zero box OR opacity 0 OR display:none
481
- * - attached: element exists in DOM (any state)
482
- * - detached: element does NOT exist in DOM
483
- */
484
- function buildWaitSelectorExpr(selector, condition) {
485
- const sel = JSON.stringify(selector);
486
- if (condition === 'attached') {
487
- return `Boolean(document.querySelector(${sel}))`;
488
- }
489
- if (condition === 'detached') {
490
- return `!document.querySelector(${sel})`;
491
- }
492
- // visible / hidden share the same probe — only the truthiness flips.
493
- const want = condition === 'hidden' ? '!' : '';
494
- return `(() => {
495
- const el = document.querySelector(${sel});
496
- if (!el) return ${condition === 'hidden' ? 'true' : 'false'};
497
- const s = getComputedStyle(el);
498
- if (s.display === 'none' || s.visibility === 'hidden' || s.opacity === '0') return ${condition === 'hidden' ? 'true' : 'false'};
499
- const r = el.getBoundingClientRect();
500
- return ${want}(r.width > 0 && r.height > 0);
501
- })()`;
502
- }
503
642
  // Convert an arbitrary console arg (`unknown`) into something printable
504
643
  // without leaking `[object Object]`. Used by the console buffer when CDP
505
644
  // hands us values we don't statically know.
@@ -649,6 +788,11 @@ function attachDebuggerListener(state) {
649
788
  }
650
789
  return;
651
790
  }
791
+ if (method === 'Runtime.bindingCalled' &&
792
+ params.name === OBSERVER_BINDING) {
793
+ void handleObserverMessage(state, params.payload ?? '');
794
+ return;
795
+ }
652
796
  if (method === 'Runtime.bindingCalled') {
653
797
  // Fired for EVERY CDP binding on the tab, not just ours. Two gates
654
798
  // before a payload is even parsed: the per-session binding name must
@@ -863,6 +1007,7 @@ async function performClick(tabId, params) {
863
1007
  }
864
1008
  return { ok: false, error: `Element not found: ${selector}` };
865
1009
  }
1010
+ const mark = await takeObserverMark(tabId, settle);
866
1011
  const baseline = await beginExpect(evaluate, expectSpec);
867
1012
  await cdp(tabId, 'Input.dispatchMouseEvent', {
868
1013
  type: 'mouseMoved',
@@ -883,7 +1028,7 @@ async function performClick(tabId, params) {
883
1028
  button: 'left',
884
1029
  clickCount: 1,
885
1030
  });
886
- const settleResult = await runSettle(tabId, settle);
1031
+ const settleResult = await settleWithChanges(tabId, settle, mark);
887
1032
  const verdict = await verifyExpect(evaluate, expectSpec, baseline, settleResult);
888
1033
  if (!verdict.ok)
889
1034
  return { ok: false, error: verdict.error };
@@ -918,9 +1063,10 @@ async function performType(tabId, params) {
918
1063
  return { ok: false, error: `Element not found: ${selector}` };
919
1064
  }
920
1065
  }
1066
+ const mark = await takeObserverMark(tabId, settle);
921
1067
  const baseline = await beginExpect(evaluate, expectSpec);
922
1068
  await cdp(tabId, 'Input.insertText', { text });
923
- const settleResult = await runSettle(tabId, settle);
1069
+ const settleResult = await settleWithChanges(tabId, settle, mark);
924
1070
  const verdict = await verifyExpect(evaluate, expectSpec, baseline, settleResult);
925
1071
  if (!verdict.ok)
926
1072
  return { ok: false, error: verdict.error };
@@ -959,9 +1105,10 @@ async function performPress(tabId, params) {
959
1105
  return { ok: false, error: `Element not found: ${params.selector}` };
960
1106
  }
961
1107
  }
1108
+ const mark = await takeObserverMark(tabId, settle);
962
1109
  const baseline = await beginExpect(evaluate, expectSpec);
963
1110
  await dispatchKeyEvent(tabId, def, modifiers);
964
- const settleResult = await runSettle(tabId, settle);
1111
+ const settleResult = await settleWithChanges(tabId, settle, mark);
965
1112
  const verdict = await verifyExpect(evaluate, expectSpec, baseline, settleResult);
966
1113
  if (!verdict.ok)
967
1114
  return { ok: false, error: verdict.error };
@@ -1010,7 +1157,7 @@ shouldCancel) {
1010
1157
  // null and we return matched=false immediately.
1011
1158
  let check = null;
1012
1159
  if (waitSelector !== undefined) {
1013
- const expr = buildWaitSelectorExpr(waitSelector, waitCondition);
1160
+ const expr = buildWaitForSelectorJs(waitSelector, waitCondition);
1014
1161
  check = async () => {
1015
1162
  try {
1016
1163
  const result = await evaluateInTab(tabId, expr);
@@ -1400,6 +1547,7 @@ async function handleSnapshot(tabId, msg, p, optStr) {
1400
1547
  ? p.exclude.filter((x) => typeof x === 'string')
1401
1548
  : undefined,
1402
1549
  max_interactive: typeof p.max_interactive === 'number' ? p.max_interactive : undefined,
1550
+ since: optStr('since'),
1403
1551
  }));
1404
1552
  return { kind: 'tool.response', id: msg.id, ok: true, result: value };
1405
1553
  }
@@ -2229,6 +2377,7 @@ async function cleanup(tabId) {
2229
2377
  }
2230
2378
  }
2231
2379
  if (state.debuggerAttached) {
2380
+ await uninstallObserver(state);
2232
2381
  try {
2233
2382
  await chrome.debugger.detach({ tabId });
2234
2383
  }
@@ -2486,6 +2635,7 @@ async function doConnectTab(tabId) {
2486
2635
  error: `Could not enable CDP: ${err instanceof Error ? err.message : String(err)}`,
2487
2636
  };
2488
2637
  }
2638
+ await installObserver(state);
2489
2639
  const ws = new WebSocket(WS_URL);
2490
2640
  state.ws = ws;
2491
2641
  // Capture the fields we'll send over the wire NOW that we've already