Skip to main content

tauri_plugin_hasgard/
lib.rs

1pub mod diff;
2mod error;
3#[cfg(any(unix, windows))]
4pub(crate) mod eval;
5#[cfg(any(unix, windows))]
6mod handler;
7#[cfg(feature = "press")]
8pub(crate) mod key;
9pub(crate) mod protocol;
10pub(crate) mod recorder;
11// Native screenshot capture for the `screenshot_native` JSON-RPC method.
12// macOS-only today; non-macOS callers receive `PERMISSION_DENIED`.
13pub(crate) mod screenshot;
14#[cfg(any(unix, windows))]
15pub(crate) mod server;
16
17pub use error::Error;
18
19#[cfg(any(unix, windows))]
20use eval::EvalEngine;
21#[cfg(any(unix, windows))]
22use recorder::Recorder;
23#[cfg(any(unix, windows))]
24use server::{EvalFn, ListWindowsFn, PressHooksRef};
25#[cfg(any(unix, windows))]
26use std::sync::Arc;
27#[cfg(any(unix, windows))]
28use tauri::Manager;
29
30#[cfg(all(any(unix, windows), debug_assertions))]
31pub(crate) const BRIDGE_JS: &str =
32    concat!(include_str!("../js/vendor/html-to-image.iife.js"), "\n", include_str!("../js/bridge.js"));
33
34/// Initialize the tauri-hasgard plugin.
35///
36/// On non-Unix, non-Windows platforms or in release builds, returns a no-op plugin.
37/// In debug builds on Unix, injects the JS bridge, stores an `EvalEngine`,
38/// and starts a Unix socket server at `TAURI_HASGARD_SOCKET` when set, otherwise
39/// at `$XDG_RUNTIME_DIR/tauri-hasgard-{identifier}.sock`.
40/// In debug builds on Windows, starts a Named Pipe server at
41/// `\\.\pipe\tauri-hasgard-{identifier}` and registers the instance under `%LOCALAPPDATA%\tauri-hasgard\instances\`.
42#[must_use]
43pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
44    #[cfg(not(all(any(unix, windows), debug_assertions)))]
45    {
46        return tauri::plugin::Builder::new("hasgard").build();
47    }
48
49    #[cfg(all(any(unix, windows), debug_assertions))]
50    {
51        tauri::plugin::Builder::new("hasgard")
52            .js_init_script(BRIDGE_JS.to_owned())
53            .setup(|app, _api| {
54                let engine = EvalEngine::new();
55                app.manage(engine.clone());
56
57                let identifier = sanitize_identifier(&app.config().identifier);
58                let socket_path = match std::env::var_os("TAURI_HASGARD_SOCKET") {
59                    Some(path) => std::path::PathBuf::from(path),
60                    None => server::socket_path(&identifier),
61                };
62
63                let eval_fn = make_eval_fn(app);
64                let list_fn = make_list_fn(app);
65                let press_hooks = make_press_hooks(app);
66
67                let recorder = Recorder::new();
68
69                // Unix binds with the std (sync) `UnixListener`, which needs no
70                // tokio runtime, so binding stays here in `setup` where a failure
71                // surfaces as a hard plugin error. `run` only upgrades the
72                // listener to tokio once it is already on the runtime.
73                #[cfg(unix)]
74                {
75                    let (listener, guard) = server::bind(&socket_path).map_err(|e| {
76                        tracing::error!(path = %socket_path.display(), "failed to bind socket: {e}");
77                        e
78                    })?;
79                    tauri::async_runtime::spawn(server::run(
80                        listener,
81                        guard,
82                        engine,
83                        Some(eval_fn),
84                        Some(list_fn),
85                        Some(press_hooks),
86                        recorder,
87                    ));
88                }
89
90                // Windows' tokio `NamedPipeServer` registers with the reactor the
91                // instant it is created, so the bind must run inside the spawned
92                // task (which lives on the tokio runtime). Binding here in `setup`
93                // panics with "there is no reactor running, must be called from
94                // the context of a Tokio 1.x runtime" (#115).
95                #[cfg(windows)]
96                tauri::async_runtime::spawn(server::run(
97                    socket_path,
98                    engine,
99                    Some(eval_fn),
100                    Some(list_fn),
101                    Some(press_hooks),
102                    recorder,
103                ));
104
105                Ok(())
106            })
107            .invoke_handler(tauri::generate_handler![handler::callback, handler::__callback])
108            .build()
109    }
110}
111
112/// Strip path separators and unsafe characters from the app identifier
113/// so it can be safely used in a socket filename.
114#[cfg(all(any(unix, windows), debug_assertions))]
115fn sanitize_identifier(raw: &str) -> String {
116    let sanitized: String = raw
117        .chars()
118        .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '_' })
119        .collect();
120    if sanitized.is_empty() { "default".to_owned() } else { sanitized }
121}
122
123/// Create an eval function from the app handle that evaluates JS in a webview.
124///
125/// If `window` is `Some(label)`, targets that specific window (error if not found).
126/// If `window` is `None`, targets the conventional `main` window.
127#[cfg(all(any(unix, windows), debug_assertions))]
128fn make_eval_fn<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> EvalFn {
129    let handle = app.clone();
130    Arc::new(move |window: Option<&str>, script: String| {
131        let target = if let Some(label) = window {
132            handle.get_webview_window(label).ok_or_else(|| format!("Window '{label}' not found"))?
133        } else {
134            handle.get_webview_window("main").ok_or_else(|| "Window 'main' not found".to_owned())?
135        };
136        // Results come back via the `__callback` IPC command (see
137        // EvalEngine::wrap_script). This eval is fire-and-forget; the IPC handler
138        // resolves the pending request, not this closure.
139        target.eval(&script).map_err(|e| e.to_string())
140    })
141}
142
143/// Create the host hooks the `press` path needs: window focus, plus a runner
144/// that executes a closure on the application's main thread.
145///
146/// Resolution mirrors `make_eval_fn`: explicit label first, otherwise `main`.
147#[cfg(all(any(unix, windows), debug_assertions))]
148fn make_press_hooks<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> PressHooksRef {
149    let focus_handle = app.clone();
150    // Only the macOS runner hops threads; elsewhere this handle would be an
151    // unused-variable warning.
152    #[cfg(target_os = "macos")]
153    let main_handle = app.clone();
154    Arc::new(crate::server::PressHooks {
155        focus: Box::new(move |window: Option<&str>| {
156            let target = if let Some(label) = window {
157                focus_handle.get_webview_window(label).ok_or_else(|| format!("Window '{label}' not found"))?
158            } else {
159                focus_handle.get_webview_window("main").ok_or_else(|| "Window 'main' not found".to_owned())?
160            };
161            target.set_focus().map_err(|e| e.to_string())?;
162
163            #[cfg(windows)]
164            {
165                use std::sync::mpsc;
166                use webview2_com::Microsoft::Web::WebView2::Win32::COREWEBVIEW2_MOVE_FOCUS_REASON_PROGRAMMATIC;
167
168                let (sender, receiver) = mpsc::sync_channel(1);
169                target
170                    .with_webview(move |webview| {
171                        let result =
172                            unsafe { webview.controller().MoveFocus(COREWEBVIEW2_MOVE_FOCUS_REASON_PROGRAMMATIC) }
173                                .map_err(|error| error.to_string());
174                        sender.send(result).expect("focus result receiver must exist");
175                    })
176                    .map_err(|error| error.to_string())?;
177                receiver
178                    .recv_timeout(std::time::Duration::from_secs(2))
179                    .map_err(|error| format!("WebView focus timed out: {error}"))??;
180            }
181
182            Ok(())
183        }),
184        // macOS is the only backend that constrains which thread may inject.
185        // Its layout lookup reaches `TSMGetInputSourceProperty`, which asserts
186        // it is on the main dispatch queue and aborts the whole process with
187        // SIGTRAP when it is not — a crash, not a recoverable error.
188        #[cfg(target_os = "macos")]
189        run_injection: Box::new(move |task: Box<dyn FnOnce() + Send>| {
190            let (done_tx, done_rx) = std::sync::mpsc::sync_channel(1);
191            main_handle
192                .run_on_main_thread(move || {
193                    task();
194                    // A closed receiver means the caller timed out and gave up;
195                    // the work still ran, so there is nothing to report.
196                    let _ = done_tx.send(());
197                })
198                .map_err(|error| format!("could not reach the main thread: {error}"))?;
199            done_rx
200                .recv_timeout(std::time::Duration::from_secs(10))
201                .map_err(|error| format!("main-thread task did not finish: {error}"))
202        }),
203        // Windows' `SendInput` and the Linux X11/libei backends inject from any
204        // thread, so the hop would buy nothing and cost something: it would
205        // stall the UI thread for the length of the combo, and on Linux the
206        // portal handshake inside `Enigo::new` can need the main loop to turn —
207        // which is precisely what blocking on it prevents. Run in place.
208        #[cfg(not(target_os = "macos"))]
209        run_injection: Box::new(|task: Box<dyn FnOnce() + Send>| {
210            task();
211            Ok(())
212        }),
213    })
214}
215
216/// The `CGWindowID` of a webview's host window, which is what
217/// `screenshot_native` captures by.
218///
219/// `NSWindow.windowNumber` *is* the `CGWindowID` for on-screen windows, so this
220/// is an exact lookup. The alternative — matching the CoreGraphics window list
221/// by owner and title — guesses, and guesses wrong as soon as an app opens two
222/// windows with the same title.
223///
224/// Returns `None` rather than an error when the id cannot be read: a window id
225/// is a convenience field on `windows.list`, and failing to read it must not
226/// take down an otherwise valid window listing.
227///
228/// The read hops to the main thread because `NSWindow` is `AppKit`, and `AppKit` is
229/// only safe there. `windows.list` is served from the socket's tokio task, so
230/// messaging the window directly would be an off-main-thread `AppKit` call — the
231/// same mistake that made `press` abort the host process on a plain character.
232/// This one happens not to assert today, which is exactly why it is worth not
233/// relying on. `WebviewWindow::title` already blocks on the main thread the same
234/// way, so this adds no new hazard to the listing path.
235#[cfg(all(target_os = "macos", debug_assertions))]
236fn native_window_id<R: tauri::Runtime>(window: &tauri::WebviewWindow<R>) -> Option<u32> {
237    use objc2_app_kit::NSWindow;
238
239    let target = window.clone();
240    let (id_tx, id_rx) = std::sync::mpsc::sync_channel(1);
241    window
242        .run_on_main_thread(move || {
243            let id = target.ns_window().ok().filter(|ptr| !ptr.is_null()).and_then(|ptr| {
244                // SAFETY: `ns_window()` returns the `NSWindow *` Tauri holds for
245                // this webview, valid for as long as the window is alive — which
246                // it is, since we are iterating the live window map. The pointer
247                // is only borrowed for the duration of this read, on the thread
248                // that owns it, and never stored.
249                let ns_window: &NSWindow = unsafe { &*ptr.cast::<NSWindow>() };
250                u32::try_from(ns_window.windowNumber()).ok()
251            });
252            // A closed receiver means the listing already gave up waiting; the
253            // id is optional, so there is nothing to report.
254            let _ = id_tx.send(id);
255        })
256        .ok()?;
257    // Bounded so a wedged main thread degrades the listing to "no id" instead
258    // of hanging the whole `windows.list` call.
259    id_rx.recv_timeout(std::time::Duration::from_secs(2)).ok().flatten()
260}
261
262#[cfg(all(any(unix, windows), debug_assertions, not(target_os = "macos")))]
263fn native_window_id<R: tauri::Runtime>(_window: &tauri::WebviewWindow<R>) -> Option<u32> {
264    None
265}
266
267/// Create a list function that enumerates all available webview windows.
268#[cfg(all(any(unix, windows), debug_assertions))]
269fn make_list_fn<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> ListWindowsFn {
270    let handle = app.clone();
271    Arc::new(move || {
272        let windows = handle.webview_windows();
273        // BTreeMap iterates in sorted key order — no explicit sort needed
274        let list: Result<Vec<serde_json::Value>, String> = windows
275            .iter()
276            .map(|(label, wv)| {
277                let url = wv.url().map_err(|error| format!("Failed to read URL for window '{label}': {error}"))?;
278                let title =
279                    wv.title().map_err(|error| format!("Failed to read title for window '{label}': {error}"))?;
280                let mut entry = serde_json::json!({
281                    "label": label,
282                    "url": url.to_string(),
283                    "title": title,
284                });
285                // Present only where native capture exists, so a client can tell
286                // "this platform cannot capture natively" from "this window has
287                // no id" without a second probe.
288                if let Some(native_id) = native_window_id(wv) {
289                    entry["native_id"] = serde_json::json!(native_id);
290                }
291                Ok(entry)
292            })
293            .collect();
294        Ok(serde_json::json!({"windows": list?}))
295    })
296}
297
298#[cfg(test)]
299mod tests {
300    #[cfg(all(any(unix, windows), debug_assertions))]
301    #[test]
302    fn bridge_js_contains_html_to_image_and_hasgard() {
303        let js = super::BRIDGE_JS;
304        assert!(js.contains("htmlToImage"), "BRIDGE_JS must include the html-to-image IIFE bundle");
305        assert!(js.contains("window.__HASGARD__"), "BRIDGE_JS must include the hasgard bridge");
306        let html_idx = js.find("htmlToImage").expect("htmlToImage missing");
307        let hasgard_idx = js.find("window.__HASGARD__").expect("window.__HASGARD__ missing");
308        assert!(html_idx < hasgard_idx, "html-to-image must be injected before hasgard bridge code");
309    }
310
311    /// The plugin embeds `bridge.js` at build time, so this guards that the
312    /// embedded copy is the real click implementation and not a stale or
313    /// truncated asset.
314    ///
315    /// It asserts on source order only where source order *is* the behaviour:
316    /// the five dispatch calls run top to bottom inside one function. What each
317    /// call produces at runtime is covered by `bridge.click.test.mjs`, which
318    /// executes the code rather than reading it -- so anything this file could
319    /// only check by matching exact whitespace belongs there, not here. An
320    /// earlier version pinned indentation and broke the moment the gesture was
321    /// wrapped in a loop, without any behaviour having changed.
322    #[cfg(all(any(unix, windows), debug_assertions))]
323    #[test]
324    fn bridge_click_dispatches_pointer_sequence() {
325        let js = super::BRIDGE_JS;
326        let scroll_idx = js
327            .find(r#"el.scrollIntoView({ behavior: "instant", block: "center", inline: "center" })"#)
328            .expect("click must scroll the target into view");
329        let pointer_down_idx = js
330            .find(r#"dispatchPointerEvent(el, "pointerdown""#)
331            .expect("click must dispatch pointerdown for Radix triggers");
332        let mouse_down_idx = js.find(r#"MouseEvent("mousedown""#).expect("click must keep mousedown compatibility");
333        let pointer_up_idx = js
334            .find(r#"dispatchPointerEvent(el, "pointerup""#)
335            .expect("click must dispatch pointerup for Radix triggers");
336        let mouse_up_idx = js.find(r#"MouseEvent("mouseup""#).expect("click must keep mouseup compatibility");
337        let click_idx = js.find(r#"dispatchPointerEvent(el, "click""#).expect("click must dispatch as a pointer event");
338
339        assert!(
340            scroll_idx < pointer_down_idx
341                && pointer_down_idx < mouse_down_idx
342                && mouse_down_idx < pointer_up_idx
343                && pointer_up_idx < mouse_up_idx
344                && mouse_up_idx < click_idx,
345            "click must dispatch pointerdown -> mousedown -> pointerup -> mouseup -> click"
346        );
347        assert!(js.contains(r#"pointerType: "mouse""#), "pointer events must include mouse pointer metadata");
348        assert!(js.contains("const rect = el.getBoundingClientRect()"), "click must measure the target");
349
350        // Both compatibility events stay behind the pointerdown gate. Matched
351        // without surrounding whitespace so that reindenting cannot fail this.
352        let gated = js.matches("if (pointerDownOk) {").count();
353        assert!(gated >= 2, "mousedown and mouseup must each sit behind a pointerDownOk gate, found {gated}");
354    }
355
356    #[cfg(all(any(unix, windows), debug_assertions))]
357    #[test]
358    fn bridge_scroll_handles_top_and_bottom_directions() {
359        let js = super::BRIDGE_JS;
360        assert!(js.contains(r#"if (dir === "top")"#), "scroll must handle direction \"top\"");
361        assert!(js.contains(r#"if (dir === "bottom")"#), "scroll must handle direction \"bottom\"");
362        assert!(
363            js.contains("target.scrollTo(window.scrollX, 0)"),
364            "scroll top on window must preserve window.scrollX and set Y=0"
365        );
366        assert!(
367            js.contains("target.scrollTo(window.scrollX, Math.max(0, max))"),
368            "scroll bottom on window must preserve window.scrollX and clamp negative max"
369        );
370        assert!(
371            js.contains("Math.max(")
372                && js.contains("docEl ? docEl.scrollHeight : 0")
373                && js.contains("body ? body.scrollHeight : 0"),
374            "scroll bottom on window must use Math.max(documentElement.scrollHeight, body.scrollHeight) for quirks-mode safety"
375        );
376        assert!(
377            js.contains("docEl ? docEl.clientHeight : window.innerHeight"),
378            "scroll bottom on window must subtract docEl.clientHeight (excludes horizontal scrollbar) instead of window.innerHeight"
379        );
380        assert!(
381            js.contains("String(dir).slice(0, 64)"),
382            "scroll error message must cap user-supplied direction length"
383        );
384        assert!(js.contains("target.scrollTop = 0"), "scroll top on element must set scrollTop = 0");
385        assert!(
386            js.contains("target.scrollTop = Math.max(0, target.scrollHeight - target.clientHeight)"),
387            "scroll bottom on element must use scrollHeight - clientHeight (not raw scrollHeight)"
388        );
389        assert!(
390            js.contains("Unknown scroll direction:"),
391            "scroll must throw on unknown direction instead of silently no-op"
392        );
393    }
394
395    #[cfg(all(any(unix, windows), debug_assertions))]
396    #[test]
397    fn bridge_eval_auto_wraps_top_level_await() {
398        // #79: top-level `await` in user scripts must compile via the
399        // async-IIFE fallback stages instead of crashing with an opaque
400        // SyntaxError from indirect eval.
401        let js = super::BRIDGE_JS;
402        assert!(js.contains("function evalScript("), "BRIDGE_JS must define evalScript");
403        assert!(
404            js.contains("(async () => (\\n\" + script + \"\\n))()"),
405            "evalScript must include the async-expression compile stage (#79)"
406        );
407        assert!(
408            js.contains("hasTopLevelAwait(script)"),
409            "evalScript must guard the async fallbacks with hasTopLevelAwait (#79)"
410        );
411        assert!(
412            js.contains("(async () => {\\n\" + script + \"\\n})()"),
413            "evalScript must include the async-statement IIFE fallback (#79)"
414        );
415        assert!(js.contains("function hasTopLevelAwait("), "BRIDGE_JS must define the hasTopLevelAwait helper (#79)");
416        assert!(
417            js.contains("top-level await detected but the script could not be auto-wrapped"),
418            "evalScript must surface a clear error when auto-wrap fails (#79)"
419        );
420
421        // Stage ordering: expression compile must precede the async fallbacks,
422        // and the async-expression stage must precede the indirect-eval path.
423        // Needles are formatting-stable substrings of the JS source, so a
424        // future `prettier`/`rustfmt` reflow of `bridge.js` does not silently
425        // break the ordering check.
426        let evalscript_idx = js.find("function evalScript(").expect("evalScript missing");
427        // SAFETY: the needle is ASCII, so `find()` returns a UTF-8 char boundary.
428        let body = &js[evalscript_idx..];
429        let expr_idx = body.find("\"return (\\n\" + script + \"\\n)\"").expect("stage 1 expression compile missing");
430        let async_expr_idx = body
431            .find("\"return (async () => (\\n\" + script + \"\\n))()\"")
432            .expect("stage 2 async-expression compile missing");
433        let async_stmt_idx = body
434            .find("\"return (async () => {\\n\" + script + \"\\n})()\"")
435            .expect("stage 3 async-statement IIFE missing");
436        let indirect_idx = body.find("var indirectEval = eval;").expect("indirect eval fallback missing");
437        assert!(expr_idx < async_expr_idx, "expression compile must precede async-expression fallback");
438        assert!(async_expr_idx < async_stmt_idx, "async-expression must precede async-statement fallback");
439        assert!(
440            async_stmt_idx < indirect_idx,
441            "async-statement IIFE must precede plain indirect eval (await guard runs first)"
442        );
443    }
444
445    #[cfg(all(any(unix, windows), debug_assertions))]
446    #[test]
447    fn bridge_native_value_setter_picks_prototype_per_element() {
448        // #85: `fill` and `type` on a <textarea> threw
449        // "The HTMLInputElement.value setter can only be used on instances of HTMLInputElement"
450        // because the old code used
451        //   Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")
452        //   || Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value");
453        // The first descriptor is always truthy, so the textarea branch was unreachable
454        // and the input setter was applied to a textarea, violating the WebIDL brand check.
455        let js = super::BRIDGE_JS;
456
457        assert!(
458            js.contains("function nativeValueSetter("),
459            "BRIDGE_JS must define a nativeValueSetter helper that picks the prototype based on the element (#85)"
460        );
461
462        // The helper must use the element's actual prototype to support input,
463        // textarea, and select uniformly without violating the brand check.
464        assert!(
465            js.contains("Object.getPrototypeOf(el)"),
466            "nativeValueSetter must derive the prototype from the element instance (#85)"
467        );
468
469        // Buggy short-circuit must be gone from fill/typeText.
470        let buggy_pattern = "Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, \"value\") ||";
471        assert!(
472            !js.contains(buggy_pattern),
473            "fill/typeText must not use the `HTMLInputElement.prototype || HTMLTextAreaElement.prototype` short-circuit (#85)"
474        );
475
476        // Bound each function body by the start of the next `function ` declaration
477        // (or end-of-string), so the slice is immune to brace indentation changes
478        // and to nested blocks closing with the same brace pattern.
479        // ASCII needles → `find()` returns offsets that are valid UTF-8 char boundaries.
480        let body_of = |fn_decl: &str| -> &str {
481            let start = js.find(fn_decl).unwrap_or_else(|| panic!("{fn_decl} missing"));
482            let after = start + fn_decl.len();
483            let end = js[after..].find("\n  function ").map_or(js.len(), |off| after + off);
484            &js[start..end]
485        };
486
487        let fill_body = body_of("function fill(params)");
488        let type_body = body_of("function typeText(params)");
489        let select_body = body_of("function select(params)");
490
491        assert!(fill_body.contains("nativeValueSetter("), "fill must call nativeValueSetter (#85)");
492        assert!(type_body.contains("nativeValueSetter("), "typeText must call nativeValueSetter (#85)");
493        assert!(
494            select_body.contains("nativeValueSetter("),
495            "select must call nativeValueSetter (#85) so a future textarea-style brand-check bug cannot reappear in any setter handler"
496        );
497
498        // The pre-refactor `select` relied on the WebIDL brand check to reject
499        // non-<select> targets implicitly. The helper drops that guarantee, so
500        // `select` must keep an explicit guard to fail fast on misrouted
501        // selectors instead of silently writing `value` on an unrelated
502        // element. The guard must be realm-safe (tag-based, not `instanceof`),
503        // because `nativeValueSetter` was added specifically to support
504        // elements coming from another window/iframe realm.
505        assert!(
506            select_body.contains("select requires a <select> element"),
507            "select must explicitly reject non-<select> targets after the nativeValueSetter refactor (#85)"
508        );
509        assert!(
510            !select_body.contains("instanceof HTMLSelectElement"),
511            "select guard must be realm-safe — `instanceof HTMLSelectElement` rejects valid <select> elements from another realm, which contradicts the cross-realm support that motivated nativeValueSetter (#85)"
512        );
513
514        // Helper must be defined before its callers (hoisting works for `function`
515        // declarations, but ordering keeps the source readable for reviewers).
516        let fill_idx = js.find("function fill(params)").expect("fill function missing");
517        let helper_idx = js.find("function nativeValueSetter(").expect("nativeValueSetter helper missing");
518        assert!(helper_idx < fill_idx, "nativeValueSetter must be declared before fill (#85)");
519    }
520
521    #[cfg(all(any(unix, windows), debug_assertions))]
522    #[test]
523    fn bridge_role_map_maps_paragraph_and_keeps_it_noninteractive() {
524        // #109: <p> text (e.g. the default Tauri template greeting rendered in
525        // a <p>) was dropped from snapshots because ROLE_MAP had no P entry, so
526        // getRole returned null and walk() never emitted the node.
527        let js = super::BRIDGE_JS;
528
529        assert!(
530            js.contains("P: \"paragraph\""),
531            "ROLE_MAP must map P to \"paragraph\" so snapshot includes <p> text (#109)"
532        );
533
534        // The paragraph role must stay non-interactive so `snapshot --interactive`
535        // still excludes <p>. Verify INTERACTIVE_ROLES does not list it.
536        let set_start = js.find("INTERACTIVE_ROLES = new Set([").expect("INTERACTIVE_ROLES set missing");
537        let set_body = &js[set_start..];
538        let set_end = set_body.find("]);").expect("INTERACTIVE_ROLES set unterminated");
539        assert!(
540            !set_body[..set_end].contains("\"paragraph\""),
541            "paragraph must stay out of INTERACTIVE_ROLES so interactive snapshots still exclude <p> (#109)"
542        );
543    }
544}