Skip to main content

fission_shell_winit/
test_control.rs

1#[cfg(not(target_arch = "wasm32"))]
2use fission_test_driver::TestCommand;
3#[cfg(not(target_arch = "wasm32"))]
4use fission_test_driver::TestEvent;
5use fission_test_driver::TestResponse;
6#[cfg(not(target_arch = "wasm32"))]
7use std::collections::VecDeque;
8#[cfg(not(target_arch = "wasm32"))]
9use std::io::{Read, Write};
10#[cfg(not(target_arch = "wasm32"))]
11use std::net::{TcpListener, TcpStream};
12use std::sync::mpsc;
13#[cfg(not(target_arch = "wasm32"))]
14use std::sync::{Arc, Mutex};
15#[cfg(not(target_arch = "wasm32"))]
16use winit::event_loop::EventLoopProxy;
17
18/// Sender for query responses from the main event loop back to the TCP server.
19pub type ResponseSender = fission_test_driver::TestResponseSender;
20/// Receiver for query responses.
21pub type ResponseReceiver = mpsc::Receiver<TestResponse>;
22/// Shared queue used on platforms where winit user events are unreliable.
23#[cfg(not(target_arch = "wasm32"))]
24pub type PendingEventQueue = Arc<Mutex<VecDeque<TestEvent>>>;
25
26#[cfg(not(target_arch = "wasm32"))]
27#[derive(Clone)]
28pub enum EventInjector {
29    Proxy(EventLoopProxy<TestEvent>),
30    Queue {
31        queue: PendingEventQueue,
32        wake_proxy: Option<EventLoopProxy<TestEvent>>,
33    },
34}
35
36#[cfg(not(target_arch = "wasm32"))]
37pub fn create_pending_event_queue() -> PendingEventQueue {
38    Arc::new(Mutex::new(VecDeque::new()))
39}
40
41/// Spawn the TCP test-control server.
42#[cfg(not(target_arch = "wasm32"))]
43pub fn spawn_server(port: u16, injector: EventInjector) -> std::thread::JoinHandle<()> {
44    std::thread::spawn(move || {
45        let listener = TcpListener::bind(format!("127.0.0.1:{}", port))
46            .unwrap_or_else(|e| panic!("failed to bind test control port {}: {}", port, e));
47        eprintln!("[fission-test-control] listening on port {}", port);
48
49        for stream in listener.incoming() {
50            match stream {
51                Ok(stream) => handle_connection(stream, &injector),
52                Err(e) => eprintln!("[fission-test-control] accept error: {}", e),
53            }
54        }
55    })
56}
57
58#[cfg(not(target_arch = "wasm32"))]
59fn handle_connection(mut stream: TcpStream, injector: &EventInjector) {
60    let mut buf = Vec::new();
61    let mut tmp = [0u8; 4096];
62
63    loop {
64        match stream.read(&mut tmp) {
65            Ok(0) => return,
66            Ok(n) => {
67                buf.extend_from_slice(&tmp[..n]);
68                if buf.windows(4).any(|w| w == b"\r\n\r\n") {
69                    break;
70                }
71            }
72            Err(_) => return,
73        }
74    }
75
76    let request = String::from_utf8_lossy(&buf);
77    let first_line = request.lines().next().unwrap_or("");
78    let parts: Vec<&str> = first_line.split_whitespace().collect();
79    let method = parts.first().copied().unwrap_or("");
80    let path = parts.get(1).copied().unwrap_or("");
81
82    if path == "/health" {
83        send_http_response(&mut stream, 200, r#"{"status":"ok"}"#);
84        return;
85    }
86
87    if method != "POST" || path != "/cmd" {
88        send_http_response(
89            &mut stream,
90            404,
91            r#"{"status":"Error","message":"not found"}"#,
92        );
93        return;
94    }
95
96    let content_length = request
97        .lines()
98        .find(|line| line.to_lowercase().starts_with("content-length:"))
99        .and_then(|line| line.split(':').nth(1))
100        .and_then(|value| value.trim().parse::<usize>().ok())
101        .unwrap_or(0);
102
103    let header_end = buf
104        .windows(4)
105        .position(|w| w == b"\r\n\r\n")
106        .map(|pos| pos + 4)
107        .unwrap_or(buf.len());
108
109    let mut body = buf[header_end..].to_vec();
110    while body.len() < content_length {
111        match stream.read(&mut tmp) {
112            Ok(0) => break,
113            Ok(n) => body.extend_from_slice(&tmp[..n]),
114            Err(_) => break,
115        }
116    }
117
118    let body_str = String::from_utf8_lossy(&body);
119    let cmd: TestCommand = match serde_json::from_str(&body_str) {
120        Ok(cmd) => cmd,
121        Err(error) => {
122            let resp = TestResponse::Error {
123                message: format!("parse error: {}", error),
124            };
125            send_http_response(&mut stream, 400, &serde_json::to_string(&resp).unwrap());
126            return;
127        }
128    };
129
130    let response = dispatch_command(cmd, injector);
131    send_http_response(&mut stream, 200, &serde_json::to_string(&response).unwrap());
132}
133
134#[cfg(not(target_arch = "wasm32"))]
135fn dispatch_command(cmd: TestCommand, injector: &EventInjector) -> TestResponse {
136    match cmd {
137        TestCommand::Tap { x, y } => {
138            inject_event(injector, TestEvent::MouseMove { x, y });
139            inject_event(injector, TestEvent::MouseDown { x, y, button: 0 });
140            inject_event(injector, TestEvent::MouseUp { x, y, button: 0 });
141            TestResponse::Ok {}
142        }
143        TestCommand::Drag {
144            start_x,
145            start_y,
146            end_x,
147            end_y,
148            steps,
149        } => {
150            let steps = steps.max(1);
151            inject_event(
152                injector,
153                TestEvent::MouseMove {
154                    x: start_x,
155                    y: start_y,
156                },
157            );
158            inject_event(
159                injector,
160                TestEvent::MouseDown {
161                    x: start_x,
162                    y: start_y,
163                    button: 0,
164                },
165            );
166            for step in 1..=steps {
167                let t = step as f32 / steps as f32;
168                let x = start_x + (end_x - start_x) * t;
169                let y = start_y + (end_y - start_y) * t;
170                inject_event(injector, TestEvent::MouseMove { x, y });
171            }
172            inject_event(
173                injector,
174                TestEvent::MouseUp {
175                    x: end_x,
176                    y: end_y,
177                    button: 0,
178                },
179            );
180            TestResponse::Ok {}
181        }
182        TestCommand::TapText { text } => query_event(injector, |response_tx| TestEvent::TapText {
183            text,
184            response_tx,
185        }),
186        TestCommand::ResolveSelector { query } => query_event(injector, |response_tx| {
187            TestEvent::ResolveSelector { query, response_tx }
188        }),
189        TestCommand::ScrollIntoView { query } => query_event(injector, |response_tx| {
190            TestEvent::ScrollIntoView { query, response_tx }
191        }),
192        TestCommand::TapSelector { query } => {
193            auto_scroll_then_query(injector, query, |query, response_tx| {
194                TestEvent::TapSelector { query, response_tx }
195            })
196        }
197        TestCommand::ActivateSelector { query } => {
198            auto_scroll_then_query(injector, query, |query, response_tx| {
199                TestEvent::ActivateSelector { query, response_tx }
200            })
201        }
202        TestCommand::FocusSelector { query } => {
203            auto_scroll_then_query(injector, query, |query, response_tx| {
204                TestEvent::FocusSelector { query, response_tx }
205            })
206        }
207        TestCommand::HoverSelector { query } => {
208            auto_scroll_then_query(injector, query, |query, response_tx| {
209                TestEvent::HoverSelector { query, response_tx }
210            })
211        }
212        TestCommand::RightClickSelector { query } => {
213            auto_scroll_then_query(injector, query, |query, response_tx| {
214                TestEvent::RightClickSelector { query, response_tx }
215            })
216        }
217        TestCommand::FillText { query, text } => {
218            auto_scroll_then_query(injector, query, |query, response_tx| TestEvent::FillText {
219                query,
220                text,
221                response_tx,
222            })
223        }
224        TestCommand::ClearText { query } => {
225            auto_scroll_then_query(injector, query, |query, response_tx| TestEvent::ClearText {
226                query,
227                response_tx,
228            })
229        }
230        TestCommand::Toggle { query } => {
231            auto_scroll_then_query(injector, query, |query, response_tx| TestEvent::Toggle {
232                query,
233                response_tx,
234            })
235        }
236        TestCommand::SelectOption { query } => {
237            auto_scroll_then_query(injector, query, |query, response_tx| {
238                TestEvent::SelectOption { query, response_tx }
239            })
240        }
241        TestCommand::Scroll { x, y, dx, dy } => {
242            inject_event(injector, TestEvent::Scroll { x, y, dx, dy });
243            TestResponse::Ok {}
244        }
245        TestCommand::ExternalFileHover { x, y, paths } => {
246            inject_event(injector, TestEvent::ExternalFileHover { x, y, paths });
247            TestResponse::Ok {}
248        }
249        TestCommand::ExternalFileDrop { x, y, paths } => {
250            inject_event(injector, TestEvent::ExternalFileDrop { x, y, paths });
251            TestResponse::Ok {}
252        }
253        TestCommand::ExternalFileCancel {} => {
254            inject_event(injector, TestEvent::ExternalFileCancel);
255            TestResponse::Ok {}
256        }
257        TestCommand::TypeText { text } => {
258            inject_event(injector, TestEvent::TextInput { text });
259            TestResponse::Ok {}
260        }
261        TestCommand::ImePreedit {
262            text,
263            cursor_start,
264            cursor_end,
265        } => {
266            let cursor = match (cursor_start, cursor_end) {
267                (Some(start), Some(end)) => Some((start, end)),
268                _ => None,
269            };
270            inject_event(injector, TestEvent::ImePreedit { text, cursor });
271            TestResponse::Ok {}
272        }
273        TestCommand::ImeCommit { text } => {
274            inject_event(injector, TestEvent::ImeCommit { text });
275            TestResponse::Ok {}
276        }
277        TestCommand::ImeCancel {} => {
278            inject_event(injector, TestEvent::ImeCancel);
279            TestResponse::Ok {}
280        }
281        TestCommand::PressKey { key, modifiers } => {
282            inject_event(
283                injector,
284                TestEvent::KeyDown {
285                    key_code: key.clone(),
286                    modifiers,
287                },
288            );
289            inject_event(
290                injector,
291                TestEvent::KeyUp {
292                    key_code: key,
293                    modifiers,
294                },
295            );
296            TestResponse::Ok {}
297        }
298        TestCommand::Screenshot { path } => query_event(injector, |response_tx| {
299            TestEvent::Screenshot { path, response_tx }
300        }),
301        TestCommand::CaptureScreenshot {} => query_event(injector, |response_tx| {
302            TestEvent::CaptureScreenshot { response_tx }
303        }),
304        TestCommand::GetText {} => {
305            query_event(injector, |response_tx| TestEvent::GetText { response_tx })
306        }
307        TestCommand::GetTree {} => {
308            query_event(injector, |response_tx| TestEvent::GetTree { response_tx })
309        }
310        TestCommand::Wait { ms } => {
311            std::thread::sleep(std::time::Duration::from_millis(ms));
312            TestResponse::Ok {}
313        }
314        TestCommand::WaitForSelector { query, timeout_ms } => {
315            wait_for_selector_state(injector, query, timeout_ms, SelectorWaitCondition::Present)
316        }
317        TestCommand::WaitForVisible { query, timeout_ms } => {
318            wait_for_selector_state(injector, query, timeout_ms, SelectorWaitCondition::Visible)
319        }
320        TestCommand::WaitForEnabled { query, timeout_ms } => {
321            wait_for_selector_state(injector, query, timeout_ms, SelectorWaitCondition::Enabled)
322        }
323        TestCommand::WaitForDisabled { query, timeout_ms } => {
324            wait_for_selector_state(injector, query, timeout_ms, SelectorWaitCondition::Disabled)
325        }
326        TestCommand::WaitForValue {
327            query,
328            value,
329            timeout_ms,
330        } => wait_for_selector_state(
331            injector,
332            query,
333            timeout_ms,
334            SelectorWaitCondition::Value(value),
335        ),
336        TestCommand::WaitForText { text, timeout_ms } => wait_for_text(injector, text, timeout_ms),
337        TestCommand::WaitForGone { query, timeout_ms } => {
338            wait_for_selector_state(injector, query, timeout_ms, SelectorWaitCondition::Gone)
339        }
340        TestCommand::Pump {} => {
341            query_event(injector, |response_tx| TestEvent::Pump { response_tx })
342        }
343        TestCommand::Quit {} => {
344            inject_event(injector, TestEvent::Quit);
345            TestResponse::Ok {}
346        }
347        TestCommand::SimulateMouseMove { x, y } => {
348            inject_event(injector, TestEvent::MouseMove { x, y });
349            TestResponse::Ok {}
350        }
351        TestCommand::SimulateRightClick { x, y } => {
352            inject_event(injector, TestEvent::MouseMove { x, y });
353            inject_event(injector, TestEvent::MouseDown { x, y, button: 1 });
354            inject_event(injector, TestEvent::MouseUp { x, y, button: 1 });
355            TestResponse::Ok {}
356        }
357        TestCommand::SimulateResize { width, height } => {
358            inject_event(injector, TestEvent::Resize { width, height });
359            TestResponse::Ok {}
360        }
361    }
362}
363
364#[cfg(not(target_arch = "wasm32"))]
365fn auto_scroll_then_query<F>(
366    injector: &EventInjector,
367    query: fission_test_driver::SelectorQuery,
368    make_event: F,
369) -> TestResponse
370where
371    F: FnOnce(fission_test_driver::SelectorQuery, ResponseSender) -> TestEvent,
372{
373    let scroll = query_event(injector, |response_tx| TestEvent::ScrollIntoView {
374        query: query.clone().include_hidden(),
375        response_tx,
376    });
377    if matches!(
378        scroll,
379        TestResponse::Error { .. } | TestResponse::SelectorError { .. }
380    ) {
381        return scroll;
382    }
383    let pump = query_event(injector, |response_tx| TestEvent::Pump { response_tx });
384    if matches!(
385        pump,
386        TestResponse::Error { .. } | TestResponse::SelectorError { .. }
387    ) {
388        return pump;
389    }
390    query_event(injector, |response_tx| make_event(query, response_tx))
391}
392
393#[cfg(not(target_arch = "wasm32"))]
394enum SelectorWaitCondition {
395    Present,
396    Visible,
397    Enabled,
398    Disabled,
399    Value(String),
400    Gone,
401}
402
403#[cfg(not(target_arch = "wasm32"))]
404fn wait_for_selector_state(
405    injector: &EventInjector,
406    query: fission_test_driver::SelectorQuery,
407    timeout_ms: u64,
408    condition: SelectorWaitCondition,
409) -> TestResponse {
410    let start = std::time::Instant::now();
411    let timeout = std::time::Duration::from_millis(timeout_ms);
412    loop {
413        let resp = query_event(injector, |response_tx| TestEvent::ResolveSelector {
414            query: query.clone(),
415            response_tx,
416        });
417        let matched = match (&condition, &resp) {
418            (SelectorWaitCondition::Gone, TestResponse::SelectorError { failure })
419                if failure.kind == fission_test_driver::SelectorFailureKind::NoMatch =>
420            {
421                true
422            }
423            (SelectorWaitCondition::Present, TestResponse::SelectorResolved { .. }) => true,
424            (SelectorWaitCondition::Visible, TestResponse::SelectorResolved { node }) => {
425                node.visibility != fission_test_driver::VisibilityState::Hidden
426            }
427            (SelectorWaitCondition::Enabled, TestResponse::SelectorResolved { node }) => {
428                !node.disabled
429            }
430            (SelectorWaitCondition::Disabled, TestResponse::SelectorResolved { node }) => {
431                node.disabled
432            }
433            (SelectorWaitCondition::Value(expected), TestResponse::SelectorResolved { node }) => {
434                node.value.as_deref() == Some(expected.as_str())
435            }
436            _ => false,
437        };
438        if matched {
439            return TestResponse::Ok {};
440        }
441        if start.elapsed() >= timeout {
442            let candidates = match resp {
443                TestResponse::SelectorResolved { node } => {
444                    vec![fission_test_driver::SelectorCandidate {
445                        node,
446                        rejected_reason: Some("wait condition did not pass".into()),
447                    }]
448                }
449                TestResponse::SelectorError { failure } => failure.candidates,
450                _ => Vec::new(),
451            };
452            return TestResponse::SelectorError {
453                failure: fission_test_driver::SelectorFailure {
454                    kind: fission_test_driver::SelectorFailureKind::Timeout,
455                    selector: query,
456                    candidates,
457                    message: format!("timed out after {timeout_ms}ms waiting for selector"),
458                },
459            };
460        }
461        let _ = query_event(injector, |response_tx| TestEvent::Pump { response_tx });
462        std::thread::sleep(std::time::Duration::from_millis(25));
463    }
464}
465
466#[cfg(not(target_arch = "wasm32"))]
467fn wait_for_text(injector: &EventInjector, text: String, timeout_ms: u64) -> TestResponse {
468    let start = std::time::Instant::now();
469    let timeout = std::time::Duration::from_millis(timeout_ms);
470    loop {
471        match query_event(injector, |response_tx| TestEvent::GetText { response_tx }) {
472            TestResponse::Text { items } if items.iter().any(|item| item.text.contains(&text)) => {
473                return TestResponse::Ok {};
474            }
475            TestResponse::Error { message } => return TestResponse::Error { message },
476            _ => {}
477        }
478        if start.elapsed() >= timeout {
479            return TestResponse::Error {
480                message: format!("timed out after {timeout_ms}ms waiting for text `{text}`"),
481            };
482        }
483        let _ = query_event(injector, |response_tx| TestEvent::Pump { response_tx });
484        std::thread::sleep(std::time::Duration::from_millis(25));
485    }
486}
487
488#[cfg(not(target_arch = "wasm32"))]
489fn query_event<F>(injector: &EventInjector, make_event: F) -> TestResponse
490where
491    F: FnOnce(ResponseSender) -> TestEvent,
492{
493    let (response_tx, response_rx) = mpsc::channel();
494    inject_event(injector, make_event(response_tx));
495    wait_for_response(&response_rx)
496}
497
498#[cfg(not(target_arch = "wasm32"))]
499fn inject_event(injector: &EventInjector, event: TestEvent) {
500    match injector {
501        EventInjector::Proxy(proxy) => {
502            let _ = proxy.send_event(event);
503        }
504        EventInjector::Queue { queue, wake_proxy } => {
505            #[cfg(target_os = "android")]
506            let debug_android_events = std::env::var_os("FISSION_DEBUG_ANDROID_EVENTS").is_some();
507            #[cfg(target_os = "android")]
508            if debug_android_events {
509                eprintln!("[android-debug] queue_inject={event:?}");
510            }
511            if let Ok(mut pending) = queue.lock() {
512                pending.push_back(event);
513                #[cfg(target_os = "android")]
514                if debug_android_events {
515                    eprintln!("[android-debug] queue_len={}", pending.len());
516                }
517            }
518            if let Some(proxy) = wake_proxy {
519                #[cfg(target_os = "android")]
520                if debug_android_events {
521                    eprintln!("[android-debug] wake_send");
522                }
523                let _ = proxy.send_event(TestEvent::Wake);
524            }
525        }
526    }
527}
528
529/// Block until the main event loop sends a response, with a 30-second timeout.
530#[cfg(not(target_arch = "wasm32"))]
531fn wait_for_response(rx: &ResponseReceiver) -> TestResponse {
532    match rx.recv_timeout(std::time::Duration::from_secs(30)) {
533        Ok(resp) => resp,
534        Err(_) => TestResponse::Error {
535            message: "timeout waiting for response from event loop".into(),
536        },
537    }
538}
539
540#[cfg(not(target_arch = "wasm32"))]
541fn send_http_response(stream: &mut TcpStream, status: u16, body: &str) {
542    let status_text = match status {
543        200 => "OK",
544        400 => "Bad Request",
545        404 => "Not Found",
546        500 => "Internal Server Error",
547        504 => "Gateway Timeout",
548        _ => "Unknown",
549    };
550    let response = format!(
551        "HTTP/1.1 {} {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
552        status, status_text, body.len(), body
553    );
554    let _ = stream.write_all(response.as_bytes());
555}