chrome-bridge-mcp 1.8.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.
@@ -0,0 +1,3711 @@
1
+ /**
2
+ * Chrome Bridge — Service Worker
3
+ *
4
+ * Mantiene una connessione WebSocket al server MCP in Crostini.
5
+ * Riceve comandi, li esegue tramite Chrome APIs, e invia le risposte.
6
+ */
7
+
8
+ import './telemetry.js';
9
+ const { pushError } = globalThis.__cbTelemetry;
10
+
11
+ const DEFAULT_PORT = 8765;
12
+ let wsUrl = `ws://localhost:${DEFAULT_PORT}`;
13
+ let extToken = '';
14
+ // true solo nella copia unpacked creata dal launch mode (launch.json presente)
15
+ let launchMode = false;
16
+
17
+ async function loadConfig() {
18
+ // launch.json esiste solo nella copia unpacked creata dal launch mode
19
+ // (porta effimera del server dedicato); nel pacchetto CWS non c'è.
20
+ let fileCfg = null;
21
+ try {
22
+ const res = await fetch(chrome.runtime.getURL('launch.json'));
23
+ if (res.ok) fileCfg = await res.json();
24
+ } catch { /* assente: modalità normale */ }
25
+ launchMode = fileCfg != null;
26
+ const cfg = await chrome.storage.local.get({ port: DEFAULT_PORT, token: '' });
27
+ wsUrl = `ws://localhost:${fileCfg?.port ?? cfg.port}`;
28
+ extToken = fileCfg?.token ?? cfg.token ?? '';
29
+ }
30
+
31
+ // --- Instrumentation MAIN-world (console/errori/web vitals/route SPA) ---
32
+ // Registrata dinamicamente: quando il toggle è off non viene iniettata su
33
+ // nessuna pagina, quindi footprint e stack pollution sono azzerati.
34
+ const INSTRUMENTATION_ID = 'chrome-bridge-instrumentation';
35
+
36
+ async function _applyInstrumentation() {
37
+ const { instrument } = await chrome.storage.local.get({ instrument: true });
38
+ try {
39
+ const existing = await chrome.scripting.getRegisteredContentScripts({ ids: [INSTRUMENTATION_ID] });
40
+ const isReg = existing.some((s) => s.id === INSTRUMENTATION_ID);
41
+ if (instrument && !isReg) {
42
+ await chrome.scripting.registerContentScripts([{
43
+ id: INSTRUMENTATION_ID,
44
+ js: ['console-capture.js', 'page-instrumentation.js'],
45
+ matches: ['<all_urls>'],
46
+ runAt: 'document_start',
47
+ world: 'MAIN',
48
+ persistAcrossSessions: true,
49
+ }]);
50
+ } else if (!instrument && isReg) {
51
+ await chrome.scripting.unregisterContentScripts({ ids: [INSTRUMENTATION_ID] });
52
+ }
53
+ } catch (err) {
54
+ console.error('[chrome-bridge] applyInstrumentation error:', err.message);
55
+ }
56
+ }
57
+
58
+ // Serializza le chiamate: toggle rapidi non devono lanciare register concorrenti
59
+ // (due chiamate che vedono entrambe isReg=false → duplicate-id error).
60
+ let instrumentationQueue = Promise.resolve();
61
+ function applyInstrumentation() {
62
+ instrumentationQueue = instrumentationQueue.then(_applyInstrumentation, _applyInstrumentation);
63
+ return instrumentationQueue;
64
+ }
65
+
66
+ chrome.storage.onChanged.addListener((changes, area) => {
67
+ if (area !== 'local') return;
68
+ if (changes.port || changes.token) {
69
+ loadConfig().then(() => {
70
+ // Chiudi e lascia che scheduleReconnect riconnetta col nuovo URL
71
+ if (ws) { try { ws.close(); } catch {} } else { connect(); }
72
+ });
73
+ }
74
+ if (changes.instrument) applyInstrumentation();
75
+ });
76
+
77
+ const RECONNECT_BASE_MS = 1000;
78
+ const RECONNECT_MAX_MS = 30000;
79
+ const KEEPALIVE_ALARM = 'chrome-bridge-keepalive';
80
+
81
+ let ws = null;
82
+ let reconnectTimer = null;
83
+ let reconnectDelay = RECONNECT_BASE_MS;
84
+ let connectionState = 'disconnected'; // 'connected' | 'connecting' | 'disconnected'
85
+
86
+ let serverVersion = null;
87
+ let sessionStats = { toolCallCount: 0, lastTool: null, lastToolTs: null, recentErrors: [] };
88
+ // Il SW MV3 muore e rinasce: ripristina i contatori di sessione
89
+ chrome.storage.session.get({ sessionStats: null }).then(({ sessionStats: saved }) => {
90
+ if (saved) sessionStats = saved;
91
+ }).catch(() => {});
92
+ function persistStats() {
93
+ chrome.storage.session.set({ sessionStats }).catch(() => {});
94
+ }
95
+
96
+ // Boot del service worker: MV3 lo termina e lo rianima, azzerando ogni stato
97
+ // in RAM (browserNetLog, mainFrameHeaders, diffBaselines, httpAuthCreds). La
98
+ // perdita era indistinguibile da "nessun dato": esporre il boot permette ai
99
+ // tool di dire `log_since` invece di un `count: 0` ambiguo.
100
+ const swBootedAt = Date.now();
101
+ chrome.storage.session.get({ swBootedAt: null }).then(({ swBootedAt: prev }) => {
102
+ if (prev) console.log(`[chrome-bridge] service worker restarted (previous boot ${new Date(prev).toISOString()})`);
103
+ chrome.storage.session.set({ swBootedAt }).catch(() => {});
104
+ }).catch(() => {});
105
+
106
+ // --- Keep-alive: impedisce che il service worker venga fermato ---
107
+ chrome.alarms.create(KEEPALIVE_ALARM, { periodInMinutes: 0.5 }); // 30s = minimo Chrome
108
+ chrome.alarms.onAlarm.addListener((alarm) => {
109
+ if (alarm.name === KEEPALIVE_ALARM) {
110
+ // Il solo fatto che l'handler esista tiene il service worker attivo
111
+ }
112
+ });
113
+
114
+ // --- Listener per popup ---
115
+ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
116
+ if (msg.type === 'getConnectionState') {
117
+ sendResponse({ state: connectionState });
118
+ return;
119
+ }
120
+ if (msg.type === 'getPopupData') {
121
+ (async () => {
122
+ const cfg = await chrome.storage.local.get({ port: 8765, instrument: true });
123
+ sendResponse({
124
+ state: connectionState,
125
+ port: cfg.port,
126
+ instrument: cfg.instrument,
127
+ extensionVersion: chrome.runtime.getManifest().version,
128
+ serverVersion,
129
+ stats: sessionStats,
130
+ });
131
+ })();
132
+ return true; // risposta asincrona
133
+ }
134
+ if (msg.type === 'getPageInfo') {
135
+ getPageInfo()
136
+ .then(sendResponse)
137
+ .catch((e) => sendResponse({ available: false, error: e.message }));
138
+ return true;
139
+ }
140
+ if (msg.type === 'reconnect') {
141
+ forceReconnect();
142
+ sendResponse({ ok: true });
143
+ return;
144
+ }
145
+ });
146
+
147
+ // --- WebSocket connection ---
148
+
149
+ function connect() {
150
+ clearTimeout(reconnectTimer);
151
+
152
+ if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
153
+ return;
154
+ }
155
+
156
+ setConnectionState('connecting');
157
+
158
+ try {
159
+ ws = new WebSocket(wsUrl);
160
+ } catch (err) {
161
+ console.error('[chrome-bridge] WebSocket creation error:', err);
162
+ scheduleReconnect();
163
+ return;
164
+ }
165
+
166
+ const socket = ws;
167
+
168
+ ws.onopen = () => {
169
+ console.log('[chrome-bridge] Connected to MCP server');
170
+ const init = { type: 'ext_init', version: chrome.runtime.getManifest().version };
171
+ if (extToken) init.token = extToken;
172
+ ws.send(JSON.stringify(init));
173
+ setConnectionState('connected');
174
+ reconnectDelay = RECONNECT_BASE_MS; // Reset backoff
175
+ };
176
+
177
+ ws.onclose = () => {
178
+ if (ws !== socket) return;
179
+ console.log('[chrome-bridge] Disconnected from MCP server');
180
+ ws = null;
181
+ serverVersion = null;
182
+ setConnectionState('disconnected');
183
+ scheduleReconnect();
184
+ };
185
+
186
+ ws.onerror = (err) => {
187
+ console.error('[chrome-bridge] WebSocket error:', err);
188
+ // onclose verrà chiamato dopo onerror
189
+ };
190
+
191
+ ws.onmessage = async (event) => {
192
+ let msg;
193
+ try {
194
+ msg = JSON.parse(event.data);
195
+ } catch {
196
+ console.error('[chrome-bridge] Invalid JSON from server');
197
+ return;
198
+ }
199
+
200
+ // Gestisci ping
201
+ if (msg.type === 'ping') {
202
+ sendMessage({ type: 'pong', timestamp: Date.now() });
203
+ return;
204
+ }
205
+
206
+ // Handshake: il server dichiara la sua versione
207
+ if (msg.type === 'ext_init_ok') {
208
+ serverVersion = msg.version || null;
209
+ return;
210
+ }
211
+
212
+ sessionStats.toolCallCount += 1;
213
+ sessionStats.lastTool = msg.type;
214
+ sessionStats.lastToolTs = Date.now();
215
+
216
+ // Esegui il comando e rispondi
217
+ try {
218
+ const result = await executeCommand(msg);
219
+ sendMessage({
220
+ id: msg.id,
221
+ type: 'result',
222
+ data: result,
223
+ timestamp: Date.now(),
224
+ });
225
+ } catch (err) {
226
+ sessionStats.recentErrors = pushError(sessionStats.recentErrors, {
227
+ ts: Date.now(), tool: msg.type, message: err.message || String(err),
228
+ });
229
+ sendMessage({
230
+ id: msg.id,
231
+ type: 'error',
232
+ error: err.message || String(err),
233
+ timestamp: Date.now(),
234
+ });
235
+ }
236
+ persistStats();
237
+ };
238
+ }
239
+
240
+ function scheduleReconnect() {
241
+ reconnectTimer = setTimeout(() => {
242
+ reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
243
+ connect();
244
+ }, reconnectDelay);
245
+ }
246
+
247
+ function sendMessage(obj) {
248
+ if (ws && ws.readyState === WebSocket.OPEN) {
249
+ ws.send(JSON.stringify(obj));
250
+ } else {
251
+ // Il server andrà comunque in timeout sul comando: qui solo traccia diagnostica
252
+ console.warn('[chrome-bridge] message dropped, WebSocket not open:', obj?.type ?? '', obj?.id ?? '');
253
+ }
254
+ }
255
+
256
+ function setConnectionState(state) {
257
+ connectionState = state;
258
+ // Broadcast al popup
259
+ chrome.runtime.sendMessage({ type: 'connectionState', state }).catch(() => {
260
+ // Popup non aperto, ignora
261
+ });
262
+ }
263
+
264
+ function forceReconnect() {
265
+ clearTimeout(reconnectTimer);
266
+ reconnectDelay = RECONNECT_BASE_MS;
267
+ if (ws) {
268
+ const s = ws;
269
+ ws = null; // evita che l'onclose del vecchio socket pianifichi un reconnect
270
+ try { s.close(); } catch {}
271
+ }
272
+ setConnectionState('disconnected');
273
+ loadConfig().then(connect);
274
+ }
275
+
276
+ // Info sulla tab attiva per la card "Pagina corrente" del popup.
277
+ // Best-effort: ogni blocco fallisce in silenzio (pagina non iniettabile,
278
+ // instrumentation spenta) e il popup degrada di conseguenza.
279
+ async function getPageInfo() {
280
+ const [tab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
281
+ if (!tab?.id || !/^https?:/.test(tab.url || '')) return { available: false };
282
+ const target = { tabId: tab.id };
283
+
284
+ let stack = [];
285
+ try {
286
+ await chrome.scripting.executeScript({ target, files: ['stack-detect.js'], world: 'MAIN' });
287
+ const [r] = await chrome.scripting.executeScript({
288
+ target, world: 'MAIN',
289
+ func: () => window.__chromeBridge_stackDetect || [],
290
+ });
291
+ stack = r?.result || [];
292
+ } catch {}
293
+
294
+ let consoleErrors = null;
295
+ let vitals = null;
296
+ try {
297
+ const [r] = await chrome.scripting.executeScript({
298
+ target, world: 'MAIN',
299
+ func: () => ({
300
+ hooked: !!window.__chromeBridge_consoleHooked,
301
+ errors: (window.__chromeBridge_consoleLogs || []).filter((l) => l.level === 'error').length,
302
+ vitals: window.__chromeBridge_vitals
303
+ ? { lcp: window.__chromeBridge_vitals.lcp, cls: Math.round((window.__chromeBridge_vitals.cls || 0) * 1000) / 1000 }
304
+ : null,
305
+ }),
306
+ });
307
+ if (r?.result?.hooked) {
308
+ consoleErrors = r.result.errors;
309
+ vitals = r.result.vitals;
310
+ }
311
+ } catch {}
312
+
313
+ return { available: true, title: tab.title || '', stack, consoleErrors, vitals };
314
+ }
315
+
316
+ // --- Command dispatcher ---
317
+
318
+ async function executeCommand(msg) {
319
+ const { type, params = {} } = msg;
320
+
321
+ switch (type) {
322
+ case 'get_tabs':
323
+ return await cmdGetTabs();
324
+ case 'navigate':
325
+ return await cmdNavigate(params);
326
+ case 'screenshot':
327
+ return await cmdScreenshot(params);
328
+ case 'execute_js':
329
+ return await cmdExecuteJs(params);
330
+ case 'click':
331
+ return await cmdClick(params);
332
+ case 'type_text':
333
+ return await cmdTypeText(params);
334
+ case 'read_page':
335
+ return await cmdReadPage(params);
336
+ case 'get_page_info':
337
+ return await cmdGetPageInfo(params);
338
+ case 'get_storage':
339
+ return await cmdGetStorage(params);
340
+ case 'get_performance':
341
+ return await cmdGetPerformance(params);
342
+ case 'query_dom':
343
+ return await cmdQueryDom(params);
344
+ case 'modify_dom':
345
+ return await cmdModifyDom(params);
346
+ case 'inject_css':
347
+ return await cmdInjectCss(params);
348
+ case 'read_console':
349
+ return await cmdReadConsole(params);
350
+ case 'monitor_network':
351
+ return await cmdMonitorNetwork(params);
352
+ case 'create_tab':
353
+ return await cmdCreateTab(params);
354
+ case 'wait_for_element':
355
+ return await cmdWaitForElement(params);
356
+ case 'scroll_to':
357
+ return await cmdScrollTo(params);
358
+ case 'set_storage':
359
+ return await cmdSetStorage(params);
360
+ case 'fill_form':
361
+ return await cmdFillForm(params);
362
+ case 'viewport_resize':
363
+ return await cmdViewportResize(params);
364
+ case 'full_page_screenshot':
365
+ return await cmdFullPageScreenshot(params);
366
+ case 'element_screenshot':
367
+ return await cmdElementScreenshot(params);
368
+ case 'highlight_elements':
369
+ return await cmdHighlightElements(params);
370
+ case 'accessibility_audit':
371
+ return await cmdAccessibilityAudit(params);
372
+ case 'collect_links':
373
+ return await cmdCollectLinks(params);
374
+ case 'measure_spacing':
375
+ return await cmdMeasureSpacing(params);
376
+ case 'watch_dom':
377
+ return await cmdWatchDom(params);
378
+ case 'emulate_media':
379
+ return await cmdEmulateMedia(params);
380
+ case 'hover':
381
+ return await cmdHover(params);
382
+ case 'press_key':
383
+ return await cmdPressKey(params);
384
+ case 'get_frames':
385
+ return await cmdGetFrames(params);
386
+ case 'tab_action':
387
+ return await cmdTabAction(params);
388
+ case 'upload_file':
389
+ return await cmdUploadFile(params);
390
+ case 'wait_for_navigation':
391
+ return await cmdWaitForNavigation(params);
392
+ case 'dismiss_overlays':
393
+ return await cmdDismissOverlays(params);
394
+ case 'wait_for_network_idle':
395
+ return await cmdWaitForNetworkIdle(params);
396
+ case 'handle_dialogs':
397
+ return await cmdHandleDialogs(params);
398
+ case 'find_text':
399
+ return await cmdFindText(params);
400
+ case 'network_rules':
401
+ return await cmdNetworkRules(params);
402
+ case 'screenshot_diff':
403
+ return await cmdScreenshotDiff(params);
404
+ case 'web_vitals':
405
+ return await cmdWebVitals(params);
406
+ case 'list_event_listeners':
407
+ return await cmdListEventListeners(params);
408
+ case 'monitor_websocket':
409
+ return await cmdMonitorWebsocket(params);
410
+ case 'seo_audit':
411
+ return await cmdSeoAudit(params);
412
+ case 'extract_table':
413
+ return await cmdExtractTable(params);
414
+ case 'unused_css':
415
+ return await cmdUnusedCss(params);
416
+ case 'drag_and_drop':
417
+ return await cmdDragAndDrop(params);
418
+ case 'clipboard':
419
+ return await cmdClipboard(params);
420
+ case 'set_geolocation':
421
+ return await cmdSetGeolocation(params);
422
+ case 'manage_downloads':
423
+ return await cmdManageDownloads(params);
424
+ case 'save_page':
425
+ return await cmdSavePage(params);
426
+ case 'set_zoom':
427
+ return await cmdSetZoom(params);
428
+ case 'http_auth':
429
+ return await cmdHttpAuth(params);
430
+ case 'get_response_headers':
431
+ return await cmdGetResponseHeaders(params);
432
+ case 'get_interactives':
433
+ return await cmdGetInteractives(params);
434
+ case 'wait_for_function':
435
+ return await cmdWaitForFunction(params);
436
+ case 'scroll_until':
437
+ return await cmdScrollUntil(params);
438
+ default:
439
+ throw new Error(`Unknown command type: ${type}`);
440
+ }
441
+ }
442
+
443
+ // --- Utility: waitForComplete ---
444
+
445
+ function waitForComplete(tabId, timeoutMs = 15000) {
446
+ return new Promise((resolve) => {
447
+ const listener = (updatedTabId, changeInfo) => {
448
+ if (updatedTabId === tabId && changeInfo.status === 'complete') {
449
+ chrome.tabs.onUpdated.removeListener(listener);
450
+ resolve(true);
451
+ }
452
+ };
453
+ chrome.tabs.onUpdated.addListener(listener);
454
+ setTimeout(() => {
455
+ chrome.tabs.onUpdated.removeListener(listener);
456
+ resolve(false);
457
+ }, timeoutMs);
458
+ });
459
+ }
460
+
461
+ // --- Utility: risolvi tab_id ---
462
+
463
+ async function resolveTabId(tab_id) {
464
+ if (tab_id) return tab_id;
465
+
466
+ const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
467
+ if (!tab) throw new Error('No active tab found');
468
+ return tab.id;
469
+ }
470
+
471
+ // --- Utility: target per chrome.scripting con frame opzionale ---
472
+
473
+ function scriptTarget(tabId, frame_id) {
474
+ const target = { tabId };
475
+ if (frame_id !== undefined && frame_id !== null) target.frameIds = [frame_id];
476
+ return target;
477
+ }
478
+
479
+ // --- Utility: chrome.userScripts (esecuzione codice utente, CWS-compliant) ---
480
+
481
+
482
+ function userScriptsAvailable() {
483
+ // L'accesso alla proprietà lancia se l'utente non ha attivato il toggle
484
+ try {
485
+ chrome.userScripts.getScripts;
486
+ return true;
487
+ } catch {
488
+ return false;
489
+ }
490
+ }
491
+
492
+ const USER_SCRIPTS_HELP = "User scripts are disabled. Open chrome://extensions, click Details on Chrome Bridge, and enable 'Allow user scripts' (on Chrome 135-137 enable Developer Mode instead). Then retry.";
493
+
494
+ async function runUserScript(target, code, world) {
495
+ if (!userScriptsAvailable()) {
496
+ // Fallback SOLO in launch mode (profilo effimero senza toggle).
497
+ // Nell'installazione normale il toggle resta il gate di consenso:
498
+ // nessuna elusione di chrome.userScripts nel pacchetto CWS.
499
+ if (launchMode) return await runViaFunction(target, code);
500
+ throw new Error(USER_SCRIPTS_HELP);
501
+ }
502
+ const results = await chrome.userScripts.execute({
503
+ target,
504
+ js: [{ code }],
505
+ world,
506
+ });
507
+ const r = results?.[0];
508
+ if (r && r.error !== undefined && r.error !== null) throw new Error(String(r.error));
509
+ return r?.result;
510
+ }
511
+
512
+ // Fallback quando chrome.userScripts non è disponibile (tipico del launch
513
+ // mode: profilo effimero senza il toggle "Allow user scripts"): il codice
514
+ // utente gira nel MAIN world via new Function. La CSP della pagina può
515
+ // bloccarlo (unsafe-eval) — l'errore spiega come rimediare.
516
+ async function runViaFunction(target, code) {
517
+ const results = await chrome.scripting.executeScript({
518
+ target,
519
+ world: 'MAIN',
520
+ func: async (userCode) => {
521
+ let fn;
522
+ try {
523
+ fn = new Function(`return (${userCode});`);
524
+ } catch {
525
+ try { fn = new Function(userCode); } catch (e) {
526
+ return { ok: false, error: String(e?.message || e) };
527
+ }
528
+ }
529
+ try {
530
+ let v = fn();
531
+ if (v && typeof v.then === 'function') v = await v;
532
+ return { ok: true, value: v === undefined ? null : v };
533
+ } catch (e) {
534
+ return { ok: false, error: String(e?.message || e) };
535
+ }
536
+ },
537
+ args: [code],
538
+ });
539
+ const r = results?.[0]?.result;
540
+ if (!r) throw new Error('No result from page');
541
+ if (!r.ok) {
542
+ const cspHint = /unsafe-eval|Content Security Policy|EvalError/i.test(r.error)
543
+ ? ' (page CSP blocks dynamic code — strip it with network_rules modify_header on "content-security-policy" and reload, or enable the "Allow user scripts" toggle)'
544
+ : '';
545
+ throw new Error(r.error + cspHint);
546
+ }
547
+ return r.value;
548
+ }
549
+
550
+ // --- Utility: captureVisibleTab con timeout ---
551
+ // Su ChromeOS una finestra totalmente occlusa/congelata può non produrre mai
552
+ // un frame: la promise di captureVisibleTab resterebbe pendente per sempre.
553
+ // Meglio un errore parlante dopo 10s che un comando appeso.
554
+
555
+ function captureVisible(windowId, options = { format: 'png' }) {
556
+ return new Promise((resolve, reject) => {
557
+ const timer = setTimeout(() => {
558
+ reject(new Error('Capture timed out after 10s: the window is not rendering frames (fully occluded, minimized, or frozen). Bring the Chrome window at least partially on screen and retry.'));
559
+ }, 10000);
560
+ chrome.tabs.captureVisibleTab(windowId, options).then(
561
+ (dataUrl) => { clearTimeout(timer); resolve(dataUrl); },
562
+ (err) => { clearTimeout(timer); reject(err); },
563
+ );
564
+ });
565
+ }
566
+
567
+ // --- Utility: rendi visibile un tab per la cattura SENZA rubare il focus OS ---
568
+ // captureVisibleTab richiede solo che il tab sia attivo nella sua finestra,
569
+ // non che la finestra sia in primo piano. Al termine ripristina il tab che
570
+ // era attivo prima e l'eventuale stato minimized della finestra.
571
+
572
+ async function withTabVisible(tabId, fn) {
573
+ const tab = await chrome.tabs.get(tabId);
574
+ const win = await chrome.windows.get(tab.windowId);
575
+ const [prevActive] = await chrome.tabs.query({ active: true, windowId: tab.windowId });
576
+ const restoreTabId = prevActive && prevActive.id !== tabId ? prevActive.id : null;
577
+ const wasMinimized = win.state === 'minimized';
578
+
579
+ if (wasMinimized) await chrome.windows.update(tab.windowId, { state: 'normal', focused: false });
580
+ if (!tab.active) await chrome.tabs.update(tabId, { active: true });
581
+ try {
582
+ return await fn(tab);
583
+ } finally {
584
+ if (restoreTabId) {
585
+ try { await chrome.tabs.update(restoreTabId, { active: true }); } catch { /* tab chiuso nel frattempo */ }
586
+ }
587
+ if (wasMinimized) {
588
+ try { await chrome.windows.update(tab.windowId, { state: 'minimized' }); } catch { /* finestra chiusa */ }
589
+ }
590
+ }
591
+ }
592
+
593
+ // --- Helper immagini (OffscreenCanvas nel service worker) ---
594
+
595
+ async function dataUrlToBitmap(dataUrl) {
596
+ const blob = await (await fetch(dataUrl)).blob();
597
+ return await createImageBitmap(blob);
598
+ }
599
+
600
+ async function blobToBase64(blob) {
601
+ const buf = new Uint8Array(await blob.arrayBuffer());
602
+ let bin = '';
603
+ const CHUNK = 0x8000;
604
+ for (let i = 0; i < buf.length; i += CHUNK) {
605
+ bin += String.fromCharCode.apply(null, buf.subarray(i, i + CHUNK));
606
+ }
607
+ return btoa(bin);
608
+ }
609
+
610
+ async function canvasToBase64(canvas) {
611
+ return blobToBase64(await canvas.convertToBlob({ type: 'image/png' }));
612
+ }
613
+
614
+ // I client LLM ridimensionano comunque le immagini a ~1568px sul lato lungo:
615
+ // pixel oltre quella soglia non arrivano mai al modello, gonfiano solo il
616
+ // payload base64. Downscale qui = stesso dettaglio percepito, meno byte.
617
+ const MAX_IMAGE_SIDE = 1568;
618
+
619
+ // Stessa regola per un canvas già disegnato (es. l'immagine di diff): senza
620
+ // questo, screenshot_diff era l'unico output visuale fuori dal cap.
621
+ async function canvasToBase64Capped(canvas, maxSide = MAX_IMAGE_SIDE) {
622
+ const scale = Math.min(1, maxSide / Math.max(canvas.width, canvas.height));
623
+ if (scale === 1) return await canvasToBase64(canvas);
624
+ const w = Math.max(1, Math.round(canvas.width * scale));
625
+ const h = Math.max(1, Math.round(canvas.height * scale));
626
+ const out = new OffscreenCanvas(w, h);
627
+ out.getContext('2d').drawImage(canvas, 0, 0, w, h);
628
+ return await canvasToBase64(out);
629
+ }
630
+
631
+ async function bitmapToBase64Capped(bitmap, maxSide = MAX_IMAGE_SIDE) {
632
+ const scale = Math.min(1, maxSide / Math.max(bitmap.width, bitmap.height));
633
+ const w = Math.max(1, Math.round(bitmap.width * scale));
634
+ const h = Math.max(1, Math.round(bitmap.height * scale));
635
+ const canvas = new OffscreenCanvas(w, h);
636
+ canvas.getContext('2d').drawImage(bitmap, 0, 0, w, h);
637
+ return await canvasToBase64(canvas);
638
+ }
639
+
640
+ // --- Implementazione comandi ---
641
+
642
+ async function cmdGetTabs() {
643
+ const tabs = await chrome.tabs.query({});
644
+ return tabs.map((t) => ({
645
+ id: t.id,
646
+ url: t.url,
647
+ title: t.title,
648
+ active: t.active,
649
+ windowId: t.windowId,
650
+ }));
651
+ }
652
+
653
+ async function cmdNavigate({ url, tab_id }) {
654
+ if (!url) throw new Error('Missing required parameter: url');
655
+
656
+ // Senza tab_id esplicito: apri SEMPRE in una nuova scheda (attiva) per non
657
+ // sovrascrivere la scheda/sessione corrente dell'utente. Con tab_id: naviga
658
+ // quella scheda specifica.
659
+ if (!tab_id) {
660
+ const newTab = await chrome.tabs.create({ url, active: true });
661
+ // Se la pagina è già 'complete' (es. cache) non aspettare il listener
662
+ if (newTab.status !== 'complete') {
663
+ await waitForComplete(newTab.id);
664
+ }
665
+ const createdTab = await chrome.tabs.get(newTab.id);
666
+ return { url: createdTab.url, title: createdTab.title, tabId: newTab.id };
667
+ }
668
+
669
+ const tabId = await resolveTabId(tab_id);
670
+
671
+ // Registra il listener PRIMA di tabs.update: una navigazione veloce (cache)
672
+ // può emettere 'complete' prima che il listener esista
673
+ const done = waitForComplete(tabId);
674
+ await chrome.tabs.update(tabId, { url });
675
+ await done;
676
+
677
+ const updatedTab = await chrome.tabs.get(tabId);
678
+ return { url: updatedTab.url, title: updatedTab.title, tabId };
679
+ }
680
+
681
+ async function cmdScreenshot({ tab_id }) {
682
+ const tabId = await resolveTabId(tab_id);
683
+
684
+ const dataUrl = await withTabVisible(tabId, async (tab) => {
685
+ // Piccolo delay per dare tempo al rendering
686
+ await new Promise((r) => setTimeout(r, 200));
687
+ return captureVisible(tab.windowId);
688
+ });
689
+
690
+ const bitmap = await dataUrlToBitmap(dataUrl);
691
+ return { image: await bitmapToBase64Capped(bitmap) };
692
+ }
693
+
694
+ async function cmdExecuteJs({ code, tab_id, frame_id }) {
695
+ if (!code) throw new Error('Missing required parameter: code');
696
+ const tabId = await resolveTabId(tab_id);
697
+ const target = scriptTarget(tabId, frame_id);
698
+
699
+ // Senza userScripts: runUserScript applica il fallback launch-mode
700
+ // oppure l'errore-guida sul toggle — niente retry doppio
701
+ if (!userScriptsAvailable()) {
702
+ const val = await runUserScript(target, code, 'MAIN');
703
+ return { result: val ?? null };
704
+ }
705
+
706
+ // Strategia: MAIN world primario — pieno accesso a variabili di pagina e
707
+ // semantica "page context" (es. <script> tag iniettati eseguono). A differenza
708
+ // del vecchio eval, il codice iniettato via userScripts non è soggetto alla
709
+ // CSP della pagina. Fallback USER_SCRIPT per i rari casi in cui MAIN fallisce.
710
+ try {
711
+ const val = await runUserScript(target, code, 'MAIN');
712
+ return { result: val ?? null };
713
+ } catch (mainErr) {
714
+ try {
715
+ const val = await runUserScript(target, code, 'USER_SCRIPT');
716
+ return { result: val ?? null };
717
+ } catch (userWorldErr) {
718
+ throw new Error(`${mainErr.message} (USER_SCRIPT world: ${userWorldErr.message})`);
719
+ }
720
+ }
721
+ }
722
+
723
+ async function cmdClick({ selector, tab_id, frame_id, force = false }) {
724
+ if (!selector) throw new Error('Missing required parameter: selector');
725
+ const tabId = await resolveTabId(tab_id);
726
+
727
+ const results = await chrome.scripting.executeScript({
728
+ target: scriptTarget(tabId, frame_id),
729
+ func: (sel, forceClick) => {
730
+ function deepQuery(sel) {
731
+ if (!sel.includes('>>>')) return document.querySelector(sel);
732
+ const parts = sel.split('>>>').map((s) => s.trim());
733
+ let ctx = document;
734
+ for (let i = 0; i < parts.length; i++) {
735
+ const found = ctx.querySelector(parts[i]);
736
+ if (!found) return null;
737
+ if (i === parts.length - 1) return found;
738
+ if (!found.shadowRoot) return null;
739
+ ctx = found.shadowRoot;
740
+ }
741
+ return null;
742
+ }
743
+ const el = deepQuery(sel);
744
+ if (!el) throw new Error(`Element not found: ${sel}`);
745
+ el.scrollIntoView({ block: 'center', inline: 'center' });
746
+ const rect = el.getBoundingClientRect();
747
+ const cx = rect.left + rect.width / 2;
748
+ const cy = rect.top + rect.height / 2;
749
+ if (!forceClick && cx >= 0 && cy >= 0 && cx <= innerWidth && cy <= innerHeight) {
750
+ const top = document.elementFromPoint(cx, cy);
751
+ if (top && top !== el && !el.contains(top) && !top.contains(el)) {
752
+ const occluderSel = top.id ? `#${top.id}` : top.tagName.toLowerCase() + (typeof top.className === 'string' && top.className.trim() ? `.${top.className.trim().split(/\s+/)[0]}` : '');
753
+ return { clicked: false, occluded: true, occluder: { selector: occluderSel, tag: top.tagName.toLowerCase(), text: (top.textContent || '').trim().substring(0, 80) } };
754
+ }
755
+ }
756
+ const opts = { bubbles: true, cancelable: true, clientX: cx, clientY: cy };
757
+ el.dispatchEvent(new PointerEvent('pointerdown', opts));
758
+ el.dispatchEvent(new MouseEvent('mousedown', opts));
759
+ el.dispatchEvent(new PointerEvent('pointerup', opts));
760
+ el.dispatchEvent(new MouseEvent('mouseup', opts));
761
+ el.click();
762
+ return { clicked: true, tagName: el.tagName, text: el.textContent?.substring(0, 100) };
763
+ },
764
+ args: [selector, force],
765
+ world: 'MAIN',
766
+ });
767
+
768
+ return results?.[0]?.result ?? { clicked: true };
769
+ }
770
+
771
+ async function cmdTypeText({ selector, text, mode = 'set', tab_id, frame_id }) {
772
+ if (!selector) throw new Error('Missing required parameter: selector');
773
+ if (text === undefined) throw new Error('Missing required parameter: text');
774
+ const tabId = await resolveTabId(tab_id);
775
+
776
+ const results = await chrome.scripting.executeScript({
777
+ target: scriptTarget(tabId, frame_id),
778
+ func: async (sel, txt, typeMode) => {
779
+ function deepQuery(sel) {
780
+ if (!sel.includes('>>>')) return document.querySelector(sel);
781
+ const parts = sel.split('>>>').map((s) => s.trim());
782
+ let ctx = document;
783
+ for (let i = 0; i < parts.length; i++) {
784
+ const found = ctx.querySelector(parts[i]);
785
+ if (!found) return null;
786
+ if (i === parts.length - 1) return found;
787
+ if (!found.shadowRoot) return null;
788
+ ctx = found.shadowRoot;
789
+ }
790
+ return null;
791
+ }
792
+ const el = deepQuery(sel);
793
+ if (!el) throw new Error(`Element not found: ${sel}`);
794
+ el.focus();
795
+ const tag = el.tagName.toLowerCase();
796
+ // Native setter: i controlled input React ignorano l'assegnazione diretta
797
+ const proto = tag === 'textarea' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
798
+ const setter = (tag === 'input' || tag === 'textarea')
799
+ ? Object.getOwnPropertyDescriptor(proto, 'value')?.set
800
+ : null;
801
+ const setValue = (value) => {
802
+ if (tag === 'input' || tag === 'textarea') {
803
+ if (setter) setter.call(el, value); else el.value = value;
804
+ } else if (el.isContentEditable) {
805
+ el.textContent = value;
806
+ } else {
807
+ el.value = value;
808
+ }
809
+ };
810
+ const getValue = () => {
811
+ if (tag === 'input' || tag === 'textarea') return el.value;
812
+ if (el.isContentEditable) return el.textContent;
813
+ return el.value ?? '';
814
+ };
815
+
816
+ if (typeMode === 'keys') {
817
+ // Eventi tastiera carattere per carattere: per autocomplete/input mascherati
818
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
819
+ for (const ch of txt) {
820
+ el.dispatchEvent(new KeyboardEvent('keydown', { key: ch, bubbles: true, cancelable: true }));
821
+ setValue((getValue() ?? '') + ch);
822
+ el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: ch }));
823
+ el.dispatchEvent(new KeyboardEvent('keyup', { key: ch, bubbles: true, cancelable: true }));
824
+ await sleep(10);
825
+ }
826
+ el.dispatchEvent(new Event('change', { bubbles: true }));
827
+ return { typed: true, tagName: tag, mode: 'keys' };
828
+ }
829
+
830
+ setValue(txt);
831
+ el.dispatchEvent(new Event('input', { bubbles: true }));
832
+ el.dispatchEvent(new Event('change', { bubbles: true }));
833
+ return { typed: true, tagName: tag };
834
+ },
835
+ args: [selector, text, mode],
836
+ world: 'MAIN',
837
+ });
838
+
839
+ return results?.[0]?.result ?? { typed: true };
840
+ }
841
+
842
+ async function cmdReadPage({ mode = 'text', tab_id, frame_id }) {
843
+ const tabId = await resolveTabId(tab_id);
844
+
845
+ if (mode === 'html') {
846
+ const results = await chrome.scripting.executeScript({
847
+ target: scriptTarget(tabId, frame_id),
848
+ func: () => document.documentElement.outerHTML,
849
+ world: 'MAIN',
850
+ });
851
+ return results?.[0]?.result ?? '';
852
+ }
853
+
854
+ if (mode === 'accessibility') {
855
+ // Ritorna una struttura semplificata dell'a11y tree
856
+ const results = await chrome.scripting.executeScript({
857
+ target: scriptTarget(tabId, frame_id),
858
+ func: () => {
859
+ function walk(el, depth = 0) {
860
+ if (depth > 10) return '';
861
+ const role = el.getAttribute('role') || el.tagName.toLowerCase();
862
+ const label = el.getAttribute('aria-label') || el.textContent?.substring(0, 50)?.trim() || '';
863
+ let output = `${' '.repeat(depth)}[${role}] ${label}\n`;
864
+ for (const child of el.children) {
865
+ output += walk(child, depth + 1);
866
+ }
867
+ return output;
868
+ }
869
+ return walk(document.body);
870
+ },
871
+ world: 'MAIN',
872
+ });
873
+ return results?.[0]?.result ?? '';
874
+ }
875
+
876
+ // mode === 'text' (default)
877
+ const results = await chrome.scripting.executeScript({
878
+ target: scriptTarget(tabId, frame_id),
879
+ func: () => document.body.innerText,
880
+ world: 'MAIN',
881
+ });
882
+ return results?.[0]?.result ?? '';
883
+ }
884
+
885
+ // --- create_tab ---
886
+
887
+ async function cmdCreateTab({ url, active = true }) {
888
+ const opts = { active };
889
+ if (url) opts.url = url;
890
+ const tab = await chrome.tabs.create(opts);
891
+ // Attendi caricamento se c'è un URL
892
+ if (url) {
893
+ await waitForComplete(tab.id);
894
+ const updated = await chrome.tabs.get(tab.id);
895
+ return { id: updated.id, url: updated.url, title: updated.title };
896
+ }
897
+ return { id: tab.id, url: tab.url || 'chrome://newtab', title: tab.title || '' };
898
+ }
899
+
900
+ // --- tab_action ---
901
+
902
+ async function cmdTabAction({ action, tab_id, bypass_cache = false }) {
903
+ if (!action) throw new Error('Missing required parameter: action');
904
+ const tabId = await resolveTabId(tab_id);
905
+
906
+ if (action === 'close') {
907
+ await chrome.tabs.remove(tabId);
908
+ return { action, closed: tabId };
909
+ }
910
+ if (action === 'activate') {
911
+ const tab = await chrome.tabs.get(tabId);
912
+ await chrome.tabs.update(tabId, { active: true });
913
+ await chrome.windows.update(tab.windowId, { focused: true });
914
+ return { action, activated: tabId };
915
+ }
916
+
917
+ // Fallback per back/forward: su ChromeOS chrome.tabs.goBack può fallire
918
+ // anche con history valida — in quel caso usa history.back()/forward() in pagina.
919
+ const historyFallback = async (direction) => {
920
+ const res = await chrome.scripting.executeScript({
921
+ target: { tabId },
922
+ func: (dir) => {
923
+ if (window.history.length <= 1) return { moved: false };
924
+ if (dir === 'back') window.history.back(); else window.history.forward();
925
+ return { moved: true };
926
+ },
927
+ args: [direction],
928
+ world: 'MAIN',
929
+ });
930
+ if (!res?.[0]?.result?.moved) {
931
+ throw new Error(`Cannot go ${direction}: no history entry in that direction`);
932
+ }
933
+ };
934
+
935
+ const done = waitForComplete(tabId);
936
+ if (action === 'reload') {
937
+ await chrome.tabs.reload(tabId, { bypassCache: bypass_cache });
938
+ } else if (action === 'back') {
939
+ try { await chrome.tabs.goBack(tabId); }
940
+ catch { await historyFallback('back'); }
941
+ } else if (action === 'forward') {
942
+ try { await chrome.tabs.goForward(tabId); }
943
+ catch { await historyFallback('forward'); }
944
+ } else {
945
+ throw new Error(`Unknown action: ${action}`);
946
+ }
947
+ // navigated:false = nessun evento 'complete' entro il timeout: history vuota
948
+ // in quella direzione (no-op) o navigazione same-document/molto lenta.
949
+ const navigated = await done;
950
+ const t = await chrome.tabs.get(tabId);
951
+ return { action, url: t.url, title: t.title, tabId, navigated };
952
+ }
953
+
954
+ // --- DevTools: get_page_info ---
955
+
956
+ async function cmdGetPageInfo({ tab_id, frame_id }) {
957
+ const tabId = await resolveTabId(tab_id);
958
+ const results = await chrome.scripting.executeScript({
959
+ target: scriptTarget(tabId, frame_id),
960
+ func: () => {
961
+ const metas = [...document.querySelectorAll('meta')].map((m) => ({
962
+ name: m.getAttribute('name') || m.getAttribute('property') || null,
963
+ content: m.getAttribute('content') || null,
964
+ charset: m.getAttribute('charset') || null,
965
+ httpEquiv: m.getAttribute('http-equiv') || null,
966
+ }));
967
+ const scripts = [...document.querySelectorAll('script')].map((s) => ({
968
+ src: s.src || null,
969
+ type: s.type || null,
970
+ async: s.async,
971
+ defer: s.defer,
972
+ inline: !s.src ? s.textContent.substring(0, 200) : null,
973
+ }));
974
+ const stylesheets = [...document.querySelectorAll('link[rel="stylesheet"], style')].map((s) => {
975
+ if (s.tagName === 'LINK') return { type: 'link', href: s.href };
976
+ return { type: 'inline', content: s.textContent.substring(0, 200) };
977
+ });
978
+ const links = [...document.querySelectorAll('a[href]')].slice(0, 100).map((a) => ({
979
+ href: a.href,
980
+ text: a.textContent.trim().substring(0, 100),
981
+ target: a.target || null,
982
+ }));
983
+ const forms = [...document.querySelectorAll('form')].map((f) => ({
984
+ action: f.action || null,
985
+ method: f.method || 'get',
986
+ id: f.id || null,
987
+ fields: [...f.elements].slice(0, 50).map((el) => ({
988
+ tag: el.tagName.toLowerCase(),
989
+ type: el.type || null,
990
+ name: el.name || null,
991
+ id: el.id || null,
992
+ })),
993
+ }));
994
+ return {
995
+ title: document.title,
996
+ url: location.href,
997
+ doctype: document.doctype ? document.doctype.name : null,
998
+ charset: document.characterSet,
999
+ metas,
1000
+ scripts,
1001
+ stylesheets,
1002
+ links,
1003
+ forms,
1004
+ };
1005
+ },
1006
+ world: 'MAIN',
1007
+ });
1008
+ return results?.[0]?.result ?? {};
1009
+ }
1010
+
1011
+ // --- DevTools: get_storage ---
1012
+
1013
+ async function cmdGetStorage({ type = 'all', tab_id }) {
1014
+ const tabId = await resolveTabId(tab_id);
1015
+ const data = {};
1016
+
1017
+ if (type === 'all' || type === 'localStorage' || type === 'sessionStorage') {
1018
+ const results = await chrome.scripting.executeScript({
1019
+ target: { tabId },
1020
+ func: (storageType) => {
1021
+ const out = {};
1022
+ if (storageType === 'all' || storageType === 'localStorage') {
1023
+ const ls = {};
1024
+ for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); ls[k] = localStorage.getItem(k); }
1025
+ out.localStorage = ls;
1026
+ }
1027
+ if (storageType === 'all' || storageType === 'sessionStorage') {
1028
+ const ss = {};
1029
+ for (let i = 0; i < sessionStorage.length; i++) { const k = sessionStorage.key(i); ss[k] = sessionStorage.getItem(k); }
1030
+ out.sessionStorage = ss;
1031
+ }
1032
+ return out;
1033
+ },
1034
+ args: [type],
1035
+ world: 'MAIN',
1036
+ });
1037
+ Object.assign(data, results?.[0]?.result ?? {});
1038
+ }
1039
+
1040
+ if (type === 'all' || type === 'cookies') {
1041
+ const tab = await chrome.tabs.get(tabId);
1042
+ const cookies = await chrome.cookies.getAll({ url: tab.url });
1043
+ data.cookies = cookies.map((c) => ({
1044
+ name: c.name, value: c.value, domain: c.domain, path: c.path,
1045
+ httpOnly: c.httpOnly, secure: c.secure, sameSite: c.sameSite,
1046
+ session: c.session, expirationDate: c.expirationDate ?? null,
1047
+ }));
1048
+ }
1049
+
1050
+ return data;
1051
+ }
1052
+
1053
+ // --- DevTools: get_performance ---
1054
+
1055
+ async function cmdGetPerformance({ tab_id }) {
1056
+ const tabId = await resolveTabId(tab_id);
1057
+ const results = await chrome.scripting.executeScript({
1058
+ target: { tabId },
1059
+ func: () => {
1060
+ const nav = performance.getEntriesByType('navigation')[0];
1061
+ const paint = performance.getEntriesByType('paint');
1062
+ const lcp = performance.getEntriesByType('largest-contentful-paint');
1063
+ const resources = performance.getEntriesByType('resource').slice(0, 50).map((r) => ({
1064
+ name: r.name,
1065
+ type: r.initiatorType,
1066
+ duration: Math.round(r.duration),
1067
+ size: r.transferSize || 0,
1068
+ }));
1069
+
1070
+ const timing = nav ? {
1071
+ dns: Math.round(nav.domainLookupEnd - nav.domainLookupStart),
1072
+ tcp: Math.round(nav.connectEnd - nav.connectStart),
1073
+ ttfb: Math.round(nav.responseStart - nav.requestStart),
1074
+ download: Math.round(nav.responseEnd - nav.responseStart),
1075
+ domInteractive: Math.round(nav.domInteractive - nav.startTime),
1076
+ domComplete: Math.round(nav.domComplete - nav.startTime),
1077
+ loadEvent: Math.round(nav.loadEventEnd - nav.startTime),
1078
+ } : null;
1079
+
1080
+ const paintMetrics = {};
1081
+ for (const p of paint) {
1082
+ paintMetrics[p.name] = Math.round(p.startTime);
1083
+ }
1084
+ if (lcp.length > 0) {
1085
+ paintMetrics['largest-contentful-paint'] = Math.round(lcp[lcp.length - 1].startTime);
1086
+ }
1087
+
1088
+ const memory = performance.memory ? {
1089
+ usedJSHeapSize: performance.memory.usedJSHeapSize,
1090
+ totalJSHeapSize: performance.memory.totalJSHeapSize,
1091
+ jsHeapSizeLimit: performance.memory.jsHeapSizeLimit,
1092
+ } : null;
1093
+
1094
+ return { timing, paint: paintMetrics, memory, resources };
1095
+ },
1096
+ world: 'MAIN',
1097
+ });
1098
+ return results?.[0]?.result ?? {};
1099
+ }
1100
+
1101
+ // --- DevTools: query_dom ---
1102
+
1103
+ async function cmdQueryDom({ selector, properties, limit = 50, tab_id, frame_id }) {
1104
+ if (!selector) throw new Error('Missing required parameter: selector');
1105
+ const tabId = await resolveTabId(tab_id);
1106
+ const results = await chrome.scripting.executeScript({
1107
+ target: scriptTarget(tabId, frame_id),
1108
+ func: (sel, props, lim) => {
1109
+ function deepQueryAll(sel) {
1110
+ if (!sel.includes('>>>')) return [...document.querySelectorAll(sel)];
1111
+ const parts = sel.split('>>>').map((s) => s.trim());
1112
+ const last = parts.pop();
1113
+ let ctx = document;
1114
+ for (const p of parts) {
1115
+ const found = ctx.querySelector(p);
1116
+ if (!found || !found.shadowRoot) return [];
1117
+ ctx = found.shadowRoot;
1118
+ }
1119
+ return [...ctx.querySelectorAll(last)];
1120
+ }
1121
+ const els = deepQueryAll(sel).slice(0, lim);
1122
+ return els.map((el) => {
1123
+ const attrs = {};
1124
+ for (const a of el.attributes) {
1125
+ attrs[a.name] = a.value;
1126
+ }
1127
+ const rect = el.getBoundingClientRect();
1128
+ const result = {
1129
+ tagName: el.tagName.toLowerCase(),
1130
+ id: el.id || null,
1131
+ className: el.className || null,
1132
+ attributes: attrs,
1133
+ textContent: el.textContent?.substring(0, 200)?.trim() || null,
1134
+ childCount: el.children.length,
1135
+ rect: {
1136
+ x: Math.round(rect.x),
1137
+ y: Math.round(rect.y),
1138
+ width: Math.round(rect.width),
1139
+ height: Math.round(rect.height),
1140
+ },
1141
+ };
1142
+ if (props && props.length > 0) {
1143
+ const computed = getComputedStyle(el);
1144
+ result.computedStyles = {};
1145
+ for (const p of props) {
1146
+ result.computedStyles[p] = computed.getPropertyValue(p);
1147
+ }
1148
+ }
1149
+ return result;
1150
+ });
1151
+ },
1152
+ args: [selector, properties || null, limit],
1153
+ world: 'MAIN',
1154
+ });
1155
+ const elements = results?.[0]?.result ?? [];
1156
+ return { count: elements.length, elements };
1157
+ }
1158
+
1159
+ // --- DevTools: modify_dom ---
1160
+
1161
+ async function cmdModifyDom({ selector, action, name, value, className, tab_id, frame_id }) {
1162
+ if (!selector) throw new Error('Missing required parameter: selector');
1163
+ if (!action) throw new Error('Missing required parameter: action');
1164
+ const tabId = await resolveTabId(tab_id);
1165
+ const results = await chrome.scripting.executeScript({
1166
+ target: scriptTarget(tabId, frame_id),
1167
+ func: (sel, act, attrName, attrValue, cls) => {
1168
+ const el = document.querySelector(sel);
1169
+ if (!el) throw new Error(`Element not found: ${sel}`);
1170
+ switch (act) {
1171
+ case 'setAttribute':
1172
+ if (!attrName) throw new Error('Missing parameter: name');
1173
+ el.setAttribute(attrName, attrValue || '');
1174
+ break;
1175
+ case 'removeAttribute':
1176
+ if (!attrName) throw new Error('Missing parameter: name');
1177
+ el.removeAttribute(attrName);
1178
+ break;
1179
+ case 'addClass':
1180
+ if (!cls) throw new Error('Missing parameter: className');
1181
+ el.classList.add(cls);
1182
+ break;
1183
+ case 'removeClass':
1184
+ if (!cls) throw new Error('Missing parameter: className');
1185
+ el.classList.remove(cls);
1186
+ break;
1187
+ case 'setStyle':
1188
+ if (attrValue === undefined) throw new Error('Missing parameter: value');
1189
+ el.style.cssText = attrValue;
1190
+ break;
1191
+ case 'setTextContent':
1192
+ if (attrValue === undefined) throw new Error('Missing parameter: value');
1193
+ el.textContent = attrValue;
1194
+ break;
1195
+ default:
1196
+ throw new Error(`Unknown action: ${act}`);
1197
+ }
1198
+ return { success: true, tagName: el.tagName.toLowerCase(), action: act };
1199
+ },
1200
+ args: [selector, action, name || null, value || null, className || null],
1201
+ world: 'MAIN',
1202
+ });
1203
+ return results?.[0]?.result ?? { success: true };
1204
+ }
1205
+
1206
+ // --- DevTools: inject_css ---
1207
+
1208
+ async function cmdInjectCss({ css, tab_id }) {
1209
+ if (!css) throw new Error('Missing required parameter: css');
1210
+ const tabId = await resolveTabId(tab_id);
1211
+ await chrome.scripting.insertCSS({
1212
+ target: { tabId },
1213
+ css,
1214
+ });
1215
+ return { success: true, injectedLength: css.length };
1216
+ }
1217
+
1218
+ // --- DevTools: read_console ---
1219
+ // Hook installed at document_start via console-capture.js content script (MAIN world).
1220
+ // This function only reads the accumulated logs.
1221
+
1222
+ // limit = 0 quando il server non lo manda: un server più VECCHIO di questa
1223
+ // estensione (caso normale, la CWS ha latenza di review) fa lo slice da sé, e un
1224
+ // default in pagina gli consegnerebbe meno voci di quelle che ha chiesto.
1225
+ async function cmdReadConsole({ clear = false, level = 'all', limit = 0, tab_id }) {
1226
+ const tabId = await resolveTabId(tab_id);
1227
+
1228
+ const results = await chrome.scripting.executeScript({
1229
+ target: { tabId },
1230
+ // Il taglio a `limit` avviene IN PAGINA: il WebSocket non trasporta ciò che
1231
+ // il modello non vedrà. E `clear` rimuove solo le voci effettivamente
1232
+ // restituite (e solo del level richiesto): cancellare l'intero buffer
1233
+ // distruggeva messaggi che nessuno aveva letto.
1234
+ func: (shouldClear, filterLevel, lim) => {
1235
+ const hooked = !!window.__chromeBridge_consoleHooked;
1236
+ const logs = window.__chromeBridge_consoleLogs || [];
1237
+ const filtered = filterLevel === 'all' ? logs : logs.filter((l) => l.level === filterLevel);
1238
+ const shown = lim > 0 ? filtered.slice(-lim) : filtered;
1239
+ if (shouldClear && shown.length) {
1240
+ const drop = new Set(shown);
1241
+ window.__chromeBridge_consoleLogs = logs.filter((l) => !drop.has(l));
1242
+ }
1243
+ return { hooked, total: filtered.length, messages: shown };
1244
+ },
1245
+ args: [clear, level, limit],
1246
+ world: 'MAIN',
1247
+ });
1248
+
1249
+ const out = results?.[0]?.result ?? {};
1250
+ const messages = out.messages ?? [];
1251
+ // "count: 0" senza contesto è indistinguibile da "hook mai installato":
1252
+ // read_console è il tool di debug più usato e non deve far concludere
1253
+ // "pagina pulita" quando in realtà non stava osservando nulla.
1254
+ const note = out.hooked === false
1255
+ ? 'Instrumentation not loaded (page opened before the extension, "Capture console & metrics" off, or a non-injectable page) — no console messages are being captured.'
1256
+ : undefined;
1257
+ return { count: out.total ?? messages.length, messages, hooked: out.hooked !== false, ...(note ? { note } : {}) };
1258
+ }
1259
+
1260
+ // --- DevTools: monitor_network (stateful) ---
1261
+
1262
+ // Always-inject: il guard in-page `__chromeBridge_networkHooked` rende
1263
+ // l'operazione idempotente e costa pochi ms. Un Set per-tab lato SW andava
1264
+ // invece fuori sincrono con la navigazione (il tab veniva rimosso su
1265
+ // 'loading', reinserito da un'iniezione risolta contro il documento morente,
1266
+ // e il nuovo documento restava senza hook con `count: 0` silenzioso).
1267
+ async function ensureNetworkHook(tabId) {
1268
+ await chrome.scripting.executeScript({
1269
+ target: { tabId },
1270
+ func: () => {
1271
+ if (window.__chromeBridge_networkHooked) return;
1272
+ window.__chromeBridge_networkHooked = true;
1273
+ window.__chromeBridge_networkRequests = [];
1274
+ window.__chromeBridge_inflight = 0;
1275
+ window.__chromeBridge_lastNetActivity = Date.now();
1276
+ const MAX = 1000;
1277
+
1278
+ // --- Patch fetch ---
1279
+ const origFetch = window.fetch.bind(window);
1280
+ window.fetch = async (...args) => {
1281
+ const req = args[0];
1282
+ const url = typeof req === 'string' ? req : req?.url || String(req);
1283
+ const method = (args[1]?.method || (req?.method) || 'GET').toUpperCase();
1284
+ const entry = { type: 'fetch', method, url, startTime: Date.now(), status: null, duration: null, error: null };
1285
+ window.__chromeBridge_inflight += 1;
1286
+ window.__chromeBridge_lastNetActivity = Date.now();
1287
+ try {
1288
+ const resp = await origFetch(...args);
1289
+ entry.status = resp.status;
1290
+ entry.duration = Date.now() - entry.startTime;
1291
+ // Ring buffer: scarta le più VECCHIE, non le nuove. Scartare le nuove
1292
+ // faceva consegnare al modello le richieste dei primi secondi di vita
1293
+ // della pagina etichettate come "most recent".
1294
+ window.__chromeBridge_networkRequests.push(entry);
1295
+ if (window.__chromeBridge_networkRequests.length > MAX) {
1296
+ window.__chromeBridge_networkRequests.shift();
1297
+ }
1298
+ window.__chromeBridge_inflight -= 1;
1299
+ window.__chromeBridge_lastNetActivity = Date.now();
1300
+ return resp;
1301
+ } catch (err) {
1302
+ entry.error = err.message;
1303
+ entry.duration = Date.now() - entry.startTime;
1304
+ // Ring buffer: scarta le più VECCHIE, non le nuove. Scartare le nuove
1305
+ // faceva consegnare al modello le richieste dei primi secondi di vita
1306
+ // della pagina etichettate come "most recent".
1307
+ window.__chromeBridge_networkRequests.push(entry);
1308
+ if (window.__chromeBridge_networkRequests.length > MAX) {
1309
+ window.__chromeBridge_networkRequests.shift();
1310
+ }
1311
+ window.__chromeBridge_inflight -= 1;
1312
+ window.__chromeBridge_lastNetActivity = Date.now();
1313
+ throw err;
1314
+ }
1315
+ };
1316
+
1317
+ // --- Patch XMLHttpRequest ---
1318
+ const OrigXHR = window.XMLHttpRequest;
1319
+ const origOpen = OrigXHR.prototype.open;
1320
+ const origSend = OrigXHR.prototype.send;
1321
+ OrigXHR.prototype.open = function (method, url, ...rest) {
1322
+ this.__cb_method = method;
1323
+ this.__cb_url = url;
1324
+ return origOpen.call(this, method, url, ...rest);
1325
+ };
1326
+ OrigXHR.prototype.send = function (...args) {
1327
+ const entry = { type: 'xhr', method: (this.__cb_method || 'GET').toUpperCase(), url: this.__cb_url || '', startTime: Date.now(), status: null, duration: null, error: null };
1328
+ this.addEventListener('load', () => {
1329
+ entry.status = this.status;
1330
+ entry.duration = Date.now() - entry.startTime;
1331
+ // Ring buffer: scarta le più VECCHIE, non le nuove. Scartare le nuove
1332
+ // faceva consegnare al modello le richieste dei primi secondi di vita
1333
+ // della pagina etichettate come "most recent".
1334
+ window.__chromeBridge_networkRequests.push(entry);
1335
+ if (window.__chromeBridge_networkRequests.length > MAX) {
1336
+ window.__chromeBridge_networkRequests.shift();
1337
+ }
1338
+ });
1339
+ this.addEventListener('error', () => {
1340
+ entry.error = 'Network error';
1341
+ entry.duration = Date.now() - entry.startTime;
1342
+ // Ring buffer: scarta le più VECCHIE, non le nuove. Scartare le nuove
1343
+ // faceva consegnare al modello le richieste dei primi secondi di vita
1344
+ // della pagina etichettate come "most recent".
1345
+ window.__chromeBridge_networkRequests.push(entry);
1346
+ if (window.__chromeBridge_networkRequests.length > MAX) {
1347
+ window.__chromeBridge_networkRequests.shift();
1348
+ }
1349
+ });
1350
+ // loadend copre load, error e abort: traccia sempre la fine dell'in-flight
1351
+ this.addEventListener('loadend', () => {
1352
+ window.__chromeBridge_inflight -= 1;
1353
+ window.__chromeBridge_lastNetActivity = Date.now();
1354
+ });
1355
+ window.__chromeBridge_inflight += 1;
1356
+ window.__chromeBridge_lastNetActivity = Date.now();
1357
+ return origSend.apply(this, args);
1358
+ };
1359
+ },
1360
+ world: 'MAIN',
1361
+ });
1362
+ }
1363
+
1364
+ async function cmdMonitorNetwork({ clear = false, source = 'page', limit = 0, tab_id }) {
1365
+ const tabId = await resolveTabId(tab_id);
1366
+
1367
+ if (source === 'browser') {
1368
+ const all = browserNetLog.get(tabId) ?? [];
1369
+ const shown = limit > 0 ? all.slice(-limit) : [...all];
1370
+ if (clear) browserNetLog.set(tabId, all.slice(0, Math.max(0, all.length - shown.length)));
1371
+ return { count: all.length, requests: shown, log_since: swBootedAt };
1372
+ }
1373
+
1374
+ await ensureNetworkHook(tabId);
1375
+
1376
+ // Taglio e clear in pagina: il WebSocket non trasporta 1000 voci (~154 KB)
1377
+ // per consegnarne 100, e clear non distrugge ciò che nessuno ha letto.
1378
+ const results = await chrome.scripting.executeScript({
1379
+ target: { tabId },
1380
+ func: (shouldClear, lim) => {
1381
+ const hooked = !!window.__chromeBridge_networkHooked;
1382
+ const requests = window.__chromeBridge_networkRequests || [];
1383
+ const shown = lim > 0 ? requests.slice(-lim) : requests.slice();
1384
+ if (shouldClear && shown.length) {
1385
+ const drop = new Set(shown);
1386
+ window.__chromeBridge_networkRequests = requests.filter((r) => !drop.has(r));
1387
+ }
1388
+ return { hooked, total: requests.length, requests: shown };
1389
+ },
1390
+ args: [clear, limit],
1391
+ world: 'MAIN',
1392
+ });
1393
+
1394
+ const out = results?.[0]?.result ?? {};
1395
+ const requests = out.requests ?? [];
1396
+ const note = out.hooked === false
1397
+ ? 'Network hook not installed on this document — no page requests are being captured.'
1398
+ : undefined;
1399
+ return { count: out.total ?? requests.length, requests, ...(note ? { note } : {}) };
1400
+ }
1401
+
1402
+ // --- wait_for_element ---
1403
+
1404
+ async function cmdWaitForElement({ selector, timeout = 10000, interval = 200, visible = false, tab_id, frame_id }) {
1405
+ if (!selector) throw new Error('Missing required parameter: selector');
1406
+ const tabId = await resolveTabId(tab_id);
1407
+ const clampedInterval = Math.max(interval, 50);
1408
+
1409
+ const results = await chrome.scripting.executeScript({
1410
+ target: scriptTarget(tabId, frame_id),
1411
+ func: (sel, tout, intv, vis) => {
1412
+ function deepQuery(sel) {
1413
+ if (!sel.includes('>>>')) return document.querySelector(sel);
1414
+ const parts = sel.split('>>>').map((s) => s.trim());
1415
+ let ctx = document;
1416
+ for (let i = 0; i < parts.length; i++) {
1417
+ const found = ctx.querySelector(parts[i]);
1418
+ if (!found) return null;
1419
+ if (i === parts.length - 1) return found;
1420
+ if (!found.shadowRoot) return null;
1421
+ ctx = found.shadowRoot;
1422
+ }
1423
+ return null;
1424
+ }
1425
+ return new Promise((resolve) => {
1426
+ const start = Date.now();
1427
+ const check = () => {
1428
+ const el = deepQuery(sel);
1429
+ if (el) {
1430
+ if (vis) {
1431
+ const style = getComputedStyle(el);
1432
+ if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
1433
+ if (Date.now() - start >= tout) {
1434
+ resolve({ found: false, error: 'Element found but not visible within timeout' });
1435
+ return;
1436
+ }
1437
+ setTimeout(check, intv);
1438
+ return;
1439
+ }
1440
+ }
1441
+ const rect = el.getBoundingClientRect();
1442
+ resolve({
1443
+ found: true,
1444
+ tagName: el.tagName.toLowerCase(),
1445
+ elapsed: Date.now() - start,
1446
+ rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) },
1447
+ });
1448
+ return;
1449
+ }
1450
+ if (Date.now() - start >= tout) {
1451
+ resolve({ found: false, error: `Element not found within ${tout}ms: ${sel}` });
1452
+ return;
1453
+ }
1454
+ setTimeout(check, intv);
1455
+ };
1456
+ check();
1457
+ });
1458
+ },
1459
+ args: [selector, timeout, clampedInterval, visible],
1460
+ world: 'MAIN',
1461
+ });
1462
+ return results?.[0]?.result ?? { found: false, error: 'No result' };
1463
+ }
1464
+
1465
+ // --- scroll_to ---
1466
+
1467
+ async function cmdScrollTo({ selector, x, y, behavior = 'auto', offset_y = 0, tab_id, frame_id }) {
1468
+ const tabId = await resolveTabId(tab_id);
1469
+
1470
+ const results = await chrome.scripting.executeScript({
1471
+ target: scriptTarget(tabId, frame_id),
1472
+ func: (sel, sx, sy, beh, offY) => {
1473
+ if (sel) {
1474
+ const el = document.querySelector(sel);
1475
+ if (!el) throw new Error(`Element not found: ${sel}`);
1476
+ const rect = el.getBoundingClientRect();
1477
+ const targetY = rect.top + window.scrollY - offY;
1478
+ window.scrollTo({ top: targetY, left: rect.left + window.scrollX, behavior: beh });
1479
+ } else if (sx !== undefined || sy !== undefined) {
1480
+ window.scrollTo({ top: sy ?? window.scrollY, left: sx ?? window.scrollX, behavior: beh });
1481
+ } else {
1482
+ window.scrollTo({ top: 0, left: 0, behavior: beh });
1483
+ }
1484
+ return {
1485
+ scrollX: Math.round(window.scrollX),
1486
+ scrollY: Math.round(window.scrollY),
1487
+ scrollWidth: document.documentElement.scrollWidth,
1488
+ scrollHeight: document.documentElement.scrollHeight,
1489
+ viewportWidth: window.innerWidth,
1490
+ viewportHeight: window.innerHeight,
1491
+ };
1492
+ },
1493
+ args: [selector || null, x ?? null, y ?? null, behavior, offset_y],
1494
+ world: 'MAIN',
1495
+ });
1496
+ return results?.[0]?.result ?? {};
1497
+ }
1498
+
1499
+ // --- set_storage ---
1500
+
1501
+ async function cmdSetStorage({ type, action, key, value, path, domain, expires, secure, sameSite, http_only, tab_id }) {
1502
+ if (!type) throw new Error('Missing required parameter: type');
1503
+ if (!action) throw new Error('Missing required parameter: action');
1504
+ const tabId = await resolveTabId(tab_id);
1505
+
1506
+ if (type === 'cookie') {
1507
+ const tab = await chrome.tabs.get(tabId);
1508
+ const sameSiteMap = { Strict: 'strict', Lax: 'lax', None: 'no_restriction' };
1509
+ if (action === 'set') {
1510
+ if (!key) throw new Error('key is required for set action');
1511
+ const details = { url: tab.url, name: key, value: value || '', path: path || '/' };
1512
+ if (domain) details.domain = domain;
1513
+ if (expires) { const ts = Math.floor(new Date(expires).getTime() / 1000); if (Number.isNaN(ts)) throw new Error(`Invalid expires date: ${expires}`); details.expirationDate = ts; }
1514
+ if (secure || sameSite === 'None') details.secure = true;
1515
+ if (sameSite) details.sameSite = sameSiteMap[sameSite];
1516
+ if (http_only) details.httpOnly = true;
1517
+ await chrome.cookies.set(details);
1518
+ return { success: true, type: 'cookie', action: 'set', key };
1519
+ }
1520
+ if (action === 'delete') {
1521
+ if (!key) throw new Error('key is required for delete action');
1522
+ const matches = (await chrome.cookies.getAll({ url: tab.url })).filter((c) => c.name === key);
1523
+ for (const c of matches) {
1524
+ const cookieUrl = (c.secure ? 'https://' : 'http://') + c.domain.replace(/^\./, '') + c.path;
1525
+ await chrome.cookies.remove({ url: cookieUrl, name: c.name });
1526
+ }
1527
+ return { success: true, type: 'cookie', action: 'delete', key, removed: matches.length };
1528
+ }
1529
+ if (action === 'clear') {
1530
+ const cookies = await chrome.cookies.getAll({ url: tab.url });
1531
+ for (const c of cookies) {
1532
+ const cookieUrl = (c.secure ? 'https://' : 'http://') + c.domain.replace(/^\./, '') + c.path;
1533
+ await chrome.cookies.remove({ url: cookieUrl, name: c.name });
1534
+ }
1535
+ return { success: true, type: 'cookie', action: 'clear', cleared: cookies.length };
1536
+ }
1537
+ throw new Error(`Invalid cookie action: ${action}`);
1538
+ }
1539
+
1540
+ const results = await chrome.scripting.executeScript({
1541
+ target: { tabId },
1542
+ func: (sType, sAction, sKey, sValue) => {
1543
+ const storage = sType === 'localStorage' ? localStorage : sessionStorage;
1544
+ if (sAction === 'set') {
1545
+ if (!sKey) throw new Error('key is required for set action');
1546
+ storage.setItem(sKey, sValue || '');
1547
+ return { success: true, type: sType, action: 'set', key: sKey };
1548
+ } else if (sAction === 'delete') {
1549
+ if (!sKey) throw new Error('key is required for delete action');
1550
+ storage.removeItem(sKey);
1551
+ return { success: true, type: sType, action: 'delete', key: sKey };
1552
+ } else if (sAction === 'clear') {
1553
+ storage.clear();
1554
+ return { success: true, type: sType, action: 'clear' };
1555
+ }
1556
+ throw new Error(`Invalid action: ${sAction}`);
1557
+ },
1558
+ args: [type, action, key || null, value || null],
1559
+ world: 'MAIN',
1560
+ });
1561
+ return results?.[0]?.result ?? { success: false };
1562
+ }
1563
+
1564
+ // --- fill_form ---
1565
+
1566
+ async function cmdFillForm({ fields, submit_selector, tab_id, frame_id }) {
1567
+ if (!fields || !Array.isArray(fields)) throw new Error('Missing required parameter: fields');
1568
+ const tabId = await resolveTabId(tab_id);
1569
+
1570
+ const results = await chrome.scripting.executeScript({
1571
+ target: scriptTarget(tabId, frame_id),
1572
+ func: (flds, submitSel) => {
1573
+ const report = [];
1574
+ for (const { selector, value } of flds) {
1575
+ try {
1576
+ const el = document.querySelector(selector);
1577
+ if (!el) { report.push({ selector, success: false, error: 'Element not found' }); continue; }
1578
+ const tag = el.tagName.toLowerCase();
1579
+ const type = (el.type || '').toLowerCase();
1580
+ const disabled = el.disabled;
1581
+ const readOnly = el.readOnly;
1582
+
1583
+ if (disabled || readOnly) {
1584
+ report.push({ selector, success: true, tagName: tag, type, warning: disabled ? 'disabled' : 'readonly' });
1585
+ continue;
1586
+ }
1587
+
1588
+ if (tag === 'select') {
1589
+ // Try match by value first, then by text
1590
+ let found = false;
1591
+ for (const opt of el.options) {
1592
+ if (opt.value === value || opt.textContent.trim() === value) {
1593
+ el.value = opt.value;
1594
+ found = true;
1595
+ break;
1596
+ }
1597
+ }
1598
+ if (!found) { report.push({ selector, success: false, tagName: tag, type: 'select', error: `Option not found: ${value}` }); continue; }
1599
+ } else if (tag === 'input' && (type === 'checkbox' || type === 'radio')) {
1600
+ const shouldCheck = value === 'true' || value === '1';
1601
+ if (el.checked !== shouldCheck) el.click();
1602
+ } else if (tag === 'textarea' || tag === 'input') {
1603
+ // React-compatible value setting
1604
+ const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
1605
+ tag === 'textarea' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype, 'value'
1606
+ )?.set;
1607
+ if (nativeInputValueSetter) {
1608
+ nativeInputValueSetter.call(el, value);
1609
+ } else {
1610
+ el.value = value;
1611
+ }
1612
+ } else {
1613
+ el.value = value;
1614
+ }
1615
+
1616
+ el.dispatchEvent(new Event('focus', { bubbles: true }));
1617
+ el.dispatchEvent(new Event('input', { bubbles: true }));
1618
+ el.dispatchEvent(new Event('change', { bubbles: true }));
1619
+ report.push({ selector, success: true, tagName: tag, type: type || tag });
1620
+ } catch (e) {
1621
+ report.push({ selector, success: false, error: e.message });
1622
+ }
1623
+ }
1624
+
1625
+ if (submitSel) {
1626
+ const btn = document.querySelector(submitSel);
1627
+ if (btn) btn.click();
1628
+ }
1629
+
1630
+ return report;
1631
+ },
1632
+ args: [fields, submit_selector || null],
1633
+ world: 'MAIN',
1634
+ });
1635
+ return { fields: results?.[0]?.result ?? [] };
1636
+ }
1637
+
1638
+ // --- viewport_resize ---
1639
+
1640
+ async function cmdViewportResize({ preset, width, height, tab_id }) {
1641
+ const tabId = await resolveTabId(tab_id);
1642
+ const tab = await chrome.tabs.get(tabId);
1643
+
1644
+ const presets = { mobile: { w: 375, h: 812 }, tablet: { w: 768, h: 1024 }, desktop: { w: 1440, h: 900 } };
1645
+ let targetW = width;
1646
+ let targetH = height;
1647
+
1648
+ if (preset && presets[preset]) {
1649
+ if (!targetW) targetW = presets[preset].w;
1650
+ if (!targetH) targetH = presets[preset].h;
1651
+ }
1652
+
1653
+ const updateOpts = {};
1654
+ if (targetW) updateOpts.width = targetW;
1655
+ if (targetH) updateOpts.height = targetH;
1656
+
1657
+ if (Object.keys(updateOpts).length === 0) throw new Error('Provide preset, width, or height');
1658
+
1659
+ await chrome.windows.update(tab.windowId, updateOpts);
1660
+ await new Promise((r) => setTimeout(r, 200));
1661
+
1662
+ const results = await chrome.scripting.executeScript({
1663
+ target: { tabId },
1664
+ func: () => ({
1665
+ viewportWidth: window.innerWidth,
1666
+ viewportHeight: window.innerHeight,
1667
+ devicePixelRatio: window.devicePixelRatio,
1668
+ }),
1669
+ world: 'MAIN',
1670
+ });
1671
+ const actual = results?.[0]?.result ?? {};
1672
+ return { requested: { width: targetW, height: targetH, preset: preset || null }, actual };
1673
+ }
1674
+
1675
+ // --- full_page_screenshot ---
1676
+
1677
+ async function cmdFullPageScreenshot({ max_scrolls = 20, delay = 500, stitch = true, tab_id }) {
1678
+ const tabId = await resolveTabId(tab_id);
1679
+ // Chrome quota: max 2 captureVisibleTab al secondo — clamp a 500ms
1680
+ const safeDelay = Math.max(delay, 500);
1681
+
1682
+ const { shots, scrollHeight, viewportHeight, viewportWidth } = await withTabVisible(tabId, async (tab) => {
1683
+ // Get page dimensions
1684
+ const dimResults = await chrome.scripting.executeScript({
1685
+ target: { tabId },
1686
+ func: () => ({
1687
+ scrollHeight: document.documentElement.scrollHeight,
1688
+ viewportHeight: window.innerHeight,
1689
+ viewportWidth: window.innerWidth,
1690
+ originalScrollY: window.scrollY,
1691
+ }),
1692
+ world: 'MAIN',
1693
+ });
1694
+ const { scrollHeight, viewportHeight, viewportWidth, originalScrollY } = dimResults?.[0]?.result ?? {};
1695
+ const steps = Math.min(Math.ceil(scrollHeight / viewportHeight), max_scrolls);
1696
+ const shots = [];
1697
+
1698
+ for (let i = 0; i < steps; i++) {
1699
+ const target = Math.min(i * viewportHeight, Math.max(0, scrollHeight - viewportHeight));
1700
+ const sRes = await chrome.scripting.executeScript({
1701
+ target: { tabId },
1702
+ func: (sy) => { window.scrollTo(0, sy); return window.scrollY; },
1703
+ args: [target],
1704
+ world: 'MAIN',
1705
+ });
1706
+ const actualY = sRes?.[0]?.result ?? target;
1707
+ await new Promise((r) => setTimeout(r, safeDelay));
1708
+ const dataUrl = await captureVisible(tab.windowId);
1709
+ shots.push({ dataUrl, y: actualY });
1710
+ if (actualY + viewportHeight >= scrollHeight) break;
1711
+ }
1712
+
1713
+ // Restore scroll position
1714
+ await chrome.scripting.executeScript({
1715
+ target: { tabId },
1716
+ func: (sy) => window.scrollTo(0, sy),
1717
+ args: [originalScrollY],
1718
+ world: 'MAIN',
1719
+ });
1720
+
1721
+ return { shots, scrollHeight, viewportHeight, viewportWidth };
1722
+ });
1723
+
1724
+ if (!shots.length) throw new Error('No captures (page dimensions unavailable)');
1725
+
1726
+ if (!stitch) {
1727
+ const captures = [];
1728
+ for (const s of shots) {
1729
+ captures.push(await bitmapToBase64Capped(await dataUrlToBitmap(s.dataUrl)));
1730
+ }
1731
+ return {
1732
+ captures,
1733
+ scrollHeight, viewportHeight, totalCaptures: shots.length,
1734
+ };
1735
+ }
1736
+
1737
+ // Stitching su OffscreenCanvas (limite hard Chrome ~16384px per lato),
1738
+ // poi slice in segmenti da ~2 viewport: un'unica immagine più alta,
1739
+ // ridotta dal client a ~1568px sul lato lungo, diventa illeggibile e
1740
+ // costringe il modello a ri-catturare (token doppi).
1741
+ const MAX_CANVAS_H = 16384;
1742
+ const first = await dataUrlToBitmap(shots[0].dataUrl);
1743
+ const dpr = first.width / viewportWidth;
1744
+ // Canvas alto quanto l'area davvero catturata: se max_scrolls tronca la
1745
+ // pagina, il resto uscirebbe come segmenti bianchi (immagini sprecate).
1746
+ const lastShot = shots[shots.length - 1];
1747
+ const coveredH = Math.round((lastShot.y + viewportHeight) * dpr);
1748
+ const fullH = Math.min(Math.round(scrollHeight * dpr), coveredH, MAX_CANVAS_H);
1749
+ const canvas = new OffscreenCanvas(first.width, fullH);
1750
+ const ctx = canvas.getContext('2d');
1751
+ ctx.drawImage(first, 0, Math.round(shots[0].y * dpr));
1752
+ for (let i = 1; i < shots.length; i++) {
1753
+ const bmp = await dataUrlToBitmap(shots[i].dataUrl);
1754
+ ctx.drawImage(bmp, 0, Math.round(shots[i].y * dpr));
1755
+ }
1756
+ const segH = Math.max(1, Math.round(viewportHeight * dpr * 2));
1757
+ const images = [];
1758
+ for (let y = 0; y < fullH; y += segH) {
1759
+ const h = Math.min(segH, fullH - y);
1760
+ const segment = await createImageBitmap(canvas, 0, y, first.width, h);
1761
+ images.push(await bitmapToBase64Capped(segment));
1762
+ }
1763
+ return {
1764
+ images,
1765
+ segments: images.length,
1766
+ scrollHeight, viewportHeight, totalCaptures: shots.length,
1767
+ truncated: scrollHeight * dpr > fullH,
1768
+ };
1769
+ }
1770
+
1771
+ // --- element_screenshot ---
1772
+
1773
+ async function cmdElementScreenshot({ selector, tab_id }) {
1774
+ if (!selector) throw new Error('Missing required parameter: selector');
1775
+ const tabId = await resolveTabId(tab_id);
1776
+
1777
+ const { rect, dataUrl } = await withTabVisible(tabId, async (tab) => {
1778
+ const res = await chrome.scripting.executeScript({
1779
+ target: { tabId },
1780
+ func: (sel) => {
1781
+ const el = document.querySelector(sel);
1782
+ if (!el) throw new Error(`Element not found: ${sel}`);
1783
+ el.scrollIntoView({ behavior: 'instant', block: 'center', inline: 'center' });
1784
+ const r = el.getBoundingClientRect();
1785
+ return { x: r.x, y: r.y, width: r.width, height: r.height, dpr: window.devicePixelRatio };
1786
+ },
1787
+ args: [selector],
1788
+ world: 'MAIN',
1789
+ });
1790
+ const rect = res?.[0]?.result;
1791
+ if (!rect || rect.width === 0 || rect.height === 0) throw new Error('Element has no visible area');
1792
+
1793
+ // Delay per rendering post-scroll (behavior:'instant' forzato per evitare smooth-scroll CSS)
1794
+ await new Promise((r) => setTimeout(r, 300));
1795
+ const dataUrl = await captureVisible(tab.windowId);
1796
+ return { rect, dataUrl };
1797
+ });
1798
+ const bitmap = await dataUrlToBitmap(dataUrl);
1799
+ const { dpr } = rect;
1800
+ const sx = Math.max(0, Math.round(rect.x * dpr));
1801
+ const sy = Math.max(0, Math.round(rect.y * dpr));
1802
+ const sw = Math.min(Math.round(rect.width * dpr), bitmap.width - sx);
1803
+ const sh = Math.min(Math.round(rect.height * dpr), bitmap.height - sy);
1804
+ if (sw <= 0 || sh <= 0) throw new Error('Element is outside the visible viewport');
1805
+ const outScale = Math.min(1, MAX_IMAGE_SIDE / Math.max(sw, sh));
1806
+ const dw = Math.max(1, Math.round(sw * outScale));
1807
+ const dh = Math.max(1, Math.round(sh * outScale));
1808
+ const canvas = new OffscreenCanvas(dw, dh);
1809
+ canvas.getContext('2d').drawImage(bitmap, sx, sy, sw, sh, 0, 0, dw, dh);
1810
+ return { image: await canvasToBase64(canvas), width: sw, height: sh };
1811
+ }
1812
+
1813
+ // --- highlight_elements ---
1814
+
1815
+ async function cmdHighlightElements({ selector, color = 'rgba(255,0,0,0.3)', border = '2px solid red', label = false, remove = false, tab_id }) {
1816
+ const tabId = await resolveTabId(tab_id);
1817
+
1818
+ const results = await chrome.scripting.executeScript({
1819
+ target: { tabId },
1820
+ func: (sel, bg, brd, showLabel, doRemove) => {
1821
+ // Remove existing highlights
1822
+ const existing = document.querySelectorAll('[data-chrome-bridge-highlight]');
1823
+ existing.forEach((el) => el.remove());
1824
+
1825
+ if (doRemove || !sel) {
1826
+ return { removed: existing.length, highlighted: 0 };
1827
+ }
1828
+
1829
+ const els = [...document.querySelectorAll(sel)].slice(0, 100);
1830
+ let count = 0;
1831
+ for (const el of els) {
1832
+ const rect = el.getBoundingClientRect();
1833
+ if (rect.width === 0 && rect.height === 0) continue;
1834
+ const overlay = document.createElement('div');
1835
+ overlay.setAttribute('data-chrome-bridge-highlight', 'true');
1836
+ overlay.style.cssText = `position:absolute;top:${rect.top+window.scrollY}px;left:${rect.left+window.scrollX}px;width:${rect.width}px;height:${rect.height}px;background:${bg};border:${brd};pointer-events:none;z-index:2147483647;box-sizing:border-box;`;
1837
+ if (showLabel) {
1838
+ const tag = el.tagName.toLowerCase();
1839
+ const cls = el.className ? `.${el.className.toString().split(' ')[0]}` : '';
1840
+ overlay.textContent = `${tag}${cls} (${Math.round(rect.width)}x${Math.round(rect.height)})`;
1841
+ overlay.style.fontSize = '10px';
1842
+ overlay.style.color = '#fff';
1843
+ overlay.style.textShadow = '0 0 2px #000';
1844
+ overlay.style.overflow = 'hidden';
1845
+ overlay.style.padding = '1px 3px';
1846
+ }
1847
+ document.body.appendChild(overlay);
1848
+ count++;
1849
+ }
1850
+ return { removed: existing.length, highlighted: count };
1851
+ },
1852
+ args: [selector || null, color, border, label, remove],
1853
+ world: 'MAIN',
1854
+ });
1855
+ return results?.[0]?.result ?? { highlighted: 0 };
1856
+ }
1857
+
1858
+ // --- accessibility_audit ---
1859
+
1860
+ async function cmdAccessibilityAudit({ scope, checks = ['all'], tab_id }) {
1861
+ const tabId = await resolveTabId(tab_id);
1862
+
1863
+ const results = await chrome.scripting.executeScript({
1864
+ target: { tabId },
1865
+ func: (scopeSel, checkList) => {
1866
+ const root = scopeSel ? document.querySelector(scopeSel) : document.body;
1867
+ if (!root) return { summary: { total: 0, errors: 0, warnings: 0 }, violations: [], warning: 'Scope element not found' };
1868
+
1869
+ const runAll = checkList.includes('all');
1870
+ const violations = [];
1871
+ const CAP = 500;
1872
+
1873
+ function selectorFor(el) {
1874
+ if (el.id) return `#${el.id}`;
1875
+ const tag = el.tagName.toLowerCase();
1876
+ const cls = el.className ? `.${el.className.toString().trim().split(/\s+/).join('.')}` : '';
1877
+ return `${tag}${cls}`;
1878
+ }
1879
+
1880
+ // Images
1881
+ if (runAll || checkList.includes('images')) {
1882
+ const imgs = [...root.querySelectorAll('img')].slice(0, CAP);
1883
+ for (const img of imgs) {
1884
+ // img.alt è '' sia per attributo mancante che vuoto: serve hasAttribute
1885
+ if (!img.hasAttribute('alt')) {
1886
+ violations.push({ type: 'images', severity: 'error', selector: selectorFor(img), message: 'Image missing alt attribute' });
1887
+ } else if (img.alt === '') {
1888
+ violations.push({ type: 'images', severity: 'warning', selector: selectorFor(img), message: 'Image has empty alt (decorative?)' });
1889
+ }
1890
+ }
1891
+ const svgs = [...root.querySelectorAll('svg')].slice(0, CAP);
1892
+ for (const svg of svgs) {
1893
+ if (!svg.getAttribute('aria-label') && !svg.getAttribute('aria-labelledby') && !svg.querySelector('title')) {
1894
+ violations.push({ type: 'images', severity: 'warning', selector: selectorFor(svg), message: 'SVG missing accessible name' });
1895
+ }
1896
+ }
1897
+ }
1898
+
1899
+ // Links
1900
+ if (runAll || checkList.includes('links')) {
1901
+ const links = [...root.querySelectorAll('a')].slice(0, CAP);
1902
+ for (const a of links) {
1903
+ const text = a.textContent.trim();
1904
+ const label = a.getAttribute('aria-label');
1905
+ if (!text && !label && !a.querySelector('img[alt]')) {
1906
+ violations.push({ type: 'links', severity: 'error', selector: selectorFor(a), message: 'Empty link without accessible name' });
1907
+ }
1908
+ }
1909
+ }
1910
+
1911
+ // Headings
1912
+ if (runAll || checkList.includes('headings')) {
1913
+ const headings = [...root.querySelectorAll('h1,h2,h3,h4,h5,h6')];
1914
+ let prevLevel = 0;
1915
+ for (const h of headings) {
1916
+ const level = parseInt(h.tagName[1]);
1917
+ if (prevLevel > 0 && level > prevLevel + 1) {
1918
+ violations.push({ type: 'headings', severity: 'warning', selector: selectorFor(h), message: `Heading skip: h${prevLevel} → h${level}` });
1919
+ }
1920
+ prevLevel = level;
1921
+ }
1922
+ }
1923
+
1924
+ // ARIA
1925
+ if (runAll || checkList.includes('aria')) {
1926
+ const ariaHidden = [...root.querySelectorAll('[aria-hidden="true"]')].slice(0, CAP);
1927
+ for (const el of ariaHidden) {
1928
+ if (el.matches('a[href], button, input, select, textarea, [tabindex]') || el.querySelector('a[href], button, input, select, textarea, [tabindex]')) {
1929
+ violations.push({ type: 'aria', severity: 'error', selector: selectorFor(el), message: 'aria-hidden on focusable element' });
1930
+ }
1931
+ }
1932
+ }
1933
+
1934
+ // Contrast (WCAG 2.1 ratio)
1935
+ if (runAll || checkList.includes('contrast')) {
1936
+ const parseColor = (c) => {
1937
+ const m = c.match(/rgba?\(([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?\)/);
1938
+ if (!m) return null;
1939
+ return { r: +m[1], g: +m[2], b: +m[3], a: m[4] === undefined ? 1 : +m[4] };
1940
+ };
1941
+ const luminance = ({ r, g, b }) => {
1942
+ const f = (v) => { v /= 255; return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; };
1943
+ return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
1944
+ };
1945
+ const effectiveBg = (el) => {
1946
+ let node = el;
1947
+ while (node && node !== document.documentElement) {
1948
+ const bg = parseColor(getComputedStyle(node).backgroundColor);
1949
+ if (bg && bg.a >= 0.99) return bg;
1950
+ node = node.parentElement;
1951
+ }
1952
+ return { r: 255, g: 255, b: 255, a: 1 };
1953
+ };
1954
+ const textEls = [...root.querySelectorAll('p, span, a, li, td, th, label, h1, h2, h3, h4, h5, h6, button')].slice(0, 200);
1955
+ for (const el of textEls) {
1956
+ if (!el.textContent.trim()) continue;
1957
+ const style = getComputedStyle(el);
1958
+ const fg = parseColor(style.color);
1959
+ if (!fg) continue;
1960
+ const bg = effectiveBg(el);
1961
+ const L1 = Math.max(luminance(fg), luminance(bg));
1962
+ const L2 = Math.min(luminance(fg), luminance(bg));
1963
+ const ratio = (L1 + 0.05) / (L2 + 0.05);
1964
+ const px = parseFloat(style.fontSize);
1965
+ const bold = parseInt(style.fontWeight) >= 700;
1966
+ const isLarge = px >= 24 || (px >= 18.66 && bold);
1967
+ const required = isLarge ? 3 : 4.5;
1968
+ if (ratio < required) {
1969
+ violations.push({
1970
+ type: 'contrast',
1971
+ severity: ratio < required - 1.5 ? 'error' : 'warning',
1972
+ selector: selectorFor(el),
1973
+ message: `Contrast ${ratio.toFixed(2)}:1 below WCAG ${required}:1 (${isLarge ? 'large' : 'normal'} text)`,
1974
+ });
1975
+ }
1976
+ }
1977
+ }
1978
+
1979
+ // Forms
1980
+ if (runAll || checkList.includes('forms')) {
1981
+ const inputs = [...root.querySelectorAll('input, select, textarea')].slice(0, CAP);
1982
+ for (const input of inputs) {
1983
+ if (input.type === 'hidden' || input.type === 'submit' || input.type === 'button') continue;
1984
+ const id = input.id;
1985
+ const hasLabel = id && root.querySelector(`label[for="${id}"]`);
1986
+ const wrappedLabel = input.closest('label');
1987
+ const ariaLabel = input.getAttribute('aria-label') || input.getAttribute('aria-labelledby');
1988
+ if (!hasLabel && !wrappedLabel && !ariaLabel) {
1989
+ violations.push({ type: 'forms', severity: 'error', selector: selectorFor(input), message: 'Form input without associated label' });
1990
+ }
1991
+ }
1992
+ }
1993
+
1994
+ const errors = violations.filter((v) => v.severity === 'error').length;
1995
+ const warnings = violations.filter((v) => v.severity === 'warning').length;
1996
+ return { summary: { total: violations.length, errors, warnings }, violations };
1997
+ },
1998
+ args: [scope || null, checks],
1999
+ world: 'MAIN',
2000
+ });
2001
+ return results?.[0]?.result ?? { summary: { total: 0, errors: 0, warnings: 0 }, violations: [] };
2002
+ }
2003
+
2004
+ // --- collect_links ---
2005
+
2006
+ async function cmdCollectLinks({ scope = 'all', selector = 'a[href]', max_links = 50, tab_id }) {
2007
+ const tabId = await resolveTabId(tab_id);
2008
+ const results = await chrome.scripting.executeScript({
2009
+ target: { tabId },
2010
+ func: (sel, linkScope, maxLinks) => {
2011
+ const origin = location.origin;
2012
+ const skipPatterns = /^(javascript:|mailto:|tel:|#|data:)/i;
2013
+ const anchors = [...document.querySelectorAll(sel)].slice(0, 500);
2014
+ const seen = new Set();
2015
+ const links = [];
2016
+ for (const a of anchors) {
2017
+ const href = a.href;
2018
+ if (!href || skipPatterns.test(href) || seen.has(href)) continue;
2019
+ seen.add(href);
2020
+ try {
2021
+ const url = new URL(href);
2022
+ const isSameOrigin = url.origin === origin;
2023
+ if (linkScope === 'same-origin' && !isSameOrigin) continue;
2024
+ if (linkScope === 'external' && isSameOrigin) continue;
2025
+ links.push({ url: href, text: a.textContent.trim().substring(0, 100) });
2026
+ } catch { continue; }
2027
+ if (links.length >= maxLinks) break;
2028
+ }
2029
+ return { links, totalAnchors: anchors.length };
2030
+ },
2031
+ args: [selector, scope, max_links],
2032
+ world: 'MAIN',
2033
+ });
2034
+ return results?.[0]?.result ?? { links: [], totalAnchors: 0 };
2035
+ }
2036
+
2037
+ // --- measure_spacing ---
2038
+
2039
+ async function cmdMeasureSpacing({ selector1, selector2, tab_id }) {
2040
+ if (!selector1 || !selector2) throw new Error('Missing required parameters: selector1, selector2');
2041
+ const tabId = await resolveTabId(tab_id);
2042
+
2043
+ const results = await chrome.scripting.executeScript({
2044
+ target: { tabId },
2045
+ func: (sel1, sel2) => {
2046
+ const el1 = document.querySelector(sel1);
2047
+ const el2 = document.querySelector(sel2);
2048
+ if (!el1) throw new Error(`Element not found: ${sel1}`);
2049
+ if (!el2) throw new Error(`Element not found: ${sel2}`);
2050
+
2051
+ function getInfo(el, sel) {
2052
+ const rect = el.getBoundingClientRect();
2053
+ const cs = getComputedStyle(el);
2054
+ return {
2055
+ selector: sel,
2056
+ tagName: el.tagName.toLowerCase(),
2057
+ rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) },
2058
+ margin: { top: parseFloat(cs.marginTop), right: parseFloat(cs.marginRight), bottom: parseFloat(cs.marginBottom), left: parseFloat(cs.marginLeft) },
2059
+ padding: { top: parseFloat(cs.paddingTop), right: parseFloat(cs.paddingRight), bottom: parseFloat(cs.paddingBottom), left: parseFloat(cs.paddingLeft) },
2060
+ hidden: rect.width === 0 && rect.height === 0,
2061
+ };
2062
+ }
2063
+
2064
+ const info1 = getInfo(el1, sel1);
2065
+ const info2 = getInfo(el2, sel2);
2066
+ const r1 = el1.getBoundingClientRect();
2067
+ const r2 = el2.getBoundingClientRect();
2068
+
2069
+ const horizontalGap = Math.max(0, Math.max(r2.left - r1.right, r1.left - r2.right));
2070
+ const verticalGap = Math.max(0, Math.max(r2.top - r1.bottom, r1.top - r2.bottom));
2071
+ const overlapX = Math.max(0, Math.min(r1.right, r2.right) - Math.max(r1.left, r2.left));
2072
+ const overlapY = Math.max(0, Math.min(r1.bottom, r2.bottom) - Math.max(r1.top, r2.top));
2073
+ const center1 = { x: r1.left + r1.width / 2, y: r1.top + r1.height / 2 };
2074
+ const center2 = { x: r2.left + r2.width / 2, y: r2.top + r2.height / 2 };
2075
+ const centerDistance = Math.round(Math.sqrt((center2.x - center1.x) ** 2 + (center2.y - center1.y) ** 2));
2076
+
2077
+ return {
2078
+ element1: info1,
2079
+ element2: info2,
2080
+ spacing: {
2081
+ horizontalGap: Math.round(horizontalGap),
2082
+ verticalGap: Math.round(verticalGap),
2083
+ overlap: overlapX > 0 && overlapY > 0,
2084
+ overlapArea: overlapX > 0 && overlapY > 0 ? { width: Math.round(overlapX), height: Math.round(overlapY) } : null,
2085
+ centerDistance,
2086
+ },
2087
+ };
2088
+ },
2089
+ args: [selector1, selector2],
2090
+ world: 'MAIN',
2091
+ });
2092
+ return results?.[0]?.result ?? {};
2093
+ }
2094
+
2095
+ // --- watch_dom (stateful) ---
2096
+
2097
+ async function cmdWatchDom({ selector = 'body', attributes = true, childList = true, characterData = false, subtree = true, clear = false, stop = false, limit = 0, tab_id }) {
2098
+ const tabId = await resolveTabId(tab_id);
2099
+
2100
+ if (stop) {
2101
+ await chrome.scripting.executeScript({
2102
+ target: { tabId },
2103
+ func: () => {
2104
+ if (window.__chromeBridge_domObserver) {
2105
+ window.__chromeBridge_domObserver.disconnect();
2106
+ window.__chromeBridge_domObserver = null;
2107
+ }
2108
+ window.__chromeBridge_domWatcherHooked = false;
2109
+ window.__chromeBridge_domMutations = [];
2110
+ },
2111
+ world: 'MAIN',
2112
+ });
2113
+ return { stopped: true, count: 0, mutations: [] };
2114
+ }
2115
+
2116
+ // Always inject: idempotent via in-page guard; handles selector change by re-observing.
2117
+ await chrome.scripting.executeScript({
2118
+ target: { tabId },
2119
+ func: (sel, opts) => {
2120
+ if (window.__chromeBridge_domWatcherHooked && window.__chromeBridge_domWatchSelector !== sel) {
2121
+ if (window.__chromeBridge_domObserver) window.__chromeBridge_domObserver.disconnect();
2122
+ window.__chromeBridge_domWatcherHooked = false;
2123
+ window.__chromeBridge_domMutations = [];
2124
+ }
2125
+ if (window.__chromeBridge_domWatcherHooked) return;
2126
+ window.__chromeBridge_domWatcherHooked = true;
2127
+ window.__chromeBridge_domWatchSelector = sel;
2128
+ window.__chromeBridge_domMutations = [];
2129
+ const MAX = 1000;
2130
+ const target = document.querySelector(sel) || document.body;
2131
+ const observer = new MutationObserver((mutationList) => {
2132
+ for (const m of mutationList) {
2133
+ // Ring buffer: dopo un'azione interessano le mutation RECENTI, quindi
2134
+ // si scartano le vecchie invece di smettere di registrare.
2135
+ if (window.__chromeBridge_domMutations.length >= MAX) window.__chromeBridge_domMutations.shift();
2136
+ const entry = {
2137
+ type: m.type,
2138
+ target: m.target.tagName ? m.target.tagName.toLowerCase() : '#text',
2139
+ timestamp: Date.now(),
2140
+ };
2141
+ if (m.type === 'attributes') entry.attributeName = m.attributeName;
2142
+ if (m.type === 'childList') {
2143
+ entry.addedNodes = m.addedNodes.length;
2144
+ entry.removedNodes = m.removedNodes.length;
2145
+ }
2146
+ // Skip our own highlight overlays
2147
+ if (m.target.hasAttribute && m.target.hasAttribute('data-chrome-bridge-highlight')) continue;
2148
+ window.__chromeBridge_domMutations.push(entry);
2149
+ }
2150
+ });
2151
+ observer.observe(target, opts);
2152
+ window.__chromeBridge_domObserver = observer;
2153
+ },
2154
+ args: [selector, { attributes, childList, characterData, subtree }],
2155
+ world: 'MAIN',
2156
+ });
2157
+
2158
+ // Read mutations
2159
+ const results = await chrome.scripting.executeScript({
2160
+ target: { tabId },
2161
+ func: (shouldClear, lim) => {
2162
+ const mutations = window.__chromeBridge_domMutations || [];
2163
+ const shown = lim > 0 ? mutations.slice(-lim) : mutations.slice();
2164
+ if (shouldClear && shown.length) {
2165
+ const drop = new Set(shown);
2166
+ window.__chromeBridge_domMutations = mutations.filter((m) => !drop.has(m));
2167
+ }
2168
+ return { total: mutations.length, mutations: shown };
2169
+ },
2170
+ args: [clear, limit],
2171
+ world: 'MAIN',
2172
+ });
2173
+ const out = results?.[0]?.result ?? {};
2174
+ const mutations = out.mutations ?? [];
2175
+ return { count: out.total ?? mutations.length, mutations };
2176
+ }
2177
+
2178
+ // --- emulate_media ---
2179
+
2180
+ async function cmdEmulateMedia({ colorScheme, reducedMotion, printMode = false, reset = false, tab_id }) {
2181
+ const tabId = await resolveTabId(tab_id);
2182
+
2183
+ const results = await chrome.scripting.executeScript({
2184
+ target: { tabId },
2185
+ func: (cs, rm, print, doReset) => {
2186
+ const STYLE_ID = '__chromeBridge_media';
2187
+ const META_ID = '__chromeBridge_colorScheme';
2188
+
2189
+ if (doReset) {
2190
+ const style = document.getElementById(STYLE_ID);
2191
+ if (style) style.remove();
2192
+ const meta = document.getElementById(META_ID);
2193
+ if (meta) meta.remove();
2194
+ // Restore original matchMedia if saved
2195
+ if (window.__chromeBridge_origMatchMedia) {
2196
+ window.matchMedia = window.__chromeBridge_origMatchMedia;
2197
+ delete window.__chromeBridge_origMatchMedia;
2198
+ }
2199
+ return { reset: true };
2200
+ }
2201
+
2202
+ // Save original matchMedia
2203
+ if (!window.__chromeBridge_origMatchMedia) {
2204
+ window.__chromeBridge_origMatchMedia = window.matchMedia.bind(window);
2205
+ }
2206
+
2207
+ const overrides = {};
2208
+ if (cs) overrides['prefers-color-scheme'] = cs;
2209
+ if (rm) overrides['prefers-reduced-motion'] = rm;
2210
+ if (print) overrides['print'] = true;
2211
+
2212
+ // Override matchMedia for JS-based checks.
2213
+ // NB: spreading a MediaQueryList loses prototype methods (addEventListener ecc.),
2214
+ // quindi per le query forzate restituiamo uno stub MQL-compatibile.
2215
+ const orig = window.__chromeBridge_origMatchMedia;
2216
+ const mkStub = (matches, query) => ({
2217
+ matches,
2218
+ media: query,
2219
+ onchange: null,
2220
+ addEventListener() {},
2221
+ removeEventListener() {},
2222
+ addListener() {},
2223
+ removeListener() {},
2224
+ dispatchEvent() { return false; },
2225
+ });
2226
+ window.matchMedia = (query) => {
2227
+ for (const [feature, val] of Object.entries(overrides)) {
2228
+ if (feature === 'print' && query.includes('print')) return mkStub(true, query);
2229
+ if (query.includes(feature) && query.includes(val)) return mkStub(true, query);
2230
+ if (query.includes(feature) && !query.includes(val)) return mkStub(false, query);
2231
+ }
2232
+ return orig(query);
2233
+ };
2234
+
2235
+ // Inject CSS + meta for CSS-based checks
2236
+ let style = document.getElementById(STYLE_ID);
2237
+ if (!style) {
2238
+ style = document.createElement('style');
2239
+ style.id = STYLE_ID;
2240
+ document.head.appendChild(style);
2241
+ }
2242
+
2243
+ let css = '';
2244
+ if (cs === 'dark') css += ':root { color-scheme: dark; }\n';
2245
+ else if (cs === 'light') css += ':root { color-scheme: light; }\n';
2246
+ if (rm === 'reduce') css += '*, *::before, *::after { animation-duration: 0.001ms !important; transition-duration: 0.001ms !important; }\n';
2247
+ if (print) css += '@media screen { body { } }\n'; // placeholder, actual print emulation via matchMedia override
2248
+ style.textContent = css;
2249
+
2250
+ // Color scheme meta tag
2251
+ let meta = document.getElementById(META_ID);
2252
+ if (cs) {
2253
+ if (!meta) {
2254
+ meta = document.createElement('meta');
2255
+ meta.id = META_ID;
2256
+ meta.name = 'color-scheme';
2257
+ document.head.appendChild(meta);
2258
+ }
2259
+ meta.content = cs === 'dark' ? 'dark' : cs === 'light' ? 'light' : 'light dark';
2260
+ } else if (meta) {
2261
+ meta.remove();
2262
+ }
2263
+
2264
+ return { emulated: overrides };
2265
+ },
2266
+ args: [colorScheme || null, reducedMotion || null, printMode, reset],
2267
+ world: 'MAIN',
2268
+ });
2269
+ return results?.[0]?.result ?? {};
2270
+ }
2271
+
2272
+ // --- hover ---
2273
+
2274
+ async function cmdHover({ selector, tab_id, frame_id }) {
2275
+ if (!selector) throw new Error('Missing required parameter: selector');
2276
+ const tabId = await resolveTabId(tab_id);
2277
+
2278
+ const results = await chrome.scripting.executeScript({
2279
+ target: scriptTarget(tabId, frame_id),
2280
+ func: (sel) => {
2281
+ function deepQuery(sel) {
2282
+ if (!sel.includes('>>>')) return document.querySelector(sel);
2283
+ const parts = sel.split('>>>').map((s) => s.trim());
2284
+ let ctx = document;
2285
+ for (let i = 0; i < parts.length; i++) {
2286
+ const found = ctx.querySelector(parts[i]);
2287
+ if (!found) return null;
2288
+ if (i === parts.length - 1) return found;
2289
+ if (!found.shadowRoot) return null;
2290
+ ctx = found.shadowRoot;
2291
+ }
2292
+ return null;
2293
+ }
2294
+ const el = deepQuery(sel);
2295
+ if (!el) throw new Error(`Element not found: ${sel}`);
2296
+ const rect = el.getBoundingClientRect();
2297
+ const cx = rect.left + rect.width / 2;
2298
+ const cy = rect.top + rect.height / 2;
2299
+ const opts = { bubbles: true, cancelable: true, clientX: cx, clientY: cy };
2300
+ el.dispatchEvent(new MouseEvent('mouseenter', { ...opts, bubbles: false }));
2301
+ el.dispatchEvent(new MouseEvent('mouseover', opts));
2302
+ el.dispatchEvent(new MouseEvent('mousemove', opts));
2303
+ return {
2304
+ tagName: el.tagName.toLowerCase(),
2305
+ text: el.textContent?.substring(0, 100)?.trim() || null,
2306
+ rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) },
2307
+ };
2308
+ },
2309
+ args: [selector],
2310
+ world: 'MAIN',
2311
+ });
2312
+ return results?.[0]?.result ?? { hovered: true };
2313
+ }
2314
+
2315
+ // --- press_key ---
2316
+
2317
+ async function cmdPressKey({ key, selector, ctrl = false, shift = false, alt = false, meta = false, tab_id, frame_id }) {
2318
+ if (!key) throw new Error('Missing required parameter: key');
2319
+ const tabId = await resolveTabId(tab_id);
2320
+
2321
+ const results = await chrome.scripting.executeScript({
2322
+ target: scriptTarget(tabId, frame_id),
2323
+ func: (k, sel, mods) => {
2324
+ function deepQuery(sel) {
2325
+ if (!sel.includes('>>>')) return document.querySelector(sel);
2326
+ const parts = sel.split('>>>').map((s) => s.trim());
2327
+ let ctx = document;
2328
+ for (let i = 0; i < parts.length; i++) {
2329
+ const found = ctx.querySelector(parts[i]);
2330
+ if (!found) return null;
2331
+ if (i === parts.length - 1) return found;
2332
+ if (!found.shadowRoot) return null;
2333
+ ctx = found.shadowRoot;
2334
+ }
2335
+ return null;
2336
+ }
2337
+ let el;
2338
+ if (sel) {
2339
+ el = deepQuery(sel);
2340
+ if (!el) throw new Error(`Element not found: ${sel}`);
2341
+ } else {
2342
+ el = document.activeElement || document.body;
2343
+ }
2344
+ const keyToCode = (kk) => {
2345
+ if (/^[a-z]$/i.test(kk)) return `Key${kk.toUpperCase()}`;
2346
+ if (/^[0-9]$/.test(kk)) return `Digit${kk}`;
2347
+ const map = {
2348
+ ' ': 'Space', '-': 'Minus', '=': 'Equal', '[': 'BracketLeft', ']': 'BracketRight',
2349
+ '\\': 'Backslash', ';': 'Semicolon', "'": 'Quote', ',': 'Comma', '.': 'Period',
2350
+ '/': 'Slash', '`': 'Backquote',
2351
+ };
2352
+ return map[kk] || kk;
2353
+ };
2354
+ const opts = {
2355
+ key: k,
2356
+ code: keyToCode(k),
2357
+ bubbles: true,
2358
+ cancelable: true,
2359
+ ctrlKey: mods.ctrl,
2360
+ shiftKey: mods.shift,
2361
+ altKey: mods.alt,
2362
+ metaKey: mods.meta,
2363
+ };
2364
+ el.dispatchEvent(new KeyboardEvent('keydown', opts));
2365
+ // keypress only for printable chars
2366
+ if (k.length === 1) {
2367
+ el.dispatchEvent(new KeyboardEvent('keypress', opts));
2368
+ }
2369
+ el.dispatchEvent(new KeyboardEvent('keyup', opts));
2370
+ return {
2371
+ key: k,
2372
+ target: el.tagName.toLowerCase(),
2373
+ modifiers: Object.entries(mods).filter(([, v]) => v).map(([m]) => m),
2374
+ };
2375
+ },
2376
+ args: [key, selector || null, { ctrl, shift, alt, meta }],
2377
+ world: 'MAIN',
2378
+ });
2379
+ return results?.[0]?.result ?? { pressed: true };
2380
+ }
2381
+
2382
+ // --- get_frames ---
2383
+
2384
+ async function cmdGetFrames({ tab_id }) {
2385
+ const tabId = await resolveTabId(tab_id);
2386
+ const frames = await chrome.webNavigation.getAllFrames({ tabId });
2387
+ return {
2388
+ frames: (frames || []).map((f) => ({
2389
+ frameId: f.frameId,
2390
+ parentFrameId: f.parentFrameId,
2391
+ url: f.url,
2392
+ })),
2393
+ };
2394
+ }
2395
+
2396
+ // --- upload_file ---
2397
+
2398
+ async function cmdUploadFile({ selector, name, mime_type, content_b64, tab_id }) {
2399
+ if (!selector) throw new Error('Missing required parameter: selector');
2400
+ if (!content_b64) throw new Error('Missing required parameter: content_b64');
2401
+ const tabId = await resolveTabId(tab_id);
2402
+ const results = await chrome.scripting.executeScript({
2403
+ target: { tabId },
2404
+ func: (sel, fname, mime, b64) => {
2405
+ const el = document.querySelector(sel);
2406
+ if (!el) throw new Error(`Element not found: ${sel}`);
2407
+ if (!(el instanceof HTMLInputElement) || el.type !== 'file') throw new Error('Element is not an input[type=file]');
2408
+ const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
2409
+ const file = new File([bytes], fname, { type: mime });
2410
+ const dt = new DataTransfer();
2411
+ dt.items.add(file);
2412
+ el.files = dt.files;
2413
+ el.dispatchEvent(new Event('input', { bubbles: true }));
2414
+ el.dispatchEvent(new Event('change', { bubbles: true }));
2415
+ return { uploaded: fname, size: bytes.length, mime };
2416
+ },
2417
+ args: [selector, name, mime_type, content_b64],
2418
+ world: 'MAIN',
2419
+ });
2420
+ return results?.[0]?.result ?? { uploaded: false };
2421
+ }
2422
+
2423
+ // --- wait_for_navigation ---
2424
+
2425
+ async function cmdWaitForNavigation({ timeout = 15000, mode = 'load', tab_id }) {
2426
+ const tabId = await resolveTabId(tab_id);
2427
+ const start = Date.now();
2428
+
2429
+ if (mode === 'spa') {
2430
+ // baseline route corrente
2431
+ const baseRes = await chrome.scripting.executeScript({
2432
+ target: { tabId },
2433
+ func: () => window.__chromeBridge_lastRoute || location.href,
2434
+ world: 'MAIN',
2435
+ });
2436
+ const baseline = baseRes?.[0]?.result ?? null;
2437
+ const res = await chrome.scripting.executeScript({
2438
+ target: { tabId },
2439
+ func: (base, tout) => new Promise((resolve) => {
2440
+ const startT = Date.now();
2441
+ const check = () => {
2442
+ const cur = window.__chromeBridge_lastRoute || location.href;
2443
+ if (cur !== base) { resolve({ navigated: true, mode: 'spa', url: cur, elapsed: Date.now() - startT }); return; }
2444
+ if (Date.now() - startT >= tout) { resolve({ navigated: false, mode: 'spa', url: cur, note: 'No SPA route change within timeout' }); return; }
2445
+ setTimeout(check, 100);
2446
+ };
2447
+ check();
2448
+ }),
2449
+ // Clamp sotto l'envelope del comando (60s) per non lasciare il poll orfano
2450
+ args: [baseline, Math.min(timeout, 59000)],
2451
+ world: 'MAIN',
2452
+ });
2453
+ return res?.[0]?.result ?? { navigated: false, mode: 'spa' };
2454
+ }
2455
+
2456
+ // mode === 'load' : navigazione full-page (tab.status complete)
2457
+ const tab = await chrome.tabs.get(tabId);
2458
+
2459
+ if (tab.status === 'complete') {
2460
+ // Attendi che una navigazione parta (entro min(timeout, 5s))
2461
+ const started = await new Promise((resolve) => {
2462
+ const listener = (id, info) => {
2463
+ if (id === tabId && info.status === 'loading') {
2464
+ chrome.tabs.onUpdated.removeListener(listener);
2465
+ resolve(true);
2466
+ }
2467
+ };
2468
+ chrome.tabs.onUpdated.addListener(listener);
2469
+ setTimeout(() => { chrome.tabs.onUpdated.removeListener(listener); resolve(false); }, Math.min(timeout, 5000));
2470
+ });
2471
+ if (!started) {
2472
+ const t = await chrome.tabs.get(tabId);
2473
+ return { navigated: false, mode: 'load', url: t.url, note: 'No navigation started' };
2474
+ }
2475
+ }
2476
+
2477
+ const completed = await waitForComplete(tabId, Math.max(0, timeout - (Date.now() - start)));
2478
+ const t = await chrome.tabs.get(tabId);
2479
+ return { navigated: completed, mode: 'load', url: t.url, title: t.title, elapsed: Date.now() - start };
2480
+ }
2481
+
2482
+ // --- dismiss_overlays ---
2483
+
2484
+ async function cmdDismissOverlays({ tab_id }) {
2485
+ const tabId = await resolveTabId(tab_id);
2486
+ const results = await chrome.scripting.executeScript({
2487
+ target: { tabId },
2488
+ func: () => {
2489
+ const dismissed = [];
2490
+ const clickIfVisible = (el, why) => {
2491
+ if (!el) return false;
2492
+ const rect = el.getBoundingClientRect();
2493
+ const style = getComputedStyle(el);
2494
+ if (rect.width === 0 || rect.height === 0 || style.visibility === 'hidden' || style.display === 'none') return false;
2495
+ const sel = el.id ? `#${el.id}` : (el.getAttribute('data-testid') ? `[data-testid="${el.getAttribute('data-testid')}"]` : el.tagName.toLowerCase());
2496
+ el.click();
2497
+ dismissed.push({ selector: sel, text: (el.textContent || '').trim().substring(0, 60), why });
2498
+ return true;
2499
+ };
2500
+
2501
+ // 1. Selettori noti di framework consent
2502
+ const known = [
2503
+ '#onetrust-accept-btn-handler',
2504
+ '#CybotCookiebotDialogBodyButtonAccept',
2505
+ '#CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll',
2506
+ 'button[mode="primary"][data-testid="uc-accept-all-button"]',
2507
+ '.cc-allow', '.cookie-accept', '#accept-cookies',
2508
+ 'button[aria-label*="accept" i]',
2509
+ '[data-cookiebanner="accept_button"]',
2510
+ ];
2511
+ for (const k of known) {
2512
+ if (dismissed.length >= 5) break;
2513
+ try { if (clickIfVisible(document.querySelector(k), 'known')) continue; } catch {}
2514
+ }
2515
+
2516
+ // 2. Euristica generica: bottone con testo accetta/accept dentro overlay fixed/sticky o z-index alto
2517
+ if (dismissed.length === 0) {
2518
+ const reText = /^(accetta|accetto|accept all|accept|agree|consenti|ok|got it|i agree|allow all)$/i;
2519
+ const candidates = [...document.querySelectorAll('button, [role=button], a')].filter((el) => {
2520
+ const t = (el.textContent || '').trim();
2521
+ return t.length <= 30 && reText.test(t);
2522
+ });
2523
+ for (const el of candidates) {
2524
+ // verifica che sia in un contenitore overlay (fixed/sticky o z-index alto risalendo i parent)
2525
+ let node = el, inOverlay = false;
2526
+ for (let i = 0; i < 6 && node; i++) {
2527
+ const s = getComputedStyle(node);
2528
+ if (s.position === 'fixed' || s.position === 'sticky' || (parseInt(s.zIndex) || 0) >= 100) { inOverlay = true; break; }
2529
+ node = node.parentElement;
2530
+ }
2531
+ if (inOverlay && clickIfVisible(el, 'heuristic')) break;
2532
+ }
2533
+ }
2534
+
2535
+ return { count: dismissed.length, dismissed };
2536
+ },
2537
+ world: 'MAIN',
2538
+ });
2539
+ return results?.[0]?.result ?? { count: 0, dismissed: [] };
2540
+ }
2541
+
2542
+ // --- wait_for_network_idle ---
2543
+
2544
+ async function cmdWaitForNetworkIdle({ idle_ms = 500, timeout = 15000, tab_id }) {
2545
+ const tabId = await resolveTabId(tab_id);
2546
+ await ensureNetworkHook(tabId);
2547
+ const results = await chrome.scripting.executeScript({
2548
+ target: { tabId },
2549
+ func: (idleMs, tout) => new Promise((resolve) => {
2550
+ const start = Date.now();
2551
+ const check = () => {
2552
+ const inflight = window.__chromeBridge_inflight || 0;
2553
+ const last = window.__chromeBridge_lastNetActivity || start;
2554
+ if (inflight === 0 && Date.now() - last >= idleMs) {
2555
+ resolve({ idle: true, elapsed: Date.now() - start });
2556
+ return;
2557
+ }
2558
+ if (Date.now() - start >= tout) {
2559
+ resolve({ idle: false, inflight, elapsed: Date.now() - start });
2560
+ return;
2561
+ }
2562
+ setTimeout(check, 100);
2563
+ };
2564
+ check();
2565
+ }),
2566
+ args: [idle_ms, timeout],
2567
+ world: 'MAIN',
2568
+ });
2569
+ return results?.[0]?.result ?? { idle: false };
2570
+ }
2571
+
2572
+ // --- handle_dialogs ---
2573
+
2574
+ async function cmdHandleDialogs({ action = 'accept', prompt_text, tab_id }) {
2575
+ const tabId = await resolveTabId(tab_id);
2576
+ const results = await chrome.scripting.executeScript({
2577
+ target: { tabId },
2578
+ func: (act, promptText) => {
2579
+ if (act === 'reset') {
2580
+ if (window.__chromeBridge_origDialogs) {
2581
+ window.alert = window.__chromeBridge_origDialogs.alert;
2582
+ window.confirm = window.__chromeBridge_origDialogs.confirm;
2583
+ window.prompt = window.__chromeBridge_origDialogs.prompt;
2584
+ delete window.__chromeBridge_origDialogs;
2585
+ }
2586
+ const log = window.__chromeBridge_dialogs || [];
2587
+ window.__chromeBridge_dialogs = [];
2588
+ return { reset: true, dialogs: log };
2589
+ }
2590
+ if (!window.__chromeBridge_origDialogs) {
2591
+ window.__chromeBridge_origDialogs = { alert: window.alert, confirm: window.confirm, prompt: window.prompt };
2592
+ window.__chromeBridge_dialogs = [];
2593
+ }
2594
+ window.__chromeBridge_dialogPolicy = { action: act, promptText: promptText ?? null };
2595
+ const push = (type, message) => {
2596
+ window.__chromeBridge_dialogs.push({ type, message: String(message ?? ''), timestamp: Date.now() });
2597
+ if (window.__chromeBridge_dialogs.length > 100) window.__chromeBridge_dialogs.shift();
2598
+ };
2599
+ window.alert = (msg) => { push('alert', msg); };
2600
+ window.confirm = (msg) => { push('confirm', msg); return window.__chromeBridge_dialogPolicy.action === 'accept'; };
2601
+ window.prompt = (msg, def) => {
2602
+ push('prompt', msg);
2603
+ const p = window.__chromeBridge_dialogPolicy;
2604
+ return p.action === 'accept' ? (p.promptText ?? def ?? '') : null;
2605
+ };
2606
+ return { installed: true, policy: act, dialogs: window.__chromeBridge_dialogs };
2607
+ },
2608
+ args: [action, prompt_text ?? null],
2609
+ world: 'MAIN',
2610
+ });
2611
+ return results?.[0]?.result ?? {};
2612
+ }
2613
+
2614
+ // --- find_text ---
2615
+
2616
+ async function cmdFindText({ text, case_sensitive = false, max_results = 20, tab_id }) {
2617
+ if (!text) throw new Error('Missing required parameter: text');
2618
+ const tabId = await resolveTabId(tab_id);
2619
+ const results = await chrome.scripting.executeScript({
2620
+ target: { tabId },
2621
+ func: (needleRaw, caseSensitive, maxResults) => {
2622
+ const needle = caseSensitive ? needleRaw : needleRaw.toLowerCase();
2623
+ const matches = [];
2624
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, {
2625
+ acceptNode: (n) => {
2626
+ const p = n.parentElement;
2627
+ if (!p || ['SCRIPT', 'STYLE', 'NOSCRIPT'].includes(p.tagName)) return NodeFilter.FILTER_REJECT;
2628
+ return NodeFilter.FILTER_ACCEPT;
2629
+ },
2630
+ });
2631
+ const selectorFor = (el) => {
2632
+ if (el.id) return `#${el.id}`;
2633
+ const tag = el.tagName.toLowerCase();
2634
+ const cls = el.classList.length ? `.${[...el.classList].slice(0, 2).join('.')}` : '';
2635
+ return `${tag}${cls}`;
2636
+ };
2637
+ let node;
2638
+ while ((node = walker.nextNode()) && matches.length < maxResults) {
2639
+ const hay = caseSensitive ? node.textContent : node.textContent.toLowerCase();
2640
+ let idx = hay.indexOf(needle);
2641
+ while (idx !== -1 && matches.length < maxResults) {
2642
+ const parent = node.parentElement;
2643
+ const rect = parent.getBoundingClientRect();
2644
+ const ctx = node.textContent.substring(Math.max(0, idx - 40), idx + needleRaw.length + 40);
2645
+ matches.push({
2646
+ selector: selectorFor(parent),
2647
+ context: ctx.trim(),
2648
+ visible: rect.width > 0 && rect.height > 0,
2649
+ position: { x: Math.round(rect.x + window.scrollX), y: Math.round(rect.y + window.scrollY) },
2650
+ });
2651
+ idx = hay.indexOf(needle, idx + 1);
2652
+ }
2653
+ }
2654
+ return { count: matches.length, matches };
2655
+ },
2656
+ args: [text, case_sensitive, max_results],
2657
+ world: 'MAIN',
2658
+ });
2659
+ return results?.[0]?.result ?? { count: 0, matches: [] };
2660
+ }
2661
+
2662
+ // --- network_rules (declarativeNetRequest) ---
2663
+ // Nota: le regole dinamiche sono globali per il browser, non per-tab.
2664
+
2665
+ async function cmdNetworkRules({ action, url_filter, redirect_url, header, header_value, header_target = 'request', resource_types }) {
2666
+ if (!action) throw new Error('Missing required parameter: action');
2667
+
2668
+ if (action === 'list') {
2669
+ const rules = await chrome.declarativeNetRequest.getDynamicRules();
2670
+ return { count: rules.length, rules };
2671
+ }
2672
+
2673
+ if (action === 'clear') {
2674
+ const rules = await chrome.declarativeNetRequest.getDynamicRules();
2675
+ await chrome.declarativeNetRequest.updateDynamicRules({
2676
+ removeRuleIds: rules.map((r) => r.id),
2677
+ });
2678
+ return { cleared: rules.length };
2679
+ }
2680
+
2681
+ if (!url_filter) throw new Error('Missing required parameter: url_filter');
2682
+ const existing = await chrome.declarativeNetRequest.getDynamicRules();
2683
+ const nextId = existing.reduce((m, r) => Math.max(m, r.id), 0) + 1;
2684
+ const types = resource_types && resource_types.length > 0 ? resource_types : undefined;
2685
+ const condition = { urlFilter: url_filter };
2686
+ if (types) condition.resourceTypes = types;
2687
+
2688
+ let rule;
2689
+ if (action === 'block') {
2690
+ rule = { id: nextId, priority: 1, action: { type: 'block' }, condition };
2691
+ } else if (action === 'redirect') {
2692
+ if (!redirect_url) throw new Error('Missing required parameter: redirect_url');
2693
+ rule = { id: nextId, priority: 1, action: { type: 'redirect', redirect: { url: redirect_url } }, condition };
2694
+ } else if (action === 'modify_header') {
2695
+ if (!header) throw new Error('Missing required parameter: header');
2696
+ const op = header_value === undefined || header_value === null || header_value === ''
2697
+ ? { header, operation: 'remove' }
2698
+ : { header, operation: 'set', value: header_value };
2699
+ // `target` decide request vs response: senza il ramo responseHeaders era
2700
+ // impossibile strippare content-security-policy / x-frame-options o
2701
+ // iniettare access-control-allow-origin — i tre blocchi più frequenti in
2702
+ // sviluppo locale — pur essendo declarativeNetRequest già concesso.
2703
+ const headerAction = header_target === 'response'
2704
+ ? { type: 'modifyHeaders', responseHeaders: [op] }
2705
+ : { type: 'modifyHeaders', requestHeaders: [op] };
2706
+ rule = { id: nextId, priority: 1, action: headerAction, condition };
2707
+ } else {
2708
+ throw new Error(`Unknown action: ${action}`);
2709
+ }
2710
+
2711
+ await chrome.declarativeNetRequest.updateDynamicRules({ addRules: [rule] });
2712
+ return { added: rule };
2713
+ }
2714
+
2715
+ // --- screenshot_diff (regressione visiva) ---
2716
+ // Baseline in memoria del service worker: persa se il SW viene sospeso.
2717
+ // NOTA: la logica capture+crop duplica cmdElementScreenshot — candidata a refactor futuro.
2718
+
2719
+ const MAX_DIFF_BASELINES = 10;
2720
+ const diffBaselines = new Map(); // name → { bitmapData: ImageData, width, height, capturedAt, selector }
2721
+
2722
+ async function captureForDiff(tabId, selector) {
2723
+ const { cropRect, dataUrl } = await withTabVisible(tabId, async (tab) => {
2724
+ let cropRect = null;
2725
+ if (selector) {
2726
+ const res = await chrome.scripting.executeScript({
2727
+ target: { tabId },
2728
+ func: (sel) => {
2729
+ const el = document.querySelector(sel);
2730
+ if (!el) throw new Error(`Element not found: ${sel}`);
2731
+ el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
2732
+ const r = el.getBoundingClientRect();
2733
+ return { x: r.x, y: r.y, width: r.width, height: r.height, dpr: window.devicePixelRatio };
2734
+ },
2735
+ args: [selector],
2736
+ world: 'MAIN',
2737
+ });
2738
+ cropRect = res?.[0]?.result;
2739
+ if (!cropRect || cropRect.width === 0 || cropRect.height === 0) throw new Error('Element has no visible area');
2740
+ }
2741
+
2742
+ await new Promise((r) => setTimeout(r, 300));
2743
+ const dataUrl = await captureVisible(tab.windowId);
2744
+ return { cropRect, dataUrl };
2745
+ });
2746
+ const bitmap = await dataUrlToBitmap(dataUrl);
2747
+
2748
+ let sx = 0, sy = 0, sw = bitmap.width, sh = bitmap.height;
2749
+ if (cropRect) {
2750
+ const dpr = cropRect.dpr;
2751
+ sx = Math.max(0, Math.round(cropRect.x * dpr));
2752
+ sy = Math.max(0, Math.round(cropRect.y * dpr));
2753
+ sw = Math.min(Math.round(cropRect.width * dpr), bitmap.width - sx);
2754
+ sh = Math.min(Math.round(cropRect.height * dpr), bitmap.height - sy);
2755
+ if (sw <= 0 || sh <= 0) throw new Error('Element is outside the visible viewport');
2756
+ }
2757
+
2758
+ const canvas = new OffscreenCanvas(sw, sh);
2759
+ const ctx = canvas.getContext('2d');
2760
+ ctx.drawImage(bitmap, sx, sy, sw, sh, 0, 0, sw, sh);
2761
+ return { canvas, ctx, width: sw, height: sh };
2762
+ }
2763
+
2764
+ async function cmdScreenshotDiff({ action, name = 'default', selector, threshold = 10, tab_id }) {
2765
+ if (!action) throw new Error('Missing required parameter: action');
2766
+
2767
+ if (action === 'list') {
2768
+ return {
2769
+ baselines: [...diffBaselines.entries()].map(([n, b]) => ({
2770
+ name: n, width: b.width, height: b.height, capturedAt: b.capturedAt, selector: b.selector,
2771
+ })),
2772
+ };
2773
+ }
2774
+
2775
+ if (action === 'clear') {
2776
+ const n = diffBaselines.size;
2777
+ diffBaselines.clear();
2778
+ return { cleared: n };
2779
+ }
2780
+
2781
+ const tabId = await resolveTabId(tab_id);
2782
+
2783
+ if (action === 'baseline') {
2784
+ if (!diffBaselines.has(name) && diffBaselines.size >= MAX_DIFF_BASELINES) {
2785
+ throw new Error(`Too many stored baselines (max ${MAX_DIFF_BASELINES}) — use action: clear to free memory`);
2786
+ }
2787
+ const { ctx, width, height } = await captureForDiff(tabId, selector);
2788
+ // Ricontrolla dopo l'await: due baseline concorrenti possono aver
2789
+ // superato entrambe il check iniziale
2790
+ if (!diffBaselines.has(name) && diffBaselines.size >= MAX_DIFF_BASELINES) {
2791
+ throw new Error(`Too many stored baselines (max ${MAX_DIFF_BASELINES}) — use action: clear to free memory`);
2792
+ }
2793
+ diffBaselines.set(name, {
2794
+ bitmapData: ctx.getImageData(0, 0, width, height),
2795
+ width, height,
2796
+ capturedAt: Date.now(),
2797
+ selector: selector || null,
2798
+ });
2799
+ return { baseline: name, width, height, selector: selector || null };
2800
+ }
2801
+
2802
+ if (action === 'compare') {
2803
+ const base = diffBaselines.get(name);
2804
+ if (!base) throw new Error(`No baseline named "${name}" — capture one first with action: baseline (note: baselines are lost if the extension service worker restarts)`);
2805
+
2806
+ const { ctx, width, height } = await captureForDiff(tabId, selector ?? base.selector ?? undefined);
2807
+ if (width !== base.width || height !== base.height) {
2808
+ return {
2809
+ match: false,
2810
+ reason: 'size_mismatch',
2811
+ baseline: { width: base.width, height: base.height },
2812
+ current: { width, height },
2813
+ };
2814
+ }
2815
+
2816
+ const cur = ctx.getImageData(0, 0, width, height);
2817
+ const a = base.bitmapData.data;
2818
+ const b = cur.data;
2819
+ const diffCanvas = new OffscreenCanvas(width, height);
2820
+ const diffCtx = diffCanvas.getContext('2d');
2821
+ const out = diffCtx.createImageData(width, height);
2822
+ let changed = 0;
2823
+ for (let i = 0; i < a.length; i += 4) {
2824
+ const delta = Math.abs(a[i] - b[i]) + Math.abs(a[i + 1] - b[i + 1]) + Math.abs(a[i + 2] - b[i + 2]);
2825
+ if (delta > threshold * 3) {
2826
+ changed++;
2827
+ out.data[i] = 255; out.data[i + 1] = 0; out.data[i + 2] = 0; out.data[i + 3] = 255;
2828
+ } else {
2829
+ // Base sbiadita per contesto
2830
+ const gray = (a[i] + a[i + 1] + a[i + 2]) / 3;
2831
+ out.data[i] = gray; out.data[i + 1] = gray; out.data[i + 2] = gray; out.data[i + 3] = 80;
2832
+ }
2833
+ }
2834
+ diffCtx.putImageData(out, 0, 0);
2835
+ const totalPixels = width * height;
2836
+ const diffPercent = (changed / totalPixels) * 100;
2837
+ // L'immagine è allegata solo se c'è qualcosa da guardare, e passa dal cap
2838
+ // a 1568px come ogni altro screenshot: un diff a 2560x1440 DPR2 sono 14,7 MP
2839
+ // spediti contro 1,38 MP che sopravvivono al ridimensionamento lato client.
2840
+ return {
2841
+ match: changed === 0,
2842
+ diff_percent: Math.round(diffPercent * 100) / 100,
2843
+ changed_pixels: changed,
2844
+ total_pixels: totalPixels,
2845
+ ...(changed === 0 ? {} : { diff_image: await canvasToBase64Capped(diffCanvas) }),
2846
+ };
2847
+ }
2848
+
2849
+ throw new Error(`Unknown action: ${action}`);
2850
+ }
2851
+
2852
+ // --- web_vitals ---
2853
+
2854
+ async function cmdWebVitals({ tab_id }) {
2855
+ const tabId = await resolveTabId(tab_id);
2856
+ const results = await chrome.scripting.executeScript({
2857
+ target: { tabId },
2858
+ func: () => {
2859
+ const v = window.__chromeBridge_vitals;
2860
+ if (!v) return { available: false, note: 'Instrumentation not loaded (page opened before extension, or chrome:// page)' };
2861
+ const nav = performance.getEntriesByType('navigation')[0];
2862
+ const paint = {};
2863
+ for (const p of performance.getEntriesByType('paint')) paint[p.name] = Math.round(p.startTime);
2864
+ return {
2865
+ available: true,
2866
+ cls: Math.round(v.cls * 1000) / 1000,
2867
+ lcp_ms: v.lcp,
2868
+ fcp_ms: paint['first-contentful-paint'] ?? null,
2869
+ ttfb_ms: nav ? Math.round(nav.responseStart - nav.requestStart) : null,
2870
+ long_tasks: v.longTasks,
2871
+ max_event_duration_ms: v.maxEventDelayMs,
2872
+ };
2873
+ },
2874
+ world: 'MAIN',
2875
+ });
2876
+ return results?.[0]?.result ?? { available: false };
2877
+ }
2878
+
2879
+ // --- list_event_listeners ---
2880
+
2881
+ async function cmdListEventListeners({ type, limit = 100, tab_id }) {
2882
+ const tabId = await resolveTabId(tab_id);
2883
+ const results = await chrome.scripting.executeScript({
2884
+ target: { tabId },
2885
+ func: (filterType, lim) => {
2886
+ const all = window.__chromeBridge_listeners || [];
2887
+ const filtered = filterType ? all.filter((l) => l.type === filterType) : all;
2888
+ const byType = {};
2889
+ for (const l of filtered) byType[l.type] = (byType[l.type] || 0) + 1;
2890
+ return {
2891
+ total: filtered.length,
2892
+ by_type: byType,
2893
+ listeners: filtered.slice(-lim),
2894
+ note: window.__chromeBridge_listeners ? undefined : 'Instrumentation not loaded for this page',
2895
+ };
2896
+ },
2897
+ args: [type ?? null, limit],
2898
+ world: 'MAIN',
2899
+ });
2900
+ return results?.[0]?.result ?? { total: 0, by_type: {}, listeners: [] };
2901
+ }
2902
+
2903
+ // --- monitor_websocket (hook lazy, come ensureNetworkHook) ---
2904
+
2905
+ // Always-inject idempotente, come ensureNetworkHook: il guard è __chromeBridge_wsHooked.
2906
+ async function ensureWsHook(tabId) {
2907
+ await chrome.scripting.executeScript({
2908
+ target: { tabId },
2909
+ func: () => {
2910
+ if (window.__chromeBridge_wsHooked) return;
2911
+ window.__chromeBridge_wsHooked = true;
2912
+ window.__chromeBridge_wsLog = [];
2913
+ const MAX = 500;
2914
+ const PREVIEW = 500;
2915
+ const push = (entry) => {
2916
+ if (window.__chromeBridge_wsLog.length >= MAX) window.__chromeBridge_wsLog.shift();
2917
+ window.__chromeBridge_wsLog.push(entry);
2918
+ };
2919
+ const preview = (data) => {
2920
+ try {
2921
+ if (typeof data === 'string') return data.substring(0, PREVIEW);
2922
+ return `<binary ${data?.byteLength ?? data?.size ?? '?'} bytes>`;
2923
+ } catch { return '<unreadable>'; }
2924
+ };
2925
+ const OrigWS = window.WebSocket;
2926
+ window.WebSocket = function (url, protocols) {
2927
+ const ws = protocols === undefined ? new OrigWS(url) : new OrigWS(url, protocols);
2928
+ push({ event: 'open_attempt', url: String(url), timestamp: Date.now() });
2929
+ ws.addEventListener('open', () => push({ event: 'open', url: String(url), timestamp: Date.now() }));
2930
+ ws.addEventListener('close', (e) => push({ event: 'close', url: String(url), code: e.code, timestamp: Date.now() }));
2931
+ ws.addEventListener('error', () => push({ event: 'error', url: String(url), timestamp: Date.now() }));
2932
+ ws.addEventListener('message', (e) => push({ event: 'message', direction: 'in', url: String(url), data: preview(e.data), timestamp: Date.now() }));
2933
+ const origSend = ws.send.bind(ws);
2934
+ ws.send = (data) => {
2935
+ push({ event: 'message', direction: 'out', url: String(url), data: preview(data), timestamp: Date.now() });
2936
+ return origSend(data);
2937
+ };
2938
+ return ws;
2939
+ };
2940
+ window.WebSocket.prototype = OrigWS.prototype;
2941
+ Object.setPrototypeOf(window.WebSocket, OrigWS);
2942
+ for (const k of ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED']) window.WebSocket[k] = OrigWS[k];
2943
+ },
2944
+ world: 'MAIN',
2945
+ });
2946
+ }
2947
+
2948
+ async function cmdMonitorWebsocket({ clear = false, tab_id }) {
2949
+ const tabId = await resolveTabId(tab_id);
2950
+ await ensureWsHook(tabId);
2951
+ const results = await chrome.scripting.executeScript({
2952
+ target: { tabId },
2953
+ func: (shouldClear) => {
2954
+ const log = window.__chromeBridge_wsLog || [];
2955
+ const out = [...log];
2956
+ if (shouldClear) window.__chromeBridge_wsLog = [];
2957
+ return out;
2958
+ },
2959
+ args: [clear],
2960
+ world: 'MAIN',
2961
+ });
2962
+ const events = results?.[0]?.result ?? [];
2963
+ return { count: events.length, events };
2964
+ }
2965
+
2966
+ // --- seo_audit ---
2967
+
2968
+ async function cmdSeoAudit({ tab_id }) {
2969
+ const tabId = await resolveTabId(tab_id);
2970
+ const results = await chrome.scripting.executeScript({
2971
+ target: { tabId },
2972
+ func: () => {
2973
+ const findings = [];
2974
+ const add = (type, severity, message, value) => {
2975
+ const f = { type, severity, message };
2976
+ if (value !== undefined) f.value = value;
2977
+ findings.push(f);
2978
+ };
2979
+
2980
+ // Title
2981
+ const title = (document.title || '').trim();
2982
+ if (!title) add('title', 'error', 'Missing <title>');
2983
+ else if (title.length < 15) add('title', 'warning', 'Title too short', title.length);
2984
+ else if (title.length > 60) add('title', 'warning', 'Title too long', title.length);
2985
+
2986
+ // Meta description
2987
+ const descEl = document.querySelector('meta[name="description"]');
2988
+ const desc = descEl ? (descEl.getAttribute('content') || '').trim() : '';
2989
+ if (!desc) add('meta_description', 'error', 'Missing meta description');
2990
+ else if (desc.length < 50) add('meta_description', 'warning', 'Meta description too short', desc.length);
2991
+ else if (desc.length > 160) add('meta_description', 'warning', 'Meta description too long', desc.length);
2992
+
2993
+ // Canonical
2994
+ const canonical = document.querySelector('link[rel="canonical"]');
2995
+ if (!canonical) {
2996
+ add('canonical', 'warning', 'Missing canonical link');
2997
+ } else {
2998
+ add('canonical', 'info', 'Canonical present', canonical.href);
2999
+ const stripHash = (u) => u.split('#')[0];
3000
+ if (canonical.href && stripHash(canonical.href) !== stripHash(location.href)) {
3001
+ add('canonical', 'info', 'Canonical differs from current URL', canonical.href);
3002
+ }
3003
+ }
3004
+
3005
+ // Robots meta
3006
+ const robots = document.querySelector('meta[name="robots"]');
3007
+ if (!robots) {
3008
+ add('robots', 'info', 'No robots meta (default: index,follow)');
3009
+ } else {
3010
+ const content = robots.getAttribute('content') || '';
3011
+ if (content.toLowerCase().includes('noindex')) add('robots', 'warning', 'Page is noindex', content);
3012
+ }
3013
+
3014
+ // h1 count
3015
+ const h1Count = document.querySelectorAll('h1').length;
3016
+ if (h1Count === 0) add('h1', 'error', 'No h1 on page');
3017
+ else if (h1Count > 1) add('h1', 'warning', 'Multiple h1 elements', h1Count);
3018
+
3019
+ // Open Graph
3020
+ for (const prop of ['og:title', 'og:description', 'og:image']) {
3021
+ if (!document.querySelector(`meta[property="${prop}"]`)) {
3022
+ add('open_graph', 'warning', `Missing ${prop}`);
3023
+ }
3024
+ }
3025
+
3026
+ // Twitter card
3027
+ if (!document.querySelector('meta[name="twitter:card"]')) {
3028
+ add('twitter_card', 'info', 'Missing twitter:card meta');
3029
+ }
3030
+
3031
+ // JSON-LD
3032
+ const ldScripts = [...document.querySelectorAll('script[type="application/ld+json"]')];
3033
+ ldScripts.forEach((script, i) => {
3034
+ try {
3035
+ const data = JSON.parse(script.textContent);
3036
+ const types = [];
3037
+ const collect = (node) => {
3038
+ if (!node || typeof node !== 'object') return;
3039
+ if (Array.isArray(node)) { node.forEach(collect); return; }
3040
+ if (node['@type']) types.push(...[].concat(node['@type']));
3041
+ if (node['@graph']) collect(node['@graph']);
3042
+ };
3043
+ collect(data);
3044
+ add('json_ld', 'info', `JSON-LD block ${i + 1}/${ldScripts.length} is valid`, types.length ? types.join(', ') : 'no @type');
3045
+ } catch (e) {
3046
+ add('json_ld', 'error', `JSON-LD block ${i + 1}/${ldScripts.length} parse error: ${e.message}`);
3047
+ }
3048
+ });
3049
+
3050
+ // hreflang
3051
+ const hreflangCount = document.querySelectorAll('link[rel="alternate"][hreflang]').length;
3052
+ if (hreflangCount > 0) add('hreflang', 'info', 'hreflang alternates present', hreflangCount);
3053
+
3054
+ // lang attribute
3055
+ if (!document.documentElement.getAttribute('lang')) {
3056
+ add('lang', 'warning', 'Missing lang attribute on <html>');
3057
+ }
3058
+
3059
+ // Viewport meta
3060
+ if (!document.querySelector('meta[name="viewport"]')) {
3061
+ add('viewport', 'error', 'No viewport meta (not mobile-friendly)');
3062
+ }
3063
+
3064
+ // Favicon
3065
+ if (!document.querySelector('link[rel*="icon"]')) {
3066
+ add('favicon', 'info', 'No favicon link found');
3067
+ }
3068
+
3069
+ // Images without alt
3070
+ const noAltCount = [...document.querySelectorAll('img')].filter((img) => !img.hasAttribute('alt')).length;
3071
+ if (noAltCount > 0) {
3072
+ add('images', 'warning', `${noAltCount} images without alt attribute (accessibility_audit has details)`, noAltCount);
3073
+ }
3074
+
3075
+ const errors = findings.filter((f) => f.severity === 'error').length;
3076
+ const warnings = findings.filter((f) => f.severity === 'warning').length;
3077
+ const info = findings.filter((f) => f.severity === 'info').length;
3078
+ return {
3079
+ summary: { errors, warnings, info },
3080
+ findings,
3081
+ page: { title, url: location.href },
3082
+ };
3083
+ },
3084
+ world: 'MAIN',
3085
+ });
3086
+ return results?.[0]?.result ?? { summary: { errors: 0, warnings: 0, info: 0 }, findings: [], page: {} };
3087
+ }
3088
+
3089
+ // --- extract_table ---
3090
+
3091
+ async function cmdExtractTable({ selector = 'table', index = 0, scan_rows, max_rows, tab_id }) {
3092
+ // scan_rows = quante righe materializzare in pagina (il filtro/paginazione
3093
+ // avviene lato server). Fallback a max_rows per compatibilità con vecchi client.
3094
+ const cap = scan_rows ?? max_rows ?? 2000;
3095
+ const tabId = await resolveTabId(tab_id);
3096
+ const results = await chrome.scripting.executeScript({
3097
+ target: { tabId },
3098
+ func: (sel, idx, maxRows) => {
3099
+ const tables = document.querySelectorAll(sel);
3100
+ const table = tables[idx];
3101
+ if (!table) {
3102
+ throw new Error(`No table found for selector "${sel}" at index ${idx} (${tables.length} found)`);
3103
+ }
3104
+
3105
+ const cellText = (cell) => (cell.textContent || '').trim();
3106
+
3107
+ // Headers: thead th, or first row's th/td as fallback
3108
+ let headers = [];
3109
+ let bodyRows;
3110
+ const thead = table.querySelector('thead');
3111
+ if (thead && thead.rows.length > 0) {
3112
+ headers = [...thead.rows[0].cells].map(cellText);
3113
+ bodyRows = table.tBodies.length
3114
+ ? [].concat(...[...table.tBodies].map((tb) => [...tb.rows]))
3115
+ : [...table.rows].filter((r) => !thead.contains(r));
3116
+ } else {
3117
+ const allRows = [...table.rows];
3118
+ if (allRows.length > 0 && [...allRows[0].cells].some((c) => c.tagName === 'TH')) {
3119
+ headers = [...allRows[0].cells].map(cellText);
3120
+ bodyRows = allRows.slice(1);
3121
+ } else {
3122
+ bodyRows = allRows;
3123
+ }
3124
+ }
3125
+
3126
+ const rowCount = bodyRows.length;
3127
+ const capped = bodyRows.slice(0, maxRows);
3128
+ const headersUsable = headers.length > 0
3129
+ && headers.every((h) => h !== '')
3130
+ && new Set(headers).size === headers.length;
3131
+
3132
+ const rows = capped.map((tr) => {
3133
+ const cells = [...tr.cells].map(cellText);
3134
+ if (!headersUsable) return cells;
3135
+ const obj = {};
3136
+ headers.forEach((h, i) => { obj[h] = cells[i] ?? ''; });
3137
+ return obj;
3138
+ });
3139
+
3140
+ return {
3141
+ caption: table.caption ? cellText(table.caption) : null,
3142
+ headers,
3143
+ row_count: rowCount,
3144
+ rows,
3145
+ truncated: rowCount > maxRows,
3146
+ tables_found: tables.length,
3147
+ };
3148
+ },
3149
+ args: [selector, index, cap],
3150
+ world: 'MAIN',
3151
+ });
3152
+ return results?.[0]?.result ?? { headers: [], rows: [], row_count: 0, truncated: false, tables_found: 0 };
3153
+ }
3154
+
3155
+ // --- unused_css ---
3156
+
3157
+ async function cmdUnusedCss({ max_selectors = 200, tab_id }) {
3158
+ const tabId = await resolveTabId(tab_id);
3159
+ const results = await chrome.scripting.executeScript({
3160
+ target: { tabId },
3161
+ func: (maxSelectors) => {
3162
+ const sheets = [];
3163
+ const unusedSelectors = [];
3164
+ let totalUnused = 0;
3165
+ let totalChecked = 0;
3166
+ let totalSkipped = 0;
3167
+
3168
+ for (const sheet of document.styleSheets) {
3169
+ const sheetInfo = { href: sheet.href || 'inline', accessible: true, total_rules: 0, unused_count: 0 };
3170
+ let rules;
3171
+ try {
3172
+ rules = sheet.cssRules;
3173
+ } catch {
3174
+ sheetInfo.accessible = false;
3175
+ sheets.push(sheetInfo);
3176
+ continue;
3177
+ }
3178
+
3179
+ // Flatten media/supports rules one level
3180
+ const styleRules = [];
3181
+ for (const rule of rules) {
3182
+ if (rule instanceof CSSStyleRule) {
3183
+ styleRules.push(rule);
3184
+ } else if ((rule instanceof CSSMediaRule || rule instanceof CSSSupportsRule) && rule.cssRules) {
3185
+ for (const inner of rule.cssRules) {
3186
+ if (inner instanceof CSSStyleRule) styleRules.push(inner);
3187
+ }
3188
+ }
3189
+ }
3190
+ sheetInfo.total_rules = styleRules.length;
3191
+
3192
+ for (const rule of styleRules) {
3193
+ const selectors = (rule.selectorText || '').split(',');
3194
+ for (const rawSel of selectors) {
3195
+ const cleaned = rawSel.replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim();
3196
+ if (!cleaned || /^[>+~\s]*$/.test(cleaned)) continue;
3197
+ totalChecked++;
3198
+ let matched;
3199
+ try {
3200
+ matched = document.querySelector(cleaned) !== null;
3201
+ } catch {
3202
+ totalSkipped++;
3203
+ continue;
3204
+ }
3205
+ if (!matched) {
3206
+ totalUnused++;
3207
+ sheetInfo.unused_count++;
3208
+ if (unusedSelectors.length < maxSelectors) unusedSelectors.push(rawSel.trim());
3209
+ }
3210
+ }
3211
+ }
3212
+ sheets.push(sheetInfo);
3213
+ }
3214
+
3215
+ return {
3216
+ sheets,
3217
+ unused_selectors: unusedSelectors,
3218
+ total_unused: totalUnused,
3219
+ total_checked: totalChecked,
3220
+ skipped: totalSkipped,
3221
+ note: 'Approximate: only current DOM state; dynamic/JS-toggled selectors may be falsely reported',
3222
+ };
3223
+ },
3224
+ args: [max_selectors],
3225
+ world: 'MAIN',
3226
+ });
3227
+ return results?.[0]?.result ?? { sheets: [], unused_selectors: [], total_unused: 0, total_checked: 0 };
3228
+ }
3229
+
3230
+ // --- drag_and_drop ---
3231
+
3232
+ async function cmdDragAndDrop({ source_selector, target_selector, mode = 'html5', tab_id, frame_id }) {
3233
+ if (!source_selector) throw new Error('Missing required parameter: source_selector');
3234
+ if (!target_selector) throw new Error('Missing required parameter: target_selector');
3235
+ const tabId = await resolveTabId(tab_id);
3236
+ const results = await chrome.scripting.executeScript({
3237
+ target: scriptTarget(tabId, frame_id),
3238
+ func: async (srcSel, tgtSel, dndMode) => {
3239
+ const src = document.querySelector(srcSel);
3240
+ if (!src) throw new Error(`Source element not found: ${srcSel}`);
3241
+ const tgt = document.querySelector(tgtSel);
3242
+ if (!tgt) throw new Error(`Target element not found: ${tgtSel}`);
3243
+ src.scrollIntoView({ block: 'center', behavior: 'instant' });
3244
+ const sr = src.getBoundingClientRect();
3245
+ const tr = tgt.getBoundingClientRect();
3246
+ const sc = { x: sr.left + sr.width / 2, y: sr.top + sr.height / 2 };
3247
+ const tc = { x: tr.left + tr.width / 2, y: tr.top + tr.height / 2 };
3248
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
3249
+
3250
+ if (dndMode === 'html5') {
3251
+ const dt = new DataTransfer();
3252
+ const fire = (el, type, x, y) => el.dispatchEvent(new DragEvent(type, {
3253
+ bubbles: true, cancelable: true, clientX: x, clientY: y, dataTransfer: dt,
3254
+ }));
3255
+ fire(src, 'dragstart', sc.x, sc.y);
3256
+ await sleep(50);
3257
+ fire(tgt, 'dragenter', tc.x, tc.y);
3258
+ fire(tgt, 'dragover', tc.x, tc.y);
3259
+ await sleep(50);
3260
+ fire(tgt, 'drop', tc.x, tc.y);
3261
+ fire(src, 'dragend', tc.x, tc.y);
3262
+ } else {
3263
+ // pointer mode: per librerie sortable basate su pointer/mouse events
3264
+ const fire = (el, type, x, y, Ctor = PointerEvent) => el.dispatchEvent(new Ctor(type, {
3265
+ bubbles: true, cancelable: true, clientX: x, clientY: y, button: 0, buttons: type.includes('up') ? 0 : 1, pointerId: 1, isPrimary: true,
3266
+ }));
3267
+ fire(src, 'pointerdown', sc.x, sc.y);
3268
+ fire(src, 'mousedown', sc.x, sc.y, MouseEvent);
3269
+ await sleep(50);
3270
+ // movimento intermedio
3271
+ const mid = { x: (sc.x + tc.x) / 2, y: (sc.y + tc.y) / 2 };
3272
+ for (const p of [mid, tc]) {
3273
+ fire(document, 'pointermove', p.x, p.y);
3274
+ fire(document, 'mousemove', p.x, p.y, MouseEvent);
3275
+ await sleep(50);
3276
+ }
3277
+ fire(tgt, 'pointerup', tc.x, tc.y);
3278
+ fire(tgt, 'mouseup', tc.x, tc.y, MouseEvent);
3279
+ }
3280
+ return { dragged: srcSel, dropped_on: tgtSel, mode: dndMode };
3281
+ },
3282
+ args: [source_selector, target_selector, mode],
3283
+ world: 'MAIN',
3284
+ });
3285
+ return results?.[0]?.result ?? { dragged: false };
3286
+ }
3287
+
3288
+ // --- clipboard ---
3289
+
3290
+ async function cmdClipboard({ action, text, tab_id }) {
3291
+ if (!action) throw new Error('Missing required parameter: action');
3292
+ const tabId = await resolveTabId(tab_id);
3293
+ // La pagina deve essere focalizzata per navigator.clipboard
3294
+ const tab = await chrome.tabs.get(tabId);
3295
+ await chrome.tabs.update(tabId, { active: true });
3296
+ await chrome.windows.update(tab.windowId, { focused: true });
3297
+
3298
+ const results = await chrome.scripting.executeScript({
3299
+ target: { tabId },
3300
+ // World ISOLATED di proposito: le permission clipboardRead/clipboardWrite
3301
+ // dell'estensione si applicano al suo isolated world, rendendo execCommand
3302
+ // funzionante senza user gesture.
3303
+ func: async (act, txt) => {
3304
+ if (act === 'write') {
3305
+ if (txt === undefined || txt === null) throw new Error('Missing required parameter: text');
3306
+ try {
3307
+ await navigator.clipboard.writeText(txt);
3308
+ return { written: true, length: txt.length, method: 'clipboard-api' };
3309
+ } catch (e) {
3310
+ // Fallback execCommand
3311
+ const ta = document.createElement('textarea');
3312
+ ta.value = txt;
3313
+ ta.style.cssText = 'position:fixed;opacity:0;';
3314
+ document.body.appendChild(ta);
3315
+ ta.select();
3316
+ const ok = document.execCommand('copy');
3317
+ ta.remove();
3318
+ if (!ok) throw new Error(`Clipboard write failed: ${e.message}`);
3319
+ return { written: true, length: txt.length, method: 'execCommand' };
3320
+ }
3321
+ }
3322
+ if (act === 'read') {
3323
+ try {
3324
+ const value = await navigator.clipboard.readText();
3325
+ return { text: value, method: 'clipboard-api' };
3326
+ } catch (e) {
3327
+ // Nessun fallback: execCommand('paste') è stato rimosso da Chrome 86+.
3328
+ throw new Error(`Clipboard read failed: ${e.message} (page may need focus)`);
3329
+ }
3330
+ }
3331
+ throw new Error(`Unknown action: ${act}`);
3332
+ },
3333
+ args: [action, text ?? null],
3334
+ world: 'ISOLATED',
3335
+ });
3336
+ return results?.[0]?.result ?? {};
3337
+ }
3338
+
3339
+ // --- set_geolocation ---
3340
+
3341
+ async function cmdSetGeolocation({ latitude, longitude, accuracy = 10, reset = false, tab_id }) {
3342
+ const tabId = await resolveTabId(tab_id);
3343
+ const results = await chrome.scripting.executeScript({
3344
+ target: { tabId },
3345
+ func: (lat, lng, acc, doReset) => {
3346
+ if (doReset) {
3347
+ if (window.__chromeBridge_origGeo) {
3348
+ navigator.geolocation.getCurrentPosition = window.__chromeBridge_origGeo.getCurrentPosition;
3349
+ navigator.geolocation.watchPosition = window.__chromeBridge_origGeo.watchPosition;
3350
+ delete window.__chromeBridge_origGeo;
3351
+ }
3352
+ return { reset: true };
3353
+ }
3354
+ if (lat === null || lng === null) throw new Error('Missing required parameters: latitude, longitude');
3355
+ if (!window.__chromeBridge_origGeo) {
3356
+ window.__chromeBridge_origGeo = {
3357
+ getCurrentPosition: navigator.geolocation.getCurrentPosition.bind(navigator.geolocation),
3358
+ watchPosition: navigator.geolocation.watchPosition.bind(navigator.geolocation),
3359
+ };
3360
+ }
3361
+ const makePosition = () => ({
3362
+ coords: {
3363
+ latitude: lat, longitude: lng, accuracy: acc,
3364
+ altitude: null, altitudeAccuracy: null, heading: null, speed: null,
3365
+ },
3366
+ timestamp: Date.now(),
3367
+ });
3368
+ navigator.geolocation.getCurrentPosition = (success) => {
3369
+ setTimeout(() => success(makePosition()), 10);
3370
+ };
3371
+ navigator.geolocation.watchPosition = (success) => {
3372
+ setTimeout(() => success(makePosition()), 10);
3373
+ return Math.floor(Math.random() * 1000000);
3374
+ };
3375
+ return { emulated: { latitude: lat, longitude: lng, accuracy: acc } };
3376
+ },
3377
+ args: [latitude ?? null, longitude ?? null, accuracy, reset],
3378
+ world: 'MAIN',
3379
+ });
3380
+ return results?.[0]?.result ?? {};
3381
+ }
3382
+
3383
+ // --- manage_downloads ---
3384
+
3385
+ async function cmdManageDownloads({ action, timeout = 30000, limit = 10 }) {
3386
+ if (!action) throw new Error('Missing required parameter: action');
3387
+
3388
+ if (action === 'list') {
3389
+ const items = await chrome.downloads.search({ orderBy: ['-startTime'], limit });
3390
+ return {
3391
+ downloads: items.map((d) => ({
3392
+ id: d.id, url: d.url, filename: d.filename, state: d.state,
3393
+ bytesReceived: d.bytesReceived, totalBytes: d.totalBytes,
3394
+ startTime: d.startTime, error: d.error || null,
3395
+ })),
3396
+ };
3397
+ }
3398
+
3399
+ if (action === 'wait_for_complete') {
3400
+ const started = Date.now();
3401
+ let trackedId = null;
3402
+
3403
+ while (Date.now() - started < timeout) {
3404
+ const items = await chrome.downloads.search({ orderBy: ['-startTime'], limit: 5 });
3405
+
3406
+ if (trackedId !== null) {
3407
+ const tracked = items.find((d) => d.id === trackedId);
3408
+ if (tracked) {
3409
+ if (tracked.state === 'complete') {
3410
+ return { completed: true, id: tracked.id, filename: tracked.filename, url: tracked.url, totalBytes: tracked.totalBytes };
3411
+ }
3412
+ if (tracked.state === 'interrupted') {
3413
+ return { completed: false, error: tracked.error, filename: tracked.filename };
3414
+ }
3415
+ await new Promise((r) => setTimeout(r, 250));
3416
+ continue;
3417
+ }
3418
+ trackedId = null; // il download tracciato è sparito dai risultati
3419
+ }
3420
+
3421
+ const inProgress = items.find((d) => d.state === 'in_progress');
3422
+ if (inProgress) {
3423
+ trackedId = inProgress.id;
3424
+ await new Promise((r) => setTimeout(r, 250));
3425
+ continue;
3426
+ }
3427
+
3428
+ const recent = items.find((d) => d.state === 'complete' && new Date(d.startTime).getTime() >= started - 5000);
3429
+ if (recent) {
3430
+ return { completed: true, id: recent.id, filename: recent.filename, url: recent.url, totalBytes: recent.totalBytes };
3431
+ }
3432
+
3433
+ await new Promise((r) => setTimeout(r, 250));
3434
+ }
3435
+ return { completed: false, error: 'timeout', note: `No download completed within ${timeout}ms` };
3436
+ }
3437
+
3438
+ throw new Error(`Unknown action: ${action}`);
3439
+ }
3440
+
3441
+ // --- save_page (MHTML via pageCapture) ---
3442
+
3443
+ async function cmdSavePage({ tab_id }) {
3444
+ const tabId = await resolveTabId(tab_id);
3445
+ const blob = await new Promise((resolve, reject) => {
3446
+ chrome.pageCapture.saveAsMHTML({ tabId }, (data) => {
3447
+ if (chrome.runtime.lastError) reject(new Error(chrome.runtime.lastError.message));
3448
+ else resolve(data);
3449
+ });
3450
+ });
3451
+ return { mhtml_b64: await blobToBase64(blob), size: blob.size };
3452
+ }
3453
+
3454
+ // --- set_zoom ---
3455
+
3456
+ async function cmdSetZoom({ factor, reset = false, tab_id }) {
3457
+ const tabId = await resolveTabId(tab_id);
3458
+ if (reset) {
3459
+ await chrome.tabs.setZoom(tabId, 0); // 0 = default zoom
3460
+ } else if (factor !== undefined && factor !== null) {
3461
+ if (factor < 0.25 || factor > 5) throw new Error('factor must be between 0.25 and 5');
3462
+ await chrome.tabs.setZoom(tabId, factor);
3463
+ }
3464
+ const current = await chrome.tabs.getZoom(tabId);
3465
+ return { zoom: current };
3466
+ }
3467
+
3468
+ // --- Browser-level network log (webRequest) ---
3469
+ const browserNetLog = new Map(); // tabId → array
3470
+
3471
+ function pushNetEntry(tabId, entry) {
3472
+ if (tabId < 0) return;
3473
+ let arr = browserNetLog.get(tabId);
3474
+ if (!arr) { arr = []; browserNetLog.set(tabId, arr); }
3475
+ if (arr.length >= 500) arr.shift();
3476
+ arr.push(entry);
3477
+ }
3478
+
3479
+ chrome.webRequest.onCompleted.addListener((d) => {
3480
+ pushNetEntry(d.tabId, { source: 'browser', type: d.type, method: d.method, url: d.url, status: d.statusCode, startTime: d.timeStamp, duration: null, fromCache: d.fromCache, ip: d.ip || null });
3481
+ }, { urls: ['<all_urls>'] });
3482
+
3483
+ chrome.webRequest.onErrorOccurred.addListener((d) => {
3484
+ pushNetEntry(d.tabId, { source: 'browser', type: d.type, method: d.method, url: d.url, status: 0, startTime: d.timeStamp, duration: null, error: d.error });
3485
+ }, { urls: ['<all_urls>'] });
3486
+
3487
+ // --- Cattura header risposta main_frame (per security_headers) ---
3488
+ const mainFrameHeaders = new Map(); // tabId → { url, status, headers: {name(lc): value}, capturedAt }
3489
+
3490
+ // Limite: header duplicati (es. CSP multiple) vengono sovrascritti — vince l'ultimo, solo quello viene auditato.
3491
+ chrome.webRequest.onHeadersReceived.addListener((d) => {
3492
+ if (d.tabId < 0 || d.type !== 'main_frame') return;
3493
+ const headers = {};
3494
+ for (const h of d.responseHeaders || []) {
3495
+ headers[h.name.toLowerCase()] = h.value ?? '';
3496
+ }
3497
+ mainFrameHeaders.set(d.tabId, { url: d.url, status: d.statusCode, headers, capturedAt: Date.now() });
3498
+ }, { urls: ['<all_urls>'], types: ['main_frame'] }, ['responseHeaders']);
3499
+
3500
+ async function cmdGetResponseHeaders({ tab_id }) {
3501
+ const tabId = await resolveTabId(tab_id);
3502
+ const entry = mainFrameHeaders.get(tabId);
3503
+ if (!entry) {
3504
+ return { available: false, note: 'No headers captured for this tab — reload the page (headers are captured from navigations after the extension loaded)' };
3505
+ }
3506
+ return { available: true, ...entry };
3507
+ }
3508
+
3509
+ // --- http_auth (credenziali per HTTP Basic/Digest auth) ---
3510
+
3511
+ let httpAuthCreds = null; // { username, password }
3512
+ const httpAuthAttempted = new Set(); // requestId già serviti (anti-loop)
3513
+
3514
+ chrome.webRequest.onAuthRequired.addListener(
3515
+ (details, callback) => {
3516
+ if (!httpAuthCreds || httpAuthAttempted.has(details.requestId)) {
3517
+ callback({});
3518
+ return;
3519
+ }
3520
+ httpAuthAttempted.add(details.requestId);
3521
+ if (httpAuthAttempted.size > 500) httpAuthAttempted.clear();
3522
+ callback({ authCredentials: { username: httpAuthCreds.username, password: httpAuthCreds.password } });
3523
+ },
3524
+ { urls: ['<all_urls>'] },
3525
+ ['asyncBlocking']
3526
+ );
3527
+
3528
+ async function cmdHttpAuth({ action, username, password }) {
3529
+ if (!action) throw new Error('Missing required parameter: action');
3530
+ if (action === 'set') {
3531
+ if (!username) throw new Error('Missing required parameter: username');
3532
+ httpAuthCreds = { username, password: password || '' };
3533
+ return { set: true, username };
3534
+ }
3535
+ if (action === 'clear') {
3536
+ httpAuthCreds = null;
3537
+ httpAuthAttempted.clear();
3538
+ return { cleared: true };
3539
+ }
3540
+ throw new Error(`Unknown action: ${action}`);
3541
+ }
3542
+
3543
+ // --- Tab lifecycle: cleanup injection state ---
3544
+
3545
+ chrome.tabs.onRemoved.addListener((tabId) => {
3546
+ browserNetLog.delete(tabId);
3547
+ mainFrameHeaders.delete(tabId);
3548
+ });
3549
+
3550
+ // --- get_interactives ---
3551
+
3552
+ async function cmdGetInteractives({ scope, limit = 100, visible_only = true, tab_id, frame_id }) {
3553
+ const tabId = await resolveTabId(tab_id);
3554
+ const results = await chrome.scripting.executeScript({
3555
+ target: scriptTarget(tabId, frame_id),
3556
+ func: (scopeSel, lim, visibleOnly) => {
3557
+ const root = scopeSel ? document.querySelector(scopeSel) : document;
3558
+ if (!root) return { count: 0, elements: [], note: 'Scope element not found' };
3559
+
3560
+ // Selettore stabile e riusabile: id > data-testid > path nth-of-type
3561
+ const stableSelector = (el) => {
3562
+ if (el.id && /^[A-Za-z][\w-]*$/.test(el.id)) return `#${el.id}`;
3563
+ const testid = el.getAttribute('data-testid');
3564
+ if (testid) return `[data-testid="${testid.replace(/"/g, '\\"')}"]`;
3565
+ const parts = [];
3566
+ let node = el;
3567
+ while (node && node.nodeType === 1 && node !== document.documentElement) {
3568
+ let part = node.tagName.toLowerCase();
3569
+ if (node.id && /^[A-Za-z][\w-]*$/.test(node.id)) { parts.unshift(`#${node.id}`); break; }
3570
+ const parent = node.parentElement;
3571
+ if (parent) {
3572
+ const sameTag = [...parent.children].filter((c) => c.tagName === node.tagName);
3573
+ if (sameTag.length > 1) part += `:nth-of-type(${sameTag.indexOf(node) + 1})`;
3574
+ }
3575
+ parts.unshift(part);
3576
+ node = node.parentElement;
3577
+ }
3578
+ return parts.join(' > ');
3579
+ };
3580
+
3581
+ const SEL = 'button, a[href], input:not([type=hidden]), select, textarea, [role=button], [role=link], [role=tab], [role=menuitem], [onclick], [tabindex]:not([tabindex="-1"]), summary';
3582
+ const seen = new Set();
3583
+ const out = [];
3584
+ for (const el of root.querySelectorAll(SEL)) {
3585
+ if (seen.has(el)) continue;
3586
+ seen.add(el);
3587
+ const rect = el.getBoundingClientRect();
3588
+ const style = getComputedStyle(el);
3589
+ const visible = rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none' && style.opacity !== '0';
3590
+ if (visibleOnly && !visible) continue;
3591
+ const cx = rect.left + rect.width / 2;
3592
+ const cy = rect.top + rect.height / 2;
3593
+ let occluded = false;
3594
+ if (visible && cx >= 0 && cy >= 0 && cx <= innerWidth && cy <= innerHeight) {
3595
+ const top = document.elementFromPoint(cx, cy);
3596
+ occluded = !!top && top !== el && !el.contains(top) && !top.contains(el);
3597
+ }
3598
+ const label = (el.getAttribute('aria-label') || el.value || el.textContent || el.getAttribute('title') || el.getAttribute('placeholder') || '').trim().replace(/\s+/g, ' ').substring(0, 80);
3599
+ out.push({
3600
+ selector: stableSelector(el),
3601
+ tag: el.tagName.toLowerCase(),
3602
+ type: el.getAttribute('type') || el.getAttribute('role') || null,
3603
+ text: label || null,
3604
+ enabled: !el.disabled,
3605
+ visible,
3606
+ occluded,
3607
+ rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) },
3608
+ });
3609
+ if (out.length >= lim) break;
3610
+ }
3611
+ return { count: out.length, elements: out };
3612
+ },
3613
+ args: [scope || null, limit, visible_only],
3614
+ world: 'MAIN',
3615
+ });
3616
+ return results?.[0]?.result ?? { count: 0, elements: [] };
3617
+ }
3618
+
3619
+ // --- wait_for_function ---
3620
+
3621
+ async function cmdWaitForFunction({ expression, timeout = 10000, polling_ms = 100, tab_id, frame_id }) {
3622
+ if (!expression) throw new Error('Missing required parameter: expression');
3623
+ const tabId = await resolveTabId(tab_id);
3624
+ const target = scriptTarget(tabId, frame_id);
3625
+ const intv = Math.max(polling_ms, 50);
3626
+ // Polling lato service worker: una micro-iniezione per tentativo,
3627
+ // robusto anche attraverso navigazioni della pagina.
3628
+ // L'espressione utente è interpolata nel codice user-script (niente eval).
3629
+ const probe = `(() => {
3630
+ try {
3631
+ const __v = (${expression});
3632
+ let s = __v;
3633
+ try { s = (typeof __v === 'object' && __v !== null) ? JSON.parse(JSON.stringify(__v)) : __v; } catch { s = String(__v); }
3634
+ return { truthy: !!__v, value: s ?? null };
3635
+ } catch (e) {
3636
+ return { truthy: false, value: { __error: e.message } };
3637
+ }
3638
+ })()`;
3639
+ const start = Date.now();
3640
+ let last = null;
3641
+ for (;;) {
3642
+ let r = null;
3643
+ try {
3644
+ r = await runUserScript(target, probe, 'MAIN');
3645
+ } catch (e) {
3646
+ r = { truthy: false, value: { __error: e.message } };
3647
+ }
3648
+ if (r && r.truthy) {
3649
+ return { satisfied: true, elapsed: Date.now() - start, value: r.value };
3650
+ }
3651
+ last = r ? r.value : null;
3652
+ if (Date.now() - start >= timeout) {
3653
+ return { satisfied: false, elapsed: Date.now() - start, lastValue: last };
3654
+ }
3655
+ await new Promise((res) => setTimeout(res, intv));
3656
+ }
3657
+ }
3658
+
3659
+ // --- scroll_until ---
3660
+
3661
+ async function cmdScrollUntil({ until = 'no_new_content', selector, max_scrolls = 20, step_px, settle_ms = 400, tab_id }) {
3662
+ const tabId = await resolveTabId(tab_id);
3663
+ if (until === 'network_idle') await ensureNetworkHook(tabId);
3664
+ const results = await chrome.scripting.executeScript({
3665
+ target: { tabId },
3666
+ func: (mode, sel, maxScrolls, stepPx, settleMs) => new Promise((resolve) => {
3667
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
3668
+ (async () => {
3669
+ let scrolls = 0;
3670
+ let lastHeight = document.documentElement.scrollHeight;
3671
+ let stableCount = 0;
3672
+ const step = stepPx || window.innerHeight;
3673
+ for (scrolls = 0; scrolls < maxScrolls; scrolls++) {
3674
+ if (mode === 'element' && sel) {
3675
+ const el = document.querySelector(sel);
3676
+ if (el) {
3677
+ const r = el.getBoundingClientRect();
3678
+ const vis = r.width > 0 && r.height > 0 && getComputedStyle(el).visibility !== 'hidden';
3679
+ if (vis) { resolve({ stopped_reason: 'element_found', scrolls, finalScrollY: Math.round(window.scrollY) }); return; }
3680
+ }
3681
+ }
3682
+ window.scrollBy(0, step);
3683
+ await sleep(settleMs);
3684
+
3685
+ if (mode === 'network_idle') {
3686
+ const inflight = window.__chromeBridge_inflight || 0;
3687
+ const last = window.__chromeBridge_lastNetActivity || Date.now();
3688
+ if (inflight === 0 && Date.now() - last >= settleMs) { resolve({ stopped_reason: 'network_idle', scrolls: scrolls + 1, finalScrollY: Math.round(window.scrollY) }); return; }
3689
+ }
3690
+
3691
+ const h = document.documentElement.scrollHeight;
3692
+ if (h === lastHeight) {
3693
+ stableCount++;
3694
+ if (mode === 'no_new_content' && stableCount >= 2) { resolve({ stopped_reason: 'no_new_content', scrolls: scrolls + 1, finalScrollY: Math.round(window.scrollY) }); return; }
3695
+ } else { stableCount = 0; lastHeight = h; }
3696
+
3697
+ // bottom reached
3698
+ if (window.innerHeight + window.scrollY >= h - 2) { resolve({ stopped_reason: 'bottom', scrolls: scrolls + 1, finalScrollY: Math.round(window.scrollY) }); return; }
3699
+ }
3700
+ resolve({ stopped_reason: 'max_scrolls', scrolls, finalScrollY: Math.round(window.scrollY) });
3701
+ })();
3702
+ }),
3703
+ args: [until, selector || null, max_scrolls, step_px || null, settle_ms],
3704
+ world: 'MAIN',
3705
+ });
3706
+ return results?.[0]?.result ?? { stopped_reason: 'error', scrolls: 0 };
3707
+ }
3708
+
3709
+ // --- Avvia la connessione + registra l'instrumentation ---
3710
+ loadConfig().then(connect);
3711
+ applyInstrumentation();