Skip to main content

fission_test_driver/
browser.rs

1#[cfg(not(target_arch = "wasm32"))]
2use anyhow::{anyhow, Context, Result};
3#[cfg(not(target_arch = "wasm32"))]
4use base64::Engine;
5#[cfg(not(target_arch = "wasm32"))]
6use serde::{Deserialize, Serialize};
7#[cfg(not(target_arch = "wasm32"))]
8use serde_json::{json, Value};
9#[cfg(not(target_arch = "wasm32"))]
10use std::collections::VecDeque;
11#[cfg(not(target_arch = "wasm32"))]
12use std::fs;
13#[cfg(not(target_arch = "wasm32"))]
14use std::net::TcpListener;
15#[cfg(not(target_arch = "wasm32"))]
16use std::path::PathBuf;
17#[cfg(not(target_arch = "wasm32"))]
18use std::process::{Child, Command, Stdio};
19#[cfg(not(target_arch = "wasm32"))]
20use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
21#[cfg(not(target_arch = "wasm32"))]
22use tungstenite::{connect, Message};
23
24#[cfg(not(target_arch = "wasm32"))]
25#[derive(Clone, Copy, Debug, PartialEq, Eq)]
26pub enum BrowserSmokeMode {
27    Dom,
28    FissionCanvas,
29}
30
31#[cfg(not(target_arch = "wasm32"))]
32#[derive(Clone, Debug)]
33pub struct BrowserTestOptions {
34    pub url: String,
35    pub mode: BrowserSmokeMode,
36    pub chrome_path: Option<PathBuf>,
37    pub cdp_port: Option<u16>,
38    pub viewport_width: u32,
39    pub viewport_height: u32,
40    pub timeout_ms: u64,
41    pub screenshot_path: Option<PathBuf>,
42}
43
44#[cfg(not(target_arch = "wasm32"))]
45impl BrowserTestOptions {
46    pub fn new(url: impl Into<String>) -> Self {
47        Self {
48            url: url.into(),
49            mode: BrowserSmokeMode::Dom,
50            chrome_path: None,
51            cdp_port: None,
52            viewport_width: 1280,
53            viewport_height: 900,
54            timeout_ms: 60_000,
55            screenshot_path: None,
56        }
57    }
58
59    pub fn fission_canvas(mut self) -> Self {
60        self.mode = BrowserSmokeMode::FissionCanvas;
61        self
62    }
63
64    pub fn screenshot(mut self, path: impl Into<PathBuf>) -> Self {
65        self.screenshot_path = Some(path.into());
66        self
67    }
68}
69
70#[cfg(not(target_arch = "wasm32"))]
71#[derive(Clone, Debug, Serialize, Deserialize)]
72pub struct BrowserSmokeReport {
73    pub url: String,
74    pub title: String,
75    pub width: u32,
76    pub height: u32,
77    pub renderer: Option<String>,
78    pub body_text_len: usize,
79    pub screenshot_path: Option<PathBuf>,
80}
81
82#[cfg(not(target_arch = "wasm32"))]
83pub fn detect_chrome() -> Option<PathBuf> {
84    if let Some(path) = std::env::var_os("FISSION_CHROME").map(PathBuf::from) {
85        if path.is_file() {
86            return Some(path);
87        }
88    }
89    for candidate in [
90        "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
91        "/Applications/Chromium.app/Contents/MacOS/Chromium",
92        "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
93    ] {
94        let path = PathBuf::from(candidate);
95        if path.is_file() {
96            return Some(path);
97        }
98    }
99    for candidate in ["google-chrome", "chromium", "chromium-browser", "chrome"] {
100        if let Ok(output) = Command::new("sh")
101            .arg("-c")
102            .arg(format!("command -v {candidate}"))
103            .output()
104        {
105            if output.status.success() {
106                let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
107                if !value.is_empty() {
108                    return Some(PathBuf::from(value));
109                }
110            }
111        }
112    }
113    None
114}
115
116#[cfg(not(target_arch = "wasm32"))]
117pub fn run_browser_smoke(options: BrowserTestOptions) -> Result<BrowserSmokeReport> {
118    let chrome = options
119        .chrome_path
120        .clone()
121        .or_else(detect_chrome)
122        .context("Chrome/Chromium was not found; set FISSION_CHROME=/path/to/chrome")?;
123    let cdp_port = options.cdp_port.unwrap_or_else(free_port);
124    let mut session = ChromeSession::launch(&chrome, cdp_port, &options)?;
125    let ws_url = wait_for_target(
126        cdp_port,
127        &options.url,
128        Duration::from_millis(options.timeout_ms),
129    )?;
130    let mut client = CdpClient::connect(&ws_url)?;
131    client.send("Runtime.enable", json!({}))?;
132    client.send("Log.enable", json!({}))?;
133    client.send("Page.enable", json!({}))?;
134    client.send(
135        "Emulation.setDeviceMetricsOverride",
136        json!({
137            "width": options.viewport_width,
138            "height": options.viewport_height,
139            "deviceScaleFactor": 1,
140            "mobile": false
141        }),
142    )?;
143
144    let deadline = Instant::now() + Duration::from_millis(options.timeout_ms);
145    let mut last_status = None;
146    while Instant::now() < deadline {
147        client.drain_events(Duration::from_millis(25))?;
148        if !client.errors.is_empty() {
149            return Err(anyhow!(
150                "browser reported errors:\n{}",
151                client.errors.join("\n")
152            ));
153        }
154        let status = read_runtime_status(&mut client)?;
155        let ready = match options.mode {
156            BrowserSmokeMode::Dom => status.ready_dom,
157            BrowserSmokeMode::FissionCanvas => status.ready_canvas && status.renderer.is_some(),
158        };
159        if ready {
160            if let Some(path) = &options.screenshot_path {
161                capture_screenshot(&mut client, path)?;
162            }
163            let report = BrowserSmokeReport {
164                url: options.url.clone(),
165                title: status.title,
166                width: status.width,
167                height: status.height,
168                renderer: status.renderer,
169                body_text_len: status.body_text_len,
170                screenshot_path: options.screenshot_path.clone(),
171            };
172            session.kill();
173            return Ok(report);
174        }
175        last_status = Some(status);
176        std::thread::sleep(Duration::from_millis(100));
177    }
178    Err(anyhow!(
179        "browser smoke test timed out for {}; last status: {:?}",
180        options.url,
181        last_status
182    ))
183}
184
185#[cfg(not(target_arch = "wasm32"))]
186#[derive(Debug, Deserialize)]
187struct RuntimeStatus {
188    ready_dom: bool,
189    ready_canvas: bool,
190    title: String,
191    width: u32,
192    height: u32,
193    body_text_len: usize,
194    renderer: Option<String>,
195}
196
197#[cfg(not(target_arch = "wasm32"))]
198fn read_runtime_status(client: &mut CdpClient) -> Result<RuntimeStatus> {
199    let expression = r#"(() => {
200      const body = document.body;
201      const canvas = document.querySelector('canvas');
202      const rect = canvas ? canvas.getBoundingClientRect() : { width: 0, height: 0 };
203      const renderer = globalThis.__FISSION_RENDERER_INFO ?? null;
204      return {
205        ready_dom: document.readyState === 'complete' && !!body && body.innerText.trim().length > 0,
206        ready_canvas: !!canvas && rect.width > 0 && rect.height > 0,
207        title: document.title || '',
208        width: Math.round(rect.width || window.innerWidth || 0),
209        height: Math.round(rect.height || window.innerHeight || 0),
210        body_text_len: body ? body.innerText.trim().length : 0,
211        renderer: renderer ? renderer.active : null,
212      };
213    })()"#;
214    let result = client.send(
215        "Runtime.evaluate",
216        json!({ "expression": expression, "returnByValue": true }),
217    )?;
218    if let Some(details) = result.get("exceptionDetails") {
219        return Err(anyhow!("runtime evaluation failed: {details}"));
220    }
221    let value = result
222        .get("result")
223        .and_then(|result| result.get("value"))
224        .cloned()
225        .context("Runtime.evaluate returned no value")?;
226    serde_json::from_value(value).context("failed to decode browser runtime status")
227}
228
229#[cfg(not(target_arch = "wasm32"))]
230fn capture_screenshot(client: &mut CdpClient, path: &PathBuf) -> Result<()> {
231    let result = client.send(
232        "Page.captureScreenshot",
233        json!({ "format": "png", "captureBeyondViewport": true }),
234    )?;
235    let data = result
236        .get("data")
237        .and_then(|value| value.as_str())
238        .context("Page.captureScreenshot returned no data")?;
239    let bytes = base64::engine::general_purpose::STANDARD
240        .decode(data)
241        .context("Chrome returned invalid screenshot base64")?;
242    if let Some(parent) = path.parent() {
243        fs::create_dir_all(parent)?;
244    }
245    fs::write(path, bytes).with_context(|| format!("failed to write {}", path.display()))
246}
247
248#[cfg(not(target_arch = "wasm32"))]
249fn wait_for_target(cdp_port: u16, expected_url: &str, timeout: Duration) -> Result<String> {
250    let deadline = Instant::now() + timeout;
251    let mut last_error = None;
252    while Instant::now() < deadline {
253        match ureq::get(&format!("http://127.0.0.1:{cdp_port}/json/list")).call() {
254            Ok(response) => {
255                let targets: Value = response.into_json()?;
256                if let Some(target) = targets.as_array().and_then(|items| {
257                    items.iter().find(|entry| {
258                        entry.get("type").and_then(Value::as_str) == Some("page")
259                            && entry
260                                .get("url")
261                                .and_then(Value::as_str)
262                                .is_some_and(|url| url.starts_with(expected_url))
263                    })
264                }) {
265                    if let Some(url) = target.get("webSocketDebuggerUrl").and_then(Value::as_str) {
266                        return Ok(url.to_string());
267                    }
268                }
269            }
270            Err(error) => last_error = Some(error.to_string()),
271        }
272        std::thread::sleep(Duration::from_millis(100));
273    }
274    Err(anyhow!(
275        "Chrome CDP target did not become ready for {expected_url}: {}",
276        last_error.unwrap_or_else(|| "no matching target".to_string())
277    ))
278}
279
280#[cfg(not(target_arch = "wasm32"))]
281fn free_port() -> u16 {
282    TcpListener::bind(("127.0.0.1", 0))
283        .expect("failed to allocate local port")
284        .local_addr()
285        .expect("failed to read local port")
286        .port()
287}
288
289#[cfg(not(target_arch = "wasm32"))]
290struct ChromeSession {
291    child: Option<Child>,
292    profile_dir: PathBuf,
293}
294
295#[cfg(not(target_arch = "wasm32"))]
296impl ChromeSession {
297    fn launch(chrome: &PathBuf, cdp_port: u16, options: &BrowserTestOptions) -> Result<Self> {
298        let profile_dir = std::env::temp_dir().join(format!(
299            "fission-cdp-{}-{}",
300            std::process::id(),
301            SystemTime::now()
302                .duration_since(UNIX_EPOCH)
303                .unwrap()
304                .as_nanos()
305        ));
306        fs::create_dir_all(&profile_dir)?;
307        let child = Command::new(chrome)
308            .arg("--headless=new")
309            .arg("--enable-unsafe-webgpu")
310            .arg("--no-first-run")
311            .arg("--no-default-browser-check")
312            .arg(format!("--remote-debugging-port={cdp_port}"))
313            .arg(format!("--user-data-dir={}", profile_dir.display()))
314            .arg(format!(
315                "--window-size={},{}",
316                options.viewport_width, options.viewport_height
317            ))
318            .arg(&options.url)
319            .stdout(Stdio::null())
320            .stderr(Stdio::null())
321            .spawn()
322            .with_context(|| format!("failed to start {}", chrome.display()))?;
323        Ok(Self {
324            child: Some(child),
325            profile_dir,
326        })
327    }
328
329    fn kill(&mut self) {
330        if let Some(mut child) = self.child.take() {
331            let _ = child.kill();
332            let _ = child.wait();
333        }
334    }
335}
336
337#[cfg(not(target_arch = "wasm32"))]
338impl Drop for ChromeSession {
339    fn drop(&mut self) {
340        self.kill();
341        let _ = fs::remove_dir_all(&self.profile_dir);
342    }
343}
344
345#[cfg(not(target_arch = "wasm32"))]
346struct CdpClient {
347    socket: tungstenite::WebSocket<tungstenite::stream::MaybeTlsStream<std::net::TcpStream>>,
348    next_id: u64,
349    backlog: VecDeque<Value>,
350    errors: Vec<String>,
351}
352
353#[cfg(not(target_arch = "wasm32"))]
354impl CdpClient {
355    fn connect(ws_url: &str) -> Result<Self> {
356        let (mut socket, _) =
357            connect(ws_url).context("failed to connect to Chrome CDP websocket")?;
358        if let tungstenite::stream::MaybeTlsStream::Plain(stream) = socket.get_mut() {
359            stream.set_read_timeout(Some(Duration::from_millis(100)))?;
360        }
361        Ok(Self {
362            socket,
363            next_id: 1,
364            backlog: VecDeque::new(),
365            errors: Vec::new(),
366        })
367    }
368
369    fn send(&mut self, method: &str, params: Value) -> Result<Value> {
370        let id = self.next_id;
371        self.next_id += 1;
372        self.socket.send(Message::Text(serde_json::to_string(
373            &json!({ "id": id, "method": method, "params": params }),
374        )?))?;
375        let deadline = Instant::now() + Duration::from_secs(15);
376        loop {
377            if let Some(message) = self.backlog.pop_front() {
378                if message.get("id").and_then(Value::as_u64) == Some(id) {
379                    return Self::command_result(method, message);
380                }
381                self.handle_event(&message);
382                continue;
383            }
384            if Instant::now() >= deadline {
385                return Err(anyhow!("CDP command timed out: {method}"));
386            }
387            let message = match self.socket.read() {
388                Ok(message) => message,
389                Err(tungstenite::Error::Io(error))
390                    if matches!(
391                        error.kind(),
392                        std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
393                    ) =>
394                {
395                    continue;
396                }
397                Err(error) => return Err(error.into()),
398            };
399            let text = match message {
400                Message::Text(text) => text,
401                Message::Binary(bytes) => String::from_utf8_lossy(&bytes).to_string(),
402                Message::Ping(_) | Message::Pong(_) => continue,
403                Message::Close(_) => return Err(anyhow!("CDP websocket closed")),
404                Message::Frame(_) => continue,
405            };
406            let value: Value = serde_json::from_str(&text)?;
407            if value.get("id").and_then(Value::as_u64) == Some(id) {
408                return Self::command_result(method, value);
409            }
410            self.handle_event(&value);
411        }
412    }
413
414    fn drain_events(&mut self, budget: Duration) -> Result<()> {
415        let deadline = Instant::now() + budget;
416        while Instant::now() < deadline {
417            match self.socket.read() {
418                Ok(Message::Text(text)) => {
419                    let value: Value = serde_json::from_str(&text)?;
420                    if value.get("id").is_some() {
421                        self.backlog.push_back(value);
422                    } else {
423                        self.handle_event(&value);
424                    }
425                }
426                Ok(Message::Binary(bytes)) => {
427                    let value: Value = serde_json::from_slice(&bytes)?;
428                    if value.get("id").is_some() {
429                        self.backlog.push_back(value);
430                    } else {
431                        self.handle_event(&value);
432                    }
433                }
434                Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_)) => {}
435                Ok(Message::Close(_)) => return Err(anyhow!("CDP websocket closed")),
436                Err(tungstenite::Error::Io(error))
437                    if matches!(
438                        error.kind(),
439                        std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
440                    ) => {}
441                Err(tungstenite::Error::ConnectionClosed) => return Ok(()),
442                Err(error) => return Err(error.into()),
443            }
444        }
445        Ok(())
446    }
447
448    fn command_result(method: &str, message: Value) -> Result<Value> {
449        if let Some(error) = message.get("error") {
450            return Err(anyhow!("{method}: {error}"));
451        }
452        Ok(message.get("result").cloned().unwrap_or_else(|| json!({})))
453    }
454
455    fn handle_event(&mut self, message: &Value) {
456        match message.get("method").and_then(Value::as_str) {
457            Some("Runtime.exceptionThrown") => self.errors.push(format!(
458                "runtime exception: {}",
459                message
460                    .pointer("/params/exceptionDetails/exception/description")
461                    .or_else(|| message.pointer("/params/exceptionDetails/text"))
462                    .and_then(Value::as_str)
463                    .unwrap_or("unknown")
464            )),
465            Some("Runtime.consoleAPICalled") => {
466                let level = message.pointer("/params/type").and_then(Value::as_str);
467                if matches!(level, Some("error" | "assert")) {
468                    self.errors
469                        .push(format!("console.{}: {}", level.unwrap(), message));
470                }
471            }
472            Some("Log.entryAdded") => {
473                if message
474                    .pointer("/params/entry/level")
475                    .and_then(Value::as_str)
476                    == Some("error")
477                {
478                    let text = message
479                        .pointer("/params/entry/text")
480                        .and_then(Value::as_str)
481                        .unwrap_or("unknown browser log error");
482                    if !text.contains("/__fission/renderer") {
483                        self.errors.push(format!("browser log error: {text}"));
484                    }
485                }
486            }
487            _ => {}
488        }
489    }
490}