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