Skip to main content

supercov_engine/
rust_wasm_browser.rs

1//! Rust tests compiled to wasm that `wasm-bindgen-test-runner` runs in a
2//! headless browser (`wasm-pack test --headless --chrome`).
3//!
4//! The runner opens the browser through WebDriver, loads its test page and
5//! reads the results off it. The region each instrumented crate keeps lives in
6//! the page's wasm memory, and nothing in the page writes it anywhere, so
7//! Supercov stands between the runner and the driver: each test's runner is
8//! pointed at a proxy (`CHROMEDRIVER_REMOTE`) whose URL names that test's
9//! evidence directory. When the runner opens a session the proxy has the
10//! browser remember every wasm instance any page creates (a script evaluated
11//! before the page's own); when the runner closes it, the proxy first takes
12//! each instance's region and writes it to the test's evidence, where the run
13//! reads it as it reads a native process's.
14//!
15//! Chrome only: the remembering script is installed through Chrome's
16//! DevTools protocol. A test run in another browser is said to be unmeasured.
17
18use std::{
19    io::{BufRead, BufReader, Read, Write},
20    net::{SocketAddr, TcpListener, TcpStream},
21    path::{Path, PathBuf},
22    process::{Child, Command, Stdio},
23    sync::{
24        Arc,
25        atomic::{AtomicBool, AtomicU64, Ordering},
26    },
27    time::{Duration, Instant},
28};
29
30use base64::Engine as _;
31use serde_json::{Value, json};
32
33/// Remember every wasm instance, however the page makes it: wasm-bindgen's
34/// browser glue streams the module.
35const REGISTRY: &str = r#"(() => {
36  if (globalThis.__supercovWasm) return;
37  const instances = (globalThis.__supercovWasm = []);
38  const Instance = WebAssembly.Instance;
39  function Recorded(module, imports) {
40    const instance = new Instance(module, imports);
41    instances.push(instance);
42    return instance;
43  }
44  Recorded.prototype = Instance.prototype;
45  WebAssembly.Instance = Recorded;
46  for (const name of ["instantiate", "instantiateStreaming"]) {
47    const original = WebAssembly[name];
48    if (typeof original !== "function") continue;
49    WebAssembly[name] = async function (...args) {
50      const result = await original.apply(WebAssembly, args);
51      instances.push(result instanceof Instance ? result : result.instance);
52      return result;
53    };
54  }
55})();"#;
56
57/// Each instrumented crate's region, whole, as base64.
58const TAKE: &str = r#"const out = [];
59for (const instance of globalThis.__supercovWasm ?? []) {
60  const exports = instance.exports;
61  for (const name of Object.keys(exports)) {
62    if (!name.startsWith('__supercov_region_') || !exports.memory) continue;
63    const packed = BigInt.asUintN(64, BigInt(exports[name]()));
64    const at = Number(packed >> 32n), length = Number(packed & 0xffffffffn);
65    if (!length) continue;
66    const bytes = new Uint8Array(exports.memory.buffer, at, length);
67    let text = '';
68    for (let index = 0; index < length; index += 0x8000)
69      text += String.fromCharCode.apply(null, bytes.subarray(index, Math.min(length, index + 0x8000)));
70    out.push([name.slice('__supercov_region_'.length), btoa(text)]);
71  }
72}
73return out;"#;
74
75/// The browser the runner would pick, as `wasm-bindgen-test-runner` picks
76/// it: a `*_REMOTE` driver first, then a driver named in the environment,
77/// then the first on `PATH`, in its order.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub enum Browser {
80    /// chromedriver at this path, with these arguments.
81    Chrome(PathBuf, Vec<String>),
82    /// Another browser, or a remote driver: its tests run unmeasured.
83    Other(String),
84}
85
86const DRIVERS: [&str; 4] = [
87    "geckodriver",
88    "safaridriver",
89    "chromedriver",
90    "msedgedriver",
91];
92
93pub fn browser(environment: &[(String, String)]) -> Option<Browser> {
94    let variable = |name: &str| {
95        environment
96            .iter()
97            .find(|(key, _)| key == name)
98            .map(|(_, value)| value.clone())
99            .or_else(|| std::env::var(name).ok())
100    };
101    let arguments = |driver: &str| {
102        variable(&format!("{}_ARGS", driver.to_uppercase()))
103            .map(|value| value.split_whitespace().map(str::to_owned).collect())
104            .unwrap_or_default()
105    };
106    for driver in DRIVERS {
107        if variable(&format!("{}_REMOTE", driver.to_uppercase())).is_some() {
108            return Some(Browser::Other(format!("a remote {driver}")));
109        }
110    }
111    let pick = |driver: &str, path: PathBuf| {
112        if driver == "chromedriver" {
113            Browser::Chrome(path, arguments(driver))
114        } else {
115            Browser::Other(driver.to_owned())
116        }
117    };
118    for driver in DRIVERS {
119        if let Some(path) = variable(&driver.to_uppercase()) {
120            return Some(pick(driver, PathBuf::from(path)));
121        }
122    }
123    let path = std::env::var_os("PATH")?;
124    for directory in std::env::split_paths(&path) {
125        for driver in DRIVERS {
126            let candidate = directory
127                .join(driver)
128                .with_extension(std::env::consts::EXE_EXTENSION);
129            if candidate.exists() {
130                return Some(pick(driver, candidate));
131            }
132        }
133    }
134    None
135}
136
137/// The proxy in front of one chromedriver, for the whole run.
138#[derive(Debug)]
139pub struct BrowserReader {
140    address: SocketAddr,
141    driver: Child,
142    stop: Arc<AtomicBool>,
143}
144
145impl BrowserReader {
146    pub fn start(chromedriver: &Path, arguments: &[String], token: &str) -> Result<Self, String> {
147        let port = free_port()?;
148        let driver = Command::new(chromedriver)
149            .args(arguments)
150            .arg(format!("--port={port}"))
151            .stdin(Stdio::null())
152            .stdout(Stdio::null())
153            .stderr(Stdio::null())
154            .spawn()
155            .map_err(|error| format!("could not start {}: {error}", chromedriver.display()))?;
156        let upstream = SocketAddr::from(([127, 0, 0, 1], port));
157        let started = Instant::now();
158        while TcpStream::connect_timeout(&upstream, Duration::from_millis(100)).is_err() {
159            if started.elapsed() > Duration::from_secs(20) {
160                return Err(format!("{} never listened", chromedriver.display()));
161            }
162            std::thread::sleep(Duration::from_millis(50));
163        }
164        let listener = TcpListener::bind("127.0.0.1:0").map_err(|error| error.to_string())?;
165        let address = listener.local_addr().map_err(|error| error.to_string())?;
166        listener
167            .set_nonblocking(true)
168            .map_err(|error| error.to_string())?;
169        let stop = Arc::new(AtomicBool::new(false));
170        let flag = Arc::clone(&stop);
171        let token = token.to_owned();
172        std::thread::Builder::new()
173            .name("supercov-browser-proxy".into())
174            .spawn(move || {
175                while !flag.load(Ordering::Acquire) {
176                    match listener.accept() {
177                        Ok((stream, _)) => {
178                            // An accepted socket keeps the listener's
179                            // non-blocking mode on macOS.
180                            let _ = stream.set_nonblocking(false);
181                            let token = token.clone();
182                            std::thread::spawn(move || {
183                                let _ = relay(stream, upstream, &token);
184                            });
185                        }
186                        Err(_) => std::thread::sleep(Duration::from_millis(10)),
187                    }
188                }
189            })
190            .map_err(|error| error.to_string())?;
191        Ok(Self {
192            address,
193            driver,
194            stop,
195        })
196    }
197
198    /// The driver URL a test's runner is given: its evidence directory, hex
199    /// in the path, is where that session's regions are written.
200    pub fn remote_url(&self, evidence: &Path) -> String {
201        let hex = evidence
202            .to_string_lossy()
203            .bytes()
204            .map(|byte| format!("{byte:02x}"))
205            .collect::<String>();
206        format!("http://{}/e/{hex}/", self.address)
207    }
208}
209
210impl Drop for BrowserReader {
211    fn drop(&mut self) {
212        self.stop.store(true, Ordering::Release);
213        let _ = self.driver.kill();
214        let _ = self.driver.wait();
215    }
216}
217
218fn free_port() -> Result<u16, String> {
219    let listener = TcpListener::bind("127.0.0.1:0").map_err(|error| error.to_string())?;
220    listener
221        .local_addr()
222        .map(|address| address.port())
223        .map_err(|error| error.to_string())
224}
225
226struct Request {
227    method: String,
228    path: String,
229    body: Vec<u8>,
230}
231
232fn read_request(stream: &mut BufReader<TcpStream>) -> Option<Request> {
233    let mut line = String::new();
234    stream.read_line(&mut line).ok()?;
235    let mut parts = line.split_whitespace();
236    let method = parts.next()?.to_owned();
237    let path = parts.next()?.to_owned();
238    let mut length = 0;
239    let mut chunked = false;
240    loop {
241        let mut header = String::new();
242        stream.read_line(&mut header).ok()?;
243        let header = header.trim_end();
244        if header.is_empty() {
245            break;
246        }
247        if let Some((name, value)) = header.split_once(':') {
248            if name.eq_ignore_ascii_case("content-length") {
249                length = value.trim().parse().ok()?;
250            } else if name.eq_ignore_ascii_case("transfer-encoding")
251                && value.to_ascii_lowercase().contains("chunked")
252            {
253                chunked = true;
254            }
255        }
256    }
257    let mut body = Vec::new();
258    if chunked {
259        loop {
260            let mut size = String::new();
261            stream.read_line(&mut size).ok()?;
262            let size = usize::from_str_radix(size.trim(), 16).ok()?;
263            let mut chunk = vec![0; size + 2];
264            stream.read_exact(&mut chunk).ok()?;
265            if size == 0 {
266                break;
267            }
268            body.extend_from_slice(&chunk[..size]);
269        }
270    } else {
271        body.resize(length, 0);
272        stream.read_exact(&mut body).ok()?;
273    }
274    Some(Request { method, path, body })
275}
276
277/// One request to chromedriver; its status and body, chunked or not.
278fn call(upstream: SocketAddr, method: &str, path: &str, body: &[u8]) -> Option<(u16, Vec<u8>)> {
279    let mut stream = TcpStream::connect(upstream).ok()?;
280    stream
281        .set_read_timeout(Some(Duration::from_secs(120)))
282        .ok()?;
283    write!(
284        stream,
285        "{method} {path} HTTP/1.1\r\nHost: {upstream}\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
286        body.len()
287    )
288    .ok()?;
289    stream.write_all(body).ok()?;
290    // Read by the response's own length: chromedriver keeps the connection
291    // open after answering, whatever the request asked.
292    let mut reader = BufReader::new(stream);
293    let mut line = String::new();
294    reader.read_line(&mut line).ok()?;
295    let status = line.split_whitespace().nth(1)?.parse().ok()?;
296    let mut length = None;
297    let mut chunked = false;
298    loop {
299        let mut header = String::new();
300        reader.read_line(&mut header).ok()?;
301        let header = header.trim_end();
302        if header.is_empty() {
303            break;
304        }
305        if let Some((name, value)) = header.split_once(':') {
306            if name.eq_ignore_ascii_case("content-length") {
307                length = value.trim().parse::<usize>().ok();
308            } else if name.eq_ignore_ascii_case("transfer-encoding")
309                && value.to_ascii_lowercase().contains("chunked")
310            {
311                chunked = true;
312            }
313        }
314    }
315    let mut payload = Vec::new();
316    if chunked {
317        loop {
318            let mut size = String::new();
319            reader.read_line(&mut size).ok()?;
320            let size = usize::from_str_radix(size.trim(), 16).ok()?;
321            let mut chunk = vec![0; size + 2];
322            reader.read_exact(&mut chunk).ok()?;
323            if size == 0 {
324                break;
325            }
326            payload.extend_from_slice(&chunk[..size]);
327        }
328    } else if let Some(length) = length {
329        payload.resize(length, 0);
330        reader.read_exact(&mut payload).ok()?;
331    } else {
332        reader.read_to_end(&mut payload).ok()?;
333    }
334    Some((status, payload))
335}
336
337static REGIONS: AtomicU64 = AtomicU64::new(0);
338
339fn relay(stream: TcpStream, upstream: SocketAddr, token: &str) -> Option<()> {
340    let mut reader = BufReader::new(stream.try_clone().ok()?);
341    let request = read_request(&mut reader)?;
342    // `/e/<hex evidence directory>/<WebDriver path>`.
343    let rest = request.path.strip_prefix("/e/")?;
344    let (hex, webdriver) = rest.split_once('/').unwrap_or((rest, ""));
345    let evidence = PathBuf::from(
346        String::from_utf8(
347            (0..hex.len())
348                .step_by(2)
349                .filter_map(|at| u8::from_str_radix(hex.get(at..at + 2)?, 16).ok())
350                .collect(),
351        )
352        .ok()?,
353    );
354    let webdriver = format!("/{webdriver}");
355    // The runner closes its window rather than its session; either ends
356    // what the page can tell.
357    let session = webdriver
358        .strip_prefix("/session/")
359        .map(|rest| rest.strip_suffix("/window").unwrap_or(rest))
360        .filter(|id| !id.contains('/'))
361        .map(str::to_owned);
362    if request.method == "DELETE"
363        && let Some(id) = &session
364    {
365        take_regions(upstream, id, &evidence, token);
366    }
367    let (status, body) = call(upstream, &request.method, &webdriver, &request.body)?;
368    if request.method == "POST"
369        && webdriver == "/session"
370        && status == 200
371        && let Ok(created) = serde_json::from_slice::<Value>(&body)
372        // W3C answers with `value.sessionId`; the older protocol the runner
373        // speaks, with `sessionId` beside `value`.
374        && let Some(id) = created["value"]["sessionId"]
375            .as_str()
376            .or_else(|| created["sessionId"].as_str())
377    {
378        let install = json!({
379            "cmd": "Page.addScriptToEvaluateOnNewDocument",
380            "params": {"source": REGISTRY},
381        });
382        let _ = call(
383            upstream,
384            "POST",
385            &format!("/session/{id}/goog/cdp/execute"),
386            install.to_string().as_bytes(),
387        );
388    }
389    let mut stream = stream;
390    let reason = if status == 200 { "OK" } else { "Error" };
391    write!(
392        stream,
393        "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
394        body.len()
395    )
396    .ok()?;
397    stream.write_all(&body).ok()?;
398    Some(())
399}
400
401/// Before the runner closes its session: each region the page's wasm holds,
402/// written to the test's evidence under the run's token.
403fn take_regions(upstream: SocketAddr, session: &str, evidence: &Path, token: &str) {
404    let script = json!({"script": TAKE, "args": []});
405    let Some((200, body)) = call(
406        upstream,
407        "POST",
408        &format!("/session/{session}/execute/sync"),
409        script.to_string().as_bytes(),
410    ) else {
411        return;
412    };
413    let Ok(value) = serde_json::from_slice::<Value>(&body) else {
414        return;
415    };
416    for region in value["value"].as_array().into_iter().flatten() {
417        let Some(bytes) = region
418            .get(1)
419            .and_then(Value::as_str)
420            .and_then(|text| base64::engine::general_purpose::STANDARD.decode(text).ok())
421        else {
422            continue;
423        };
424        if crate::rust_worker::region_token(&bytes) != Some(token) {
425            continue;
426        }
427        let _ = std::fs::create_dir_all(evidence);
428        let _ = std::fs::write(
429            evidence.join(format!(
430                "{token}-browser-{}-{}.region",
431                std::process::id(),
432                REGIONS.fetch_add(1, Ordering::Relaxed)
433            )),
434            &bytes,
435        );
436    }
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442
443    #[test]
444    fn the_runner_s_browser_is_picked_as_it_picks_it() {
445        let env = |pairs: &[(&str, &str)]| {
446            pairs
447                .iter()
448                .map(|(name, value)| ((*name).to_owned(), (*value).to_owned()))
449                .collect::<Vec<_>>()
450        };
451        assert_eq!(
452            browser(&env(&[("CHROMEDRIVER", "/c/chromedriver")])),
453            Some(Browser::Chrome(
454                PathBuf::from("/c/chromedriver"),
455                Vec::new()
456            ))
457        );
458        assert_eq!(
459            browser(&env(&[("CHROMEDRIVER", "/c/cd"), ("GECKODRIVER", "/g/gd")])),
460            Some(Browser::Other("geckodriver".into())),
461            "the runner tries geckodriver first"
462        );
463        assert_eq!(
464            browser(&env(&[("CHROMEDRIVER_REMOTE", "http://x/")])),
465            Some(Browser::Other("a remote chromedriver".into()))
466        );
467    }
468}