@agent360/browser-mcp 1.16.0 → 1.19.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.
@@ -203,11 +203,76 @@ function debuggerForceDetach(tabId) {
203
203
  } catch {}
204
204
  }
205
205
 
206
+ // Sync local Set when Chrome auto-detaches (navigation, idle, devtools opened, etc.)
207
+ chrome.debugger.onDetach.addListener((source, reason) => {
208
+ if (source.tabId) {
209
+ debuggerAttached.delete(source.tabId);
210
+ if (reason && reason !== 'target_closed') {
211
+ console.log(`[MCP] Debugger auto-detached from tab ${source.tabId} (reason: ${reason})`);
212
+ }
213
+ }
214
+ });
215
+
216
+ // Methods that are safe to retry without double-effect.
217
+ // Side-effectful methods (Input.*, DOM.setFileInputFiles) must NEVER auto-retry:
218
+ // Chrome may detach AFTER processing the input (e.g., keystroke triggered navigation),
219
+ // and a blind retry would double-type or double-click.
220
+ const RETRYABLE_CDP_METHODS = new Set([
221
+ 'DOM.getDocument',
222
+ 'DOM.querySelector',
223
+ 'DOM.querySelectorAll',
224
+ 'DOM.focus',
225
+ 'DOM.describeNode',
226
+ 'Runtime.evaluate',
227
+ 'Runtime.enable',
228
+ 'Page.captureScreenshot',
229
+ 'Page.enable',
230
+ 'Network.enable',
231
+ 'Network.disable',
232
+ 'Network.getResponseBody',
233
+ ]);
234
+
235
+ // CDP wrapper with auto-recovery: re-attaches on detach errors.
236
+ // For read-only methods (whitelist above), retries once after re-attach.
237
+ // For side-effectful methods, only re-attaches and throws — caller must decide.
238
+ async function cdpSend(tabId, method, params = {}) {
239
+ await debuggerAttach(tabId);
240
+ try {
241
+ return await chrome.debugger.sendCommand({ tabId }, method, params);
242
+ } catch (e) {
243
+ const msg = e?.message || String(e);
244
+ const isDetachError =
245
+ msg.includes('not attached') ||
246
+ msg.includes('Detached') ||
247
+ msg.includes('detached') ||
248
+ msg.includes('Debugger is gone') ||
249
+ msg.includes('No tab with given id');
250
+ if (!isDetachError) throw e;
251
+ debuggerAttached.delete(tabId);
252
+ if (!RETRYABLE_CDP_METHODS.has(method)) {
253
+ // Re-attach for next caller, but don't auto-retry side-effectful command
254
+ try { await debuggerAttach(tabId); } catch {}
255
+ throw new Error(`Debugger detached during ${method} — not auto-retried (side-effect risk). Original: ${msg}`);
256
+ }
257
+ await new Promise(r => setTimeout(r, 100));
258
+ await debuggerAttach(tabId);
259
+ return await chrome.debugger.sendCommand({ tabId }, method, params);
260
+ }
261
+ }
262
+
206
263
  // Clean up debugger + session refs when tabs close
