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}