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