use crate::error::{CdpError, CdpResult};
use serde::Deserialize;
use tracing::debug;
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct ChromeTarget {
pub title: String,
pub r#type: String,
pub url: String,
pub web_socket_debugger_url: String,
}
pub async fn get_websocket_url(host: &str) -> CdpResult<String> {
let url = format!("http://{}/json/list", host);
let targets: Vec<ChromeTarget> = reqwest::get(&url).await?.json().await?;
let target = select_target(&targets).ok_or(CdpError::NoPageTargetFound(host.to_string()))?;
debug!("Found target: {} - {}", target.title, target.url);
Ok(target.web_socket_debugger_url.clone())
}
fn select_target(targets: &[ChromeTarget]) -> Option<&ChromeTarget> {
let is_attachable = |t: &ChromeTarget| {
t.r#type == "page"
&& !t.web_socket_debugger_url.is_empty()
&& !t.url.starts_with("devtools://")
};
let is_web_page = |t: &ChromeTarget| {
t.url.starts_with("http://")
|| t.url.starts_with("https://")
|| t.url.starts_with("file://")
|| t.url.starts_with("about:")
};
targets
.iter()
.find(|t| is_attachable(t) && is_web_page(t))
.or_else(|| targets.iter().find(|t| is_attachable(t)))
}
#[cfg(test)]
mod tests {
use super::*;
fn t(ty: &str, url: &str, ws: &str) -> ChromeTarget {
ChromeTarget {
title: url.to_string(),
r#type: ty.to_string(),
url: url.to_string(),
web_socket_debugger_url: ws.to_string(),
}
}
#[test]
fn skips_devtools_even_when_listed_first() {
let targets = vec![
t(
"page",
"devtools://devtools/bundled/devtools_app.html",
"ws://x/devtools",
),
t("page", "https://m.baidu.com/", "ws://x/baidu"),
];
assert_eq!(
select_target(&targets).map(|t| t.url.as_str()),
Some("https://m.baidu.com/"),
);
}
#[test]
fn prefers_real_web_page_over_chrome_internal() {
let targets = vec![
t("page", "chrome://newtab/", "ws://x/newtab"),
t(
"page",
"devtools://devtools/bundled/devtools_app.html",
"ws://x/devtools",
),
t("page", "https://example.com/", "ws://x/example"),
];
assert_eq!(
select_target(&targets).map(|t| t.url.as_str()),
Some("https://example.com/"),
);
}
#[test]
fn accepts_http_https_file_and_about_schemes() {
for url in [
"http://example.com/",
"https://example.com/",
"file:///tmp/index.html",
"about:blank",
] {
let targets = vec![t("page", url, "ws://x/page")];
assert_eq!(
select_target(&targets).map(|t| t.url.as_str()),
Some(url),
"scheme {url} should be accepted as a web page",
);
}
}
#[test]
fn falls_back_to_chrome_internal_when_no_web_page() {
let targets = vec![
t(
"page",
"devtools://devtools/bundled/devtools_app.html",
"ws://x/devtools",
),
t("page", "chrome://newtab/", "ws://x/newtab"),
];
assert_eq!(
select_target(&targets).map(|t| t.url.as_str()),
Some("chrome://newtab/"),
);
}
#[test]
fn returns_none_when_only_devtools() {
let targets = vec![t(
"page",
"devtools://devtools/bundled/devtools_app.html",
"ws://x/devtools",
)];
assert!(select_target(&targets).is_none());
}
#[test]
fn ignores_non_page_and_empty_ws() {
let targets = vec![
t(
"service_worker",
"chrome-extension://abc/sw.js",
"ws://x/sw",
),
t("iframe", "https://ads.example/", "ws://x/iframe"),
t("page", "https://real.example/", ""),
t("page", "https://good.example/", "ws://x/good"),
];
assert_eq!(
select_target(&targets).map(|t| t.url.as_str()),
Some("https://good.example/"),
);
}
#[test]
fn returns_none_on_empty_list() {
let targets: Vec<ChromeTarget> = vec![];
assert!(select_target(&targets).is_none());
}
#[test]
fn single_web_page_still_selected() {
let targets = vec![t("page", "https://example.com/", "ws://x/example")];
assert_eq!(
select_target(&targets).map(|t| t.url.as_str()),
Some("https://example.com/"),
);
}
mod http_tests {
use super::*;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn chrome_target_json(ty: &str, url: &str, ws: &str) -> String {
format!(
r#"{{"title":"{url}","type":"{ty}","url":"{url}","webSocketDebuggerUrl":"{ws}"}}"#
)
}
fn json_list_body(targets: &[(&str, &str, &str)]) -> String {
let entries: Vec<String> = targets
.iter()
.map(|(ty, url, ws)| chrome_target_json(ty, url, ws))
.collect();
format!("[{}]", entries.join(","))
}
async fn json_list_endpoint(server: &MockServer, body: &str) -> String {
Mock::given(method("GET"))
.and(path("/json/list"))
.respond_with(ResponseTemplate::new(200).set_body_string(body))
.mount(server)
.await;
server.address().to_string()
}
#[tokio::test]
async fn get_websocket_url_skips_devtools_first_in_json_list() {
let server = MockServer::start().await;
let body = json_list_body(&[
(
"page",
"devtools://devtools/bundled/devtools_app.html",
"ws://devtools",
),
("page", "https://m.baidu.com/", "ws://baidu"),
]);
let host = json_list_endpoint(&server, &body).await;
let ws = get_websocket_url(&host)
.await
.expect("should pick a target");
assert_eq!(ws, "ws://baidu");
}
#[tokio::test]
async fn get_websocket_url_errors_when_only_devtools() {
let server = MockServer::start().await;
let body = json_list_body(&[(
"page",
"devtools://devtools/bundled/devtools_app.html",
"ws://devtools",
)]);
let host = json_list_endpoint(&server, &body).await;
let err = get_websocket_url(&host)
.await
.expect_err("devtools must not be selected");
assert!(
matches!(err, CdpError::NoPageTargetFound(_)),
"expected NoPageTargetFound, got {err:?}",
);
}
#[tokio::test]
async fn get_websocket_url_happy_path_unchanged() {
let server = MockServer::start().await;
let body = json_list_body(&[("page", "https://example.com/", "ws://example")]);
let host = json_list_endpoint(&server, &body).await;
let ws = get_websocket_url(&host)
.await
.expect("happy path should still work");
assert_eq!(ws, "ws://example");
}
}
}