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