Skip to main content

victauri_plugin/
js_bridge.rs

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