1pub struct BridgeCapacities {
3 pub console_logs: usize,
5 pub mutation_log: usize,
7 pub network_log: usize,
9 pub navigation_log: usize,
11 pub dialog_log: usize,
13 pub long_tasks: usize,
15}
16
17impl Default for BridgeCapacities {
18 fn default() -> Self {
19 Self {
20 console_logs: 1000,
21 mutation_log: 500,
22 network_log: 1000,
23 navigation_log: 200,
24 dialog_log: 100,
25 long_tasks: 100,
26 }
27 }
28}
29
30#[must_use]
32pub fn init_script(caps: &BridgeCapacities) -> String {
33 format!(
34 "\n(function() {{\
35 \n if (window.__VICTAURI__) return;\
36 \n\
37 \n var CAP_CONSOLE = {console_logs};\
38 \n var CAP_MUTATION = {mutation_log};\
39 \n var CAP_NETWORK = {network_log};\
40 \n var CAP_NAVIGATION = {navigation_log};\
41 \n var CAP_DIALOG = {dialog_log};\
42 \n var CAP_LONG_TASKS = {long_tasks};\
43 \n",
44 console_logs = caps.console_logs,
45 mutation_log = caps.mutation_log,
46 network_log = caps.network_log,
47 navigation_log = caps.navigation_log,
48 dialog_log = caps.dialog_log,
49 long_tasks = caps.long_tasks,
50 ) + INIT_SCRIPT_BODY
51}
52
53const INIT_SCRIPT_BODY: &str = r#"
56 var refMap = new Map();
57 var refCounter = 0;
58 var weakRefMap = new Map();
59
60 function resolveRef(refId) {
61 var direct = refMap.get(refId);
62 if (direct) {
63 if (direct.isConnected) return direct;
64 refMap.delete(refId);
65 return null;
66 }
67 var weak = weakRefMap.get(refId);
68 if (weak) {
69 var el = weak.deref();
70 if (el && el.isConnected) return el;
71 weakRefMap.delete(refId);
72 return null;
73 }
74 return null;
75 }
76
77 var REF_MAP_LIMIT = 10000;
78
79 function registerRef(node) {
80 var ref_id = 'e' + (refCounter++);
81 if (refMap.size >= REF_MAP_LIMIT) {
82 var oldest = refMap.keys().next().value;
83 refMap.delete(oldest);
84 weakRefMap.delete(oldest);
85 }
86 refMap.set(ref_id, node);
87 if (typeof WeakRef !== 'undefined') {
88 weakRefMap.set(ref_id, new WeakRef(node));
89 }
90 return ref_id;
91 }
92
93 function getStaleRefs() {
94 var stale = [];
95 weakRefMap.forEach(function(weak, refId) {
96 var el = weak.deref();
97 if (!el || !el.isConnected) {
98 stale.push(refId);
99 weakRefMap.delete(refId);
100 refMap.delete(refId);
101 }
102 });
103 return stale;
104 }
105 var consoleLogs = [];
106 var mutationLog = [];
107 var networkLog = [];
108 var networkCounter = 0;
109 var navigationLog = [];
110 var dialogLog = [];
111 var interactionLog = [];
112 var CAP_INTERACTION = 500;
113 var ipcWaiters = [];
114 var longTasks = [];
115 var listenerCount = 0;
116
117 // ── Network route rules (Phase 1: interception / mock / block / delay) ──
118 var routeRules = [];
119 var routeCounter = 0;
120 var routeMatchLog = [];
121 var CAP_ROUTE_MATCHES = 200;
122
123 // Convert a glob ("*" wildcard) to a RegExp. Other chars are escaped.
124 function globToRegExp(glob) {
125 var re = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
126 return new RegExp('^' + re + '$');
127 }
128
129 // Find the first active route rule matching url+method, or null.
130 // Never matches Victauri's own internal IPC traffic.
131 function matchRoute(url, method) {
132 if (!routeRules.length) return null;
133 if (url.indexOf('plugin%3Avictauri%7C') !== -1 || url.indexOf('plugin:victauri|') !== -1) {
134 return null;
135 }
136 var m = (method || 'GET').toUpperCase();
137 for (var i = 0; i < routeRules.length; i++) {
138 var r = routeRules[i];
139 if (r.times && r.triggered >= r.times) continue;
140 if (r.method && r.method.toUpperCase() !== m) continue;
141 var hit = false;
142 try {
143 if (r.match_type === 'exact') hit = (url === r.pattern);
144 else if (r.match_type === 'regex') hit = new RegExp(r.pattern).test(url);
145 else if (r.match_type === 'glob') hit = globToRegExp(r.pattern).test(url);
146 else hit = (url.indexOf(r.pattern) !== -1); // substring (default)
147 } catch (e) { hit = false; }
148 if (hit) return r;
149 }
150 return null;
151 }
152
153 function recordRouteMatch(rule, url, method) {
154 rule.triggered = (rule.triggered || 0) + 1;
155 routeMatchLog.push({
156 rule_id: rule.id, action: rule.action, url: url,
157 method: (method || 'GET').toUpperCase(), timestamp: Date.now(),
158 trigger_count: rule.triggered,
159 });
160 if (routeMatchLog.length > CAP_ROUTE_MATCHES) routeMatchLog.shift();
161 }
162
163 function checkActionable(el) {
164 if (!el || !el.isConnected) return { error: 'element is detached from DOM', hint: 'RETRY_LATER' };
165 if (el.disabled) return { error: 'element is disabled (disabled attribute)', hint: 'RETRY_LATER' };
166 if (el.getAttribute && el.getAttribute('aria-disabled') === 'true') return { error: 'element is disabled (aria-disabled)', hint: 'RETRY_LATER' };
167 // Use the element's OWN document/window so the viewport and occlusion
168 // (elementFromPoint) checks are correct for elements inside same-origin
169 // iframes — getBoundingClientRect() is relative to the element's own
170 // frame viewport, not the top document.
171 var doc = el.ownerDocument || document;
172 var win = doc.defaultView || window;
173 var cs = win.getComputedStyle(el);
174 if (cs.display === 'none') return { error: 'element is not visible (display: none)', hint: 'RETRY_LATER' };
175 if (cs.visibility === 'hidden') return { error: 'element is not visible (visibility: hidden)', hint: 'RETRY_LATER' };
176 if (parseFloat(cs.opacity) < 0.01) return { error: 'element is not visible (opacity: ' + cs.opacity + ')', hint: 'RETRY_LATER' };
177 var rect = el.getBoundingClientRect();
178 if (rect.width === 0 && rect.height === 0) return { error: 'element has zero size', hint: 'RETRY_LATER' };
179 if (cs.pointerEvents === 'none') return { error: 'element has pointer-events: none', hint: 'RETRY_LATER' };
180 var vw = win.innerWidth || doc.documentElement.clientWidth;
181 var vh = win.innerHeight || doc.documentElement.clientHeight;
182 if (rect.bottom < 0 || rect.top > vh || rect.right < 0 || rect.left > vw) {
183 el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
184 rect = el.getBoundingClientRect();
185 if (rect.bottom < 0 || rect.top > vh || rect.right < 0 || rect.left > vw) {
186 return { error: 'element is outside viewport after scroll attempt', hint: 'CHECK_INPUT' };
187 }
188 }
189 var cx = rect.left + rect.width / 2;
190 var cy = rect.top + rect.height / 2;
191 var topEl = doc.elementFromPoint(cx, cy);
192 if (topEl && topEl !== el && !el.contains(topEl) && !topEl.contains(el)) {
193 var tag = topEl.tagName ? topEl.tagName.toLowerCase() : 'unknown';
194 var info = tag;
195 if (topEl.id) info += '#' + topEl.id;
196 else if (topEl.className && typeof topEl.className === 'string') {
197 var cls = topEl.className.trim().split(/\s+/)[0];
198 if (cls) info += '.' + cls;
199 }
200 return { error: 'element is covered by ' + info + ' at (' + Math.round(cx) + ',' + Math.round(cy) + ')', hint: 'RETRY_LATER' };
201 }
202 return null;
203 }
204
205 function withAutoWait(refId, timeoutMs, actionFn) {
206 return new Promise(function(resolve) {
207 var deadline = Date.now() + (timeoutMs || 5000);
208 function attempt() {
209 var el = resolveRef(refId);
210 if (!el) {
211 if (Date.now() >= deadline) { resolve({ ok: false, error: 'ref not found: ' + refId, hint: 'CHECK_INPUT' }); return; }
212 setTimeout(attempt, 50); return;
213 }
214 var check = checkActionable(el);
215 if (check) {
216 if (check.hint === 'CHECK_INPUT' || Date.now() >= deadline) {
217 var msg = Date.now() >= deadline ? 'timeout (' + (timeoutMs || 5000) + 'ms): ' + check.error : check.error;
218 resolve({ ok: false, error: msg, hint: check.hint || 'RETRY_LATER' }); return;
219 }
220 setTimeout(attempt, 50); return;
221 }
222 try { var r = actionFn(el); resolve(r || { ok: true }); }
223 catch (e) { resolve({ ok: false, error: 'action threw: ' + e.message, hint: 'CHECK_INPUT' }); }
224 }
225 attempt();
226 });
227 }
228
229 // ── Public API ───────────────────────────────────────────────────────────
230
231 window.__VICTAURI__ = {
232 version: '0.7.0',
233 _captureIpcBodies: true,
234
235 // ── DOM ──────────────────────────────────────────────────────────────
236
237 snapshot: function(format) {
238 var previousRefs = new Set(refMap.keys());
239 refMap.clear();
240 var fmt = format || 'compact';
241 var tree;
242 if (fmt === 'json') {
243 tree = walkDom(document.body);
244 } else {
245 tree = walkDomCompact(document.body, 0);
246 }
247 var currentRefs = new Set(refMap.keys());
248 var stale = [];
249 previousRefs.forEach(function(refId) {
250 if (!currentRefs.has(refId)) {
251 var weak = weakRefMap.get(refId);
252 if (weak) {
253 var el = weak.deref();
254 if (!el || !el.isConnected) {
255 stale.push(refId);
256 weakRefMap.delete(refId);
257 }
258 } else {
259 stale.push(refId);
260 }
261 }
262 });
263 weakRefMap.forEach(function(weak, rid) {
264 if (!weak.deref()) {
265 weakRefMap.delete(rid);
266 stale.push(rid);
267 }
268 });
269 return { tree: tree, stale_refs: stale, format: fmt };
270 },
271
272 getRef: function(refId) {
273 return resolveRef(refId);
274 },
275
276 getStaleRefs: function() {
277 return getStaleRefs();
278 },
279
280 findElements: function(query) {
281 var results = [];
282 var maxResults = query.max_results || 10;
283
284 if (query.css) {
285 try { document.body.matches(query.css); } catch(e) {
286 return { error: 'invalid CSS selector: ' + query.css + ' — ' + e.message };
287 }
288 }
289
290 function matches(el) {
291 if (query.text) {
292 var txt = (el.textContent || '').trim();
293 if (query.exact) {
294 if (txt !== query.text) return false;
295 } else {
296 if (txt.toLowerCase().indexOf(query.text.toLowerCase()) === -1) return false;
297 }
298 }
299 if (query.role) {
300 var role = el.getAttribute('role') || inferRole(el);
301 if (role !== query.role) return false;
302 }
303 if (query.test_id) {
304 if (el.getAttribute('data-testid') !== query.test_id) return false;
305 }
306 if (query.css) {
307 if (!el.matches(query.css)) return false;
308 }
309 if (query.name) {
310 var name = el.getAttribute('aria-label')
311 || el.getAttribute('title')
312 || el.getAttribute('placeholder') || '';
313 if (name.toLowerCase().indexOf(query.name.toLowerCase()) === -1) return false;
314 }
315 if (query.tag) {
316 if (el.tagName.toLowerCase() !== query.tag.toLowerCase()) return false;
317 }
318 if (query.placeholder) {
319 if ((el.getAttribute('placeholder') || '').toLowerCase().indexOf(query.placeholder.toLowerCase()) === -1) return false;
320 }
321 if (query.alt) {
322 if ((el.getAttribute('alt') || '').toLowerCase().indexOf(query.alt.toLowerCase()) === -1) return false;
323 }
324 if (query.title_attr) {
325 if ((el.getAttribute('title') || '').toLowerCase().indexOf(query.title_attr.toLowerCase()) === -1) return false;
326 }
327 if (query.enabled === true && el.disabled) return false;
328 if (query.enabled === false && !el.disabled) return false;
329 return true;
330 }
331
332 function buildResult(node, style) {
333 var existingRef = null;
334 refMap.forEach(function(el, refId) {
335 if (el === node) existingRef = refId;
336 });
337 var ref_id = existingRef || registerRef(node);
338 var role = node.getAttribute('role') || inferRole(node);
339 var rect = node.getBoundingClientRect();
340 var vis = true;
341 if (style) {
342 vis = style.display !== 'none' && style.visibility !== 'hidden';
343 }
344 return {
345 ref_id: ref_id,
346 tag: node.tagName.toLowerCase(),
347 role: role,
348 name: node.getAttribute('aria-label') || node.getAttribute('title') || null,
349 text: (node.textContent || '').trim().substring(0, 100),
350 bounds: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) },
351 visible: vis,
352 enabled: !node.disabled,
353 value: node.value || null
354 };
355 }
356
357 if (query.label) {
358 var labels = document.querySelectorAll('label');
359 for (var li = 0; li < labels.length && results.length < maxResults; li++) {
360 var lbl = labels[li];
361 if ((lbl.textContent || '').toLowerCase().indexOf(query.label.toLowerCase()) === -1) continue;
362 var target = null;
363 var forAttr = lbl.getAttribute('for');
364 if (forAttr) {
365 target = document.getElementById(forAttr);
366 }
367 if (!target) {
368 target = lbl.querySelector('input, textarea, select');
369 }
370 if (target) {
371 var ts = window.getComputedStyle(target);
372 results.push(buildResult(target, ts));
373 }
374 }
375 return results;
376 }
377
378 function search(node) {
379 if (results.length >= maxResults) return;
380 if (!node || node.nodeType !== 1) return;
381 var style = window.getComputedStyle(node);
382 if (style.display === 'none' || style.visibility === 'hidden') return;
383
384 if (matches(node)) {
385 results.push(buildResult(node, style));
386 }
387
388 for (var c = 0; c < node.children.length; c++) {
389 search(node.children[c]);
390 }
391 if (node.shadowRoot) {
392 for (var s = 0; s < node.shadowRoot.children.length; s++) {
393 search(node.shadowRoot.children[s]);
394 }
395 }
396 // Same-origin iframe traversal.
397 if (node.tagName === 'IFRAME' || node.tagName === 'FRAME') {
398 try {
399 var idoc = node.contentDocument;
400 if (idoc && idoc.body) search(idoc.body);
401 } catch (e) { /* cross-origin: skip */ }
402 }
403 }
404
405 search(document.body);
406 return results;
407 },
408
409 // ── Interactions ─────────────────────────────────────────────────────
410
411 click: function(refId, timeoutMs) {
412 return withAutoWait(refId, timeoutMs, function(el) {
413 el.click();
414 return { ok: true };
415 });
416 },
417
418 doubleClick: function(refId, timeoutMs) {
419 return withAutoWait(refId, timeoutMs, function(el) {
420 el.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true }));
421 return { ok: true };
422 });
423 },
424
425 hover: function(refId, timeoutMs) {
426 return withAutoWait(refId, timeoutMs, function(el) {
427 el.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
428 el.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
429 return { ok: true };
430 });
431 },
432
433 fill: function(refId, value, timeoutMs) {
434 return withAutoWait(refId, timeoutMs, function(el) {
435 if (!el.matches('input, textarea, [contenteditable="true"]')) {
436 return { ok: false, error: 'element is not fillable (not input, textarea, or contenteditable): ' + (el.tagName || '').toLowerCase(), hint: 'CHECK_INPUT' };
437 }
438 var proto = el instanceof HTMLTextAreaElement
439 ? HTMLTextAreaElement.prototype
440 : HTMLInputElement.prototype;
441 var desc = Object.getOwnPropertyDescriptor(proto, 'value');
442 if (desc && desc.set) {
443 desc.set.call(el, value);
444 } else {
445 el.value = value;
446 }
447 el.dispatchEvent(new Event('input', { bubbles: true }));
448 el.dispatchEvent(new Event('change', { bubbles: true }));
449 return { ok: true };
450 });
451 },
452
453 type: function(refId, text, timeoutMs) {
454 return withAutoWait(refId, timeoutMs, function(el) {
455 el.focus();
456 var proto = el instanceof HTMLTextAreaElement
457 ? HTMLTextAreaElement.prototype
458 : HTMLInputElement.prototype;
459 var desc = Object.getOwnPropertyDescriptor(proto, 'value');
460 for (var i = 0; i < text.length; i++) {
461 var ch = text[i];
462 el.dispatchEvent(new KeyboardEvent('keydown', { key: ch, bubbles: true }));
463 el.dispatchEvent(new KeyboardEvent('keypress', { key: ch, bubbles: true }));
464 var current = el.value || '';
465 if (desc && desc.set) {
466 desc.set.call(el, current + ch);
467 } else {
468 el.value = current + ch;
469 }
470 el.dispatchEvent(new InputEvent('input', { bubbles: true, data: ch, inputType: 'insertText' }));
471 el.dispatchEvent(new KeyboardEvent('keyup', { key: ch, bubbles: true }));
472 }
473 el.dispatchEvent(new Event('change', { bubbles: true }));
474 return { ok: true };
475 });
476 },
477
478 pressKey: function(key) {
479 var target = document.activeElement || document.body;
480 var parts = key.split('+');
481 if (parts.length === 1 || (parts.length === 2 && parts[0] === '' && parts[1] === '')) {
482 var k = parts.length === 1 ? key : '+';
483 target.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true }));
484 target.dispatchEvent(new KeyboardEvent('keyup', { key: k, bubbles: true }));
485 return { ok: true };
486 }
487 var finalKey = parts.pop();
488 var mods = { ctrlKey: false, shiftKey: false, altKey: false, metaKey: false };
489 for (var m = 0; m < parts.length; m++) {
490 var mod = parts[m];
491 if (mod === 'Control' || mod === 'Ctrl') mods.ctrlKey = true;
492 else if (mod === 'Shift') mods.shiftKey = true;
493 else if (mod === 'Alt') mods.altKey = true;
494 else if (mod === 'Meta' || mod === 'Command' || mod === 'Cmd') mods.metaKey = true;
495 }
496 var modKeys = [];
497 if (mods.ctrlKey) modKeys.push('Control');
498 if (mods.shiftKey) modKeys.push('Shift');
499 if (mods.altKey) modKeys.push('Alt');
500 if (mods.metaKey) modKeys.push('Meta');
501 for (var i = 0; i < modKeys.length; i++) {
502 target.dispatchEvent(new KeyboardEvent('keydown', { key: modKeys[i], bubbles: true, ctrlKey: mods.ctrlKey, shiftKey: mods.shiftKey, altKey: mods.altKey, metaKey: mods.metaKey }));
503 }
504 target.dispatchEvent(new KeyboardEvent('keydown', { key: finalKey, bubbles: true, ctrlKey: mods.ctrlKey, shiftKey: mods.shiftKey, altKey: mods.altKey, metaKey: mods.metaKey }));
505 target.dispatchEvent(new KeyboardEvent('keyup', { key: finalKey, bubbles: true, ctrlKey: mods.ctrlKey, shiftKey: mods.shiftKey, altKey: mods.altKey, metaKey: mods.metaKey }));
506 for (var j = modKeys.length - 1; j >= 0; j--) {
507 target.dispatchEvent(new KeyboardEvent('keyup', { key: modKeys[j], bubbles: true, ctrlKey: mods.ctrlKey, shiftKey: mods.shiftKey, altKey: mods.altKey, metaKey: mods.metaKey }));
508 }
509 return { ok: true };
510 },
511
512 selectOption: function(refId, values, timeoutMs) {
513 return withAutoWait(refId, timeoutMs, function(el) {
514 if (el.tagName !== 'SELECT') {
515 return { ok: false, error: 'element is not a <select>', hint: 'CHECK_INPUT' };
516 }
517 var valSet = new Set(values);
518 for (var i = 0; i < el.options.length; i++) {
519 el.options[i].selected = valSet.has(el.options[i].value);
520 }
521 el.dispatchEvent(new Event('change', { bubbles: true }));
522 return { ok: true };
523 });
524 },
525
526 scrollTo: function(refId, x, y, timeoutMs) {
527 if (refId) {
528 return withAutoWait(refId, timeoutMs, function(el) {
529 el.scrollIntoView({ behavior: 'smooth', block: 'center' });
530 return { ok: true };
531 });
532 } else {
533 window.scrollTo({ left: x || 0, top: y || 0, behavior: 'smooth' });
534 return Promise.resolve({ ok: true });
535 }
536 },
537
538 focusElement: function(refId, timeoutMs) {
539 return withAutoWait(refId, timeoutMs, function(el) {
540 el.focus();
541 return { ok: true, tag: el.tagName.toLowerCase() };
542 });
543 },
544
545 // ── IPC Log ──────────────────────────────────────────────────────────
546
547 getIpcLog: function(limit) {
548 var ipcPrefix = 'http://ipc.localhost/';
549 var victauriPrefix = 'plugin%3Avictauri%7C';
550 var entries = [];
551 for (var i = 0; i < networkLog.length; i++) {
552 var n = networkLog[i];
553 if (n.url.indexOf(ipcPrefix) !== 0) continue;
554 var raw = n.url.substring(ipcPrefix.length);
555 if (raw.indexOf(victauriPrefix) === 0) continue;
556 var command;
557 try { command = decodeURIComponent(raw); } catch(e) { command = raw; }
558 entries.push({
559 id: n.id,
560 command: command,
561 args: n.request_args || {},
562 timestamp: n.timestamp,
563 status: n.status === 200 ? 'ok' : (n.status === 'pending' ? 'pending' : 'error'),
564 duration_ms: n.duration_ms,
565 result: n.response_body || null,
566 error: n.status !== 200 && n.status !== 'pending' ? 'HTTP ' + n.status : null,
567 });
568 }
569 if (limit) return entries.slice(-limit);
570 return entries;
571 },
572
573 clearIpcLog: function() {
574 var ipcPrefix = 'http://ipc.localhost/';
575 for (var i = networkLog.length - 1; i >= 0; i--) {
576 if (networkLog[i].url.indexOf(ipcPrefix) === 0) networkLog.splice(i, 1);
577 }
578 },
579
580 waitForIpcComplete: function(timeoutMs) {
581 var log = window.__VICTAURI__.getIpcLog();
582 if (log.length > 0) {
583 var last = log[log.length - 1];
584 if (last.duration_ms !== null && last.duration_ms !== undefined && last.result !== null) {
585 return Promise.resolve(true);
586 }
587 }
588 return new Promise(function(resolve) {
589 var timer = setTimeout(function() {
590 var idx = ipcWaiters.indexOf(waiterFn);
591 if (idx !== -1) ipcWaiters.splice(idx, 1);
592 resolve(false);
593 }, timeoutMs || 500);
594 function waiterFn() {
595 clearTimeout(timer);
596 resolve(true);
597 }
598 ipcWaiters.push(waiterFn);
599 });
600 },
601
602 // ── Console ──────────────────────────────────────────────────────────
603
604 getConsoleLogs: function(since) {
605 if (since) return consoleLogs.filter(function(l) { return l.timestamp >= since; });
606 return consoleLogs;
607 },
608
609 clearConsoleLogs: function() {
610 consoleLogs.length = 0;
611 },
612
613 // ── Mutations ────────────────────────────────────────────────────────
614
615 getMutationLog: function(since) {
616 if (since) return mutationLog.filter(function(m) { return m.timestamp >= since; });
617 return mutationLog;
618 },
619
620 clearMutationLog: function() {
621 mutationLog.length = 0;
622 },
623
624 // ── Network ──────────────────────────────────────────────────────────
625
626 getNetworkLog: function(filter, limit) {
627 var log = networkLog;
628 if (filter) {
629 log = log.filter(function(e) { return e.url.indexOf(filter) !== -1; });
630 }
631 if (limit) log = log.slice(-limit);
632 return log;
633 },
634
635 clearNetworkLog: function() {
636 networkLog.length = 0;
637 },
638
639 // ── Network routing (interception / mock / block / delay) ──────────────
640 // Add a route rule. `rule` is an object: { pattern, match_type, method,
641 // action ('block'|'fulfill'|'delay'), status, status_text, headers,
642 // body, content_type, delay_ms, times }. Returns the assigned id.
643 addRoute: function(rule) {
644 if (typeof rule === 'string') { try { rule = JSON.parse(rule); } catch (e) { return { ok: false, error: 'invalid rule JSON' }; } }
645 if (!rule || !rule.pattern) return { ok: false, error: 'route rule requires a pattern' };
646 var r = {
647 id: ++routeCounter,
648 pattern: String(rule.pattern),
649 match_type: rule.match_type || 'substring',
650 method: rule.method || null,
651 action: rule.action || 'fulfill',
652 status: typeof rule.status === 'number' ? rule.status : 200,
653 status_text: rule.status_text || '',
654 headers: rule.headers || {},
655 body: (rule.body === undefined || rule.body === null) ? '' : rule.body,
656 content_type: rule.content_type || 'application/json',
657 delay_ms: typeof rule.delay_ms === 'number' ? rule.delay_ms : 0,
658 times: typeof rule.times === 'number' ? rule.times : 0,
659 triggered: 0,
660 };
661 routeRules.push(r);
662 return { ok: true, id: r.id, rule: r };
663 },
664
665 getRouteRules: function() { return routeRules; },
666
667 clearRoute: function(id) {
668 var before = routeRules.length;
669 routeRules = routeRules.filter(function(r) { return r.id !== id; });
670 return { ok: true, removed: before - routeRules.length };
671 },
672
673 clearRoutes: function() {
674 var n = routeRules.length;
675 routeRules = [];
676 return { ok: true, removed: n };
677 },
678
679 getRouteMatches: function(limit) {
680 return limit ? routeMatchLog.slice(-limit) : routeMatchLog;
681 },
682
683 // ── Storage ──────────────────────────────────────────────────────────
684
685 getLocalStorage: function(key) {
686 if (key !== undefined && key !== null) {
687 var v = localStorage.getItem(key);
688 try { return JSON.parse(v); } catch(e) { return v; }
689 }
690 var obj = {};
691 for (var i = 0; i < localStorage.length; i++) {
692 var k = localStorage.key(i);
693 var val = localStorage.getItem(k);
694 try { obj[k] = JSON.parse(val); } catch(e) { obj[k] = val; }
695 }
696 return obj;
697 },
698
699 setLocalStorage: function(key, value) {
700 localStorage.setItem(key, typeof value === 'string' ? value : JSON.stringify(value));
701 return { ok: true };
702 },
703
704 deleteLocalStorage: function(key) {
705 localStorage.removeItem(key);
706 return { ok: true };
707 },
708
709 getSessionStorage: function(key) {
710 if (key !== undefined && key !== null) {
711 var v = sessionStorage.getItem(key);
712 try { return JSON.parse(v); } catch(e) { return v; }
713 }
714 var obj = {};
715 for (var i = 0; i < sessionStorage.length; i++) {
716 var k = sessionStorage.key(i);
717 var val = sessionStorage.getItem(k);
718 try { obj[k] = JSON.parse(val); } catch(e) { obj[k] = val; }
719 }
720 return obj;
721 },
722
723 setSessionStorage: function(key, value) {
724 sessionStorage.setItem(key, typeof value === 'string' ? value : JSON.stringify(value));
725 return { ok: true };
726 },
727
728 deleteSessionStorage: function(key) {
729 sessionStorage.removeItem(key);
730 return { ok: true };
731 },
732
733 getCookies: function() {
734 if (!document.cookie) return [];
735 return document.cookie.split(';').map(function(c) {
736 var parts = c.trim().split('=');
737 return { name: parts[0], value: parts.slice(1).join('=') };
738 });
739 },
740
741 // ── Navigation ───────────────────────────────────────────────────────
742
743 getNavigationLog: function() {
744 return navigationLog;
745 },
746
747 navigate: function(url) {
748 window.location.href = url;
749 return { ok: true };
750 },
751
752 navigateBack: function() {
753 history.back();
754 return { ok: true };
755 },
756
757 // ── Dialogs ──────────────────────────────────────────────────────────
758
759 getDialogLog: function() {
760 return dialogLog;
761 },
762
763 clearDialogLog: function() {
764 dialogLog.length = 0;
765 },
766
767 setDialogAutoResponse: function(type, action, text) {
768 dialogAutoResponses[type] = { action: action, text: text };
769 return { ok: true };
770 },
771
772 // ── Combined Event Stream ────────────────────────────────────────────
773
774 getEventStream: function(since) {
775 var events = [];
776 var ts = since || 0;
777
778 consoleLogs.forEach(function(l) {
779 if (l.timestamp >= ts) {
780 events.push({ type: 'console', level: l.level, message: l.message, timestamp: l.timestamp });
781 }
782 });
783
784 mutationLog.forEach(function(m) {
785 if (m.timestamp >= ts) {
786 events.push({ type: 'dom_mutation', count: m.count, timestamp: m.timestamp });
787 }
788 });
789
790 var ipcPrefix = 'http://ipc.localhost/';
791 var victauriPrefix = 'plugin%3Avictauri%7C';
792 networkLog.forEach(function(n) {
793 if (n.timestamp >= ts && n.url.indexOf(ipcPrefix) === 0) {
794 var raw = n.url.substring(ipcPrefix.length);
795 if (raw.indexOf(victauriPrefix) === 0) return;
796 var cmd; try { cmd = decodeURIComponent(raw); } catch(e) { cmd = raw; }
797 events.push({ type: 'ipc', command: cmd, status: n.status === 200 ? 'ok' : (n.status === 'pending' ? 'pending' : 'error'), duration_ms: n.duration_ms, timestamp: n.timestamp });
798 }
799 });
800
801 networkLog.forEach(function(n) {
802 if (n.timestamp >= ts) {
803 events.push({ type: 'network', method: n.method, url: n.url, status: n.status, duration_ms: n.duration_ms, timestamp: n.timestamp });
804 }
805 });
806
807 navigationLog.forEach(function(n) {
808 if (n.timestamp >= ts) {
809 events.push({ type: 'navigation', url: n.url, nav_type: n.type, timestamp: n.timestamp });
810 }
811 });
812
813 interactionLog.forEach(function(i) {
814 if (i.timestamp >= ts) {
815 events.push({ type: 'dom_interaction', action: i.action, selector: i.selector, value: i.value, timestamp: i.timestamp });
816 }
817 });
818
819 events.sort(function(a, b) { return a.timestamp - b.timestamp; });
820 return events;
821 },
822
823 // ── Wait ─────────────────────────────────────────────────────────────
824
825 waitFor: function(opts) {
826 return new Promise(function(resolve) {
827 var timeout = opts.timeout_ms || 10000;
828 var poll = opts.poll_ms || 200;
829 var start = Date.now();
830
831 function check() {
832 var elapsed = Date.now() - start;
833 if (elapsed >= timeout) {
834 resolve({ ok: false, error: 'timeout after ' + timeout + 'ms', elapsed_ms: elapsed });
835 return;
836 }
837
838 function getFullText(root) {
839 var text = root.innerText || '';
840 var els = root.querySelectorAll('*');
841 for (var j = 0; j < els.length; j++) {
842 if (els[j].shadowRoot) text += ' ' + getFullText(els[j].shadowRoot);
843 }
844 return text;
845 }
846 var met = false;
847 if (opts.condition === 'text' && opts.value) {
848 met = getFullText(document.body).indexOf(opts.value) !== -1;
849 } else if (opts.condition === 'text_gone' && opts.value) {
850 met = getFullText(document.body).indexOf(opts.value) === -1;
851 } else if (opts.condition === 'selector' && opts.value) {
852 met = !!document.querySelector(opts.value);
853 } else if (opts.condition === 'selector_gone' && opts.value) {
854 met = !document.querySelector(opts.value);
855 } else if (opts.condition === 'url' && opts.value) {
856 met = window.location.href.indexOf(opts.value) !== -1;
857 } else if (opts.condition === 'ipc_idle') {
858 met = networkLog.filter(function(n) { return n.url.indexOf('http://ipc.localhost/') === 0; }).every(function(n) { return n.status !== 'pending'; });
859 } else if (opts.condition === 'network_idle') {
860 met = networkLog.every(function(n) { return n.status !== 'pending'; });
861 }
862
863 if (met) {
864 resolve({ ok: true, elapsed_ms: Date.now() - start });
865 } else {
866 setTimeout(check, poll);
867 }
868 }
869 check();
870 });
871 },
872 // ── CSS / Style Introspection ────────────────────────────────────────
873
874 getStyles: function(refId, properties) {
875 var el = resolveRef(refId);
876 if (!el) return { error: 'ref not found: ' + refId };
877 var computed = window.getComputedStyle(el);
878 var result = {};
879 if (properties && properties.length > 0) {
880 for (var i = 0; i < properties.length; i++) {
881 result[properties[i]] = computed.getPropertyValue(properties[i]);
882 }
883 } else {
884 var important = ['display','position','width','height','margin','padding',
885 'color','background-color','font-size','font-family','font-weight',
886 'border','border-radius','opacity','visibility','overflow','z-index',
887 'flex-direction','justify-content','align-items','gap','grid-template-columns',
888 'box-shadow','transform','transition','cursor','pointer-events','text-align',
889 'line-height','letter-spacing','white-space','text-overflow','max-width',
890 'max-height','min-width','min-height','top','right','bottom','left'];
891 for (var i = 0; i < important.length; i++) {
892 var v = computed.getPropertyValue(important[i]);
893 if (v && v !== '' && v !== 'none' && v !== 'normal' && v !== 'auto' && v !== '0px' && v !== 'rgba(0, 0, 0, 0)') {
894 result[important[i]] = v;
895 }
896 }
897 }
898 return { ref_id: refId, tag: el.tagName.toLowerCase(), styles: result };
899 },
900
901 getBoundingBoxes: function(refIds) {
902 var results = [];
903 for (var i = 0; i < refIds.length; i++) {
904 var el = resolveRef(refIds[i]);
905 if (!el) { results.push({ ref_id: refIds[i], error: 'ref not found' }); continue; }
906 var rect = el.getBoundingClientRect();
907 var computed = window.getComputedStyle(el);
908 results.push({
909 ref_id: refIds[i],
910 tag: el.tagName.toLowerCase(),
911 x: Math.round(rect.x),
912 y: Math.round(rect.y),
913 width: Math.round(rect.width),
914 height: Math.round(rect.height),
915 margin: {
916 top: parseInt(computed.marginTop) || 0,
917 right: parseInt(computed.marginRight) || 0,
918 bottom: parseInt(computed.marginBottom) || 0,
919 left: parseInt(computed.marginLeft) || 0,
920 },
921 padding: {
922 top: parseInt(computed.paddingTop) || 0,
923 right: parseInt(computed.paddingRight) || 0,
924 bottom: parseInt(computed.paddingBottom) || 0,
925 left: parseInt(computed.paddingLeft) || 0,
926 },
927 border: {
928 top: parseInt(computed.borderTopWidth) || 0,
929 right: parseInt(computed.borderRightWidth) || 0,
930 bottom: parseInt(computed.borderBottomWidth) || 0,
931 left: parseInt(computed.borderLeftWidth) || 0,
932 },
933 });
934 }
935 return results;
936 },
937
938 // ── Visual Debug Overlays ────────────────────────────────────────────
939
940 highlightElement: function(refId, color, label) {
941 var el = resolveRef(refId);
942 if (!el) return { error: 'ref not found: ' + refId };
943 var c = color || 'rgba(255, 0, 0, 0.3)';
944 var overlay = document.createElement('div');
945 overlay.className = '__victauri_highlight__';
946 overlay.setAttribute('data-victauri-ref', refId);
947 var rect = el.getBoundingClientRect();
948 overlay.style.cssText = 'position:fixed;pointer-events:none;z-index:2147483647;' +
949 'border:2px solid ' + c + ';background:' + c + ';' +
950 'left:' + rect.left + 'px;top:' + rect.top + 'px;' +
951 'width:' + rect.width + 'px;height:' + rect.height + 'px;' +
952 'transition:all 0.2s ease;';
953 if (label) {
954 var tag = document.createElement('span');
955 tag.textContent = label;
956 tag.style.cssText = 'position:absolute;top:-20px;left:0;background:#222;color:#fff;' +
957 'font-size:11px;padding:2px 6px;border-radius:3px;white-space:nowrap;font-family:monospace;';
958 overlay.appendChild(tag);
959 }
960 document.body.appendChild(overlay);
961 return { ok: true, ref_id: refId };
962 },
963
964 clearHighlights: function() {
965 var overlays = document.querySelectorAll('.__victauri_highlight__');
966 for (var i = 0; i < overlays.length; i++) overlays[i].remove();
967 return { ok: true, removed: overlays.length };
968 },
969
970 // ── CSS Injection ────────────────────────────────────────────────────
971
972 injectCss: function(css) {
973 var existing = document.getElementById('__victauri_injected_css__');
974 if (existing) existing.remove();
975 var style = document.createElement('style');
976 style.id = '__victauri_injected_css__';
977 style.textContent = css;
978 document.head.appendChild(style);
979 return { ok: true, length: css.length };
980 },
981
982 removeInjectedCss: function() {
983 var existing = document.getElementById('__victauri_injected_css__');
984 if (!existing) return { ok: true, removed: false };
985 existing.remove();
986 return { ok: true, removed: true };
987 },
988
989 // ── Accessibility Audit ──────────────────────────────────────────────
990
991 auditAccessibility: function() {
992 var violations = [];
993 var warnings = [];
994
995 // Images without alt text
996 var imgs = document.querySelectorAll('img');
997 for (var i = 0; i < imgs.length; i++) {
998 if (!imgs[i].hasAttribute('alt')) {
999 violations.push({ rule: 'img-alt', severity: 'critical', element: describeEl(imgs[i]),
1000 message: 'Image missing alt attribute' });
1001 } else if (imgs[i].alt.trim() === '') {
1002 warnings.push({ rule: 'img-alt-empty', severity: 'minor', element: describeEl(imgs[i]),
1003 message: 'Image has empty alt (ok if decorative)' });
1004 }
1005 }
1006
1007 // Form inputs without labels
1008 var inputs = document.querySelectorAll('input, select, textarea');
1009 for (var i = 0; i < inputs.length; i++) {
1010 var inp = inputs[i];
1011 if (inp.type === 'hidden') continue;
1012 var hasLabel = false;
1013 if (inp.id) {
1014 try { hasLabel = !!document.querySelector('label[for=\"' + CSS.escape(inp.id) + '\"]'); }
1015 catch(e) { /* malformed id — skip */ }
1016 }
1017 var hasAria = inp.getAttribute('aria-label') || inp.getAttribute('aria-labelledby');
1018 var hasTitle = inp.title;
1019 var hasPlaceholder = inp.placeholder;
1020 if (!hasLabel && !hasAria && !hasTitle && !hasPlaceholder) {
1021 violations.push({ rule: 'input-label', severity: 'serious', element: describeEl(inp),
1022 message: 'Form input has no accessible label' });
1023 }
1024 }
1025
1026 // Buttons without accessible text
1027 var buttons = document.querySelectorAll('button, [role="button"]');
1028 for (var i = 0; i < buttons.length; i++) {
1029 var btn = buttons[i];
1030 var text = (btn.textContent || '').trim();
1031 var ariaLabel = btn.getAttribute('aria-label');
1032 var ariaLabelledBy = btn.getAttribute('aria-labelledby');
1033 if (!text && !ariaLabel && !ariaLabelledBy && !btn.title) {
1034 var hasImg = btn.querySelector('img[alt], svg[aria-label]');
1035 if (!hasImg) {
1036 violations.push({ rule: 'button-name', severity: 'serious', element: describeEl(btn),
1037 message: 'Button has no accessible name' });
1038 }
1039 }
1040 }
1041
1042 // Links without text
1043 var links = document.querySelectorAll('a[href]');
1044 for (var i = 0; i < links.length; i++) {
1045 var link = links[i];
1046 var text = (link.textContent || '').trim();
1047 var ariaLabel = link.getAttribute('aria-label');
1048 if (!text && !ariaLabel && !link.title) {
1049 violations.push({ rule: 'link-name', severity: 'serious', element: describeEl(link),
1050 message: 'Link has no accessible text' });
1051 }
1052 }
1053
1054 // Missing document language
1055 if (!document.documentElement.lang) {
1056 violations.push({ rule: 'html-lang', severity: 'serious', element: '<html>',
1057 message: 'Document missing lang attribute' });
1058 }
1059
1060 // Heading hierarchy
1061 var headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
1062 var prevLevel = 0;
1063 for (var i = 0; i < headings.length; i++) {
1064 var level = parseInt(headings[i].tagName.charAt(1));
1065 if (level > prevLevel + 1 && prevLevel > 0) {
1066 warnings.push({ rule: 'heading-order', severity: 'moderate', element: describeEl(headings[i]),
1067 message: 'Heading level skipped from h' + prevLevel + ' to h' + level });
1068 }
1069 prevLevel = level;
1070 }
1071
1072 // Missing page title
1073 if (!document.title || document.title.trim() === '') {
1074 violations.push({ rule: 'document-title', severity: 'serious', element: '<head>',
1075 message: 'Document has no title' });
1076 }
1077
1078 // Color contrast (simplified — checks text elements against backgrounds)
1079 var textEls = document.querySelectorAll('p, span, a, button, h1, h2, h3, h4, h5, h6, li, td, th, label, div');
1080 var contrastIssues = 0;
1081 for (var i = 0; i < textEls.length && contrastIssues < 10; i++) {
1082 var el = textEls[i];
1083 if (!el.textContent || el.textContent.trim() === '') continue;
1084 if (el.children.length > 0 && el.children[0].textContent === el.textContent) continue;
1085 var cs = window.getComputedStyle(el);
1086 var fg = parseColor(cs.color);
1087 var bg = parseColor(cs.backgroundColor);
1088 if (fg && bg && bg.a > 0) {
1089 var ratio = contrastRatio(fg, bg);
1090 var fontSize = parseFloat(cs.fontSize);
1091 var isBold = parseInt(cs.fontWeight) >= 700;
1092 var isLarge = fontSize >= 24 || (fontSize >= 18.66 && isBold);
1093 var threshold = isLarge ? 3 : 4.5;
1094 if (ratio < threshold) {
1095 contrastIssues++;
1096 warnings.push({ rule: 'color-contrast', severity: 'serious',
1097 element: describeEl(el),
1098 message: 'Contrast ratio ' + ratio.toFixed(2) + ':1 (needs ' + threshold + ':1)',
1099 details: { fg: cs.color, bg: cs.backgroundColor, ratio: ratio.toFixed(2) } });
1100 }
1101 }
1102 }
1103
1104 // ARIA role validity
1105 var ariaEls = document.querySelectorAll('[role]');
1106 var validRoles = new Set(['alert','alertdialog','application','article','banner','button',
1107 'cell','checkbox','columnheader','combobox','complementary','contentinfo','definition',
1108 'dialog','directory','document','feed','figure','form','grid','gridcell','group',
1109 'heading','img','link','list','listbox','listitem','log','main','marquee','math',
1110 'menu','menubar','menuitem','menuitemcheckbox','menuitemradio','meter','navigation',
1111 'none','note','option','presentation','progressbar','radio','radiogroup','region',
1112 'row','rowgroup','rowheader','scrollbar','search','searchbox','separator','slider',
1113 'spinbutton','status','switch','tab','table','tablist','tabpanel','term','textbox',
1114 'timer','toolbar','tooltip','tree','treegrid','treeitem']);
1115 for (var i = 0; i < ariaEls.length; i++) {
1116 var role = ariaEls[i].getAttribute('role');
1117 if (role && !validRoles.has(role)) {
1118 warnings.push({ rule: 'aria-role', severity: 'moderate', element: describeEl(ariaEls[i]),
1119 message: 'Invalid ARIA role: ' + role });
1120 }
1121 }
1122
1123 // Tab index > 0
1124 var tabbable = document.querySelectorAll('[tabindex]');
1125 for (var i = 0; i < tabbable.length; i++) {
1126 var ti = parseInt(tabbable[i].getAttribute('tabindex'));
1127 if (ti > 0) {
1128 warnings.push({ rule: 'tabindex-positive', severity: 'moderate', element: describeEl(tabbable[i]),
1129 message: 'Positive tabindex disrupts natural tab order (tabindex=' + ti + ')' });
1130 }
1131 }
1132
1133 return {
1134 violations: violations,
1135 warnings: warnings,
1136 summary: {
1137 critical: violations.filter(function(v) { return v.severity === 'critical'; }).length,
1138 serious: violations.filter(function(v) { return v.severity === 'serious'; }).length + warnings.filter(function(w) { return w.severity === 'serious'; }).length,
1139 moderate: warnings.filter(function(w) { return w.severity === 'moderate'; }).length,
1140 minor: warnings.filter(function(w) { return w.severity === 'minor'; }).length,
1141 total: violations.length + warnings.length,
1142 }
1143 };
1144 },
1145
1146 // ── Performance Metrics ──────────────────────────────────────────────
1147
1148 getPerformanceMetrics: function() {
1149 var result = {};
1150
1151 // Navigation timing
1152 var nav = performance.getEntriesByType('navigation')[0];
1153 if (nav) {
1154 result.navigation = {
1155 dns_ms: Math.round(nav.domainLookupEnd - nav.domainLookupStart),
1156 connect_ms: Math.round(nav.connectEnd - nav.connectStart),
1157 ttfb_ms: Math.round(nav.responseStart - nav.requestStart),
1158 response_ms: Math.round(nav.responseEnd - nav.responseStart),
1159 dom_interactive_ms: Math.round(nav.domInteractive - nav.startTime),
1160 dom_complete_ms: Math.round(nav.domComplete - nav.startTime),
1161 load_event_ms: Math.round(nav.loadEventEnd - nav.startTime),
1162 transfer_size: nav.transferSize,
1163 encoded_body_size: nav.encodedBodySize,
1164 decoded_body_size: nav.decodedBodySize,
1165 };
1166 }
1167
1168 // Resource summary
1169 var resources = performance.getEntriesByType('resource');
1170 var byType = {};
1171 var totalTransfer = 0;
1172 for (var i = 0; i < resources.length; i++) {
1173 var r = resources[i];
1174 var type = r.initiatorType || 'other';
1175 if (!byType[type]) byType[type] = { count: 0, total_ms: 0, total_bytes: 0 };
1176 byType[type].count++;
1177 byType[type].total_ms += r.duration;
1178 byType[type].total_bytes += r.transferSize || 0;
1179 totalTransfer += r.transferSize || 0;
1180 }
1181 result.resources = {
1182 total_count: resources.length,
1183 total_transfer_bytes: totalTransfer,
1184 by_type: byType,
1185 slowest: resources.sort(function(a, b) { return b.duration - a.duration; }).slice(0, 5).map(function(r) {
1186 return { name: r.name.split('/').pop().split('?')[0], duration_ms: Math.round(r.duration), size: r.transferSize || 0, type: r.initiatorType };
1187 }),
1188 };
1189
1190 // Paint timing
1191 var paints = performance.getEntriesByType('paint');
1192 result.paint = {};
1193 for (var i = 0; i < paints.length; i++) {
1194 result.paint[paints[i].name] = Math.round(paints[i].startTime);
1195 }
1196
1197 // Memory (Chrome/Edge)
1198 if (performance.memory) {
1199 result.js_heap = {
1200 used_mb: Math.round(performance.memory.usedJSHeapSize / 1048576 * 100) / 100,
1201 total_mb: Math.round(performance.memory.totalJSHeapSize / 1048576 * 100) / 100,
1202 limit_mb: Math.round(performance.memory.jsHeapSizeLimit / 1048576 * 100) / 100,
1203 };
1204 }
1205
1206 // Long tasks (if PerformanceObserver captured any)
1207 if (longTasks.length > 0) {
1208 result.long_tasks = {
1209 count: longTasks.length,
1210 total_ms: Math.round(longTasks.reduce(function(s, t) { return s + t.duration; }, 0)),
1211 worst_ms: Math.round(Math.max.apply(null, longTasks.map(function(t) { return t.duration; }))),
1212 };
1213 }
1214
1215 // DOM stats
1216 result.dom = {
1217 elements: document.querySelectorAll('*').length,
1218 max_depth: (function() { var d = 0; var walk = function(el, depth) { if (depth > d) d = depth; for (var i = 0; i < el.children.length && i < 5; i++) walk(el.children[i], depth + 1); }; walk(document.body, 0); return d; })(),
1219 event_listeners: listenerCount,
1220 };
1221
1222 return result;
1223 },
1224
1225 getDiagnostics: function() {
1226 var diag = { warnings: [], info: {} };
1227
1228 // Service worker detection
1229 if (navigator.serviceWorker && navigator.serviceWorker.controller) {
1230 diag.warnings.push({
1231 id: 'service-worker-active',
1232 severity: 'high',
1233 message: 'Active service worker detected — may intercept fetch calls to ipc.localhost, causing IPC log gaps',
1234 details: { scope: navigator.serviceWorker.controller.scriptURL }
1235 });
1236 }
1237
1238 // Closed shadow DOM detection
1239 var allEls = document.querySelectorAll('*');
1240 var closedShadowCount = 0;
1241 for (var i = 0; i < allEls.length; i++) {
1242 if (allEls[i].attachShadow && !allEls[i].shadowRoot) {
1243 var tagName = allEls[i].tagName.toLowerCase();
1244 if (tagName.includes('-')) closedShadowCount++;
1245 }
1246 }
1247 if (closedShadowCount > 0) {
1248 diag.warnings.push({
1249 id: 'closed-shadow-dom',
1250 severity: 'medium',
1251 message: closedShadowCount + ' custom element(s) may use closed shadow DOM — their contents are invisible to dom_snapshot',
1252 details: { count: closedShadowCount }
1253 });
1254 }
1255
1256 // iframe detection
1257 var iframes = document.querySelectorAll('iframe');
1258 if (iframes.length > 0) {
1259 diag.warnings.push({
1260 id: 'iframes-present',
1261 severity: 'medium',
1262 message: iframes.length + ' iframe(s) found — Victauri bridge is not injected inside iframes (Tauri limitation)',
1263 details: { count: iframes.length, srcs: Array.from(iframes).slice(0, 5).map(function(f) { return f.src || '(empty)'; }) }
1264 });
1265 }
1266
1267 // DOM size warning
1268 var elementCount = allEls.length;
1269 if (elementCount > 5000) {
1270 diag.warnings.push({
1271 id: 'large-dom',
1272 severity: 'low',
1273 message: 'DOM has ' + elementCount + ' elements — dom_snapshot may be slow (>100ms)',
1274 details: { count: elementCount }
1275 });
1276 }
1277
1278 // CSP detection (best-effort)
1279 var cspMeta = document.querySelector('meta[http-equiv="Content-Security-Policy"]');
1280 if (cspMeta) {
1281 var cspContent = cspMeta.getAttribute('content') || '';
1282 diag.info.csp_meta = cspContent;
1283 if (cspContent.indexOf('unsafe-eval') === -1 && cspContent.indexOf('script-src') !== -1) {
1284 diag.info.csp_note = 'CSP restricts eval — Victauri uses native webview.eval() which bypasses CSP on most platforms';
1285 }
1286 }
1287
1288 // Environment info
1289 diag.info.bridge_version = window.__VICTAURI__.version;
1290 diag.info.user_agent = navigator.userAgent;
1291 diag.info.url = window.location.href;
1292 diag.info.dom_elements = elementCount;
1293 diag.info.open_shadow_roots = (function() { var c = 0; for (var i = 0; i < allEls.length; i++) { if (allEls[i].shadowRoot) c++; } return c; })();
1294 diag.info.event_listeners = listenerCount;
1295 diag.info.protocol = window.location.protocol;
1296
1297 return diag;
1298 },
1299 };
1300
1301 try {
1302 Object.freeze(window.__VICTAURI__);
1303 Object.defineProperty(window, '__VICTAURI__', {
1304 value: window.__VICTAURI__,
1305 configurable: false,
1306 writable: false,
1307 });
1308 } catch(e) {}
1309
1310 // ── Accessibility Helpers ────────────────────────────────────────────────
1311
1312 function describeEl(el) {
1313 var s = '<' + el.tagName.toLowerCase();
1314 if (el.id) s += ' id="' + el.id + '"';
1315 if (el.className && typeof el.className === 'string') {
1316 var cls = el.className.trim();
1317 if (cls) s += ' class="' + cls.substring(0, 50) + '"';
1318 }
1319 s += '>';
1320 return s;
1321 }
1322
1323 function parseColor(str) {
1324 if (!str) return null;
1325 var m = str.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
1326 if (!m) return null;
1327 return { r: parseInt(m[1]), g: parseInt(m[2]), b: parseInt(m[3]), a: m[4] !== undefined ? parseFloat(m[4]) : 1 };
1328 }
1329
1330 function luminance(c) {
1331 var rs = c.r / 255, gs = c.g / 255, bs = c.b / 255;
1332 var r = rs <= 0.03928 ? rs / 12.92 : Math.pow((rs + 0.055) / 1.055, 2.4);
1333 var g = gs <= 0.03928 ? gs / 12.92 : Math.pow((gs + 0.055) / 1.055, 2.4);
1334 var b = bs <= 0.03928 ? bs / 12.92 : Math.pow((bs + 0.055) / 1.055, 2.4);
1335 return 0.2126 * r + 0.7152 * g + 0.0722 * b;
1336 }
1337
1338 function contrastRatio(fg, bg) {
1339 var l1 = luminance(fg), l2 = luminance(bg);
1340 var lighter = Math.max(l1, l2), darker = Math.min(l1, l2);
1341 return (lighter + 0.05) / (darker + 0.05);
1342 }
1343
1344 // ── Long Task Observer ──────────────────────────────────────────────────
1345
1346 try {
1347 var ltObserver = new PerformanceObserver(function(list) {
1348 var entries = list.getEntries();
1349 for (var i = 0; i < entries.length; i++) {
1350 longTasks.push({ duration: entries[i].duration, startTime: entries[i].startTime });
1351 if (longTasks.length > CAP_LONG_TASKS) longTasks.shift();
1352 }
1353 });
1354 ltObserver.observe({ type: 'longtask', buffered: true });
1355 } catch(e) {}
1356
1357 // ── Event Listener Counter ──────────────────────────────────────────────
1358
1359 (function() {
1360 var origAdd = EventTarget.prototype.addEventListener;
1361 var origRemove = EventTarget.prototype.removeEventListener;
1362 EventTarget.prototype.addEventListener = function() {
1363 listenerCount++;
1364 return origAdd.apply(this, arguments);
1365 };
1366 EventTarget.prototype.removeEventListener = function() {
1367 if (listenerCount > 0) listenerCount--;
1368 return origRemove.apply(this, arguments);
1369 };
1370 })();
1371
1372 // ── DOM Walking ──────────────────────────────────────────────────────────
1373
1374 function walkDom(node) {
1375 if (!node || node.nodeType !== 1) return null;
1376
1377 var style = window.getComputedStyle(node);
1378 var visible = style.display !== 'none'
1379 && style.visibility !== 'hidden'
1380 && style.opacity !== '0';
1381
1382 if (!visible) return null;
1383
1384 var ref_id = registerRef(node);
1385
1386 var rect = node.getBoundingClientRect();
1387 var role = node.getAttribute('role') || inferRole(node);
1388 var name = node.getAttribute('aria-label')
1389 || node.getAttribute('title')
1390 || node.getAttribute('placeholder')
1391 || (node.tagName === 'BUTTON' ? node.textContent.trim().substring(0, 80) : null)
1392 || (node.tagName === 'A' ? node.textContent.trim().substring(0, 80) : null);
1393
1394 var element = {
1395 ref_id: ref_id,
1396 tag: node.tagName.toLowerCase(),
1397 role: role,
1398 name: name,
1399 text: getDirectText(node),
1400 value: (node.tagName === 'INPUT' && (node.getAttribute('type') || '').toLowerCase() === 'password') ? '[REDACTED]' : (node.value || null),
1401 enabled: !node.disabled,
1402 visible: true,
1403 focusable: node.tabIndex >= 0 || ['INPUT','BUTTON','SELECT','TEXTAREA','A'].indexOf(node.tagName) !== -1,
1404 bounds: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
1405 children: [],
1406 attributes: {}
1407 };
1408
1409 var interestingAttrs = ['data-testid', 'id', 'type', 'href', 'src', 'checked', 'selected'];
1410 for (var a = 0; a < interestingAttrs.length; a++) {
1411 if (node.hasAttribute(interestingAttrs[a])) {
1412 element.attributes[interestingAttrs[a]] = node.getAttribute(interestingAttrs[a]);
1413 }
1414 }
1415
1416 for (var c = 0; c < node.children.length; c++) {
1417 var childEl = walkDom(node.children[c]);
1418 if (childEl) element.children.push(childEl);
1419 }
1420
1421 if (node.shadowRoot) {
1422 for (var s = 0; s < node.shadowRoot.children.length; s++) {
1423 var shadowChild = walkDom(node.shadowRoot.children[s]);
1424 if (shadowChild) element.children.push(shadowChild);
1425 }
1426 }
1427
1428 // Same-origin iframe traversal: descend into accessible frame documents.
1429 // Cross-origin frames throw on contentDocument access — mark and skip.
1430 if (node.tagName === 'IFRAME' || node.tagName === 'FRAME') {
1431 try {
1432 var idoc = node.contentDocument;
1433 if (idoc && idoc.body) {
1434 var frameChild = walkDom(idoc.body);
1435 if (frameChild) {
1436 frameChild.frame = true;
1437 element.children.push(frameChild);
1438 }
1439 } else {
1440 element.attributes['cross_origin_frame'] = 'true';
1441 }
1442 } catch (e) {
1443 element.attributes['cross_origin_frame'] = 'true';
1444 }
1445 }
1446
1447 return element;
1448 }
1449
1450 function walkDomCompact(node, depth) {
1451 if (!node || node.nodeType !== 1) return '';
1452
1453 var style = window.getComputedStyle(node);
1454 var visible = style.display !== 'none'
1455 && style.visibility !== 'hidden'
1456 && style.opacity !== '0';
1457
1458 if (!visible) return '';
1459
1460 var ref_id = registerRef(node);
1461 var indent = '';
1462 for (var d = 0; d < depth; d++) indent += ' ';
1463
1464 var role = node.getAttribute('role') || inferRole(node);
1465 var name = node.getAttribute('aria-label')
1466 || node.getAttribute('title')
1467 || node.getAttribute('placeholder')
1468 || '';
1469 var text = getDirectText(node) || '';
1470 var tag = node.tagName.toLowerCase();
1471
1472 var line = indent + '[' + ref_id + '] ';
1473
1474 if (role && role !== tag) {
1475 line += role;
1476 } else {
1477 line += tag;
1478 }
1479
1480 if (name) {
1481 line += ' "' + name.substring(0, 60) + '"';
1482 } else if (text && text.length <= 60) {
1483 line += ' "' + text + '"';
1484 } else if (text) {
1485 line += ' "' + text.substring(0, 57) + '..."';
1486 }
1487
1488 if (node.disabled) line += ' [disabled]';
1489 if (node.value) {
1490 var isPassword = node.tagName === 'INPUT' && (node.getAttribute('type') || '').toLowerCase() === 'password';
1491 line += ' value=' + JSON.stringify(isPassword ? '[REDACTED]' : node.value.substring(0, 40));
1492 }
1493
1494 var testId = node.getAttribute('data-testid');
1495 if (testId) line += ' @' + testId;
1496
1497 var type = node.getAttribute('type');
1498 if (type && tag === 'input') line += ' type=' + type;
1499
1500 var href = node.getAttribute('href');
1501 if (href && tag === 'a') line += ' href=' + href.substring(0, 60);
1502
1503 var result = line + '\n';
1504
1505 for (var c = 0; c < node.children.length; c++) {
1506 result += walkDomCompact(node.children[c], depth + 1);
1507 }
1508
1509 if (node.shadowRoot) {
1510 for (var s = 0; s < node.shadowRoot.children.length; s++) {
1511 result += walkDomCompact(node.shadowRoot.children[s], depth + 1);
1512 }
1513 }
1514
1515 // Same-origin iframe traversal (see walkDom for rationale).
1516 if (node.tagName === 'IFRAME' || node.tagName === 'FRAME') {
1517 try {
1518 var idoc = node.contentDocument;
1519 if (idoc && idoc.body) {
1520 result += indent + ' ⤷ iframe content:\n';
1521 result += walkDomCompact(idoc.body, depth + 2);
1522 } else {
1523 result += indent + ' ⤷ [cross-origin iframe]\n';
1524 }
1525 } catch (e) {
1526 result += indent + ' ⤷ [cross-origin iframe]\n';
1527 }
1528 }
1529
1530 return result;
1531 }
1532
1533 function inferRole(node) {
1534 var tag = node.tagName;
1535 var roles = {
1536 'BUTTON': 'button', 'A': 'link', 'INPUT': 'textbox',
1537 'SELECT': 'combobox', 'TEXTAREA': 'textbox', 'IMG': 'img',
1538 'NAV': 'navigation', 'MAIN': 'main', 'HEADER': 'banner',
1539 'FOOTER': 'contentinfo', 'ASIDE': 'complementary',
1540 'H1': 'heading', 'H2': 'heading', 'H3': 'heading',
1541 'H4': 'heading', 'H5': 'heading', 'H6': 'heading',
1542 'UL': 'list', 'OL': 'list', 'LI': 'listitem',
1543 'TABLE': 'table', 'FORM': 'form', 'DIALOG': 'dialog',
1544 };
1545 if (tag === 'INPUT') {
1546 var type = node.getAttribute('type');
1547 if (type === 'checkbox') return 'checkbox';
1548 if (type === 'radio') return 'radio';
1549 if (type === 'range') return 'slider';
1550 if (type === 'submit' || type === 'button') return 'button';
1551 }
1552 return roles[tag] || null;
1553 }
1554
1555 function getDirectText(node) {
1556 var text = '';
1557 for (var i = 0; i < node.childNodes.length; i++) {
1558 if (node.childNodes[i].nodeType === 3) text += node.childNodes[i].textContent;
1559 }
1560 text = text.trim();
1561 return text.length > 0 ? text.substring(0, 200) : null;
1562 }
1563
1564 // ── Console Hooking ──────────────────────────────────────────────────────
1565
1566 var originalConsole = {
1567 log: console.log, warn: console.warn,
1568 error: console.error, info: console.info, debug: console.debug
1569 };
1570
1571 var CTRL_RE = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F\x1B]/g;
1572
1573 function hookConsole(level) {
1574 console[level] = function() {
1575 var args = Array.prototype.slice.call(arguments);
1576 var msg = args.map(String).join(' ').replace(CTRL_RE, '');
1577 consoleLogs.push({ level: level, message: msg, timestamp: Date.now() });
1578 if (consoleLogs.length > CAP_CONSOLE) consoleLogs.shift();
1579 originalConsole[level].apply(console, args);
1580 };
1581 }
1582
1583 hookConsole('log');
1584 hookConsole('warn');
1585 hookConsole('error');
1586 hookConsole('info');
1587 hookConsole('debug');
1588
1589 // ── Global Error Capture ────────────────────────────────────────────────
1590
1591 window.addEventListener('error', function(e) {
1592 var msg = e.message || 'Unknown error';
1593 if (e.filename) msg += ' at ' + e.filename + ':' + e.lineno + ':' + e.colno;
1594 consoleLogs.push({ level: 'error', message: ('[uncaught] ' + msg).replace(CTRL_RE, ''), timestamp: Date.now() });
1595 if (consoleLogs.length > CAP_CONSOLE) consoleLogs.shift();
1596 });
1597
1598 window.addEventListener('unhandledrejection', function(e) {
1599 var msg = e.reason ? (e.reason.message || String(e.reason)) : 'Unhandled promise rejection';
1600 consoleLogs.push({ level: 'error', message: ('[unhandled rejection] ' + msg).replace(CTRL_RE, ''), timestamp: Date.now() });
1601 if (consoleLogs.length > CAP_CONSOLE) consoleLogs.shift();
1602 });
1603
1604 // ── Interaction Observer (for record mode) ────────────────────────────────
1605
1606 function bestSelector(el) {
1607 if (el.dataset && el.dataset.testid) return '[data-testid="' + el.dataset.testid + '"]';
1608 if (el.id) return '#' + el.id;
1609 if (el.getAttribute && el.getAttribute('role')) {
1610 var role = el.getAttribute('role');
1611 var text = (el.textContent || '').trim().substring(0, 50);
1612 if (text) return '[role="' + role + '"]:has-text("' + text + '")';
1613 return '[role="' + role + '"]';
1614 }
1615 var tag = (el.tagName || 'div').toLowerCase();
1616 var text = (el.textContent || '').trim().substring(0, 50);
1617 if (text && ['button', 'a', 'label', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'span'].indexOf(tag) !== -1) {
1618 return tag + ':has-text("' + text + '")';
1619 }
1620 if (el.name) return tag + '[name="' + el.name + '"]';
1621 if (el.className && typeof el.className === 'string') {
1622 var cls = el.className.trim().split(/\s+/).slice(0, 2).join('.');
1623 if (cls) return tag + '.' + cls;
1624 }
1625 return tag;
1626 }
1627
1628 function pushInteraction(action, el, value) {
1629 interactionLog.push({
1630 type: 'dom_interaction',
1631 action: action,
1632 selector: bestSelector(el),
1633 value: value || null,
1634 timestamp: Date.now()
1635 });
1636 if (interactionLog.length > CAP_INTERACTION) interactionLog.shift();
1637 }
1638
1639 document.addEventListener('click', function(e) {
1640 if (e.isTrusted && e.target) pushInteraction('click', e.target, null);
1641 }, true);
1642
1643 document.addEventListener('dblclick', function(e) {
1644 if (e.isTrusted && e.target) pushInteraction('double_click', e.target, null);
1645 }, true);
1646
1647 document.addEventListener('change', function(e) {
1648 if (!e.isTrusted || !e.target) return;
1649 var el = e.target;
1650 var tag = (el.tagName || '').toLowerCase();
1651 if (tag === 'select') {
1652 pushInteraction('select', el, el.value);
1653 } else if (tag === 'input' || tag === 'textarea') {
1654 var isPassword = tag === 'input' && el.type === 'password';
1655 pushInteraction('fill', el, isPassword ? '[REDACTED]' : el.value);
1656 }
1657 }, true);
1658
1659 document.addEventListener('keydown', function(e) {
1660 if (!e.isTrusted) return;
1661 if (['Enter', 'Escape', 'Tab', 'Backspace', 'Delete', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].indexOf(e.key) !== -1) {
1662 pushInteraction('key_press', e.target || document.body, e.key);
1663 }
1664 }, true);
1665
1666 // ── Mutation Observer (deferred) ─────────────────────────────────────────
1667
1668 var mutationBatchCount = 0;
1669 var mutationBatchTimer = null;
1670 var __mutationObserver = null;
1671
1672 function startMutationObserver() {
1673 if (!document.documentElement) return false;
1674 __mutationObserver = new MutationObserver(function(mutations) {
1675 mutationBatchCount += mutations.length;
1676 if (!mutationBatchTimer) {
1677 mutationBatchTimer = setTimeout(function() {
1678 mutationLog.push({ count: mutationBatchCount, timestamp: Date.now() });
1679 if (mutationLog.length > CAP_MUTATION) mutationLog.shift();
1680 mutationBatchCount = 0;
1681 mutationBatchTimer = null;
1682 }, 100);
1683 }
1684 });
1685 __mutationObserver.observe(document.documentElement, {
1686 childList: true, subtree: true, attributes: true, characterData: true,
1687 });
1688 return true;
1689 }
1690
1691 if (!startMutationObserver()) {
1692 document.addEventListener('DOMContentLoaded', startMutationObserver);
1693 }
1694
1695 // IPC logging is derived from the network log: Tauri 2.0 sends all IPC
1696 // via fetch to http://ipc.localhost/<command>. The fetch interceptor below
1697 // captures these, and getIpcLog() filters them from networkLog. This avoids
1698 // the need to patch __TAURI_INTERNALS__.invoke, which Tauri freezes with
1699 // configurable:false, writable:false.
1700
1701 // ── Network Interception ─────────────────────────────────────────────────
1702
1703 (function interceptNetwork() {
1704 // fetch
1705 var origFetch = window.fetch;
1706 if (origFetch) {
1707 window.fetch = function(input, init) {
1708 var id = ++networkCounter;
1709 var url = typeof input === 'string' ? input : (input && input.url ? input.url : String(input));
1710 var method = (init && init.method) || (input && input.method) || 'GET';
1711 var isIpc = url.indexOf('http://ipc.localhost/') === 0;
1712 var isVictauriInternal = isIpc && url.indexOf('plugin%3Avictauri%7C') !== -1;
1713 var entry = { id: id, method: method.toUpperCase(), url: url, timestamp: Date.now(), status: 'pending', duration_ms: null };
1714
1715 if (isIpc && !isVictauriInternal && init && init.body && window.__VICTAURI__._captureIpcBodies !== false) {
1716 try {
1717 var bodyStr = typeof init.body === 'string' ? init.body : null;
1718 if (bodyStr) {
1719 var parsed = JSON.parse(bodyStr);
1720 entry.request_args = parsed;
1721 }
1722 } catch(e) {}
1723 }
1724
1725 if (!isVictauriInternal) {
1726 networkLog.push(entry);
1727 if (networkLog.length > CAP_NETWORK) networkLog.shift();
1728 }
1729
1730 var self = this;
1731 function flushIpcWaiters() {
1732 for (var w = ipcWaiters.length - 1; w >= 0; w--) { ipcWaiters[w](); }
1733 ipcWaiters.length = 0;
1734 }
1735
1736 // Phase 1: apply a matching route rule (block / fulfill / delay).
1737 var route = matchRoute(url, method);
1738 if (route) {
1739 recordRouteMatch(route, url, method);
1740 if (route.action === 'block') {
1741 entry.status = 'blocked';
1742 entry.blocked = true;
1743 entry.duration_ms = Date.now() - entry.timestamp;
1744 if (isIpc) flushIpcWaiters();
1745 return Promise.reject(new TypeError('victauri: request blocked by route #' + route.id + ' (' + url + ')'));
1746 }
1747 if (route.action === 'fulfill') {
1748 var makeResp = function() {
1749 var bodyStr = (typeof route.body === 'string') ? route.body : JSON.stringify(route.body);
1750 var hdrs = { 'content-type': route.content_type };
1751 for (var k in route.headers) { if (Object.prototype.hasOwnProperty.call(route.headers, k)) hdrs[k] = route.headers[k]; }
1752 entry.status = route.status;
1753 entry.status_text = route.status_text;
1754 entry.mocked = true;
1755 entry.duration_ms = Date.now() - entry.timestamp;
1756 if (isIpc) {
1757 try { entry.response_body = JSON.parse(bodyStr); } catch (e) { entry.response_body = bodyStr; }
1758 flushIpcWaiters();
1759 }
1760 return new Response(bodyStr, { status: route.status, statusText: route.status_text, headers: hdrs });
1761 };
1762 return route.delay_ms > 0
1763 ? new Promise(function(res) { setTimeout(function() { res(makeResp()); }, route.delay_ms); })
1764 : Promise.resolve(makeResp());
1765 }
1766 if (route.action === 'delay' && route.delay_ms > 0) {
1767 return new Promise(function(resolve, reject) {
1768 setTimeout(function() { doRealFetch().then(resolve, reject); }, route.delay_ms);
1769 });
1770 }
1771 }
1772 return doRealFetch();
1773
1774 function doRealFetch() {
1775 return origFetch.call(self, input, init).then(function(response) {
1776 entry.status = response.status;
1777 entry.status_text = response.statusText;
1778 entry.duration_ms = Date.now() - entry.timestamp;
1779
1780 if (isIpc) {
1781 if (window.__VICTAURI__._captureIpcBodies !== false) {
1782 var cloned = response.clone();
1783 cloned.text().then(function(text) {
1784 try { entry.response_body = JSON.parse(text); } catch(e) { entry.response_body = text; }
1785 }).catch(function() {}).then(function() {
1786 flushIpcWaiters();
1787 });
1788 } else {
1789 flushIpcWaiters();
1790 }
1791 }
1792
1793 return response;
1794 }, function(err) {
1795 entry.status = 'error';
1796 entry.error = String(err);
1797 entry.duration_ms = Date.now() - entry.timestamp;
1798 flushIpcWaiters();
1799 throw err;
1800 });
1801 }
1802 };
1803 }
1804
1805 // XMLHttpRequest
1806 var origOpen = XMLHttpRequest.prototype.open;
1807 var origSend = XMLHttpRequest.prototype.send;
1808 XMLHttpRequest.prototype.open = function(method, url) {
1809 this.__victauri_net = { method: method, url: url };
1810 return origOpen.apply(this, arguments);
1811 };
1812 XMLHttpRequest.prototype.send = function() {
1813 if (this.__victauri_net) {
1814 var isVictauriInternal = this.__victauri_net.url.indexOf('plugin%3Avictauri%7C') !== -1
1815 || this.__victauri_net.url.indexOf('plugin:victauri|') !== -1;
1816 if (isVictauriInternal) {
1817 return origSend.apply(this, arguments);
1818 }
1819 var id = ++networkCounter;
1820 var entry = {
1821 id: id,
1822 method: this.__victauri_net.method.toUpperCase(),
1823 url: this.__victauri_net.url,
1824 timestamp: Date.now(),
1825 status: 'pending',
1826 duration_ms: null,
1827 };
1828 networkLog.push(entry);
1829 if (networkLog.length > CAP_NETWORK) networkLog.shift();
1830 var self = this;
1831 this.addEventListener('load', function() {
1832 entry.status = self.status;
1833 entry.status_text = self.statusText;
1834 entry.duration_ms = Date.now() - entry.timestamp;
1835 });
1836 this.addEventListener('error', function() {
1837 entry.status = 'error';
1838 entry.duration_ms = Date.now() - entry.timestamp;
1839 });
1840
1841 // Phase 1 routing for XHR: block + delay are supported here.
1842 // `fulfill` (synthetic response) is fetch-only — faking the full
1843 // XHR response surface is unreliable; document as a limitation.
1844 var xroute = matchRoute(this.__victauri_net.url, this.__victauri_net.method);
1845 if (xroute) {
1846 recordRouteMatch(xroute, this.__victauri_net.url, this.__victauri_net.method);
1847 if (xroute.action === 'block') {
1848 entry.status = 'blocked';
1849 entry.blocked = true;
1850 entry.duration_ms = Date.now() - entry.timestamp;
1851 var blockedXhr = this;
1852 setTimeout(function() {
1853 try { blockedXhr.dispatchEvent(new Event('error')); } catch (e) {}
1854 }, 0);
1855 return; // do not send
1856 }
1857 if ((xroute.action === 'delay' || xroute.action === 'fulfill') && xroute.delay_ms > 0) {
1858 var dArgs = arguments, dSelf = this;
1859 setTimeout(function() { origSend.apply(dSelf, dArgs); }, xroute.delay_ms);
1860 return;
1861 }
1862 }
1863 }
1864 return origSend.apply(this, arguments);
1865 };
1866 })();
1867
1868 // ── Navigation Tracking ──────────────────────────────────────────────────
1869
1870 (function trackNavigation() {
1871 navigationLog.push({ url: window.location.href, timestamp: Date.now(), type: 'initial' });
1872
1873 var origPushState = history.pushState;
1874 var origReplaceState = history.replaceState;
1875 history.pushState = function() {
1876 var result = origPushState.apply(this, arguments);
1877 navigationLog.push({ url: window.location.href, timestamp: Date.now(), type: 'pushState' });
1878 if (navigationLog.length > CAP_NAVIGATION) navigationLog.shift();
1879 return result;
1880 };
1881 history.replaceState = function() {
1882 var result = origReplaceState.apply(this, arguments);
1883 navigationLog.push({ url: window.location.href, timestamp: Date.now(), type: 'replaceState' });
1884 if (navigationLog.length > CAP_NAVIGATION) navigationLog.shift();
1885 return result;
1886 };
1887 window.addEventListener('popstate', function() {
1888 navigationLog.push({ url: window.location.href, timestamp: Date.now(), type: 'popstate' });
1889 });
1890 window.addEventListener('hashchange', function(e) {
1891 navigationLog.push({ url: window.location.href, timestamp: Date.now(), type: 'hashchange', old_url: e.oldURL });
1892 });
1893 })();
1894
1895 // ── Dialog Capture ───────────────────────────────────────────────────────
1896
1897 var dialogAutoResponses = { alert: { action: 'accept' }, confirm: { action: 'accept' }, prompt: { action: 'accept', text: '' } };
1898
1899 // ── Resource Cleanup ────────────────────────────────────────────────────
1900
1901 window.addEventListener('pagehide', function() {
1902 if (__mutationObserver) { __mutationObserver.disconnect(); __mutationObserver = null; }
1903 if (mutationBatchTimer) { clearTimeout(mutationBatchTimer); mutationBatchTimer = null; }
1904 console.log = originalConsole.log;
1905 console.warn = originalConsole.warn;
1906 console.error = originalConsole.error;
1907 console.info = originalConsole.info;
1908 console.debug = originalConsole.debug;
1909 consoleLogs.length = 0;
1910 mutationLog.length = 0;
1911 networkLog.length = 0;
1912 navigationLog.length = 0;
1913 dialogLog.length = 0;
1914 interactionLog.length = 0;
1915 refMap.clear();
1916 weakRefMap.clear();
1917 refCounter = 0;
1918 });
1919
1920 (function captureDialogs() {
1921 window.alert = function(msg) {
1922 dialogLog.push({ type: 'alert', message: String(msg || ''), timestamp: Date.now() });
1923 if (dialogLog.length > CAP_DIALOG) dialogLog.shift();
1924 };
1925 window.confirm = function(msg) {
1926 var resp = dialogAutoResponses.confirm;
1927 var result = resp.action === 'accept';
1928 dialogLog.push({ type: 'confirm', message: String(msg || ''), timestamp: Date.now(), result: result });
1929 if (dialogLog.length > CAP_DIALOG) dialogLog.shift();
1930 return result;
1931 };
1932 window.prompt = function(msg, defaultValue) {
1933 var resp = dialogAutoResponses.prompt;
1934 var result = resp.action === 'accept' ? (resp.text || defaultValue || '') : null;
1935 dialogLog.push({ type: 'prompt', message: String(msg || ''), timestamp: Date.now(), result: result });
1936 if (dialogLog.length > CAP_DIALOG) dialogLog.shift();
1937 return result;
1938 };
1939 })();
1940
1941 // Signal to the Rust backend that the JS bridge is fully initialized.
1942 try {
1943 window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {
1944 id: '__victauri_bridge_ready__',
1945 result: ''
1946 });
1947 } catch(e) {}
1948})();
1949"#;