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