@jobshimo/browser-link 0.16.0 → 0.18.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.
@@ -1,6 +1,7 @@
1
- import { buildSnapshotJs, buildFindJs, buildClickResolveJs, buildTypeResolveJs, buildFocusJs, } from './inpage/builders.js';
1
+ import { buildSnapshotJs, buildFindJs, buildClickResolveJs, buildTypeResolveJs, buildFocusJs, buildStateJs, } from './inpage/builders.js';
2
2
  import { buildKeyEventSequence, resolveKey, modifiersToBitmask, MODIFIER_BITS, } from './keymap.js';
3
3
  import { resolveSettleParams, settleSafely } from './settle.js';
4
+ import { runFlow, } from './flow.js';
4
5
  const WS_URL = 'ws://127.0.0.1:17529';
5
6
  const CDP_VERSION = '1.3';
6
7
  const CONSOLE_BUFFER_MAX = 200;
@@ -528,6 +529,239 @@ async function waitForLoad(tabId, timeoutMs = 20_000) {
528
529
  chrome.tabs.onUpdated.addListener(handler);
529
530
  });
530
531
  }
532
+ /**
533
+ * `performFind` / `performClick` / `performType` / `performPress` /
534
+ * `performWaitFor` — the extracted bodies of the standalone `find` /
535
+ * `click` / `type` / `press` / `wait_for` cases in `handleTool` below.
536
+ *
537
+ * Both the standalone `case` handlers AND `browser.flow`'s step executor
538
+ * (`runFlow` in `./flow.js`) call these exact functions — there is one
539
+ * implementation of each action, never a copy that can drift. Each
540
+ * returns an `ActionOutcome<T>` (`{ok:true,result} | {ok:false,error}`)
541
+ * instead of a wire-level `tool.response`, so the two call sites can
542
+ * unwrap it however they need to (the standalone case wraps it straight
543
+ * into a `tool.response`; `runFlow` inspects `result` for its own
544
+ * fail-fast rules — e.g. `wait_for`'s `matched:false` is `ok:true` here,
545
+ * matching the standalone tool's contract, and it is `runFlow`, not this
546
+ * function, that decides a non-match should stop the flow).
547
+ *
548
+ * `performType`'s `selector` is optional here (the standalone tool's JSON
549
+ * schema still requires it, so that path never changes): when omitted,
550
+ * this skips element resolution entirely and types into whatever
551
+ * currently has focus, mirroring how `performPress` already behaves with
552
+ * no `selector`. That is what lets a `browser.flow` step continue typing
553
+ * into a freshly-appeared, auto-focused input (e.g. a search box that
554
+ * opened after a preceding click) without a selector for it.
555
+ */
556
+ async function performFind(tabId, params) {
557
+ try {
558
+ const { text } = params;
559
+ if (!text)
560
+ return { ok: false, error: 'find: text required' };
561
+ const value = await evaluateInTab(tabId, buildFindJs({ text, role: params.role, exact: params.exact === true }));
562
+ return { ok: true, result: value };
563
+ }
564
+ catch (err) {
565
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
566
+ }
567
+ }
568
+ async function performClick(tabId, params) {
569
+ try {
570
+ const { selector, force = false } = params;
571
+ const settle = resolveSettleParams({
572
+ settle_ms: params.settle_ms,
573
+ settle_timeout_ms: params.settle_timeout_ms,
574
+ });
575
+ const resolved = await evaluateInTab(tabId, buildClickResolveJs({ selector, force }));
576
+ if (!resolved.ok) {
577
+ if (resolved.reason === 'occluded') {
578
+ return {
579
+ ok: false,
580
+ error: `Element covered by ${resolved.blocker} — click the covering element or dismiss it first`,
581
+ };
582
+ }
583
+ if (resolved.reason === 'invalid-selector') {
584
+ return { ok: false, error: `Invalid selector "${selector}": ${resolved.error}` };
585
+ }
586
+ return { ok: false, error: `Element not found: ${selector}` };
587
+ }
588
+ await cdp(tabId, 'Input.dispatchMouseEvent', {
589
+ type: 'mouseMoved',
590
+ x: resolved.x,
591
+ y: resolved.y,
592
+ });
593
+ await cdp(tabId, 'Input.dispatchMouseEvent', {
594
+ type: 'mousePressed',
595
+ x: resolved.x,
596
+ y: resolved.y,
597
+ button: 'left',
598
+ clickCount: 1,
599
+ });
600
+ await cdp(tabId, 'Input.dispatchMouseEvent', {
601
+ type: 'mouseReleased',
602
+ x: resolved.x,
603
+ y: resolved.y,
604
+ button: 'left',
605
+ clickCount: 1,
606
+ });
607
+ const settleResult = await runSettle(tabId, settle);
608
+ const result = { clicked: selector, tag: resolved.tag };
609
+ if (settleResult)
610
+ result.settle = settleResult;
611
+ return { ok: true, result };
612
+ }
613
+ catch (err) {
614
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
615
+ }
616
+ }
617
+ async function performType(tabId, params) {
618
+ try {
619
+ const { selector, text, clear = false } = params;
620
+ const settle = resolveSettleParams({
621
+ settle_ms: params.settle_ms,
622
+ settle_timeout_ms: params.settle_timeout_ms,
623
+ });
624
+ if (selector !== undefined) {
625
+ const resolved = await evaluateInTab(tabId, buildTypeResolveJs({ selector, clear }));
626
+ if (!resolved.ok) {
627
+ if (resolved.reason === 'invalid-selector') {
628
+ return { ok: false, error: `Invalid selector "${selector}": ${resolved.error}` };
629
+ }
630
+ return { ok: false, error: `Element not found: ${selector}` };
631
+ }
632
+ }
633
+ await cdp(tabId, 'Input.insertText', { text });
634
+ const settleResult = await runSettle(tabId, settle);
635
+ const result = { typed: text.length };
636
+ if (selector !== undefined)
637
+ result.selector = selector;
638
+ if (settleResult)
639
+ result.settle = settleResult;
640
+ return { ok: true, result };
641
+ }
642
+ catch (err) {
643
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
644
+ }
645
+ }
646
+ async function performPress(tabId, params) {
647
+ try {
648
+ const key = params.key;
649
+ if (!key)
650
+ return { ok: false, error: 'press: key required' };
651
+ const def = resolveKey(key);
652
+ if (!def)
653
+ return { ok: false, error: `press: unrecognized key "${key}"` };
654
+ const modifierNames = params.modifiers ?? [];
655
+ const modifiers = modifiersToBitmask(modifierNames) | (def.needsShift ? MODIFIER_BITS.Shift : 0);
656
+ const settle = resolveSettleParams({
657
+ settle_ms: params.settle_ms,
658
+ settle_timeout_ms: params.settle_timeout_ms,
659
+ });
660
+ if (params.selector) {
661
+ const focused = await evaluateInTab(tabId, buildFocusJs({ selector: params.selector }));
662
+ if (!focused) {
663
+ return { ok: false, error: `Element not found: ${params.selector}` };
664
+ }
665
+ }
666
+ await dispatchKeyEvent(tabId, def, modifiers);
667
+ const settleResult = await runSettle(tabId, settle);
668
+ const result = { key, modifiers: modifierNames };
669
+ if (settleResult)
670
+ result.settle = settleResult;
671
+ return { ok: true, result };
672
+ }
673
+ catch (err) {
674
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
675
+ }
676
+ }
677
+ async function performWaitFor(tabId, state, params) {
678
+ try {
679
+ const waitSelector = params.selector;
680
+ const waitExpression = params.expression;
681
+ const waitNetworkUrl = params.network_url;
682
+ const waitCondition = params.condition ?? 'visible';
683
+ // Defensive caps mirror the dispatcher's contract and keep CodeQL
684
+ // happy about setTimeout durations driven by request params.
685
+ const MAX_WAIT_TIMEOUT_MS = 30_000;
686
+ const MIN_POLL_MS = 50;
687
+ const MAX_POLL_MS = 1_000;
688
+ const requestedTimeout = params.timeout_ms ?? 5_000;
689
+ const timeoutMs = requestedTimeout < MAX_WAIT_TIMEOUT_MS ? requestedTimeout : MAX_WAIT_TIMEOUT_MS;
690
+ const requestedPoll = params.poll_interval_ms ?? 100;
691
+ const pollIntervalMs = requestedPoll < MIN_POLL_MS
692
+ ? MIN_POLL_MS
693
+ : requestedPoll > MAX_POLL_MS
694
+ ? MAX_POLL_MS
695
+ : requestedPoll;
696
+ // Build the check function based on which mode the caller picked. The
697
+ // dispatcher already enforced "exactly one of selector / expression /
698
+ // network_url" so we just pick the first defined one. If none are
699
+ // defined (shouldn't happen from a well-formed call), the check stays
700
+ // null and we return matched=false immediately.
701
+ let check = null;
702
+ if (waitSelector !== undefined) {
703
+ const expr = buildWaitSelectorExpr(waitSelector, waitCondition);
704
+ check = async () => {
705
+ try {
706
+ const result = await evaluateInTab(tabId, expr);
707
+ return Boolean(result);
708
+ }
709
+ catch {
710
+ // If the page is unreachable mid-poll, count as "not yet matched".
711
+ return false;
712
+ }
713
+ };
714
+ }
715
+ else if (waitExpression !== undefined) {
716
+ const wrapped = `Boolean(${waitExpression})`;
717
+ check = async () => {
718
+ try {
719
+ const result = await evaluateInTab(tabId, wrapped);
720
+ return Boolean(result);
721
+ }
722
+ catch {
723
+ return false;
724
+ }
725
+ };
726
+ }
727
+ else if (waitNetworkUrl !== undefined) {
728
+ const needle = waitNetworkUrl.toLowerCase();
729
+ check = () => {
730
+ for (const id of state.networkOrder) {
731
+ const r = state.networkBuffer.get(id);
732
+ if (!r)
733
+ continue;
734
+ if (r.finished_at === undefined)
735
+ continue;
736
+ if (r.url.toLowerCase().includes(needle))
737
+ return Promise.resolve(true);
738
+ }
739
+ return Promise.resolve(false);
740
+ };
741
+ }
742
+ const startedAt = Date.now();
743
+ let checks = 0;
744
+ let matched = false;
745
+ while (check) {
746
+ checks++;
747
+ if (await check()) {
748
+ matched = true;
749
+ break;
750
+ }
751
+ if (Date.now() - startedAt >= timeoutMs)
752
+ break;
753
+ await sleep(pollIntervalMs);
754
+ }
755
+ const elapsedMs = Date.now() - startedAt;
756
+ const result = matched
757
+ ? { matched: true, elapsed_ms: elapsedMs, checks }
758
+ : { matched: false, elapsed_ms: elapsedMs, checks, reason: 'timeout' };
759
+ return { ok: true, result };
760
+ }
761
+ catch (err) {
762
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
763
+ }
764
+ }
531
765
  async function handleTool(state, msg) {
532
766
  const tabId = state.tabId;
533
767
  // Params come over the wire as JSON: each field is unknown until we
@@ -572,22 +806,20 @@ async function handleTool(state, msg) {
572
806
  }));
