Skip to main content

fission_test_driver/
lib.rs

1//! Automated UI testing client and protocol for Fission applications.
2//!
3//! This crate provides the JSON protocol types (shared between the test client
4//! and the desktop shell server) and a [`LiveTestClient`] that drives a running
5//! Fission application over HTTP.
6//!
7//! # Architecture
8//!
9//! The application must be launched with `FISSION_TEST_CONTROL_PORT=<port>`.
10//! The [`LiveTestClient`] connects to `http://127.0.0.1:<port>` and sends
11//! [`TestCommand`] JSON payloads to `/cmd`, receiving [`TestResponse`] replies.
12
13#[cfg(not(target_arch = "wasm32"))]
14use anyhow::{anyhow, Result};
15#[cfg(not(target_arch = "wasm32"))]
16use base64::Engine;
17use serde::{Deserialize, Serialize};
18
19pub mod golden;
20pub use golden::{compare_png_to_golden, GoldenOptions, GoldenReport};
21
22#[cfg(not(target_arch = "wasm32"))]
23pub mod browser;
24#[cfg(not(target_arch = "wasm32"))]
25pub use browser::{
26    detect_chrome, run_browser_smoke, BrowserSmokeMode, BrowserSmokeReport, BrowserTestOptions,
27};
28
29// --- Protocol types (shared between client and server) ---
30
31/// A command sent from the test client to the running application.
32///
33/// Serialized with `#[serde(tag = "cmd")]`. See the crate-level docs for
34/// the full command reference.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36#[serde(tag = "cmd")]
37pub enum TestCommand {
38    Tap {
39        x: f32,
40        y: f32,
41    },
42    Drag {
43        start_x: f32,
44        start_y: f32,
45        end_x: f32,
46        end_y: f32,
47        steps: u32,
48    },
49    TapText {
50        text: String,
51    },
52    ResolveSelector {
53        query: SelectorQuery,
54    },
55    TapSelector {
56        query: SelectorQuery,
57    },
58    ActivateSelector {
59        query: SelectorQuery,
60    },
61    FocusSelector {
62        query: SelectorQuery,
63    },
64    HoverSelector {
65        query: SelectorQuery,
66    },
67    RightClickSelector {
68        query: SelectorQuery,
69    },
70    ScrollIntoView {
71        query: SelectorQuery,
72    },
73    FillText {
74        query: SelectorQuery,
75        text: String,
76    },
77    ClearText {
78        query: SelectorQuery,
79    },
80    Toggle {
81        query: SelectorQuery,
82    },
83    SelectOption {
84        query: SelectorQuery,
85    },
86    WaitForSelector {
87        query: SelectorQuery,
88        timeout_ms: u64,
89    },
90    WaitForVisible {
91        query: SelectorQuery,
92        timeout_ms: u64,
93    },
94    WaitForEnabled {
95        query: SelectorQuery,
96        timeout_ms: u64,
97    },
98    WaitForDisabled {
99        query: SelectorQuery,
100        timeout_ms: u64,
101    },
102    WaitForValue {
103        query: SelectorQuery,
104        value: String,
105        timeout_ms: u64,
106    },
107    WaitForText {
108        text: String,
109        timeout_ms: u64,
110    },
111    WaitForGone {
112        query: SelectorQuery,
113        timeout_ms: u64,
114    },
115    Scroll {
116        x: f32,
117        y: f32,
118        dx: f32,
119        dy: f32,
120    },
121    ExternalFileHover {
122        x: f32,
123        y: f32,
124        paths: Vec<String>,
125    },
126    ExternalFileDrop {
127        x: f32,
128        y: f32,
129        paths: Vec<String>,
130    },
131    ExternalFileCancel {},
132    TypeText {
133        text: String,
134    },
135    ImePreedit {
136        text: String,
137        cursor_start: Option<usize>,
138        cursor_end: Option<usize>,
139    },
140    ImeCommit {
141        text: String,
142    },
143    ImeCancel {},
144    PressKey {
145        key: String,
146        modifiers: u8,
147    },
148    Screenshot {
149        path: String,
150    },
151    CaptureScreenshot {},
152    PauseAnimations {},
153    ResumeAnimations {},
154    AdvanceClock {
155        ms: u64,
156    },
157    CaptureAt {
158        ms: u64,
159    },
160    WaitForIdle {
161        timeout_ms: u64,
162        ignore_repeating_motion: bool,
163    },
164    GetText {},
165    GetTree {},
166    Wait {
167        ms: u64,
168    },
169    Pump {},
170    Quit {},
171    // NEW: simulate real winit-level events for realistic testing
172    SimulateMouseMove {
173        x: f32,
174        y: f32,
175    },
176    SimulateRightClick {
177        x: f32,
178        y: f32,
179    },
180    SimulateResize {
181        /// Target logical viewport width in test-space pixels.
182        width: u32,
183        /// Target logical viewport height in test-space pixels.
184        height: u32,
185    },
186}
187
188/// Events injected into the winit event loop via `EventLoopProxy`.
189///
190/// Input-simulation variants (`MouseMove`, `MouseDown`, etc.) travel through
191/// the **same** `Event::UserEvent` → handler path as real `WindowEvent`s, so
192/// test code exercises identical code paths as real user interaction.
193///
194/// Query / control variants (`Screenshot`, `GetText`, etc.) also go through
195/// the proxy so the main loop can respond via a dedicated response channel.
196#[derive(Debug, Clone)]
197pub enum TestEvent {
198    // --- Input simulation (mirrors winit WindowEvents) ---
199    MouseMove {
200        x: f32,
201        y: f32,
202    },
203    MouseDown {
204        x: f32,
205        y: f32,
206        button: u8,
207    }, // 0=left, 1=right, 2=middle
208    MouseUp {
209        x: f32,
210        y: f32,
211        button: u8,
212    },
213    KeyDown {
214        key_code: String,
215        modifiers: u8,
216    },
217    KeyUp {
218        key_code: String,
219        modifiers: u8,
220    },
221    TextInput {
222        text: String,
223    },
224    ImePreedit {
225        text: String,
226        cursor: Option<(usize, usize)>,
227    },
228    ImeCommit {
229        text: String,
230    },
231    ImeCancel,
232    Scroll {
233        x: f32,
234        y: f32,
235        dx: f32,
236        dy: f32,
237    },
238    ExternalFileHover {
239        x: f32,
240        y: f32,
241        paths: Vec<String>,
242    },
243    ExternalFileDrop {
244        x: f32,
245        y: f32,
246        paths: Vec<String>,
247    },
248    ExternalFileCancel,
249    Resize {
250        width: u32,
251        height: u32,
252    },
253    // --- Queries / control (need response channel) ---
254    Screenshot {
255        path: String,
256        response_tx: TestResponseSender,
257    },
258    CaptureScreenshot {
259        response_tx: TestResponseSender,
260    },
261    PauseAnimations {
262        response_tx: TestResponseSender,
263    },
264    ResumeAnimations {
265        response_tx: TestResponseSender,
266    },
267    AdvanceClock {
268        ms: u64,
269        response_tx: TestResponseSender,
270    },
271    CaptureAt {
272        ms: u64,
273        response_tx: TestResponseSender,
274    },
275    MotionStatus {
276        response_tx: TestResponseSender,
277    },
278    GetText {
279        response_tx: TestResponseSender,
280    },
281    GetTree {
282        response_tx: TestResponseSender,
283    },
284    Pump {
285        response_tx: TestResponseSender,
286    },
287    Wake,
288    Quit,
289    /// Internal: TapText resolves a text label to coordinates; the server
290    /// injects this so the main loop can do the lookup with access to the IR.
291    TapText {
292        text: String,
293        response_tx: TestResponseSender,
294    },
295    ResolveSelector {
296        query: SelectorQuery,
297        response_tx: TestResponseSender,
298    },
299    TapSelector {
300        query: SelectorQuery,
301        response_tx: TestResponseSender,
302    },
303    ActivateSelector {
304        query: SelectorQuery,
305        response_tx: TestResponseSender,
306    },
307    FocusSelector {
308        query: SelectorQuery,
309        response_tx: TestResponseSender,
310    },
311    HoverSelector {
312        query: SelectorQuery,
313        response_tx: TestResponseSender,
314    },
315    RightClickSelector {
316        query: SelectorQuery,
317        response_tx: TestResponseSender,
318    },
319    ScrollIntoView {
320        query: SelectorQuery,
321        response_tx: TestResponseSender,
322    },
323    FillText {
324        query: SelectorQuery,
325        text: String,
326        response_tx: TestResponseSender,
327    },
328    ClearText {
329        query: SelectorQuery,
330        response_tx: TestResponseSender,
331    },
332    Toggle {
333        query: SelectorQuery,
334        response_tx: TestResponseSender,
335    },
336    SelectOption {
337        query: SelectorQuery,
338        response_tx: TestResponseSender,
339    },
340    /// Internal: Wait is handled server-side (sleep) then responds.
341    Wait {
342        ms: u64,
343        response_tx: TestResponseSender,
344    },
345}
346
347/// A high-level selector for resolving semantic nodes without manual coordinates.
348#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
349#[serde(tag = "kind", rename_all = "snake_case")]
350pub enum Selector {
351    /// Match [`fission_ir::Semantics::identifier`].
352    SemanticIdentifier { identifier: String },
353    /// Match a stable widget id. Accepts a 32-character raw id or an explicit id key.
354    WidgetId { widget_id: String },
355    /// Match a stable test identifier. This currently aliases semantic identifier.
356    TestId { test_id: String },
357    /// Match an accessibility identifier. This currently aliases semantic identifier.
358    AccessibilityIdentifier { identifier: String },
359    /// Match by role and label.
360    RoleLabel { role: String, label: String },
361    /// Match by label only.
362    Label { label: String },
363}
364
365impl Selector {
366    pub fn semantic_identifier(identifier: impl Into<String>) -> Self {
367        Self::SemanticIdentifier {
368            identifier: identifier.into(),
369        }
370    }
371
372    pub fn widget_id(widget_id: impl Into<String>) -> Self {
373        Self::WidgetId {
374            widget_id: widget_id.into(),
375        }
376    }
377
378    pub fn test_id(test_id: impl Into<String>) -> Self {
379        Self::TestId {
380            test_id: test_id.into(),
381        }
382    }
383
384    pub fn accessibility_identifier(identifier: impl Into<String>) -> Self {
385        Self::AccessibilityIdentifier {
386            identifier: identifier.into(),
387        }
388    }
389
390    pub fn role_label(role: impl Into<String>, label: impl Into<String>) -> Self {
391        Self::RoleLabel {
392            role: role.into(),
393            label: label.into(),
394        }
395    }
396
397    pub fn label(label: impl Into<String>) -> Self {
398        Self::Label {
399            label: label.into(),
400        }
401    }
402}
403
404/// A selector query with optional scoping and duplicate disambiguation.
405#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
406pub struct SelectorQuery {
407    pub selector: Selector,
408    #[serde(default, skip_serializing_if = "Option::is_none")]
409    pub scope: Option<Box<SelectorQuery>>,
410    #[serde(default, skip_serializing_if = "Option::is_none")]
411    pub index: Option<usize>,
412    #[serde(default)]
413    pub include_hidden: bool,
414}
415
416impl SelectorQuery {
417    pub fn new(selector: Selector) -> Self {
418        Self {
419            selector,
420            scope: None,
421            index: None,
422            include_hidden: false,
423        }
424    }
425
426    pub fn semantic_identifier(identifier: impl Into<String>) -> Self {
427        Self::new(Selector::semantic_identifier(identifier))
428    }
429
430    pub fn widget_id(widget_id: impl Into<String>) -> Self {
431        Self::new(Selector::widget_id(widget_id))
432    }
433
434    pub fn test_id(test_id: impl Into<String>) -> Self {
435        Self::new(Selector::test_id(test_id))
436    }
437
438    pub fn accessibility_identifier(identifier: impl Into<String>) -> Self {
439        Self::new(Selector::accessibility_identifier(identifier))
440    }
441
442    pub fn role_label(role: impl Into<String>, label: impl Into<String>) -> Self {
443        Self::new(Selector::role_label(role, label))
444    }
445
446    pub fn label(label: impl Into<String>) -> Self {
447        Self::new(Selector::label(label))
448    }
449
450    pub fn scoped(mut self, scope: SelectorQuery) -> Self {
451        self.scope = Some(Box::new(scope));
452        self
453    }
454
455    pub fn index(mut self, index: usize) -> Self {
456        self.index = Some(index);
457        self
458    }
459
460    pub fn include_hidden(mut self) -> Self {
461        self.include_hidden = true;
462        self
463    }
464}
465
466/// A logical rectangle in test-space pixels.
467#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
468pub struct Bounds {
469    pub x: f32,
470    pub y: f32,
471    pub width: f32,
472    pub height: f32,
473}
474
475/// How much of a semantic node is visible after viewport and clipping are applied.
476#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
477pub enum VisibilityState {
478    FullyVisible,
479    PartiallyVisible,
480    Hidden,
481}
482
483/// Machine-readable selector failure category.
484#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
485pub enum SelectorFailureKind {
486    NoMatch,
487    Ambiguous,
488    FoundButNotVisible,
489    Disabled,
490    ReadOnly,
491    UnsupportedAction,
492    Timeout,
493    StaleFrame,
494}
495
496/// A candidate considered while resolving a selector.
497#[derive(Debug, Clone, Serialize, Deserialize)]
498pub struct SelectorCandidate {
499    pub node: SemanticNode,
500    pub rejected_reason: Option<String>,
501}
502
503/// Detailed selector failure response.
504#[derive(Debug, Clone, Serialize, Deserialize)]
505pub struct SelectorFailure {
506    pub kind: SelectorFailureKind,
507    pub selector: SelectorQuery,
508    pub candidates: Vec<SelectorCandidate>,
509    pub message: String,
510}
511
512/// A visible text element with its bounding rectangle, in logical test-space
513/// pixels, returned by [`TestCommand::GetText`].
514#[derive(Debug, Clone, Serialize, Deserialize)]
515pub struct TextItem {
516    pub text: String,
517    pub x: f32,
518    pub y: f32,
519    pub width: f32,
520    pub height: f32,
521}
522
523/// A node in the semantic accessibility tree, returned by [`TestCommand::GetTree`].
524/// Bounding rectangles are expressed in logical test-space pixels.
525#[derive(Debug, Clone, Serialize, Deserialize)]
526pub struct SemanticNode {
527    pub identifier: Option<String>,
528    pub widget_id: String,
529    pub stable_node_id: String,
530    pub parent: Option<String>,
531    pub children: Vec<String>,
532    pub role: String,
533    pub label: Option<String>,
534    pub value: Option<String>,
535    pub value_present: bool,
536    pub focusable: bool,
537    pub disabled: bool,
538    pub read_only: bool,
539    pub checked: Option<bool>,
540    pub actions: Vec<String>,
541    pub text_selection: Option<(usize, usize)>,
542    pub masked: bool,
543    pub scrollable_x: bool,
544    pub scrollable_y: bool,
545    pub logical_bounds: Bounds,
546    pub visible_bounds: Option<Bounds>,
547    pub visibility: VisibilityState,
548    pub x: f32,
549    pub y: f32,
550    pub width: f32,
551    pub height: f32,
552}
553
554/// The response from the application to a [`TestCommand`].
555#[derive(Debug, Clone, Serialize, Deserialize)]
556#[serde(tag = "status")]
557pub enum TestResponse {
558    Ok {},
559    Text {
560        items: Vec<TextItem>,
561    },
562    Tree {
563        nodes: Vec<SemanticNode>,
564    },
565    Screenshot {
566        png_base64: String,
567        /// PNG width in logical test-space pixels.
568        width: u32,
569        /// PNG height in logical test-space pixels.
570        height: u32,
571    },
572    MotionStatus {
573        finite: usize,
574        repeating: usize,
575        ripples: usize,
576    },
577    SelectorResolved {
578        node: SemanticNode,
579    },
580    SelectorError {
581        failure: SelectorFailure,
582    },
583    Error {
584        message: String,
585    },
586}
587
588/// Per-command response channel used by the shell event loop.
589pub type TestResponseSender = std::sync::mpsc::Sender<TestResponse>;
590
591// --- Client ---
592
593/// An HTTP client that drives a running Fission application for automated UI testing.
594///
595/// Connect to a running application via [`LiveTestClient::connect(port)`]. The
596/// application must have been started with `FISSION_TEST_CONTROL_PORT=<port>`.
597///
598/// # Example
599///
600/// ```rust,ignore
601/// let client = LiveTestClient::connect(9876);
602/// client.wait_for_ready(5000).unwrap();
603/// client.tap_text("Submit").unwrap();
604/// client.assert_text_visible("Success").unwrap();
605/// client.screenshot("/tmp/result.png").unwrap();
606/// client.quit().unwrap();
607/// ```
608#[cfg(not(target_arch = "wasm32"))]
609pub struct LiveTestClient {
610    base_url: String,
611}
612
613#[cfg(not(target_arch = "wasm32"))]
614impl LiveTestClient {
615    pub fn connect(port: u16) -> Self {
616        Self {
617            base_url: format!("http://127.0.0.1:{}", port),
618        }
619    }
620
621    pub fn wait_for_ready(&self, timeout_ms: u64) -> Result<()> {
622        let start = std::time::Instant::now();
623        let timeout = std::time::Duration::from_millis(timeout_ms);
624        loop {
625            match ureq::get(&format!("{}/health", self.base_url)).call() {
626                Ok(_) => return Ok(()),
627                Err(_) => {
628                    if start.elapsed() > timeout {
629                        return Err(anyhow!("timed out waiting for test server"));
630                    }
631                    std::thread::sleep(std::time::Duration::from_millis(100));
632                }
633            }
634        }
635    }
636
637    fn send(&self, cmd: TestCommand) -> Result<TestResponse> {
638        let body = serde_json::to_string(&cmd)?;
639        let resp = ureq::post(&format!("{}/cmd", self.base_url))
640            .set("Content-Type", "application/json")
641            .send_string(&body)
642            .map_err(|e| anyhow!("request failed: {}", e))?;
643        let text = resp.into_string()?;
644        let response: TestResponse = serde_json::from_str(&text)?;
645        if let TestResponse::Error { message } = &response {
646            return Err(anyhow!("server error: {}", message));
647        }
648        if let TestResponse::SelectorError { failure } = &response {
649            return Err(anyhow!("selector error: {}", failure.message));
650        }
651        Ok(response)
652    }
653
654    pub fn tap(&self, x: f32, y: f32) -> Result<()> {
655        self.send(TestCommand::Tap { x, y })?;
656        Ok(())
657    }
658
659    pub fn tap_text(&self, text: &str) -> Result<()> {
660        // Pump first to ensure layout positions are current
661        self.pump()?;
662        self.send(TestCommand::TapText {
663            text: text.to_string(),
664        })?;
665        // Pump after to render the result of the tap
666        self.pump()?;
667        Ok(())
668    }
669
670    pub fn tap_text_without_pump(&self, text: &str) -> Result<()> {
671        self.send(TestCommand::TapText {
672            text: text.to_string(),
673        })?;
674        Ok(())
675    }
676
677    pub fn resolve_selector(&self, query: SelectorQuery) -> Result<SemanticNode> {
678        match self.send(TestCommand::ResolveSelector { query })? {
679            TestResponse::SelectorResolved { node } => Ok(node),
680            other => Err(anyhow!(
681                "unexpected response to ResolveSelector: {:?}",
682                other
683            )),
684        }
685    }
686
687    pub fn scroll_into_view(&self, query: SelectorQuery) -> Result<SemanticNode> {
688        let node = match self.send(TestCommand::ScrollIntoView { query })? {
689            TestResponse::SelectorResolved { node } => node,
690            other => {
691                return Err(anyhow!(
692                    "unexpected response to ScrollIntoView: {:?}",
693                    other
694                ))
695            }
696        };
697        self.pump()?;
698        Ok(node)
699    }
700
701    pub fn tap_selector(&self, query: SelectorQuery) -> Result<()> {
702        self.pump()?;
703        self.send(TestCommand::TapSelector { query })?;
704        self.pump()?;
705        Ok(())
706    }
707
708    pub fn tap_semantic_identifier(&self, identifier: &str) -> Result<()> {
709        self.tap_selector(SelectorQuery::semantic_identifier(identifier))
710    }
711
712    pub fn activate_selector(&self, query: SelectorQuery) -> Result<()> {
713        self.pump()?;
714        self.send(TestCommand::ActivateSelector { query })?;
715        self.pump()?;
716        Ok(())
717    }
718
719    pub fn focus_selector(&self, query: SelectorQuery) -> Result<()> {
720        self.pump()?;
721        self.send(TestCommand::FocusSelector { query })?;
722        self.pump()?;
723        Ok(())
724    }
725
726    pub fn hover_selector(&self, query: SelectorQuery) -> Result<()> {
727        self.pump()?;
728        self.send(TestCommand::HoverSelector { query })?;
729        self.pump()?;
730        Ok(())
731    }
732
733    pub fn right_click_selector(&self, query: SelectorQuery) -> Result<()> {
734        self.pump()?;
735        self.send(TestCommand::RightClickSelector { query })?;
736        self.pump()?;
737        Ok(())
738    }
739
740    pub fn fill_text_selector(&self, query: SelectorQuery, text: &str) -> Result<()> {
741        self.pump()?;
742        self.send(TestCommand::FillText {
743            query,
744            text: text.to_string(),
745        })?;
746        self.pump()?;
747        Ok(())
748    }
749
750    pub fn fill_text_semantic_identifier(&self, identifier: &str, text: &str) -> Result<()> {
751        self.fill_text_selector(SelectorQuery::semantic_identifier(identifier), text)
752    }
753
754    pub fn clear_text_selector(&self, query: SelectorQuery) -> Result<()> {
755        self.pump()?;
756        self.send(TestCommand::ClearText { query })?;
757        self.pump()?;
758        Ok(())
759    }
760
761    pub fn toggle_selector(&self, query: SelectorQuery) -> Result<()> {
762        self.pump()?;
763        self.send(TestCommand::Toggle { query })?;
764        self.pump()?;
765        Ok(())
766    }
767
768    pub fn select_option(&self, query: SelectorQuery) -> Result<()> {
769        self.pump()?;
770        self.send(TestCommand::SelectOption { query })?;
771        self.pump()?;
772        Ok(())
773    }
774
775    pub fn wait_for_selector(&self, query: SelectorQuery, timeout_ms: u64) -> Result<()> {
776        self.send(TestCommand::WaitForSelector { query, timeout_ms })?;
777        Ok(())
778    }
779
780    pub fn wait_for_visible(&self, query: SelectorQuery, timeout_ms: u64) -> Result<()> {
781        self.send(TestCommand::WaitForVisible { query, timeout_ms })?;
782        Ok(())
783    }
784
785    pub fn wait_for_enabled(&self, query: SelectorQuery, timeout_ms: u64) -> Result<()> {
786        self.send(TestCommand::WaitForEnabled { query, timeout_ms })?;
787        Ok(())
788    }
789
790    pub fn wait_for_disabled(&self, query: SelectorQuery, timeout_ms: u64) -> Result<()> {
791        self.send(TestCommand::WaitForDisabled { query, timeout_ms })?;
792        Ok(())
793    }
794
795    pub fn wait_for_value(&self, query: SelectorQuery, value: &str, timeout_ms: u64) -> Result<()> {
796        self.send(TestCommand::WaitForValue {
797            query,
798            value: value.to_string(),
799            timeout_ms,
800        })?;
801        Ok(())
802    }
803
804    pub fn wait_for_text(&self, text: &str, timeout_ms: u64) -> Result<()> {
805        self.send(TestCommand::WaitForText {
806            text: text.to_string(),
807            timeout_ms,
808        })?;
809        Ok(())
810    }
811
812    pub fn wait_for_gone(&self, query: SelectorQuery, timeout_ms: u64) -> Result<()> {
813        self.send(TestCommand::WaitForGone { query, timeout_ms })?;
814        Ok(())
815    }
816
817    pub fn drag(
818        &self,
819        start_x: f32,
820        start_y: f32,
821        end_x: f32,
822        end_y: f32,
823        steps: u32,
824    ) -> Result<()> {
825        self.send(TestCommand::Drag {
826            start_x,
827            start_y,
828            end_x,
829            end_y,
830            steps,
831        })?;
832        self.pump()?;
833        Ok(())
834    }
835
836    pub fn external_file_hover(&self, x: f32, y: f32, paths: impl Into<Vec<String>>) -> Result<()> {
837        self.send(TestCommand::ExternalFileHover {
838            x,
839            y,
840            paths: paths.into(),
841        })?;
842        self.pump()?;
843        Ok(())
844    }
845
846    pub fn external_file_drop(&self, x: f32, y: f32, paths: impl Into<Vec<String>>) -> Result<()> {
847        self.send(TestCommand::ExternalFileDrop {
848            x,
849            y,
850            paths: paths.into(),
851        })?;
852        self.pump()?;
853        Ok(())
854    }
855
856    pub fn external_file_cancel(&self) -> Result<()> {
857        self.send(TestCommand::ExternalFileCancel {})?;
858        self.pump()?;
859        Ok(())
860    }
861
862    pub fn scroll(&self, x: f32, y: f32, dx: f32, dy: f32) -> Result<()> {
863        self.send(TestCommand::Scroll { x, y, dx, dy })?;
864        Ok(())
865    }
866
867    pub fn press_key(&self, key: &str, modifiers: u8) -> Result<()> {
868        self.send(TestCommand::PressKey {
869            key: key.to_string(),
870            modifiers,
871        })?;
872        self.pump()?;
873        Ok(())
874    }
875
876    pub fn type_text(&self, text: &str) -> Result<()> {
877        self.send(TestCommand::TypeText {
878            text: text.to_string(),
879        })?;
880        Ok(())
881    }
882
883    pub fn ime_preedit(&self, text: &str, cursor: Option<(usize, usize)>) -> Result<()> {
884        self.send(TestCommand::ImePreedit {
885            text: text.to_string(),
886            cursor_start: cursor.map(|range| range.0),
887            cursor_end: cursor.map(|range| range.1),
888        })?;
889        self.pump()?;
890        Ok(())
891    }
892
893    pub fn ime_commit(&self, text: &str) -> Result<()> {
894        self.send(TestCommand::ImeCommit {
895            text: text.to_string(),
896        })?;
897        self.pump()?;
898        Ok(())
899    }
900
901    pub fn ime_cancel(&self) -> Result<()> {
902        self.send(TestCommand::ImeCancel {})?;
903        self.pump()?;
904        Ok(())
905    }
906
907    pub fn screenshot(&self, path: &str) -> Result<()> {
908        std::fs::write(path, self.capture_screenshot_png()?)?;
909        Ok(())
910    }
911
912    /// Captures the current frame as encoded PNG bytes.
913    pub fn capture_screenshot_png(&self) -> Result<Vec<u8>> {
914        screenshot_bytes(self.send(TestCommand::CaptureScreenshot {})?)
915    }
916
917    /// Compares the current frame to a golden image and writes an optional heatmap.
918    pub fn compare_golden(
919        &self,
920        golden_path: impl AsRef<std::path::Path>,
921        diff_path: Option<impl AsRef<std::path::Path>>,
922        options: GoldenOptions,
923    ) -> Result<GoldenReport> {
924        let report = compare_png_to_golden(
925            &self.capture_screenshot_png()?,
926            golden_path,
927            diff_path,
928            options,
929        )?;
930        if !report.passed(options) {
931            return Err(anyhow!(
932                "golden comparison changed {:.4}% of pixels (allowed {:.4}%)",
933                report.changed_percent,
934                options.max_changed_percent
935            ));
936        }
937        Ok(report)
938    }
939
940    /// Freezes the animation clock while leaving production motion declarations active.
941    pub fn pause_animations(&self) -> Result<()> {
942        self.send(TestCommand::PauseAnimations {})?;
943        Ok(())
944    }
945
946    /// Resumes advancing animations from the currently frozen clock value.
947    pub fn resume_animations(&self) -> Result<()> {
948        self.send(TestCommand::ResumeAnimations {})?;
949        Ok(())
950    }
951
952    /// Deterministically advances the application and motion clock.
953    pub fn advance_clock(&self, ms: u64) -> Result<()> {
954        self.send(TestCommand::AdvanceClock { ms })?;
955        self.pump()
956    }
957
958    /// Advances the clock and captures the resulting frame to `path`.
959    pub fn capture_at(&self, ms: u64, path: &str) -> Result<()> {
960        std::fs::write(path, self.capture_at_png(ms)?)?;
961        Ok(())
962    }
963
964    /// Advances the clock and returns the resulting frame as encoded PNG bytes.
965    pub fn capture_at_png(&self, ms: u64) -> Result<Vec<u8>> {
966        screenshot_bytes(self.send(TestCommand::CaptureAt { ms })?)
967    }
968
969    /// Waits for finite motion to settle, optionally ignoring repeating motion.
970    pub fn wait_for_idle(&self, timeout_ms: u64, ignore_repeating_motion: bool) -> Result<()> {
971        self.send(TestCommand::WaitForIdle {
972            timeout_ms,
973            ignore_repeating_motion,
974        })?;
975        Ok(())
976    }
977
978    pub fn get_text(&self) -> Result<Vec<TextItem>> {
979        match self.send(TestCommand::GetText {})? {
980            TestResponse::Text { items } => Ok(items),
981            other => Err(anyhow!("unexpected response: {:?}", other)),
982        }
983    }
984
985    pub fn get_tree(&self) -> Result<Vec<SemanticNode>> {
986        match self.send(TestCommand::GetTree {})? {
987            TestResponse::Tree { nodes } => Ok(nodes),
988            other => Err(anyhow!("unexpected response: {:?}", other)),
989        }
990    }
991
992    pub fn wait(&self, ms: u64) -> Result<()> {
993        self.send(TestCommand::Wait { ms })?;
994        Ok(())
995    }
996
997    pub fn pump(&self) -> Result<()> {
998        self.send(TestCommand::Pump {})?;
999        Ok(())
1000    }
1001
1002    pub fn quit(&self) -> Result<()> {
1003        let _ = self.send(TestCommand::Quit {});
1004        Ok(())
1005    }
1006
1007    // --- NEW: simulate real winit-level events ---
1008
1009    /// Simulate a mouse move to (x, y) — goes through the real CursorMoved path.
1010    pub fn simulate_mouse_move(&self, x: f32, y: f32) -> Result<()> {
1011        self.send(TestCommand::SimulateMouseMove { x, y })?;
1012        Ok(())
1013    }
1014
1015    /// Simulate a right-click at (x, y) — move + down + up with right button.
1016    pub fn right_click(&self, x: f32, y: f32) -> Result<()> {
1017        self.send(TestCommand::SimulateRightClick { x, y })?;
1018        Ok(())
1019    }
1020
1021    /// Simulate a window resize in logical test-space pixels.
1022    pub fn simulate_resize(&self, width: u32, height: u32) -> Result<()> {
1023        self.send(TestCommand::SimulateResize { width, height })?;
1024        Ok(())
1025    }
1026
1027    // --- High-level helpers ---
1028
1029    pub fn tap_text_and_wait(&self, text: &str, ms: u64) -> Result<()> {
1030        self.tap_text(text)?;
1031        self.wait(ms)?;
1032        Ok(())
1033    }
1034
1035    pub fn assert_text_visible(&self, needle: &str) -> Result<()> {
1036        let items = self.get_text()?;
1037        let found = items.iter().any(|t| t.text.contains(needle));
1038        if !found {
1039            let all: Vec<&str> = items.iter().map(|t| t.text.as_str()).collect();
1040            return Err(anyhow!(
1041                "expected '{}' to be visible, found: {:?}",
1042                needle,
1043                &all[..all.len().min(20)]
1044            ));
1045        }
1046        Ok(())
1047    }
1048
1049    pub fn assert_text_not_visible(&self, needle: &str) -> Result<()> {
1050        let items = self.get_text()?;
1051        let found = items.iter().any(|t| t.text.contains(needle));
1052        if found {
1053            return Err(anyhow!("expected '{}' to NOT be visible", needle));
1054        }
1055        Ok(())
1056    }
1057}
1058
1059#[cfg(not(target_arch = "wasm32"))]
1060fn screenshot_bytes(response: TestResponse) -> Result<Vec<u8>> {
1061    match response {
1062        TestResponse::Screenshot {
1063            png_base64,
1064            width: _,
1065            height: _,
1066        } => base64::engine::general_purpose::STANDARD
1067            .decode(png_base64)
1068            .map_err(|error| anyhow!("invalid screenshot payload: {error}")),
1069        other => Err(anyhow!("expected screenshot response, received {other:?}")),
1070    }
1071}
1072
1073#[cfg(test)]
1074mod tests {
1075    use super::TestCommand;
1076
1077    #[test]
1078    fn deterministic_motion_commands_have_stable_wire_shapes() {
1079        assert_eq!(
1080            serde_json::to_value(TestCommand::PauseAnimations {}).expect("serialize pause"),
1081            serde_json::json!({ "cmd": "PauseAnimations" })
1082        );
1083        assert_eq!(
1084            serde_json::to_value(TestCommand::AdvanceClock { ms: 160 })
1085                .expect("serialize clock advance"),
1086            serde_json::json!({ "cmd": "AdvanceClock", "ms": 160 })
1087        );
1088        assert_eq!(
1089            serde_json::to_value(TestCommand::WaitForIdle {
1090                timeout_ms: 2_000,
1091                ignore_repeating_motion: true,
1092            })
1093            .expect("serialize idle wait"),
1094            serde_json::json!({
1095                "cmd": "WaitForIdle",
1096                "timeout_ms": 2_000,
1097                "ignore_repeating_motion": true
1098            })
1099        );
1100    }
1101}