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