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