use std::collections::HashMap;
use std::path::PathBuf;
use std::process::Stdio;
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use futures_util::{SinkExt, StreamExt};
use serde::de::DeserializeOwned;
use serde_json::{json, Value};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::sync::{mpsc, oneshot};
use tokio_tungstenite::tungstenite::Message;
pub fn chrome() -> Option<PathBuf> {
if let Some(path) = std::env::var_os("OJ_TEST_CHROME") {
return Some(PathBuf::from(path));
}
let home = std::env::var_os("HOME").map(PathBuf::from)?;
let caches = [
std::env::var_os("PLAYWRIGHT_BROWSERS_PATH").map(PathBuf::from),
Some(home.join("Library/Caches/ms-playwright")),
Some(home.join(".cache/ms-playwright")),
];
let layouts: [(&str, &[&str]); 2] = [
(
"chromium_headless_shell-",
&[
"chrome-headless-shell-linux64/chrome-headless-shell",
"chrome-headless-shell-mac-arm64/chrome-headless-shell",
"chrome-headless-shell-mac-x64/chrome-headless-shell",
],
),
(
"chromium-",
&[
"chrome-linux64/chrome",
"chrome-linux/chrome",
"chrome-mac-arm64/Chromium.app/Contents/MacOS/Chromium",
"chrome-mac/Chromium.app/Contents/MacOS/Chromium",
],
),
];
for cache in caches.into_iter().flatten() {
for (prefix, bins) in layouts {
let mut dirs: Vec<(u32, PathBuf)> = std::fs::read_dir(&cache)
.into_iter()
.flatten()
.flatten()
.filter_map(|e| {
let name = e.file_name().into_string().ok()?;
let rev = name.strip_prefix(prefix)?.parse().ok()?;
Some((rev, e.path()))
})
.collect();
dirs.sort_by_key(|(rev, _)| std::cmp::Reverse(*rev));
for (_, dir) in dirs {
if let Some(bin) = bins.iter().map(|b| dir.join(b)).find(|b| b.exists()) {
return Some(bin);
}
}
}
}
None
}
pub fn skip() -> bool {
let missing = chrome().is_none();
if missing && std::env::var_os("OJ_REQUIRE_BROWSER").is_some() {
panic!("OJ_REQUIRE_BROWSER is set but no Chrome was found");
}
if missing {
eprintln!("skipping: no Chrome (npx --prefix e2e playwright install chromium)");
}
missing
}
const CDP: Duration = Duration::from_secs(30);
type Pending = Arc<Mutex<HashMap<u64, oneshot::Sender<Result<Value, Value>>>>>;
pub struct Page {
chrome: tokio::process::Child,
profile: PathBuf,
_slot: tokio::sync::SemaphorePermit<'static>,
out: mpsc::UnboundedSender<Message>,
pending: Pending,
next_id: AtomicU64,
session: String,
errors: Arc<Mutex<Vec<String>>>,
console: Arc<Mutex<Vec<String>>>,
}
impl Page {
pub async fn open(url: &str) -> Page {
static NEXT: AtomicUsize = AtomicUsize::new(0);
static SLOTS: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(4);
let slot = SLOTS.acquire().await.unwrap();
let profile = std::env::temp_dir().join(format!(
"oj-chrome-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
let mut chrome = tokio::process::Command::new(chrome().expect("check skip() first"))
.args([
"--headless",
"--no-sandbox",
"--remote-debugging-port=0",
"--no-first-run",
"--no-default-browser-check",
"--disable-gpu",
"--disable-background-timer-throttling",
"--disable-renderer-backgrounding",
])
.arg(format!("--user-data-dir={}", profile.display()))
.arg("about:blank")
.stdout(Stdio::null())
.stderr(Stdio::piped())
.kill_on_drop(true)
.spawn()
.expect("spawn chrome");
let endpoint = tokio::time::timeout(CDP, devtools_endpoint(chrome.stderr.take().unwrap()))
.await
.expect("chrome printed its DevTools endpoint");
let (ws, _) = tokio_tungstenite::connect_async(endpoint.as_str())
.await
.expect("connect to chrome");
let (mut sink, mut stream) = ws.split();
let (out, mut out_rx) = mpsc::unbounded_channel::<Message>();
tokio::spawn(async move {
while let Some(msg) = out_rx.recv().await {
if sink.send(msg).await.is_err() {
break;
}
}
});
let pending: Pending = Arc::default();
let errors: Arc<Mutex<Vec<String>>> = Arc::default();
let console: Arc<Mutex<Vec<String>>> = Arc::default();
{
let (pending, errors, console) = (
Arc::clone(&pending),
Arc::clone(&errors),
Arc::clone(&console),
);
tokio::spawn(async move {
while let Some(Ok(msg)) = stream.next().await {
let Ok(text) = msg.into_text() else { continue };
let Ok(frame) = serde_json::from_str::<Value>(&text) else {
continue;
};
if let Some(id) = frame["id"].as_u64() {
if let Some(tx) = pending.lock().unwrap().remove(&id) {
let _ = tx.send(match frame.get("error") {
Some(err) => Err(err.clone()),
None => Ok(frame["result"].clone()),
});
}
} else if frame["method"] == "Runtime.exceptionThrown" {
let detail = &frame["params"]["exceptionDetails"];
let text = detail["exception"]["description"]
.as_str()
.or(detail["text"].as_str())
.unwrap_or("uncaught exception");
errors.lock().unwrap().push(text.to_string());
} else if frame["method"] == "Runtime.consoleAPICalled" {
let params = &frame["params"];
let kind = params["type"].as_str().unwrap_or("");
if kind == "error" || kind == "warning" {
let text = params["args"]
.as_array()
.into_iter()
.flatten()
.map(|a| match a["value"].as_str() {
Some(s) => s.to_string(),
None => a["description"]
.as_str()
.map(str::to_string)
.unwrap_or_else(|| a["value"].to_string()),
})
.collect::<Vec<_>>()
.join(" ");
console.lock().unwrap().push(format!("{kind}: {text}"));
}
} else if frame["method"] == "Log.entryAdded" {
let entry = &frame["params"]["entry"];
let level = entry["level"].as_str().unwrap_or("");
if level == "error" || level == "warning" {
let text = entry["text"].as_str().unwrap_or("");
let url = entry["url"].as_str().unwrap_or("");
console
.lock()
.unwrap()
.push(format!("{level}: {text} {url}"));
}
}
}
});
}
let mut page = Page {
chrome,
profile,
_slot: slot,
out,
pending,
next_id: AtomicU64::new(1),
session: String::new(),
errors,
console,
};
let target = page
.call(None, "Target.createTarget", json!({ "url": "about:blank" }))
.await;
let attached = page
.call(
None,
"Target.attachToTarget",
json!({ "targetId": target["targetId"], "flatten": true }),
)
.await;
page.session = attached["sessionId"].as_str().unwrap().to_string();
page.send("Runtime.enable", json!({})).await;
page.send("Page.enable", json!({})).await;
page.send("Log.enable", json!({})).await;
page.send("Page.navigate", json!({ "url": url })).await;
page
}
async fn call(&self, session: Option<&str>, method: &str, params: Value) -> Value {
match self.try_call(session, method, params).await {
Ok(result) => result,
Err(err) => panic!("chrome {method}: {err}"),
}
}
async fn try_call(
&self,
session: Option<&str>,
method: &str,
params: Value,
) -> Result<Value, Value> {
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let (tx, rx) = oneshot::channel();
self.pending.lock().unwrap().insert(id, tx);
let mut frame = json!({ "id": id, "method": method, "params": params });
if let Some(session) = session {
frame["sessionId"] = json!(session);
}
let _ = self.out.send(Message::text(frame.to_string()));
match tokio::time::timeout(CDP, rx).await {
Ok(Ok(reply)) => reply,
Ok(Err(_)) => panic!("chrome {method}: connection closed"),
Err(_) => panic!("chrome {method}: no answer in {CDP:?}"),
}
}
async fn send(&self, method: &str, params: Value) -> Value {
self.call(Some(&self.session), method, params).await
}
async fn try_eval(&self, expr: &str) -> Result<Value, String> {
let res = self
.try_call(
Some(&self.session),
"Runtime.evaluate",
json!({ "expression": expr, "returnByValue": true, "awaitPromise": true }),
)
.await
.map_err(|err| err.to_string())?;
if let Some(detail) = res.get("exceptionDetails") {
return Err(detail["exception"]["description"]
.as_str()
.or(detail["text"].as_str())
.unwrap_or("exception")
.to_string());
}
Ok(res["result"].get("value").cloned().unwrap_or(Value::Null))
}
pub async fn eval<T: DeserializeOwned>(&self, expr: &str) -> T {
let value = self
.try_eval(expr)
.await
.unwrap_or_else(|e| panic!("evaluate {expr}: {e}"));
serde_json::from_value(value).unwrap_or_else(|e| panic!("evaluate {expr} result: {e}"))
}
pub async fn wait_for(&self, predicate: &str, within: Duration) {
if !self.poll(predicate, within).await {
panic!(
"timed out after {within:?} waiting for {predicate}; page errors: {:?}",
self.errors()
);
}
}
pub async fn poll(&self, predicate: &str, within: Duration) -> bool {
let deadline = tokio::time::Instant::now() + within;
let expr = format!("Boolean({predicate})");
loop {
if self.try_eval(&expr).await == Ok(Value::Bool(true)) {
return true;
}
if tokio::time::Instant::now() >= deadline {
return false;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
pub fn console(&self) -> Vec<String> {
self.console.lock().unwrap().clone()
}
pub fn errors(&self) -> Vec<String> {
self.errors.lock().unwrap().clone()
}
pub async fn close(mut self) {
let _ = self.out.send(Message::text(
json!({ "id": 0, "method": "Browser.close", "params": {} }).to_string(),
));
if tokio::time::timeout(Duration::from_secs(5), self.chrome.wait())
.await
.is_err()
{
let _ = self.chrome.kill().await;
}
}
}
impl Drop for Page {
fn drop(&mut self) {
let _ = self.chrome.start_kill();
let _ = std::fs::remove_dir_all(&self.profile);
}
}
async fn devtools_endpoint(stderr: tokio::process::ChildStderr) -> String {
let mut lines = BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await {
if let Some(url) = line.strip_prefix("DevTools listening on ") {
let url = url.trim().to_string();
tokio::spawn(async move { while let Ok(Some(_)) = lines.next_line().await {} });
return url;
}
}
panic!("chrome exited before printing its DevTools endpoint");
}