@agent360/browser-mcp 1.16.1 → 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.
- package/extension/background.js +998 -40
- package/extension/manifest.json +2 -2
- package/extension/offscreen.js +15 -1
- package/index.js +49 -9
- package/package.json +1 -1
- package/tools.js +56 -3
package/extension/background.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
316
|
+
await cdpSend(tabId, 'Input.dispatchMouseEvent', {
|
|
252
317
|
type: 'mousePressed', x, y, button: 'left', clickCount: 1,
|
|
253
318
|
});
|
|
254
|
-
await
|
|
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
|
|
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
|
|
297
|
-
const { nodeId } = await
|
|
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
|
|
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
|
|
406
|
+
await cdpSend(tabId, 'Input.dispatchKeyEvent', {
|
|
342
407
|
type: 'keyDown', key: 'a', code: 'KeyA', modifiers: 2,
|
|
343
408
|
});
|
|
344
|
-
await
|
|
409
|
+
await cdpSend(tabId, 'Input.dispatchKeyEvent', {
|
|
345
410
|
type: 'keyUp', key: 'a', code: 'KeyA',
|
|
346
411
|
});
|
|
347
|
-
await
|
|
412
|
+
await cdpSend(tabId, 'Input.dispatchKeyEvent', {
|
|
348
413
|
type: 'keyDown', key: 'Backspace', code: 'Backspace',
|
|
349
414
|
});
|
|
350
|
-
await
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1444
|
-
const { nodeId } = await
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
2547
|
+
await cdpSend(tabId, 'Input.dispatchMouseEvent', {
|
|
1590
2548
|
type: 'mousePressed', x: pos.x, y: pos.y, button: 'left', clickCount: 1,
|
|
1591
2549
|
});
|
|
1592
|
-
await
|
|
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
|
|
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
|
|
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
|
|
2611
|
+
await cdpSend(tabId, 'Input.dispatchMouseEvent', {
|
|
1654
2612
|
type: 'mousePressed', x: ox, y: oy, button: 'left', clickCount: 1,
|
|
1655
2613
|
});
|
|
1656
|
-
await
|
|
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));
|