207
264
  chrome.tabs.onRemoved.addListener((tabId) => {
208
265
  debuggerAttached.delete(tabId);
209
- for (const [, session] of sessions) {
266
+ for (const [port, session] of sessions) {
267
+ if (!session.tabIds.has(tabId)) continue;
210
268
  session.tabIds.delete(tabId);
269
+ if (session.tabIds.size === 0) {
270
+ // Last tab closed — tell offscreen to terminate the MCP server.
271
+ // Resulting WS-close triggers the existing session_disconnect → releaseSession path.
272
+ chrome.runtime.sendMessage({ type: 'terminate_mcp_session', port }).catch(() => {});
273
+ } else {
274
+ persistSessions();
275
+ }
211
276
  }
212
277
  });
213
278
 
@@ -216,14 +281,14 @@ async function debuggerType(tabId, text) {
216
281
  try {
217
282
  for (let i = 0; i < text.length; i++) {
218
283
  const char = text[i];
219
- await chrome.debugger.sendCommand({ tabId }, 'Input.dispatchKeyEvent', {
284
+ await cdpSend(tabId, 'Input.dispatchKeyEvent', {
220
285
  type: 'keyDown',
221
286
  text: char,
222
287
  key: char,
223
288
  code: `Key${char.toUpperCase()}`,
224
289
  unmodifiedText: char,
225
290
  });
226
- await chrome.debugger.sendCommand({ tabId }, 'Input.dispatchKeyEvent', {
291
+ await cdpSend(tabId, 'Input.dispatchKeyEvent', {
227
292
  type: 'keyUp',
228
293
  key: char,
229
294
  code: `Key${char.toUpperCase()}`,
@@ -243,20 +308,20 @@ async function debuggerClick(tabId, x, y) {
243
308
  await debuggerAttach(tabId);
244
309
  try {
245
310
  // 1. mouseMoved first (triggers hover state, required by some frameworks)
246
- await chrome.debugger.sendCommand({ tabId }, 'Input.dispatchMouseEvent', {
311
+ await cdpSend(tabId, 'Input.dispatchMouseEvent', {
247
312
  type: 'mouseMoved', x, y,
248
313
  });
249
314
  await new Promise(r => setTimeout(r, 30));
250
315
  // 2. mousePressed + mouseReleased (fires trusted mousedown/mouseup)
251
- await chrome.debugger.sendCommand({ tabId }, 'Input.dispatchMouseEvent', {
316
+ await cdpSend(tabId, 'Input.dispatchMouseEvent', {
252
317
  type: 'mousePressed', x, y, button: 'left', clickCount: 1,
253
318
  });
254
- await chrome.debugger.sendCommand({ tabId }, 'Input.dispatchMouseEvent', {
319
+ await cdpSend(tabId, 'Input.dispatchMouseEvent', {
255
320
  type: 'mouseReleased', x, y, button: 'left', clickCount: 1,
256
321
  });
257
322
  // 3. CDP doesn't synthesize 'click' event from mousePressed/mouseReleased.
258
323
  // Fire JS click + React/Angular framework fallbacks.
259
- await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', {
324
+ await cdpSend(tabId, 'Runtime.evaluate', {
260
325
  expression: `(() => {
261
326
  const el = document.elementFromPoint(${x}, ${y});
262
327
  if (!el) return;
@@ -293,12 +358,12 @@ async function debuggerClick(tabId, x, y) {
293
358
  async function debuggerFocus(tabId, selector) {
294
359
  await debuggerAttach(tabId);
295
360
  try {
296
- const { root } = await chrome.debugger.sendCommand({ tabId }, 'DOM.getDocument', {});
297
- const { nodeId } = await chrome.debugger.sendCommand({ tabId }, 'DOM.querySelector', {
361
+ const { root } = await cdpSend(tabId, 'DOM.getDocument', {});
362
+ const { nodeId } = await cdpSend(tabId, 'DOM.querySelector', {
298
363
  nodeId: root.nodeId, selector,
299
364
  });
300
365
  if (!nodeId) throw new Error('Element not found: ' + selector);
301
- await chrome.debugger.sendCommand({ tabId }, 'DOM.focus', { nodeId });
366
+ await cdpSend(tabId, 'DOM.focus', { nodeId });
302
367
  return nodeId;
303
368
  } catch (e) {
304
369
  await debuggerDetach(tabId);
@@ -338,16 +403,16 @@ async function debuggerFill(tabId, selector, value) {
338
403
  await debuggerAttach(tabId);
339
404
  try {
340
405
  // Ctrl+A to select all, then Backspace to clear
341
- await chrome.debugger.sendCommand({ tabId }, 'Input.dispatchKeyEvent', {
406
+ await cdpSend(tabId, 'Input.dispatchKeyEvent', {
342
407
  type: 'keyDown', key: 'a', code: 'KeyA', modifiers: 2,
343
408
  });
344
- await chrome.debugger.sendCommand({ tabId }, 'Input.dispatchKeyEvent', {
409
+ await cdpSend(tabId, 'Input.dispatchKeyEvent', {
345
410
  type: 'keyUp', key: 'a', code: 'KeyA',
346
411
  });
347
- await chrome.debugger.sendCommand({ tabId }, 'Input.dispatchKeyEvent', {
412
+ await cdpSend(tabId, 'Input.dispatchKeyEvent', {
348
413
  type: 'keyDown', key: 'Backspace', code: 'Backspace',
349
414
  });
350
- await chrome.debugger.sendCommand({ tabId }, 'Input.dispatchKeyEvent', {
415
+ await cdpSend(tabId, 'Input.dispatchKeyEvent', {
351
416
  type: 'keyUp', key: 'Backspace', code: 'Backspace',
352
417
  });
353
418
  } finally {
@@ -359,7 +424,7 @@ async function debuggerFill(tabId, selector, value) {
359
424
  async function debuggerEval(tabId, expression) {
360
425
  await debuggerAttach(tabId);
361
426
  try {
362
- const result = await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', {
427
+ const result = await cdpSend(tabId, 'Runtime.evaluate', {
363
428
  expression,
364
429
  returnByValue: true,
365
430
  });
@@ -647,6 +712,795 @@ function buildDeepQueryJS(selector) {
647
712
  })()`;
648
713
  }
649
714
 
715
+ // ── Date Input Helpers ──────────────────────────────────────────────────────
716
+
717
+ const MONTHS_EN = ['january','february','march','april','may','june','july','august','september','october','november','december'];
718
+ const MONTHS_DA = ['januar','februar','marts','april','maj','juni','juli','august','september','oktober','november','december'];
719
+ const MONTHS_ABBR_EN = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec'];
720
+
721
+ function parsePlaceholderFormat(placeholder) {
722
+ if (!placeholder) return null;
723
+ const upper = placeholder.toUpperCase();
724
+ let sep = null;
725
+ if (upper.includes('/')) sep = '/';
726
+ else if (upper.includes('-')) sep = '-';
727
+ else if (upper.includes('.')) sep = '.';
728
+ else return null;
729
+ const parts = upper.split(sep);
730
+ if (parts.length !== 3) return null;
731
+ const order = parts.map(p => p.includes('Y') ? 'Y' : p.includes('M') ? 'M' : p.includes('D') ? 'D' : null);
732
+ if (order.includes(null) || new Set(order).size !== 3) return null;
733
+ const padded = parts.map(p => p.length >= 2);
734
+ return { sep, order, padded };
735
+ }
736
+
737
+ function isoToFormat(iso, fmt) {
738
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
739
+ if (!m) throw new Error('Invalid ISO date: ' + iso);
740
+ const [, y, mo, d] = m;
741
+ return fmt.order.map((slot, i) => {
742
+ if (slot === 'Y') return y;
743
+ if (slot === 'M') return fmt.padded[i] ? mo : String(parseInt(mo, 10));
744
+ if (slot === 'D') return fmt.padded[i] ? d : String(parseInt(d, 10));
745
+ }).join(fmt.sep);
746
+ }
747
+
748
+ function parseMonthYearText(text) {
749
+ if (!text) return null;
750
+ const cleaned = text.toLowerCase().trim();
751
+ const tables = [MONTHS_EN, MONTHS_DA, MONTHS_ABBR_EN];
752
+ for (const table of tables) {
753
+ for (let i = 0; i < table.length; i++) {
754
+ if (cleaned.includes(table[i])) {
755
+ const ym = cleaned.match(/(\d{4})/);
756
+ if (ym) return { year: parseInt(ym[1], 10), month: i + 1 };
757
+ }
758
+ }
759
+ }
760
+ const num = cleaned.match(/(\d{1,2})[\/\-\s.](\d{4})/);
761
+ if (num) return { year: parseInt(num[2], 10), month: parseInt(num[1], 10) };
762
+ return null;
763
+ }
764
+
765
+ function valueLooksLikeIso(value, iso) {
766
+ if (!value || !iso) return false;
767
+ const [y, m, d] = iso.split('-');
768
+ const digits = value.replace(/\D/g, '');
769
+ if (digits.includes(y + m + d)) return true;
770
+ if (digits.includes(m + d + y)) return true;
771
+ if (digits.includes(d + m + y)) return true;
772
+ const hasYear = value.includes(y);
773
+ const hasMonth = value.includes(m) || value.includes(String(parseInt(m, 10)));
774
+ const hasDay = value.includes(d) || value.includes(String(parseInt(d, 10)));
775
+ return hasYear && hasMonth && hasDay;
776
+ }
777
+
778
+ async function getDateInputInfo(tabId, selector) {
779
+ const json = await debuggerEval(tabId, `(() => {
780
+ const el = document.querySelector(${JSON.stringify(selector)});
781
+ if (!el) return JSON.stringify({ found: false });
782
+ return JSON.stringify({
783
+ found: true,
784
+ tag: el.tagName,
785
+ inputType: (el.type || '').toLowerCase(),
786
+ readOnly: !!el.readOnly,
787
+ disabled: !!el.disabled,
788
+ placeholder: el.placeholder || '',
789
+ ariaLabel: el.getAttribute('aria-label') || '',
790
+ value: el.value !== undefined ? el.value : (el.textContent || ''),
791
+ });
792
+ })()`);
793
+ return JSON.parse(json);
794
+ }
795
+
796
+ async function readBackValue(tabId, selector) {
797
+ const json = await debuggerEval(tabId, `(() => {
798
+ const el = document.querySelector(${JSON.stringify(selector)});
799
+ if (!el) return JSON.stringify({ value: null });
800
+ return JSON.stringify({ value: el.value !== undefined ? el.value : (el.textContent || '') });
801
+ })()`);
802
+ return JSON.parse(json).value;
803
+ }
804
+
805
+ async function setDateNative(tabId, selector, iso) {
806
+ const r = await safeExecuteScript(tabId, (sel, val) => {
807
+ const el = document.querySelector(sel);
808
+ if (!el) return { ok: false, error: 'not-found' };
809
+ try {
810
+ el.scrollIntoView({ block: 'center', behavior: 'instant' });
811
+ el.focus();
812
+ const proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
813
+ const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
814
+ if (setter) setter.call(el, val); else el.value = val;
815
+ el.dispatchEvent(new Event('input', { bubbles: true }));
816
+ el.dispatchEvent(new Event('change', { bubbles: true }));
817
+ el.blur();
818
+ return { ok: true, value: el.value };
819
+ } catch (e) {
820
+ return { ok: false, error: e.message };
821
+ }
822
+ }, [selector, iso]);
823
+ if (r.cspBlocked) {
824
+ await debuggerEval(tabId, `(() => {
825
+ const el = document.querySelector(${JSON.stringify(selector)});
826
+ if (!el) return;
827
+ const proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
828
+ const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
829
+ if (setter) setter.call(el, ${JSON.stringify(iso)}); else el.value = ${JSON.stringify(iso)};
830
+ el.dispatchEvent(new Event('input', { bubbles: true }));
831
+ el.dispatchEvent(new Event('change', { bubbles: true }));
832
+ el.blur();
833
+ })()`);
834
+ return { ok: true, csp: true };
835
+ }
836
+ return r.result || { ok: false, error: 'no-result' };
837
+ }
838
+
839
+ async function setDateMaskedTyping(tabId, selector, iso, format) {
840
+ const formatted = isoToFormat(iso, format);
841
+ await debuggerFocus(tabId, selector);
842
+ await debuggerAttach(tabId);
843
+ try {
844
+ await cdpSend(tabId, 'Input.dispatchKeyEvent', {
845
+ type: 'keyDown', key: 'a', code: 'KeyA', modifiers: 2,
846
+ });
847
+ await cdpSend(tabId, 'Input.dispatchKeyEvent', {
848
+ type: 'keyUp', key: 'a', code: 'KeyA',
849
+ });
850
+ await cdpSend(tabId, 'Input.dispatchKeyEvent', {
851
+ type: 'keyDown', key: 'Backspace', code: 'Backspace',
852
+ });
853
+ await cdpSend(tabId, 'Input.dispatchKeyEvent', {
854
+ type: 'keyUp', key: 'Backspace', code: 'Backspace',
855
+ });
856
+ await cdpSend(tabId, 'Input.insertText', { text: formatted });
857
+ await cdpSend(tabId, 'Input.dispatchKeyEvent', {
858
+ type: 'keyDown', key: 'Tab', code: 'Tab',
859
+ });
860
+ await cdpSend(tabId, 'Input.dispatchKeyEvent', {
861
+ type: 'keyUp', key: 'Tab', code: 'Tab',
862
+ });
863
+ } finally {
864
+ await debuggerDetach(tabId);
865
+ }
866
+ return { ok: true, formatted };
867
+ }
868
+
869
+ const PICKER_OPEN_SELECTORS = [
870
+ '[role="dialog"] [role="grid"]',
871
+ '[role="dialog"] [role="gridcell"]',
872
+ '.react-datepicker',
873
+ '.MuiPickersPopper-root',
874
+ '.ant-picker-dropdown:not(.ant-picker-dropdown-hidden)',
875
+ '[class*="DayPicker"]:not(input)',
876
+ '[class*="Calendar"][class*="open" i]',
877
+ ];
878
+
879
+ async function isPickerOpen(tabId) {
880
+ return await debuggerEval(tabId, `(() => {
881
+ const sels = ${JSON.stringify(PICKER_OPEN_SELECTORS)};
882
+ for (const s of sels) {
883
+ try { if (document.querySelector(s)) return true; } catch {}
884
+ }
885
+ return false;
886
+ })()`);
887
+ }
888
+
889
+ async function getPickerRoot(tabId) {
890
+ return await debuggerEval(tabId, `(() => {
891
+ const sels = ${JSON.stringify(PICKER_OPEN_SELECTORS)};
892
+ for (const s of sels) {
893
+ try {
894
+ const el = document.querySelector(s);
895
+ if (el) {
896
+ const root = el.closest('[role="dialog"], .react-datepicker, .MuiPickersPopper-root, .ant-picker-dropdown') || el;
897
+ // Return a stable selector path — for runtime use we re-query each time
898
+ return true;
899
+ }
900
+ } catch {}
901
+ }
902
+ return false;
903
+ })()`);
904
+ }
905
+
906
+ async function setDatePicker(tabId, selector, iso) {
907
+ const [yStr, mStr, dStr] = iso.split('-');
908
+ const targetYear = parseInt(yStr, 10);
909
+ const targetMonth = parseInt(mStr, 10);
910
+ const targetDay = parseInt(dStr, 10);
911
+
912
+ const inputEl = await resolveElement(tabId, selector);
913
+ if (!inputEl) return { ok: false, error: 'input-not-found' };
914
+ await debuggerClick(tabId, inputEl.x, inputEl.y);
915
+
916
+ let opened = false;
917
+ for (let i = 0; i < 20; i++) {
918
+ await new Promise(r => setTimeout(r, 100));
919
+ if (await isPickerOpen(tabId)) { opened = true; break; }
920
+ }
921
+
922
+ if (!opened) {
923
+ const triggerClicked = await safeExecuteScript(tabId, (sel) => {
924
+ const el = document.querySelector(sel);
925
+ if (!el) return false;
926
+ const candidates = [
927
+ ...(el.parentElement?.querySelectorAll('button, [role="button"], [aria-haspopup]') || []),
928
+ ...(el.parentElement?.parentElement?.querySelectorAll('button, [role="button"], [aria-haspopup]') || []),
929
+ ];
930
+ for (const c of candidates) {
931
+ const label = (c.getAttribute('aria-label') || '').toLowerCase();
932
+ if (label.includes('calendar') || label.includes('date') || label.includes('vælg dato') || label.includes('open') || label.includes('åbn')) {
933
+ c.click();
934
+ return true;
935
+ }
936
+ }
937
+ for (const c of candidates) {
938
+ if (c.querySelector('svg, [class*="calendar" i]')) {
939
+ c.click();
940
+ return true;
941
+ }
942
+ }
943
+ return false;
944
+ }, [selector]);
945
+ if (triggerClicked.result) {
946
+ for (let i = 0; i < 15; i++) {
947
+ await new Promise(r => setTimeout(r, 100));
948
+ if (await isPickerOpen(tabId)) { opened = true; break; }
949
+ }
950
+ }
951
+ }
952
+
953
+ if (!opened) return { ok: false, error: 'picker-did-not-open' };
954
+
955
+ const MAX_NAV = 36;
956
+ let navAttempts = 0;
957
+ let lastHeader = null;
958
+ let stuck = 0;
959
+ let navExitReason = 'reached-target';
960
+ let lastReachedMonthYear = null;
961
+ for (let i = 0; i < MAX_NAV; i++) {
962
+ const headerJson = await debuggerEval(tabId, `(() => {
963
+ const roots = [
964
+ document.querySelector('[role="dialog"]'),
965
+ document.querySelector('.react-datepicker'),
966
+ document.querySelector('.MuiPickersPopper-root'),
967
+ document.querySelector('.ant-picker-dropdown:not(.ant-picker-dropdown-hidden)'),
968
+ ].filter(Boolean);
969
+ for (const root of roots) {
970
+ const candidates = [
971
+ root.querySelector('[role="heading"]'),
972
+ root.querySelector('[aria-live]'),
973
+ root.querySelector('.MuiPickersCalendarHeader-label'),
974
+ root.querySelector('.react-datepicker__current-month'),
975
+ root.querySelector('.ant-picker-header-view'),
976
+ ].filter(Boolean);
977
+ for (const el of candidates) {
978
+ const t = (el.textContent || '').trim();
979
+ if (t.length > 0 && t.length < 80) return JSON.stringify({ text: t });
980
+ }
981
+ }
982
+ return JSON.stringify({});
983
+ })()`);
984
+ const header = JSON.parse(headerJson);
985
+ const parsed = parseMonthYearText(header.text || '');
986
+ if (!parsed) {
987
+ navExitReason = header.text ? 'header-parse-failed' : 'no-header-found';
988
+ break;
989
+ }
990
+ lastReachedMonthYear = `${parsed.year}-${String(parsed.month).padStart(2, '0')}`;
991
+
992
+ if (header.text === lastHeader) {
993
+ stuck++;
994
+ if (stuck >= 3) { navExitReason = 'navigation-stuck'; break; }
995
+ } else {
996
+ stuck = 0;
997
+ lastHeader = header.text;
998
+ }
999
+
1000
+ const delta = (targetYear * 12 + targetMonth) - (parsed.year * 12 + parsed.month);
1001
+ if (delta === 0) break;
1002
+ if (i === MAX_NAV - 1) {
1003
+ navExitReason = 'max-nav-exceeded';
1004
+ }
1005
+
1006
+ const dir = delta > 0 ? 'next' : 'prev';
1007
+ const navClicked = await safeExecuteScript(tabId, (direction) => {
1008
+ const roots = [
1009
+ document.querySelector('[role="dialog"]'),
1010
+ document.querySelector('.react-datepicker'),
1011
+ document.querySelector('.MuiPickersPopper-root'),
1012
+ document.querySelector('.ant-picker-dropdown:not(.ant-picker-dropdown-hidden)'),
1013
+ ].filter(Boolean);
1014
+ const labels = direction === 'next'
1015
+ ? ['next month', 'next', 'forward', 'næste']
1016
+ : ['previous month', 'previous', 'prev', 'back', 'forrige'];
1017
+ const classFallbacks = direction === 'next'
1018
+ ? ['.react-datepicker__navigation--next', '.ant-picker-header-next-btn', '.ant-picker-header-super-next-btn']
1019
+ : ['.react-datepicker__navigation--previous', '.ant-picker-header-prev-btn', '.ant-picker-header-super-prev-btn'];
1020
+ for (const root of roots) {
1021
+ const buttons = [...root.querySelectorAll('button, [role="button"]')];
1022
+ for (const b of buttons) {
1023
+ const label = (b.getAttribute('aria-label') || b.title || '').toLowerCase();
1024
+ if (labels.some(l => label.includes(l))) { b.click(); return true; }
1025
+ }
1026
+ for (const cs of classFallbacks) {
1027
+ const b = root.querySelector(cs);
1028
+ if (b) { b.click(); return true; }
1029
+ }
1030
+ }
1031
+ return false;
1032
+ }, [dir]);
1033
+
1034
+ if (!navClicked.result) {
1035
+ await debuggerAttach(tabId);
1036
+ try {
1037
+ const key = delta > 0 ? 'PageDown' : 'PageUp';
1038
+ await cdpSend(tabId, 'Input.dispatchKeyEvent', {
1039
+ type: 'keyDown', key, code: key,
1040
+ });
1041
+ await cdpSend(tabId, 'Input.dispatchKeyEvent', {
1042
+ type: 'keyUp', key, code: key,
1043
+ });
1044
+ } finally {
1045
+ await debuggerDetach(tabId);
1046
+ }
1047
+ }
1048
+ navAttempts++;
1049
+ await new Promise(r => setTimeout(r, 90));
1050
+ }
1051
+
1052
+ const dayResult = await safeExecuteScript(tabId, (day, year, month, monthsEn, monthsDa, monthsAbbr) => {
1053
+ const roots = [
1054
+ document.querySelector('[role="dialog"]'),
1055
+ document.querySelector('.react-datepicker'),
1056
+ document.querySelector('.MuiPickersPopper-root'),
1057
+ document.querySelector('.ant-picker-dropdown:not(.ant-picker-dropdown-hidden)'),
1058
+ ].filter(Boolean);
1059
+ const monthEn = monthsEn[month - 1];
1060
+ const monthDa = monthsDa[month - 1];
1061
+ const monthAbbr = monthsAbbr[month - 1];
1062
+
1063
+ for (const root of roots) {
1064
+ const cells = [...root.querySelectorAll('[role="gridcell"], .react-datepicker__day, .ant-picker-cell, [class*="PickersDay"]')];
1065
+ const isDisabled = (c) => c.getAttribute('aria-disabled') === 'true' ||
1066
+ c.classList.contains('disabled') ||
1067
+ c.classList.contains('react-datepicker__day--disabled') ||
1068
+ c.classList.contains('ant-picker-cell-disabled') ||
1069
+ c.classList.contains('Mui-disabled');
1070
+ const isOutside = (c) => {
1071
+ const cls = c.className || '';
1072
+ if (/outside|other-month|--prev|--next|adjacent/i.test(cls)) return true;
1073
+ if (c.classList.contains('react-datepicker__day--outside-month')) return true;
1074
+ if (c.classList.contains('ant-picker-cell') && !c.classList.contains('ant-picker-cell-in-view')) return true;
1075
+ return false;
1076
+ };
1077
+
1078
+ for (const c of cells) {
1079
+ if (isDisabled(c) || isOutside(c)) continue;
1080
+ const label = (c.getAttribute('aria-label') || '').toLowerCase();
1081
+ if (!label) continue;
1082
+ const matchesMonth = label.includes(monthEn) || label.includes(monthDa) || label.includes(monthAbbr);
1083
+ const matchesYear = label.includes(String(year));
1084
+ const dayPattern = new RegExp('\\b' + day + '(st|nd|rd|th)?\\b');
1085
+ const dayPaddedPattern = new RegExp('\\b' + String(day).padStart(2, '0') + '\\b');
1086
+ if (matchesMonth && matchesYear && (dayPattern.test(label) || dayPaddedPattern.test(label))) {
1087
+ c.click();
1088
+ return { ok: true, method: 'aria-label', label };
1089
+ }
1090
+ }
1091
+
1092
+ for (const c of cells) {
1093
+ if (isDisabled(c) || isOutside(c)) continue;
1094
+ const text = (c.textContent || '').trim();
1095
+ if (text === String(day) || text === String(day).padStart(2, '0')) {
1096
+ c.click();
1097
+ return { ok: true, method: 'text-content' };
1098
+ }
1099
+ }
1100
+ }
1101
+ return { ok: false, error: 'day-not-found' };
1102
+ }, [targetDay, targetYear, targetMonth, MONTHS_EN, MONTHS_DA, MONTHS_ABBR_EN]);
1103
+
1104
+ if (!dayResult.result || !dayResult.result.ok) {
1105
+ return {
1106
+ ok: false,
1107
+ error: dayResult.result?.error || 'day-click-failed',
1108
+ navAttempts,
1109
+ navExitReason,
1110
+ lastReachedMonthYear,
1111
+ targetMonthYear: `${targetYear}-${String(targetMonth).padStart(2, '0')}`,
1112
+ };
1113
+ }
1114
+
1115
+ await new Promise(r => setTimeout(r, 350));
1116
+ return { ok: true, method: dayResult.result.method, navAttempts };
1117
+ }
1118
+
1119
+ async function collectVisibleErrors(tabId, selector) {
1120
+ const json = await debuggerEval(tabId, `(() => {
1121
+ const errs = [];
1122
+ const el = document.querySelector(${JSON.stringify(selector)});
1123
+ if (el?.getAttribute('aria-invalid') === 'true') errs.push('aria-invalid=true on input');
1124
+ const candidates = [
1125
+ ...document.querySelectorAll('[role="alert"], .error-text, [class*="error" i]:not(input):not(button)'),
1126
+ ].slice(0, 8);
1127
+ for (const c of candidates) {
1128
+ const t = (c.textContent || '').trim();
1129
+ if (t && t.length < 200 && c.offsetHeight > 0) errs.push(t);
1130
+ }
1131
+ return JSON.stringify(errs);
1132
+ })()`);
1133
+ try { return JSON.parse(json); } catch { return []; }
1134
+ }
1135
+
1136
+ // ── Overlay Dismissal Helper ────────────────────────────────────────────────
1137
+
1138
+ async function dismissOverlays(tabId, scope = 'non_critical', maxPasses = 3) {
1139
+ // Clamp to sensible range; reject sloppy input
1140
+ const passes = Math.max(1, Math.min(10, Number.isInteger(maxPasses) ? maxPasses : 3));
1141
+ const allDismissed = [];
1142
+ const allSkipped = [];
1143
+
1144
+ for (let pass = 0; pass < passes; pass++) {
1145
+ const r = await safeExecuteScript(tabId, (s) => {
1146
+ const dismissed = [];
1147
+ const skipped = [];
1148
+
1149
+ // "Safe" texts cannot revert form data — they're purely informational close affordances
1150
+ const safeTexts = [
1151
+ "luk", "dismiss", "close", "got it", "got it, thanks",
1152
+ "not now", "ikke nu", "senere", "later",
1153
+ "don't show", "don't show again", "dont show again", "dont show",
1154
+ "no thanks", "maybe later", "ok", "ok!", "okay",
1155
+ ];
1156
+ // "Ambiguous" texts MAY revert partial form data ("Cancel" usually reverts state)
1157
+ // — only used when overlay has no editable form fields, or in aggressive scope
1158
+ const ambiguousTexts = [
1159
+ "skip", "cancel", "afvis", "spring over",
1160
+ ];
1161
+ const xChars = ['×', '✕', '✖', '⨯'];
1162
+
1163
+ const isVisible = (el) => {
1164
+ if (!el || !el.offsetParent && el.tagName !== 'BODY') return false;
1165
+ const rect = el.getBoundingClientRect();
1166
+ return rect.width > 0 && rect.height > 0;
1167
+ };
1168
+
1169
+ const findCloseAffordance = (overlay, allowAmbiguous) => {
1170
+ const all = [...overlay.querySelectorAll('button, [role="button"], a[href="#"], [aria-label]')];
1171
+ const allTexts = allowAmbiguous ? [...safeTexts, ...ambiguousTexts] : safeTexts;
1172
+
1173
+ // Priority 1: aria-label match (close/dismiss/luk/afvis are always safe)
1174
+ for (const c of all) {
1175
+ if (!isVisible(c)) continue;
1176
+ const label = (c.getAttribute('aria-label') || '').toLowerCase();
1177
+ if (!label) continue;
1178
+ if (label.includes('close') || label.includes('dismiss') || label.includes('luk')) {
1179
+ return { el: c, method: 'aria-label', label };
1180
+ }
1181
+ if (allowAmbiguous && label.includes('afvis')) {
1182
+ return { el: c, method: 'aria-label', label };
1183
+ }
1184
+ }
1185
+
1186
+ // Priority 2: button text exact match
1187
+ for (const c of all) {
1188
+ if (!isVisible(c)) continue;
1189
+ const text = (c.textContent || '').trim().toLowerCase();
1190
+ if (!text || text.length > 30) continue;
1191
+ if (allTexts.some(t => text === t || text === t + '!' || text === t + '.')) {
1192
+ return { el: c, method: 'text-exact', label: text };
1193
+ }
1194
+ }
1195
+ // Priority 3: button text contains
1196
+ for (const c of all) {
1197
+ if (!isVisible(c)) continue;
1198
+ const text = (c.textContent || '').trim().toLowerCase();
1199
+ if (!text || text.length > 40) continue;
1200
+ if (allTexts.some(t => text.includes(t))) {
1201
+ return { el: c, method: 'text-contains', label: text };
1202
+ }
1203
+ }
1204
+
1205
+ // Priority 4: × character buttons (always safe — these are universal close)
1206
+ for (const c of all) {
1207
+ if (!isVisible(c)) continue;
1208
+ const text = (c.textContent || '').trim();
1209
+ if (xChars.includes(text)) {
1210
+ return { el: c, method: 'x-char', label: text };
1211
+ }
1212
+ }
1213
+
1214
+ return null;
1215
+ };
1216
+
1217
+ const overlays = new Set();
1218
+ const selectors = [
1219
+ '[role="dialog"]:not([aria-hidden="true"])',
1220
+ '[role="alertdialog"]:not([aria-hidden="true"])',
1221
+ '[role="tooltip"]:not([aria-hidden="true"])',
1222
+ '[role="alert"]',
1223
+ '[class*="modal" i]:not([class*="-hidden"]):not([style*="display: none"])',
1224
+ '[class*="tooltip" i]:not([class*="-hidden"])',
1225
+ '[class*="popover" i]:not([class*="-hidden"])',
1226
+ '[class*="overlay" i]:not([class*="-hidden"])',
1227
+ '[class*="banner" i]:not([class*="-hidden"]):not(input):not(button)',
1228
+ '[data-testid*="dialog" i]',
1229
+ '[data-testid*="modal" i]',
1230
+ ];
1231
+ for (const sel of selectors) {
1232
+ try {
1233
+ for (const el of document.querySelectorAll(sel)) {
1234
+ if (isVisible(el) && el.tagName !== 'INPUT' && el.tagName !== 'BUTTON') {
1235
+ overlays.add(el);
1236
+ }
1237
+ }
1238
+ } catch {}
1239
+ }
1240
+
1241
+ for (const overlay of overlays) {
1242
+ const role = overlay.getAttribute('role') || (overlay.className || '').split(' ')[0] || 'unknown';
1243
+
1244
+ // Inspect for editable form fields
1245
+ const editableTextInputs = overlay.querySelectorAll(
1246
+ 'input:not([type="hidden"]):not([type="button"]):not([type="submit"]):not([type="reset"]):not([type="checkbox"]):not([type="radio"]):not([readonly]):not([disabled]), textarea:not([readonly]):not([disabled]), [contenteditable="true"]'
1247
+ );
1248
+ const allEditableInputs = overlay.querySelectorAll(
1249
+ 'input:not([type="hidden"]):not([type="button"]):not([type="submit"]):not([type="reset"]):not([readonly]):not([disabled]), textarea:not([readonly]):not([disabled]), [contenteditable="true"]'
1250
+ );
1251
+ const hasTextFields = editableTextInputs.length > 0;
1252
+ const hasOnlyCheckboxRadios = !hasTextFields && allEditableInputs.length > 0;
1253
+
1254
+ // Determine if ambiguous keywords (Skip/Cancel/Afvis) are allowed
1255
+ let allowAmbiguous;
1256
+ if (s === 'aggressive') {
1257
+ allowAmbiguous = true;
1258
+ } else if (role === 'tooltip' || role === 'alert') {
1259
+ allowAmbiguous = true; // tooltips never hold form data
1260
+ } else if (hasTextFields) {
1261
+ allowAmbiguous = false; // protect form data — only safe keywords
1262
+ } else {
1263
+ allowAmbiguous = true; // checkbox-only or empty dialogs — fair game
1264
+ }
1265
+
1266
+ const found = findCloseAffordance(overlay, allowAmbiguous);
1267
+ if (found) {
1268
+ try {
1269
+ found.el.click();
1270
+ dismissed.push({ role, method: found.method, label: found.label, scope: allowAmbiguous ? 'ambiguous-ok' : 'safe-only' });
1271
+ } catch (e) {
1272
+ skipped.push({ role, reason: 'click-error', error: e.message });
1273
+ }
1274
+ } else {
1275
+ skipped.push({
1276
+ role,
1277
+ reason: hasTextFields && !allowAmbiguous
1278
+ ? 'no-safe-dismiss-affordance (text fields present)'
1279
+ : 'no-dismiss-affordance-found',
1280
+ hasTextFields,
1281
+ hasOnlyCheckboxRadios,
1282
+ });
1283
+ }
1284
+ }
1285
+
1286
+ return { dismissed, skipped };
1287
+ }, [scope]);
1288
+
1289
+ const passResult = r.result || { dismissed: [], skipped: [] };
1290
+ if (pass === 0) allSkipped.push(...passResult.skipped);
1291
+ if (passResult.dismissed.length === 0) break;
1292
+ allDismissed.push(...passResult.dismissed);
1293
+ await new Promise(r2 => setTimeout(r2, 250));
1294
+ }
1295
+
1296
+ return { dismissed: allDismissed, skipped: allSkipped };
1297
+ }
1298
+
1299
+ // ── Combobox / Autocomplete Helper ──────────────────────────────────────────
1300
+
1301
+ async function setCombobox(tabId, selector, values, opts = {}) {
1302
+ const valueList = Array.isArray(values) ? values : [values];
1303
+ const multi = !!opts.multi;
1304
+ const queryPrefixLen = opts.query_chars || 4;
1305
+ const waitMs = opts.wait_ms || 3000;
1306
+ const waitIterations = Math.max(1, Math.ceil(waitMs / 100));
1307
+ const results = [];
1308
+
1309
+ for (const val of valueList) {
1310
+ try {
1311
+ const inputEl = await resolveElement(tabId, selector);
1312
+ if (!inputEl) {
1313
+ results.push({ value: val, ok: false, error: 'input-not-found' });
1314
+ continue;
1315
+ }
1316
+ await debuggerClick(tabId, inputEl.x, inputEl.y);
1317
+ await new Promise(r => setTimeout(r, 120));
1318
+
1319
+ // Clear input only if non-empty. Backspace on empty multi-select deletes the previous chip
1320
+ // (react-select, MUI Autocomplete, Meta combobox all behave this way) — so we use native
1321
+ // value-setter to clear cleanly without ever pressing Backspace on an empty field.
1322
+ const currentValue = await readBackValue(tabId, selector);
1323
+ if (currentValue) {
1324
+ await safeExecuteScript(tabId, (sel) => {
1325
+ const el = document.querySelector(sel);
1326
+ if (!el) return;
1327
+ const proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
1328
+ const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
1329
+ if (setter) setter.call(el, ''); else el.value = '';
1330
+ el.dispatchEvent(new Event('input', { bubbles: true }));
1331
+ }, [selector]);
1332
+ }
1333
+
1334
+ // Type partial query via Input.insertText (bypasses per-keystroke validators)
1335
+ const query = val.slice(0, Math.min(queryPrefixLen, val.length));
1336
+ await debuggerAttach(tabId);
1337
+ await cdpSend(tabId, 'Input.insertText', { text: query });
1338
+
1339
+ // Wait for listbox/options to appear
1340
+ let ready = false;
1341
+ for (let i = 0; i < waitIterations; i++) {
1342
+ await new Promise(r => setTimeout(r, 100));
1343
+ const found = await debuggerEval(tabId, `(() => {
1344
+ const lbs = document.querySelectorAll('[role="listbox"], [role="grid"][aria-label*="suggest" i], [class*="autocomplete" i] [class*="option" i], [class*="menu" i][role]:not([aria-hidden="true"])');
1345
+ for (const lb of lbs) {
1346
+ if (lb.offsetHeight === 0) continue;
1347
+ const opts = lb.querySelectorAll('[role="option"], [role="menuitem"], [data-option-index], [class*="option" i]:not([class*="optgroup" i])');
1348
+ if (opts.length > 0) return true;
1349
+ }
1350
+ return false;
1351
+ })()`);
1352
+ if (found) { ready = true; break; }
1353
+ }
1354
+
1355
+ if (!ready) {
1356
+ results.push({ value: val, ok: false, error: 'no-options-rendered', query, waitMs });
1357
+ continue;
1358
+ }
1359
+
1360
+ // Find and click matching option
1361
+ const click = await safeExecuteScript(tabId, (query) => {
1362
+ const lbs = [...document.querySelectorAll('[role="listbox"], [role="grid"][aria-label*="suggest" i], [class*="autocomplete" i], [class*="menu" i][role]:not([aria-hidden="true"])')]
1363
+ .filter(lb => lb.offsetHeight > 0);
1364
+
1365
+ const queryLower = query.toLowerCase();
1366
+ const allOptions = [];
1367
+ for (const lb of lbs) {
1368
+ const opts = [...lb.querySelectorAll('[role="option"], [role="menuitem"], [data-option-index]')];
1369
+ if (opts.length === 0) {
1370
+ opts.push(...lb.querySelectorAll('li, [class*="option" i]:not([class*="optgroup" i])'));
1371
+ }
1372
+ const enabled = opts.filter(o =>
1373
+ o.getAttribute('aria-disabled') !== 'true' &&
1374
+ !o.classList.contains('disabled') &&
1375
+ o.offsetHeight > 0
1376
+ );
1377
+ allOptions.push(...enabled);
1378
+ }
1379
+
1380
+ for (const o of allOptions) {
1381
+ const text = (o.textContent || '').trim().toLowerCase();
1382
+ if (text === queryLower) {
1383
+ o.click();
1384
+ return { ok: true, method: 'exact', text: o.textContent.trim() };
1385
+ }
1386
+ }
1387
+ for (const o of allOptions) {
1388
+ const text = (o.textContent || '').trim().toLowerCase();
1389
+ if (text.startsWith(queryLower)) {
1390
+ o.click();
1391
+ return { ok: true, method: 'startsWith', text: o.textContent.trim() };
1392
+ }
1393
+ }
1394
+ for (const o of allOptions) {
1395
+ const text = (o.textContent || '').trim().toLowerCase();
1396
+ if (text.includes(queryLower)) {
1397
+ o.click();
1398
+ return { ok: true, method: 'contains', text: o.textContent.trim() };
1399
+ }
1400
+ }
1401
+
1402
+ return { ok: false, error: 'no-match-found', optionCount: allOptions.length };
1403
+ }, [val]);
1404
+
1405
+ if (click.result?.ok) {
1406
+ results.push({ value: val, ok: true, method: click.result.method, selected: click.result.text });
1407
+ if (multi) {
1408
+ await new Promise(r => setTimeout(r, 250));
1409
+ }
1410
+ } else {
1411
+ results.push({ value: val, ok: false, error: click.result?.error || 'click-failed' });
1412
+ }
1413
+ } catch (e) {
1414
+ results.push({ value: val, ok: false, error: e?.message || String(e) });
1415
+ }
1416
+ }
1417
+
1418
+ return { ok: results.every(r => r.ok), results };
1419
+ }
1420
+
1421
+ // ── File Drop Helper (for drop-zones without <input type="file">) ───────────
1422
+
1423
+ async function dropFileOnTarget(tabId, selector, files) {
1424
+ const fileList = Array.isArray(files) ? files : [files];
1425
+
1426
+ // Sweep any stale tags from previous failed runs before tagging fresh
1427
+ await debuggerEval(tabId, `(() => {
1428
+ document.querySelectorAll('[data-bmcp-drop-tag]').forEach(el => el.removeAttribute('data-bmcp-drop-tag'));
1429
+ })()`);
1430
+
1431
+ // Strategy 1: search subtree (and 2 ancestor levels) for a file input — even if hidden
1432
+ const inputJson = await debuggerEval(tabId, `(() => {
1433
+ const target = document.querySelector(${JSON.stringify(selector)});
1434
+ if (!target) return JSON.stringify({ found: false, error: 'target-not-found' });
1435
+
1436
+ const candidates = [];
1437
+ candidates.push(...target.querySelectorAll('input[type="file"]'));
1438
+ if (candidates.length === 0 && target.parentElement) {
1439
+ candidates.push(...target.parentElement.querySelectorAll('input[type="file"]'));
1440
+ }
1441
+ if (candidates.length === 0 && target.parentElement?.parentElement) {
1442
+ candidates.push(...target.parentElement.parentElement.querySelectorAll('input[type="file"]'));
1443
+ }
1444
+ if (candidates.length === 0) {
1445
+ // Last resort: any file input on the page
1446
+ candidates.push(...document.querySelectorAll('input[type="file"]'));
1447
+ }
1448
+ if (candidates.length === 0) return JSON.stringify({ found: false });
1449
+
1450
+ // Tag the first viable input with a unique data-attribute so we can re-query reliably
1451
+ const tag = '__bmcp_drop_target_' + Math.random().toString(36).slice(2, 10);
1452
+ candidates[0].setAttribute('data-bmcp-drop-tag', tag);
1453
+ return JSON.stringify({ found: true, tag, accept: candidates[0].accept || '', multiple: !!candidates[0].multiple });
1454
+ })()`);
1455
+
1456
+ const inputInfo = JSON.parse(inputJson);
1457
+
1458
+ if (inputInfo.found) {
1459
+ const taggedSel = `[data-bmcp-drop-tag="${inputInfo.tag}"]`;
1460
+ let result;
1461
+ let caughtError;
1462
+ try {
1463
+ await debuggerAttach(tabId);
1464
+ const docResult = await cdpSend(tabId, 'DOM.getDocument', {});
1465
+ const queryResult = await cdpSend(tabId, 'DOM.querySelector', {
1466
+ nodeId: docResult.root.nodeId,
1467
+ selector: taggedSel,
1468
+ });
1469
+ if (queryResult.nodeId) {
1470
+ await cdpSend(tabId, 'DOM.setFileInputFiles', {
1471
+ nodeId: queryResult.nodeId,
1472
+ files: fileList,
1473
+ });
1474
+ result = { ok: true, method: 'hidden-input', files: fileList, accept: inputInfo.accept };
1475
+ }
1476
+ } catch (e) {
1477
+ caughtError = e?.message || String(e);
1478
+ } finally {
1479
+ // Always remove the tag attribute — success or failure
1480
+ try {
1481
+ await debuggerEval(tabId, `(() => {
1482
+ const el = document.querySelector(${JSON.stringify(taggedSel)});
1483
+ if (el) el.removeAttribute('data-bmcp-drop-tag');
1484
+ })()`);
1485
+ } catch {}
1486
+ }
1487
+ if (result) return result;
1488
+ if (caughtError) {
1489
+ return {
1490
+ ok: false,
1491
+ error: 'setFileInputFiles-failed',
1492
+ detail: caughtError,
1493
+ };
1494
+ }
1495
+ }
1496
+
1497
+ return {
1498
+ ok: false,
1499
+ error: 'no-file-input-found',
1500
+ hint: 'No <input type="file"> found in target subtree, parent, or page. Pure drag-drop zones (without backing input) require synthesizing File objects from disk content via mcp-server, which is not yet implemented. Try selecting a more specific selector, or fall back to manual upload via browser_ask_user.',
1501
+ };
1502
+ }
1503
+
650
1504
  // ── Command Dispatcher ──────────────────────────────────────────────────────
651
1505
 
652
1506
  async function dispatch(port, method, params) {
@@ -712,7 +1566,7 @@ async function dispatch(port, method, params) {
712
1566
  // user is in terminal. Debugger works regardless of tab focus.
713
1567
  try {
714
1568
  await debuggerAttach(tab.id);
715
- const { data } = await chrome.debugger.sendCommand({ tabId: tab.id }, 'Page.captureScreenshot', {
1569
+ const { data } = await cdpSend(tab.id, 'Page.captureScreenshot', {
716
1570
  format: 'png',
717
1571
  });
718
1572
  return { image: 'data:image/png;base64,' + data };
@@ -801,6 +1655,110 @@ async function dispatch(port, method, params) {
801
1655
  }
802
1656
  }
803
1657
 
1658
+ case 'set_date': {
1659
+ const tab = await getSessionTab(port);
1660
+ if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
1661
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(params.date)) {
1662
+ return { ok: false, error: 'date must be ISO format YYYY-MM-DD, got: ' + params.date };
1663
+ }
1664
+
1665
+ const info = await getDateInputInfo(tab.id, params.selector);
1666
+ if (!info.found) return { ok: false, error: 'Element not found: ' + params.selector };
1667
+
1668
+ const tried = [];
1669
+ const iso = params.date;
1670
+
1671
+ // Path A: native <input type="date"> or <input type="datetime-local">
1672
+ if (info.tag === 'INPUT' && (info.inputType === 'date' || info.inputType === 'datetime-local')) {
1673
+ await setDateNative(tab.id, params.selector, iso);
1674
+ await new Promise(r => setTimeout(r, 200));
1675
+ const v = await readBackValue(tab.id, params.selector);
1676
+ tried.push({ path: 'native', value: v });
1677
+ if (v && v.startsWith(iso)) return { ok: true, method: 'native', value: v };
1678
+ }
1679
+
1680
+ // Path B: masked text input — parse format and type via Input.insertText
1681
+ if (info.tag === 'INPUT' && !info.readOnly && !info.disabled) {
1682
+ const fmt = parsePlaceholderFormat(info.placeholder) || parsePlaceholderFormat(info.ariaLabel);
1683
+ if (fmt) {
1684
+ try {
1685
+ await setDateMaskedTyping(tab.id, params.selector, iso, fmt);
1686
+ await new Promise(r => setTimeout(r, 250));
1687
+ const v = await readBackValue(tab.id, params.selector);
1688
+ tried.push({ path: 'masked', format: fmt.order.join(fmt.sep), value: v });
1689
+ if (valueLooksLikeIso(v, iso)) return { ok: true, method: 'masked', value: v, format: fmt.order.join(fmt.sep) };
1690
+ } catch (e) {
1691
+ tried.push({ path: 'masked', error: e.message });
1692
+ }
1693
+ } else {
1694
+ tried.push({
1695
+ path: 'masked',
1696
+ skipped: true,
1697
+ reason: 'no-parseable-format',
1698
+ placeholder: info.placeholder,
1699
+ ariaLabel: info.ariaLabel,
1700
+ });
1701
+ }
1702
+ } else {
1703
+ tried.push({
1704
+ path: 'masked',
1705
+ skipped: true,
1706
+ reason: info.tag !== 'INPUT' ? 'not-input-element' : (info.readOnly ? 'readonly' : 'disabled'),
1707
+ });
1708
+ }
1709
+
1710
+ // Path C: calendar-picker navigation
1711
+ if (!params.skip_picker) {
1712
+ const r = await setDatePicker(tab.id, params.selector, iso);
1713
+ await new Promise(r2 => setTimeout(r2, 200));
1714
+ const v = await readBackValue(tab.id, params.selector);
1715
+ tried.push({ path: 'picker', ...r, value: v });
1716
+ if (r.ok && valueLooksLikeIso(v, iso)) return { ok: true, method: 'picker', value: v, navAttempts: r.navAttempts };
1717
+ }
1718
+
1719
+ const visibleErrors = await collectVisibleErrors(tab.id, params.selector);
1720
+ const finalValue = await readBackValue(tab.id, params.selector);
1721
+ return {
1722
+ ok: false,
1723
+ error: 'all-paths-failed',
1724
+ tried,
1725
+ current_value: finalValue,
1726
+ visible_errors: visibleErrors,
1727
+ input_info: info,
1728
+ };
1729
+ }
1730
+
1731
+ case 'dismiss_overlays': {
1732
+ const tab = await getSessionTab(port);
1733
+ if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
1734
+ const scope = params.scope || 'non_critical';
1735
+ const maxPasses = params.max_passes ?? 3;
1736
+ const r = await dismissOverlays(tab.id, scope, maxPasses);
1737
+ return { ok: true, dismissed: r.dismissed, skipped: r.skipped, count: r.dismissed.length };
1738
+ }
1739
+
1740
+ case 'set_combobox': {
1741
+ const tab = await getSessionTab(port);
1742
+ if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
1743
+ if (!params.selector) return { ok: false, error: 'selector required' };
1744
+ if (!params.values && !params.value) return { ok: false, error: 'value or values required' };
1745
+ const values = params.values || [params.value];
1746
+ const r = await setCombobox(tab.id, params.selector, values, {
1747
+ multi: !!params.multi,
1748
+ query_chars: params.query_chars,
1749
+ });
1750
+ const visibleErrors = r.ok ? [] : await collectVisibleErrors(tab.id, params.selector);
1751
+ return r.ok ? r : { ...r, visible_errors: visibleErrors };
1752
+ }
1753
+
1754
+ case 'drop_file': {
1755
+ const tab = await getSessionTab(port);
1756
+ if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
1757
+ const files = Array.isArray(params.files) ? params.files : [params.files || params.file];
1758
+ if (!files[0]) return { ok: false, error: 'files or file required' };
1759
+ return await dropFileOnTarget(tab.id, params.selector || 'body', files);
1760
+ }
1761
+
804
1762
  case 'wait': {
805
1763
  const tab = await getSessionTab(port);
806
1764
  if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
@@ -834,14 +1792,14 @@ async function dispatch(port, method, params) {
834
1792
 
835
1793
  await debuggerAttach(tab.id);
836
1794
  try {
837
- await chrome.debugger.sendCommand({ tabId: tab.id }, 'Input.dispatchKeyEvent', {
1795
+ await cdpSend(tab.id, 'Input.dispatchKeyEvent', {
838
1796
  type: 'keyDown',
839
1797
  key,
840
1798
  code: params.code || key,
841
1799
  modifiers,
842
1800
  text: key.length === 1 ? key : '',
843
1801
  });
844
- await chrome.debugger.sendCommand({ tabId: tab.id }, 'Input.dispatchKeyEvent', {
1802
+ await cdpSend(tab.id, 'Input.dispatchKeyEvent', {
845
1803
  type: 'keyUp',
846
1804
  key,
847
1805
  code: params.code || key,
@@ -867,7 +1825,7 @@ async function dispatch(port, method, params) {
867
1825
  const dy = params.y || 0;
868
1826
  try {
869
1827
  await debuggerAttach(tab.id);
870
- await chrome.debugger.sendCommand({ tabId: tab.id }, 'Input.dispatchMouseEvent', {
1828
+ await cdpSend(tab.id, 'Input.dispatchMouseEvent', {
871
1829
  type: 'mouseWheel', x: 400, y: 300, deltaX: dx, deltaY: dy,
872
1830
  });
873
1831
  await debuggerDetach(tab.id);
@@ -885,7 +1843,7 @@ async function dispatch(port, method, params) {
885
1843
  if (!el) return { ok: false, error: 'Element not found: ' + params.selector };
886
1844
  await debuggerAttach(tab.id);
887
1845
  try {
888
- await chrome.debugger.sendCommand({ tabId: tab.id }, 'Input.dispatchMouseEvent', {
1846
+ await cdpSend(tab.id, 'Input.dispatchMouseEvent', {
889
1847
  type: 'mouseMoved', x: el.x, y: el.y,
890
1848
  });
891
1849
  // Hold hover for duration (default 500ms) so menus/tooltips appear
@@ -953,7 +1911,7 @@ async function dispatch(port, method, params) {
953
1911
  await debuggerAttach(tab.id);
954
1912
  try {
955
1913
  // Enable page events to catch dialogs
956
- await chrome.debugger.sendCommand({ tabId: tab.id }, 'Page.enable', {});
1914
+ await cdpSend(tab.id, 'Page.enable', {});
957
1915
 
958
1916
  // Wait for dialog to appear (or handle existing one)
959
1917
  const result = await new Promise((resolve) => {
@@ -967,7 +1925,7 @@ async function dispatch(port, method, params) {
967
1925
  chrome.debugger.onEvent.removeListener(listener);
968
1926
  clearTimeout(timeout);
969
1927
 
970
- chrome.debugger.sendCommand({ tabId: tab.id }, 'Page.handleJavaScriptDialog', {
1928
+ cdpSend(tab.id, 'Page.handleJavaScriptDialog', {
971
1929
  accept: action === 'accept',
972
1930
  promptText: promptText,
973
1931
  }).then(() => {
@@ -996,7 +1954,7 @@ async function dispatch(port, method, params) {
996
1954
 
997
1955
  await debuggerAttach(tab.id);
998
1956
  try {
999
- await chrome.debugger.sendCommand({ tabId: tab.id }, 'Network.enable', {});
1957
+ await cdpSend(tab.id, 'Network.enable', {});
1000
1958
 
1001
1959
  const result = await new Promise((resolve) => {
1002
1960
  const timer = setTimeout(() => {
@@ -1015,7 +1973,7 @@ async function dispatch(port, method, params) {
1015
1973
  chrome.debugger.onEvent.removeListener(listener);
1016
1974
  clearTimeout(timer);
1017
1975
  // Try to get response body
1018
- chrome.debugger.sendCommand({ tabId: tab.id }, 'Network.getResponseBody', {
1976
+ cdpSend(tab.id, 'Network.getResponseBody', {
1019
1977
  requestId: eventParams.requestId,
1020
1978
  }).then(bodyResult => {
1021
1979
  resolve({
@@ -1040,7 +1998,7 @@ async function dispatch(port, method, params) {
1040
1998
  chrome.debugger.onEvent.addListener(listener);
1041
1999
  });
1042
2000
 
1043
- await chrome.debugger.sendCommand({ tabId: tab.id }, 'Network.disable', {});
2001
+ await cdpSend(tab.id, 'Network.disable', {});
1044
2002
  return result;
1045
2003
  } finally {
1046
2004
  await debuggerDetach(tab.id);
@@ -1141,7 +2099,7 @@ async function dispatch(port, method, params) {
1141
2099
  const count = params.count || 50;
1142
2100
  try {
1143
2101
  await debuggerAttach(tab.id);
1144
- await chrome.debugger.sendCommand({ tabId: tab.id }, 'Runtime.enable');
2102
+ await cdpSend(tab.id, 'Runtime.enable');
1145
2103
  // Collect console messages for a brief period
1146
2104
  const logs = [];
1147
2105
  const handler = (source, method, eventParams) => {
@@ -1155,7 +2113,7 @@ async function dispatch(port, method, params) {
1155
2113
  };
1156
2114
  chrome.debugger.onEvent.addListener(handler);
1157
2115
  // Also grab existing console via page JS
1158
- const { result } = await chrome.debugger.sendCommand({ tabId: tab.id }, 'Runtime.evaluate', {
2116
+ const { result } = await cdpSend(tab.id, 'Runtime.evaluate', {
1159
2117
  expression: `(() => {
1160
2118
  if (!window.__mcpConsoleLogs) {
1161
2119
  window.__mcpConsoleLogs = [];
@@ -1425,7 +2383,7 @@ async function dispatch(port, method, params) {
1425
2383
  try {
1426
2384
  await debuggerAttach(tab.id);
1427
2385
  // Find the file input element
1428
- const { result: nodeResult } = await chrome.debugger.sendCommand({ tabId: tab.id }, 'Runtime.evaluate', {
2386
+ const { result: nodeResult } = await cdpSend(tab.id, 'Runtime.evaluate', {
1429
2387
  expression: `(() => {
1430
2388
  const el = document.querySelector(${JSON.stringify(selector)});
1431
2389
  if (!el) return JSON.stringify({ found: false, error: 'File input not found: ${selector}' });
@@ -1440,8 +2398,8 @@ async function dispatch(port, method, params) {
1440
2398
  }
1441
2399
 
1442
2400
  // Get the DOM node ID for the file input
1443
- const { result: docResult } = await chrome.debugger.sendCommand({ tabId: tab.id }, 'DOM.getDocument', {});
1444
- const { nodeId } = await chrome.debugger.sendCommand({ tabId: tab.id }, 'DOM.querySelector', {
2401
+ const { result: docResult } = await cdpSend(tab.id, 'DOM.getDocument', {});
2402
+ const { nodeId } = await cdpSend(tab.id, 'DOM.querySelector', {
1445
2403
  nodeId: docResult.root.nodeId,
1446
2404
  selector: selector,
1447
2405
  });
@@ -1453,7 +2411,7 @@ async function dispatch(port, method, params) {
1453
2411
 
1454
2412
  // Set files on the input using CDP
1455
2413
  const files = Array.isArray(params.files) ? params.files : [params.files || params.file];
1456
- await chrome.debugger.sendCommand({ tabId: tab.id }, 'DOM.setFileInputFiles', {
2414
+ await cdpSend(tab.id, 'DOM.setFileInputFiles', {
1457
2415
  nodeId: nodeId,
1458
2416
  files: files,
1459
2417
  });
@@ -1483,7 +2441,7 @@ async function dispatch(port, method, params) {
1483
2441
  async function detectCaptcha(tabId) {
1484
2442
  try {
1485
2443
  await debuggerAttach(tabId);
1486
- const { result } = await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', {
2444
+ const { result } = await cdpSend(tabId, 'Runtime.evaluate', {
1487
2445
  expression: `(() => {
1488
2446
  const res = { found: false, types: [] };
1489
2447
 
@@ -1565,7 +2523,7 @@ async function clickRecaptchaCheckbox(tabId) {
1565
2523
  try {
1566
2524
  await debuggerAttach(tabId);
1567
2525
  // Find the reCAPTCHA anchor iframe position
1568
- const { result } = await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', {
2526
+ const { result } = await cdpSend(tabId, 'Runtime.evaluate', {
1569
2527
  expression: `(() => {
1570
2528
  const iframe = document.querySelector('iframe[src*="recaptcha/api2/anchor"], iframe[src*="recaptcha/enterprise/anchor"]');
1571
2529
  if (!iframe) return JSON.stringify({ found: false });
@@ -1582,14 +2540,14 @@ async function clickRecaptchaCheckbox(tabId) {
1582
2540
  }
1583
2541
 
1584
2542
  // Click the checkbox using real mouse events
1585
- await chrome.debugger.sendCommand({ tabId }, 'Input.dispatchMouseEvent', {
2543
+ await cdpSend(tabId, 'Input.dispatchMouseEvent', {
1586
2544
  type: 'mouseMoved', x: pos.x, y: pos.y,
1587
2545
  });
1588
2546
  await new Promise(r => setTimeout(r, 100 + Math.random() * 200));
1589
- await chrome.debugger.sendCommand({ tabId }, 'Input.dispatchMouseEvent', {
2547
+ await cdpSend(tabId, 'Input.dispatchMouseEvent', {
1590
2548
  type: 'mousePressed', x: pos.x, y: pos.y, button: 'left', clickCount: 1,
1591
2549
  });
1592
- await chrome.debugger.sendCommand({ tabId }, 'Input.dispatchMouseEvent', {
2550
+ await cdpSend(tabId, 'Input.dispatchMouseEvent', {
1593
2551
  type: 'mouseReleased', x: pos.x, y: pos.y, button: 'left', clickCount: 1,
1594
2552
  });
1595
2553
  await debuggerDetach(tabId);
@@ -1604,7 +2562,7 @@ async function clickCaptchaGridCells(tabId, cells) {
1604
2562
  try {
1605
2563
  await debuggerAttach(tabId);
1606
2564
  // Find the challenge iframe position and dimensions
1607
- const { result } = await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', {
2565
+ const { result } = await cdpSend(tabId, 'Runtime.evaluate', {
1608
2566
  expression: `(() => {
1609
2567
  const iframe = document.querySelector('iframe[src*="recaptcha/api2/bframe"], iframe[src*="recaptcha/enterprise/bframe"]');
1610
2568
  if (!iframe) return JSON.stringify({ found: false });
@@ -1646,14 +2604,14 @@ async function clickCaptchaGridCells(tabId, cells) {
1646
2604
  const ox = x + Math.round((Math.random() - 0.5) * cellSize * 0.3);
1647
2605
  const oy = y + Math.round((Math.random() - 0.5) * cellSize * 0.3);
1648
2606
 
1649
- await chrome.debugger.sendCommand({ tabId }, 'Input.dispatchMouseEvent', {
2607
+ await cdpSend(tabId, 'Input.dispatchMouseEvent', {
1650
2608
  type: 'mouseMoved', x: ox, y: oy,
1651
2609
  });
1652
2610
  await new Promise(r => setTimeout(r, 150 + Math.random() * 300));
1653
- await chrome.debugger.sendCommand({ tabId }, 'Input.dispatchMouseEvent', {
2611
+ await cdpSend(tabId, 'Input.dispatchMouseEvent', {
1654
2612
  type: 'mousePressed', x: ox, y: oy, button: 'left', clickCount: 1,
1655
2613
  });
1656
- await chrome.debugger.sendCommand({ tabId }, 'Input.dispatchMouseEvent', {
2614
+ await cdpSend(tabId, 'Input.dispatchMouseEvent', {
1657
2615
  type: 'mouseReleased', x: ox, y: oy, button: 'left', clickCount: 1,
1658
2616
  });
1659
2617
  await new Promise(r => setTimeout(r, 200 + Math.random() * 400));