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.
- package/LICENSE +21 -0
- package/README.md +185 -0
- package/extension/console-capture.js +46 -0
- package/extension/icons/icon-128.png +0 -0
- package/extension/icons/icon-16.png +0 -0
- package/extension/icons/icon-48.png +0 -0
- package/extension/manifest.json +44 -0
- package/extension/page-instrumentation.js +115 -0
- package/extension/popup.css +128 -0
- package/extension/popup.html +64 -0
- package/extension/popup.js +142 -0
- package/extension/service-worker.js +3711 -0
- package/extension/stack-detect.js +54 -0
- package/extension/telemetry.js +29 -0
- package/install.sh +70 -0
- package/package.json +57 -0
- package/server/assertions.js +81 -0
- package/server/cli.js +380 -0
- package/server/formatters.js +35 -0
- package/server/har.js +22 -0
- package/server/index.js +107 -0
- package/server/launcher.js +97 -0
- package/server/link-checker.js +57 -0
- package/server/protocol.js +135 -0
- package/server/security-headers.js +68 -0
- package/server/stub-server.js +81 -0
- package/server/tools.js +1728 -0
- package/server/ws-manager.js +655 -0
- package/tools/measure-schema.mjs +108 -0
|
@@ -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();
|