use super::cdp::{Cdp, discover};
use futures_util::{SinkExt, StreamExt};
use serde_json::{Value, json};
use std::{
sync::{Arc, Mutex},
time::Duration,
};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::{TcpListener, TcpStream},
};
use tokio_tungstenite::tungstenite::Message;
pub(crate) struct FakeBrowser {
http_port: u16,
commands: Arc<Mutex<Vec<(String, Value)>>>,
handles: Vec<tokio::task::JoinHandle<()>>,
}
impl FakeBrowser {
pub(crate) async fn start() -> Self {
let ws_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let ws_port = ws_listener.local_addr().unwrap().port();
let ws_url = format!("ws://127.0.0.1:{ws_port}/devtools/browser/fake");
let commands: Arc<Mutex<Vec<(String, Value)>>> = Arc::new(Mutex::new(Vec::new()));
let ws_commands = Arc::clone(&commands);
let ws_handle = tokio::spawn(async move {
while let Ok((stream, _)) = ws_listener.accept().await {
let commands = Arc::clone(&ws_commands);
tokio::spawn(async move { serve_ws(stream, commands).await });
}
});
let http_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let http_port = http_listener.local_addr().unwrap().port();
let body =
json!({ "Browser": "FakeBrowser/1.0", "webSocketDebuggerUrl": ws_url }).to_string();
let http_handle = tokio::spawn(async move {
while let Ok((mut socket, _)) = http_listener.accept().await {
let body = body.clone();
tokio::spawn(async move {
let mut buf = [0u8; 1024];
let _ = socket.read(&mut buf).await;
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = socket.write_all(response.as_bytes()).await;
let _ = socket.shutdown().await;
});
}
});
Self {
http_port,
commands,
handles: vec![ws_handle, http_handle],
}
}
pub(crate) fn http_port(&self) -> u16 {
self.http_port
}
pub(crate) async fn connect(&self) -> Cdp {
let endpoint = discover("127.0.0.1", self.http_port, Duration::from_secs(5))
.await
.unwrap();
assert_eq!(endpoint.browser, "FakeBrowser/1.0");
Cdp::connect(endpoint, Duration::from_secs(5))
.await
.unwrap()
}
pub(crate) fn saw(&self, method: &str) -> bool {
self.commands().iter().any(|(name, _)| name == method)
}
pub(crate) fn params_of(&self, method: &str) -> Vec<Value> {
self.commands()
.iter()
.filter(|(name, _)| name == method)
.map(|(_, params)| params.clone())
.collect()
}
pub(crate) fn saw_expression(&self, needle: &str) -> bool {
self.params_of("Runtime.evaluate").iter().any(|params| {
params
.get("expression")
.and_then(Value::as_str)
.is_some_and(|expression| expression.contains(needle))
})
}
pub(crate) fn commands(&self) -> Vec<(String, Value)> {
self.commands
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone()
}
}
impl Drop for FakeBrowser {
fn drop(&mut self) {
for handle in &self.handles {
handle.abort();
}
}
}
async fn serve_ws(stream: TcpStream, commands: Arc<Mutex<Vec<(String, Value)>>>) {
let Ok(mut ws) = tokio_tungstenite::accept_async(stream).await else {
return;
};
while let Some(Ok(message)) = ws.next().await {
let Ok(text) = message.into_text() else {
continue;
};
let Ok(value) = serde_json::from_str::<Value>(&text) else {
continue;
};
let method = value
.get("method")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let params = value.get("params").cloned().unwrap_or(Value::Null);
commands
.lock()
.unwrap_or_else(|e| e.into_inner())
.push((method.clone(), params.clone()));
let id = value.get("id").cloned().unwrap_or(Value::Null);
let response = match reply(&method, ¶ms) {
Ok(result) => json!({ "id": id, "result": result }),
Err(message) => json!({ "id": id, "error": { "code": -32601, "message": message } }),
};
if ws.send(Message::text(response.to_string())).await.is_err() {
break;
}
}
}
fn reply(method: &str, params: &Value) -> Result<Value, String> {
Ok(match method {
"Target.getTargets" => json!({
"targetInfos": [
{ "targetId": "T1", "type": "page", "title": "Fake Page", "url": "https://example.test/" },
{ "targetId": "T9", "type": "service_worker", "title": "", "url": "https://example.test/sw.js" }
]
}),
"Target.attachToTarget" => json!({ "sessionId": "S1" }),
"Target.createTarget" => json!({ "targetId": "T2" }),
"Target.getTargetInfo" => json!({
"targetInfo": { "targetId": "T2", "type": "page", "title": "New Tab", "url": "about:blank" }
}),
"Target.activateTarget"
| "Page.navigate"
| "DOM.setFileInputFiles"
| "Input.dispatchMouseEvent"
| "Input.insertText"
| "Input.dispatchKeyEvent" => json!({}),
"Page.getLayoutMetrics" => json!({ "cssContentSize": { "width": 800, "height": 1200 } }),
"Page.captureScreenshot" => json!({ "data": png_base64() }),
"Runtime.evaluate" => {
if expression_contains(params, "__stale_session__") {
return Err("Session with given id not found".to_string());
}
evaluate_reply(params)
}
other => return Err(format!("FakeBrowser has no handler for {other}")),
})
}
fn expression_contains(params: &Value, needle: &str) -> bool {
params
.get("expression")
.and_then(Value::as_str)
.is_some_and(|expression| expression.contains(needle))
}
fn evaluate_reply(params: &Value) -> Value {
let expression = params
.get("expression")
.and_then(Value::as_str)
.unwrap_or_default();
let return_by_value = params
.get("returnByValue")
.and_then(Value::as_bool)
.unwrap_or(true);
let result = if expression.starts_with("!!") {
json!({ "type": "boolean", "value": !expression.contains("__never__") })
} else if expression.contains("const interactable") {
json!({ "type": "object", "value": {
"title": "Fake Page",
"url": "https://example.test/",
"refs": ["e1 <button> \"Go\""],
"total": 1,
"text": "Hello world"
}})
} else if expression.contains("readyState") {
json!({ "type": "string", "value": "complete" })
} else if expression.contains("document.title") {
json!({ "type": "object", "value": { "title": "Fake Page", "url": "https://example.test/" } })
} else if expression.contains("outerHTML") {
json!({ "type": "string", "value": "<html><body>Hello world</body></html>" })
} else if expression.contains("innerText") {
json!({ "type": "string", "value": "Hello world" })
} else if expression.contains("getBoundingClientRect") {
json!({ "type": "object", "value": { "x": 10.0, "y": 20.0, "width": 100.0, "height": 40.0 } })
} else if expression.contains("return el || null") {
if return_by_value {
json!({ "type": "object", "subtype": "null", "value": Value::Null })
} else {
json!({ "type": "object", "className": "HTMLInputElement", "objectId": "OBJ1" })
}
} else {
json!({ "type": "object", "value": { "ok": true } })
};
json!({ "result": result })
}
fn png_base64() -> String {
let image = image::RgbaImage::from_pixel(2, 2, image::Rgba([10, 20, 30, 255]));
let mut bytes = Vec::new();
image::DynamicImage::ImageRgba8(image)
.write_to(
&mut std::io::Cursor::new(&mut bytes),
image::ImageFormat::Png,
)
.unwrap();
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bytes)
}