573
807
  return { kind: 'tool.response', id: msg.id, ok: true, result: value };
574
808
  }
809
+ case 'state': {
810
+ const value = await evaluateInTab(tabId, buildStateJs());
811
+ return { kind: 'tool.response', id: msg.id, ok: true, result: value };
812
+ }
575
813
  case 'find': {
576
- const text = str('text');
577
- if (!text) {
578
- return {
579
- kind: 'tool.response',
580
- id: msg.id,
581
- ok: false,
582
- error: 'find: text required',
583
- };
584
- }
585
- const value = await evaluateInTab(tabId, buildFindJs({
586
- text,
814
+ const outcome = await performFind(tabId, {
815
+ text: str('text'),
587
816
  role: optStr('role'),
588
817
  exact: p.exact === true,
589
- }));
590
- return { kind: 'tool.response', id: msg.id, ok: true, result: value };
818
+ });
819
+ if (!outcome.ok) {
820
+ return { kind: 'tool.response', id: msg.id, ok: false, error: outcome.error };
821
+ }
822
+ return { kind: 'tool.response', id: msg.id, ok: true, result: outcome.result };
591
823
  }
592
824
  case 'canvas_screenshot': {
593
825
  const selector = optStr('selector');
@@ -629,109 +861,67 @@ async function handleTool(state, msg) {
629
861
  return { kind: 'tool.response', id: msg.id, ok: true, result: body };
630
862
  }
631
863
  case 'click': {
632
- const selector = str('selector');
633
- const force = p.force === true;
634
- const settle = resolveSettleParams(p);
635
- const resolved = await evaluateInTab(tabId, buildClickResolveJs({ selector, force }));
636
- if (!resolved.ok) {
637
- if (resolved.reason === 'occluded') {
638
- return {
639
- kind: 'tool.response',
640
- id: msg.id,
641
- ok: false,
642
- error: `Element covered by ${resolved.blocker} — click the covering element or dismiss it first`,
643
- };
644
- }
645
- return {
646
- kind: 'tool.response',
647
- id: msg.id,
648
- ok: false,
649
- error: `Element not found: ${selector}`,
650
- };
651
- }
652
- await cdp(tabId, 'Input.dispatchMouseEvent', {
653
- type: 'mouseMoved',
654
- x: resolved.x,
655
- y: resolved.y,
864
+ const outcome = await performClick(tabId, {
865
+ selector: str('selector'),
866
+ force: p.force === true,
867
+ settle_ms: typeof p.settle_ms === 'number' ? p.settle_ms : undefined,
868
+ settle_timeout_ms: typeof p.settle_timeout_ms === 'number' ? p.settle_timeout_ms : undefined,
656
869
  });
657
- await cdp(tabId, 'Input.dispatchMouseEvent', {
658
- type: 'mousePressed',
659
- x: resolved.x,
660
- y: resolved.y,
661
- button: 'left',
662
- clickCount: 1,
663
- });
664
- await cdp(tabId, 'Input.dispatchMouseEvent', {
665
- type: 'mouseReleased',
666
- x: resolved.x,
667
- y: resolved.y,
668
- button: 'left',
669
- clickCount: 1,
670
- });
671
- const settleResult = await runSettle(tabId, settle);
672
- const result = { clicked: selector, tag: resolved.tag };
673
- if (settleResult)
674
- result.settle = settleResult;
675
- return { kind: 'tool.response', id: msg.id, ok: true, result };
870
+ if (!outcome.ok) {
871
+ return { kind: 'tool.response', id: msg.id, ok: false, error: outcome.error };
872
+ }
873
+ return { kind: 'tool.response', id: msg.id, ok: true, result: outcome.result };
676
874
  }
677
875
  case 'type': {
678
- const selector = str('selector');
679
- const text = str('text');
680
- const clear = p.clear === true;
681
- const settle = resolveSettleParams(p);
682
- const focused = await evaluateInTab(tabId, buildTypeResolveJs({ selector, clear }));
683
- if (!focused) {
684
- return {
685
- kind: 'tool.response',
686
- id: msg.id,
687
- ok: false,
688
- error: `Element not found: ${selector}`,
689
- };
876
+ const outcome = await performType(tabId, {
877
+ selector: str('selector'),
878
+ text: str('text'),
879
+ clear: p.clear === true,
880
+ settle_ms: typeof p.settle_ms === 'number' ? p.settle_ms : undefined,
881
+ settle_timeout_ms: typeof p.settle_timeout_ms === 'number' ? p.settle_timeout_ms : undefined,
882
+ });
883
+ if (!outcome.ok) {
884
+ return { kind: 'tool.response', id: msg.id, ok: false, error: outcome.error };
690
885
  }
691
- await cdp(tabId, 'Input.insertText', { text });
692
- const settleResult = await runSettle(tabId, settle);
693
- const result = { typed: text.length, selector };
694
- if (settleResult)
695
- result.settle = settleResult;
696
- return { kind: 'tool.response', id: msg.id, ok: true, result };
886
+ return { kind: 'tool.response', id: msg.id, ok: true, result: outcome.result };
697
887
  }
698
888
  case 'press': {
699
- const key = optStr('key');
700
- if (!key) {
701
- return { kind: 'tool.response', id: msg.id, ok: false, error: 'press: key required' };
702
- }
703
- const def = resolveKey(key);
704
- if (!def) {
705
- return {
706
- kind: 'tool.response',
707
- id: msg.id,
708
- ok: false,
709
- error: `press: unrecognized key "${key}"`,
710
- };
711
- }
712
889
  const modifierNames = Array.isArray(p.modifiers)
713
890
  ? p.modifiers.filter((m) => typeof m === 'string')
714
891
  : [];
715
- const modifiers = modifiersToBitmask(modifierNames) | (def.needsShift ? MODIFIER_BITS.Shift : 0);
716
- const selector = optStr('selector');
717
- const settle = resolveSettleParams(p);
718
- if (selector) {
719
- const focused = await evaluateInTab(tabId, buildFocusJs({ selector }));
720
- if (!focused) {
721
- return {
722
- kind: 'tool.response',
723
- id: msg.id,
724
- ok: false,
725
- error: `Element not found: ${selector}`,
726
- };
727
- }
892
+ const outcome = await performPress(tabId, {
893
+ key: optStr('key'),
894
+ modifiers: modifierNames,
895
+ selector: optStr('selector'),
896
+ settle_ms: typeof p.settle_ms === 'number' ? p.settle_ms : undefined,
897
+ settle_timeout_ms: typeof p.settle_timeout_ms === 'number' ? p.settle_timeout_ms : undefined,
898
+ });
899
+ if (!outcome.ok) {
900
+ return { kind: 'tool.response', id: msg.id, ok: false, error: outcome.error };
728
901
  }
729
- await dispatchKeyEvent(tabId, def, modifiers);
730
- const settleResult = await runSettle(tabId, settle);
731
- const result = { key, modifiers: modifierNames };
732
- if (settleResult)
733
- result.settle = settleResult;
734
- return { kind: 'tool.response', id: msg.id, ok: true, result };
902
+ return { kind: 'tool.response', id: msg.id, ok: true, result: outcome.result };
903
+ }
904
+ case 'flow': {
905
+ // Wire-boundary narrowing: `p.steps` is untrusted JSON. Keep only
906
+ // object-shaped entries here — `runFlow`'s own `stepKind()` guard
907
+ // re-validates each one at runtime regardless, so a malformed
908
+ // entry fails the flow cleanly instead of throwing.
909
+ const rawSteps = Array.isArray(p.steps)
910
+ ? p.steps.filter((s) => typeof s === 'object' && s !== null)
911
+ : [];
912
+ const flowResult = await runFlow(rawSteps, {
913
+ performFind: (params) => performFind(tabId, params),
914
+ performClick: (params) => performClick(tabId, params),
915
+ performType: (params) => performType(tabId, params),
916
+ performPress: (params) => performPress(tabId, params),
917
+ performWaitFor: (params) => performWaitFor(tabId, state, params),
918
+ buildRecoverySnapshot: () => evaluateInTab(tabId, buildSnapshotJs({ only_interactive: true, max_interactive: 40 })),
919
+ });
920
+ // The wire-level response is ok:true whenever the flow RAN (even a
921
+ // failed step is a legitimate business outcome, same pattern as
922
+ // wait_for's matched:false) — flowResult itself carries the
923
+ // ok:true/false the agent reads.
924
+ return { kind: 'tool.response', id: msg.id, ok: true, result: flowResult };
735
925
  }
736
926
  case 'drag': {
737
927
  const optNum = (key) => {
@@ -1019,95 +1209,18 @@ async function handleTool(state, msg) {
1019
1209
  return { kind: 'tool.response', id: msg.id, ok: true, result: value };
1020
1210
  }
1021
1211
  case 'wait_for': {
1022
- const optNum = (key) => {
1023
- const v = p[key];
1024
- return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : undefined;
1025
- };
1026
- const waitSelector = optStr('selector');
1027
- const waitExpression = optStr('expression');
1028
- const waitNetworkUrl = optStr('network_url');
1029
- const waitCondition = optStr('condition') ?? 'visible';
1030
- // Defensive caps mirror the dispatcher's contract and keep CodeQL
1031
- // happy about setTimeout durations driven by request params.
1032
- const MAX_WAIT_TIMEOUT_MS = 30_000;
1033
- const MIN_POLL_MS = 50;
1034
- const MAX_POLL_MS = 1_000;
1035
- const requestedTimeout = optNum('timeout_ms') ?? 5_000;
1036
- const timeoutMs = requestedTimeout < MAX_WAIT_TIMEOUT_MS ? requestedTimeout : MAX_WAIT_TIMEOUT_MS;
1037
- const requestedPoll = optNum('poll_interval_ms') ?? 100;
1038
- const pollIntervalMs = requestedPoll < MIN_POLL_MS
1039
- ? MIN_POLL_MS
1040
- : requestedPoll > MAX_POLL_MS
1041
- ? MAX_POLL_MS
1042
- : requestedPoll;
1043
- // Build the check function based on which mode the caller picked.
1044
- // The dispatcher already enforced "exactly one of selector / expression
1045
- // / network_url" so we just pick the first defined one. If none are
1046
- // defined (shouldn't happen from a well-formed call), the check stays
1047
- // null and we return matched=false immediately.
1048
- let check = null;
1049
- if (waitSelector !== undefined) {
1050
- const expr = buildWaitSelectorExpr(waitSelector, waitCondition);
1051
- check = async () => {
1052
- try {
1053
- const result = await evaluateInTab(tabId, expr);
1054
- return Boolean(result);
1055
- }
1056
- catch {
1057
- // If the page is unreachable mid-poll, count as "not yet matched".
1058
- return false;
1059
- }
1060
- };
1061
- }
1062
- else if (waitExpression !== undefined) {
1063
- const wrapped = `Boolean(${waitExpression})`;
1064
- check = async () => {
1065
- try {
1066
- const result = await evaluateInTab(tabId, wrapped);
1067
- return Boolean(result);
1068
- }
1069
- catch {
1070
- return false;
1071
- }
1072
- };
1073
- }
1074
- else if (waitNetworkUrl !== undefined) {
1075
- const needle = waitNetworkUrl.toLowerCase();
1076
- check = () => {
1077
- for (const id of state.networkOrder) {
1078
- const r = state.networkBuffer.get(id);
1079
- if (!r)
1080
- continue;
1081
- if (r.finished_at === undefined)
1082
- continue;
1083
- if (r.url.toLowerCase().includes(needle))
1084
- return Promise.resolve(true);
1085
- }
1086
- return Promise.resolve(false);
1087
- };
1088
- }
1089
- const startedAt = Date.now();
1090
- let checks = 0;
1091
- let matched = false;
1092
- while (check) {
1093
- checks++;
1094
- if (await check()) {
1095
- matched = true;
1096
- break;
1097
- }
1098
- if (Date.now() - startedAt >= timeoutMs)
1099
- break;
1100
- await sleep(pollIntervalMs);
1212
+ const outcome = await performWaitFor(tabId, state, {
1213
+ selector: optStr('selector'),
1214
+ expression: optStr('expression'),
1215
+ network_url: optStr('network_url'),
1216
+ condition: optStr('condition'),
1217
+ timeout_ms: typeof p.timeout_ms === 'number' ? p.timeout_ms : undefined,
1218
+ poll_interval_ms: typeof p.poll_interval_ms === 'number' ? p.poll_interval_ms : undefined,
1219
+ });
1220
+ if (!outcome.ok) {
1221
+ return { kind: 'tool.response', id: msg.id, ok: false, error: outcome.error };
1101
1222
  }
1102
- const elapsedMs = Date.now() - startedAt;
1103
- return {
1104
- kind: 'tool.response',
1105
- id: msg.id,
1106
- ok: true,
1107
- result: matched
1108
- ? { matched: true, elapsed_ms: elapsedMs, checks }
1109
- : { matched: false, elapsed_ms: elapsedMs, checks, reason: 'timeout' },
1110
- };
1223
+ return { kind: 'tool.response', id: msg.id, ok: true, result: outcome.result };
1111
1224
  }
1112
1225
  case 'dialog_respond': {
1113
1226
  const accept = p.accept === true;