mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-08-28 21:50:46 +02:00
1381 lines
50 KiB
Rust
1381 lines
50 KiB
Rust
//! Where a profile's browser actually is, and how to talk to it.
|
|
//!
|
|
//! Until this module existed, every automation tool answered "where is this
|
|
//! browser?" by reading a LOCAL debugging port out of the LOCAL profile
|
|
//! directory. A profile launched on a leased host has no local port and no
|
|
//! local process, so a customer who paid for remote execution could start a
|
|
//! session and then do nothing with it — the one thing the feature exists for.
|
|
//!
|
|
//! There is exactly one resolver here, [`resolve`], and one connection type,
|
|
//! [`CdpConnection`]. Tools ask for a target and get either a page socket on
|
|
//! this machine or a relayed socket to the fleet; nothing above this module
|
|
//! branches on which. That is deliberate: a parallel set of remote-only tools
|
|
//! would drift from the local ones within a release.
|
|
//!
|
|
//! The remote arm reaches donutbrowser-infra with the USER's own access token.
|
|
//! The desktop holds no fleet credential and knows no fleet hostname — infra
|
|
//! verifies the session belongs to the caller and relays onward with its own
|
|
//! service credential. That boundary is why this is a relay and not a direct
|
|
//! connection.
|
|
|
|
use crate::profile::types::BrowserProfile;
|
|
use serde_json::Value;
|
|
use std::time::Duration;
|
|
use tokio::net::TcpStream;
|
|
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
|
use tokio_tungstenite::tungstenite::handshake::client::Request as WsRequest;
|
|
use tokio_tungstenite::tungstenite::protocol::WebSocketConfig;
|
|
use tokio_tungstenite::tungstenite::Message;
|
|
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
|
|
|
|
/// How long the WebSocket handshake may take.
|
|
///
|
|
/// A remote attach crosses desktop → infra → wayfern → agent → the VM, so this
|
|
/// is far longer than a loopback connect needs. It matches the relay's own
|
|
/// upstream handshake budget: waiting longer than the server does can only
|
|
/// report a timeout the server already reported.
|
|
const CONNECT_TIMEOUT: Duration = Duration::from_secs(20);
|
|
|
|
/// How long one CDP command may wait for its reply.
|
|
///
|
|
/// Without a cap, a browser that never answers holds the caller until the
|
|
/// socket dies — 90 seconds on the relay, indefinitely on loopback. An
|
|
/// automation client that hangs is worse than one that fails.
|
|
const COMMAND_TIMEOUT: Duration = Duration::from_secs(60);
|
|
|
|
/// Attempts at establishing a connection before giving up.
|
|
const CONNECT_ATTEMPTS: u32 = 3;
|
|
|
|
/// Delay before the second connect attempt; doubles for the third.
|
|
const CONNECT_RETRY_BASE: Duration = Duration::from_millis(400);
|
|
|
|
/// Ceiling on a relayed CDP message.
|
|
///
|
|
/// Matches the relay's client-facing cap, which matches the fleet's upstream
|
|
/// frame cap. Lower, and a screenshot the server was willing to carry is
|
|
/// dropped on arrival; higher buys nothing, because the frame never crosses the
|
|
/// relay in the first place.
|
|
const REMOTE_MAX_MESSAGE_BYTES: usize = 16 * 1024 * 1024;
|
|
|
|
/// Command ids for the two messages the remote arm sends before any tool does.
|
|
///
|
|
/// Held far above anything a caller uses, so a late reply to the attach
|
|
/// handshake can never be mistaken for a tool's answer: both travel on one
|
|
/// socket, the handshake on the BROWSER session and every tool on the page
|
|
/// session.
|
|
const HANDSHAKE_GET_TARGETS_ID: u64 = 9_000_001;
|
|
const HANDSHAKE_ATTACH_ID: u64 = 9_000_002;
|
|
|
|
/// Where a profile's browser is, and what is needed to reach it.
|
|
#[derive(Debug, Clone)]
|
|
pub enum CdpTarget {
|
|
/// A browser on this machine. The URL is a PAGE-level socket, so commands
|
|
/// carry no CDP session id.
|
|
Local { ws_url: String },
|
|
/// A browser on the fleet, reached through the infra relay. The relay bridges
|
|
/// a BROWSER-level socket, so the connection attaches to a page and stamps
|
|
/// every subsequent message with the resulting session id.
|
|
Remote {
|
|
ws_url: String,
|
|
bearer: String,
|
|
session_id: String,
|
|
},
|
|
}
|
|
|
|
impl CdpTarget {
|
|
/// True when this browser is on the leased fleet rather than this machine.
|
|
pub fn is_remote(&self) -> bool {
|
|
matches!(self, Self::Remote { .. })
|
|
}
|
|
|
|
/// A short label for logs and errors. Never carries the credential.
|
|
pub fn describe(&self) -> String {
|
|
match self {
|
|
Self::Local { .. } => "local browser".to_string(),
|
|
Self::Remote { session_id, .. } => format!("remote session {session_id}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Why a target could not be reached, or a command could not be run.
|
|
///
|
|
/// The variants exist so a caller can tell "come back when it is up" from "that
|
|
/// credential is no good" from "the socket broke". Collapsing them into one
|
|
/// string is how a session that is merely still provisioning gets reported as a
|
|
/// broken one.
|
|
#[derive(Debug)]
|
|
pub enum CdpError {
|
|
/// Nothing is listening, or the relay could not reach the browser.
|
|
Unreachable(String),
|
|
/// The relay refused the credential.
|
|
Unauthorized(String),
|
|
/// The session exists but is not in a state that can be driven.
|
|
NotDrivable(String),
|
|
/// The socket broke, or a reply never arrived.
|
|
Transport(String),
|
|
/// The browser answered with a CDP `error` object.
|
|
Protocol(String),
|
|
}
|
|
|
|
impl std::fmt::Display for CdpError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Self::Unreachable(m) => write!(f, "browser unreachable: {m}"),
|
|
Self::Unauthorized(m) => write!(f, "not authorised to drive this browser: {m}"),
|
|
Self::NotDrivable(m) => write!(f, "browser is not drivable yet: {m}"),
|
|
Self::Transport(m) => write!(f, "CDP transport failed: {m}"),
|
|
Self::Protocol(m) => write!(f, "CDP error: {m}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl CdpError {
|
|
/// Whether a fresh connection attempt could plausibly succeed.
|
|
///
|
|
/// A refused credential and a session that is still provisioning are answers,
|
|
/// not failures. Retrying either spends the caller's time and, on the relay,
|
|
/// burns one of the four attachments a session is allowed — so the retry can
|
|
/// make the next honest attempt fail too.
|
|
fn is_retryable(&self) -> bool {
|
|
matches!(self, Self::Unreachable(_) | Self::Transport(_))
|
|
}
|
|
}
|
|
|
|
/// Why a profile could not be resolved to a browser at all.
|
|
#[derive(Debug)]
|
|
pub enum ResolveError {
|
|
/// The profile is not one this app can drive.
|
|
Unsupported(String),
|
|
/// Neither a local process nor a live remote session.
|
|
NotRunning(String),
|
|
/// A remote session exists but its endpoint could not be read.
|
|
Endpoint(String),
|
|
}
|
|
|
|
impl std::fmt::Display for ResolveError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Self::Unsupported(m) | Self::NotRunning(m) | Self::Endpoint(m) => write!(f, "{m}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Whether the caller is willing to wait for a browser that is still coming up.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum Patience {
|
|
/// One attempt, used to decide local-vs-remote without stalling.
|
|
Immediate,
|
|
/// The full retry budget, used once the answer is known to be local.
|
|
WaitForLaunch,
|
|
}
|
|
|
|
impl Patience {
|
|
fn attempts(self, waiting: u32) -> u32 {
|
|
match self {
|
|
Self::Immediate => 1,
|
|
Self::WaitForLaunch => waiting,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Find the browser for `profile`, wherever it is running.
|
|
///
|
|
/// Local wins when both look possible: the profile lock makes a genuine overlap
|
|
/// impossible, and a browser on this machine is free to drive while a relayed
|
|
/// one crosses two networks.
|
|
///
|
|
/// The local check is deliberately split in two. One cheap probe decides the
|
|
/// arm, so a profile running on the fleet is not held behind twenty-five
|
|
/// seconds of local retries; only once remote has been ruled out does the local
|
|
/// probe spend its full budget waiting for a browser that is still starting.
|
|
/// The same split covers a stale `process_id` left by a crash — nothing answers
|
|
/// on the recorded port, so the fleet session is found instead of a dead one.
|
|
pub async fn resolve(profile: &BrowserProfile) -> Result<CdpTarget, ResolveError> {
|
|
if profile.browser != "wayfern" {
|
|
return Err(ResolveError::Unsupported(format!(
|
|
"Profile '{}' runs {}, which cannot be driven over CDP",
|
|
profile.name, profile.browser
|
|
)));
|
|
}
|
|
|
|
let has_local_process = profile.process_id.is_some();
|
|
|
|
if has_local_process {
|
|
if let Some(ws_url) = local_page_ws_url(profile, Patience::Immediate).await {
|
|
return Ok(CdpTarget::Local { ws_url });
|
|
}
|
|
}
|
|
|
|
if let Some(session) =
|
|
crate::remote_session::live_session_for_profile(&profile.id.to_string()).await
|
|
{
|
|
let endpoint = crate::remote_session::cdp_endpoint(&session.session_id)
|
|
.await
|
|
.map_err(|e| ResolveError::Endpoint(e.to_string()))?;
|
|
let bearer = crate::remote_session::access_token_for_cdp().map_err(ResolveError::Endpoint)?;
|
|
log::info!(
|
|
"Driving profile '{}' through remote session {}",
|
|
profile.name,
|
|
session.session_id
|
|
);
|
|
return Ok(CdpTarget::Remote {
|
|
ws_url: endpoint.ws_url,
|
|
bearer,
|
|
session_id: session.session_id,
|
|
});
|
|
}
|
|
|
|
if has_local_process {
|
|
return match local_page_ws_url(profile, Patience::WaitForLaunch).await {
|
|
Some(ws_url) => Ok(CdpTarget::Local { ws_url }),
|
|
None => Err(ResolveError::NotRunning(format!(
|
|
"No CDP connection available for profile '{}'. Make sure the browser is running.",
|
|
profile.name
|
|
))),
|
|
};
|
|
}
|
|
|
|
Err(ResolveError::NotRunning(format!(
|
|
"Profile '{}' is not running",
|
|
profile.name
|
|
)))
|
|
}
|
|
|
|
/// The debugging port a locally launched browser registered for this profile.
|
|
async fn local_cdp_port(profile: &BrowserProfile, patience: Patience) -> Option<u16> {
|
|
let profiles_dir = crate::profile::manager::ProfileManager::instance().get_profiles_dir();
|
|
let profile_path = profile.get_profile_data_path(&profiles_dir);
|
|
let profile_path_str = profile_path.to_string_lossy().to_string();
|
|
|
|
// Port info is written once the process is up, so a tool called straight
|
|
// after a launch has to wait for it.
|
|
for attempt in 0..patience.attempts(10) {
|
|
if attempt > 0 {
|
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
|
}
|
|
if let Some(port) = crate::wayfern_manager::WayfernManager::instance()
|
|
.get_cdp_port(&profile_path_str)
|
|
.await
|
|
{
|
|
return Some(port);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// A page-level socket on a locally running browser.
|
|
///
|
|
/// Returns `None` rather than an error: a miss is how the resolver decides the
|
|
/// browser is not local, and a port whose process has since died answers
|
|
/// nothing, which is exactly the signal that decision needs.
|
|
async fn local_page_ws_url(profile: &BrowserProfile, patience: Patience) -> Option<String> {
|
|
let port = local_cdp_port(profile, patience).await?;
|
|
let listing = format!("http://127.0.0.1:{port}/json");
|
|
let client = reqwest::Client::new();
|
|
|
|
let mut last_err = String::new();
|
|
for attempt in 0..patience.attempts(15) {
|
|
if attempt > 0 {
|
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
|
}
|
|
match client
|
|
.get(&listing)
|
|
.timeout(Duration::from_secs(3))
|
|
.send()
|
|
.await
|
|
{
|
|
Ok(response) => match response.json::<Vec<Value>>().await {
|
|
Ok(targets) => {
|
|
if let Some(ws_url) = pick_local_page_socket(&targets) {
|
|
return Some(ws_url);
|
|
}
|
|
last_err = "no page target found in browser".to_string();
|
|
}
|
|
Err(e) => last_err = format!("failed to parse CDP targets: {e}"),
|
|
},
|
|
Err(e) => last_err = format!("failed to reach the browser's CDP endpoint: {e}"),
|
|
}
|
|
}
|
|
|
|
if patience == Patience::WaitForLaunch {
|
|
log::warn!("Local CDP discovery on port {port} gave up: {last_err}");
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Pick a drivable page from what `/json` lists on a local browser.
|
|
pub fn pick_local_page_socket(targets: &[Value]) -> Option<String> {
|
|
targets
|
|
.iter()
|
|
.find(|t| t.get("type").and_then(Value::as_str) == Some("page"))
|
|
.and_then(|t| t.get("webSocketDebuggerUrl"))
|
|
.and_then(Value::as_str)
|
|
.map(str::to_string)
|
|
}
|
|
|
|
/// Pick a drivable page from a `Target.getTargets` reply.
|
|
///
|
|
/// DevTools' own frontend is a page target too, and attaching to it drives the
|
|
/// inspector instead of the site — a failure that reports success and moves
|
|
/// nothing.
|
|
pub fn pick_remote_page_target(result: &Value) -> Option<String> {
|
|
result
|
|
.get("targetInfos")
|
|
.and_then(Value::as_array)?
|
|
.iter()
|
|
.find(|info| {
|
|
let is_page = info.get("type").and_then(Value::as_str) == Some("page");
|
|
let url = info.get("url").and_then(Value::as_str).unwrap_or_default();
|
|
is_page && !url.starts_with("devtools://")
|
|
})
|
|
.and_then(|info| info.get("targetId"))
|
|
.and_then(Value::as_str)
|
|
.map(str::to_string)
|
|
}
|
|
|
|
/// One outgoing CDP message, addressed to a page when a session id is in play.
|
|
///
|
|
/// Flattened sessions keep `method` at the top level on the way back, so event
|
|
/// matching is identical on both arms and no caller has to know which it is on.
|
|
pub fn cdp_frame(session: Option<&str>, id: u64, method: &str, params: Value) -> Value {
|
|
let mut message = serde_json::json!({ "id": id, "method": method, "params": params });
|
|
if let Some(session) = session {
|
|
message["sessionId"] = Value::String(session.to_string());
|
|
}
|
|
message
|
|
}
|
|
|
|
/// Why the peer hung up.
|
|
#[derive(Debug, Clone)]
|
|
struct CloseInfo {
|
|
code: u16,
|
|
reason: String,
|
|
}
|
|
|
|
/// An open CDP conversation with one browser, local or relayed.
|
|
pub struct CdpConnection {
|
|
stream: WebSocketStream<MaybeTlsStream<TcpStream>>,
|
|
/// Set only for a relayed connection: stamped onto every outgoing message so
|
|
/// page-level commands reach the page rather than the browser.
|
|
cdp_session: Option<String>,
|
|
closed: Option<CloseInfo>,
|
|
}
|
|
|
|
impl CdpConnection {
|
|
fn new(stream: WebSocketStream<MaybeTlsStream<TcpStream>>) -> Self {
|
|
Self {
|
|
stream,
|
|
cdp_session: None,
|
|
closed: None,
|
|
}
|
|
}
|
|
|
|
/// Send `method` as command `id`.
|
|
pub async fn send_command(
|
|
&mut self,
|
|
id: u64,
|
|
method: &str,
|
|
params: Value,
|
|
) -> Result<(), CdpError> {
|
|
use futures_util::sink::SinkExt;
|
|
let frame = cdp_frame(self.cdp_session.as_deref(), id, method, params);
|
|
self
|
|
.stream
|
|
.send(Message::Text(frame.to_string().into()))
|
|
.await
|
|
.map_err(|e| CdpError::Transport(format!("failed to send CDP command: {e}")))
|
|
}
|
|
|
|
/// The next text message, or `None` once the peer has gone.
|
|
///
|
|
/// Binary frames, pings and pongs are consumed silently; a close is recorded
|
|
/// so its reason survives into whatever error the caller builds.
|
|
pub async fn next_text(&mut self) -> Option<Result<String, CdpError>> {
|
|
use futures_util::stream::StreamExt;
|
|
loop {
|
|
match self.stream.next().await? {
|
|
Ok(Message::Text(text)) => return Some(Ok(text.to_string())),
|
|
Ok(Message::Close(frame)) => {
|
|
self.closed = frame.map(|f| CloseInfo {
|
|
code: u16::from(f.code),
|
|
reason: f.reason.to_string(),
|
|
});
|
|
return None;
|
|
}
|
|
Ok(_) => continue,
|
|
Err(e) => {
|
|
return Some(Err(CdpError::Transport(format!(
|
|
"CDP WebSocket error: {e}"
|
|
))))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Turn a hang-up into the error it means.
|
|
///
|
|
/// The relay's close codes are its whole vocabulary: 1008 is "that credential
|
|
/// is no good", 1013 is "come back when the session is up". Reporting either
|
|
/// as a generic transport failure throws away the only actionable thing the
|
|
/// server said.
|
|
pub fn closed_error(&self, context: &str) -> CdpError {
|
|
match &self.closed {
|
|
Some(info) if info.reason.is_empty() => {
|
|
classify_close(info.code, format!("{context} (close {})", info.code))
|
|
}
|
|
Some(info) => classify_close(
|
|
info.code,
|
|
format!("{context} ({}: {})", info.code, info.reason),
|
|
),
|
|
None => CdpError::Transport(format!("{context} (connection closed)")),
|
|
}
|
|
}
|
|
|
|
/// Send a command and read its reply, bounded by [`COMMAND_TIMEOUT`].
|
|
pub async fn call(&mut self, id: u64, method: &str, params: Value) -> Result<Value, CdpError> {
|
|
self.send_command(id, method, params).await?;
|
|
self.await_reply(id, COMMAND_TIMEOUT).await
|
|
}
|
|
|
|
/// Read until the reply to `id` arrives, discarding events on the way.
|
|
pub async fn await_reply(&mut self, id: u64, timeout: Duration) -> Result<Value, CdpError> {
|
|
let deadline = tokio::time::Instant::now() + timeout;
|
|
loop {
|
|
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
|
if remaining.is_zero() {
|
|
return Err(CdpError::Transport(
|
|
"timed out waiting for a CDP response".to_string(),
|
|
));
|
|
}
|
|
let text = match tokio::time::timeout(remaining, self.next_text()).await {
|
|
Err(_) => {
|
|
return Err(CdpError::Transport(
|
|
"timed out waiting for a CDP response".to_string(),
|
|
))
|
|
}
|
|
Ok(None) => return Err(self.closed_error("no response received from CDP")),
|
|
Ok(Some(result)) => result?,
|
|
};
|
|
|
|
let response: Value = serde_json::from_str(&text)
|
|
.map_err(|e| CdpError::Protocol(format!("failed to parse CDP response: {e}")))?;
|
|
if response.get("id") != Some(&Value::from(id)) {
|
|
continue;
|
|
}
|
|
if let Some(error) = response.get("error") {
|
|
return Err(CdpError::Protocol(error.to_string()));
|
|
}
|
|
return Ok(
|
|
response
|
|
.get("result")
|
|
.cloned()
|
|
.unwrap_or_else(|| serde_json::json!({})),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Hang up politely so the peer releases its side immediately.
|
|
///
|
|
/// On the relay every open socket costs a real stream on the leased host and
|
|
/// counts against the session's attachment cap, so dropping the TCP
|
|
/// connection and letting it time out is not good enough.
|
|
pub async fn close(mut self) {
|
|
let _ = self.stream.close(None).await;
|
|
}
|
|
|
|
/// Move a browser-level socket onto a page.
|
|
///
|
|
/// The relay bridges `/devtools/browser/<id>`. Every tool here speaks
|
|
/// `Page.*`, `Runtime.*` and `Input.*`, which a browser socket answers with
|
|
/// `'Page.navigate' wasn't found`. Attaching flat, and stamping the resulting
|
|
/// session id onto everything after it, is what makes the tools this app
|
|
/// already has work remotely without a single per-tool change.
|
|
async fn attach_to_page(&mut self) -> Result<(), CdpError> {
|
|
let targets = self
|
|
.call(
|
|
HANDSHAKE_GET_TARGETS_ID,
|
|
"Target.getTargets",
|
|
serde_json::json!({}),
|
|
)
|
|
.await?;
|
|
let target_id = pick_remote_page_target(&targets)
|
|
.ok_or_else(|| CdpError::NotDrivable("the remote browser has no page open".to_string()))?;
|
|
|
|
let attached = self
|
|
.call(
|
|
HANDSHAKE_ATTACH_ID,
|
|
"Target.attachToTarget",
|
|
serde_json::json!({ "targetId": target_id, "flatten": true }),
|
|
)
|
|
.await?;
|
|
|
|
let session = attached
|
|
.get("sessionId")
|
|
.and_then(Value::as_str)
|
|
.ok_or_else(|| {
|
|
CdpError::Protocol("Target.attachToTarget returned no sessionId".to_string())
|
|
})?;
|
|
self.cdp_session = Some(session.to_string());
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Ids used by the one-shot command runners below.
|
|
///
|
|
/// A caller never picks these, so they are stated once here rather than being
|
|
/// re-derived at each call site.
|
|
const RUN_PAGE_ENABLE_ID: u64 = 1;
|
|
const RUN_COMMAND_ID: u64 = 2;
|
|
const RUN_PAGE_DISABLE_ID: u64 = 3;
|
|
|
|
/// The attach handshake and the commands after it share one socket, so a
|
|
/// handshake reply carrying a command's id would be handed back as that
|
|
/// command's result. Checked at compile time because the failure it prevents is
|
|
/// silent: the wrong reply is still a well-formed reply.
|
|
const _: () = {
|
|
assert!(HANDSHAKE_GET_TARGETS_ID != HANDSHAKE_ATTACH_ID);
|
|
assert!(HANDSHAKE_GET_TARGETS_ID != RUN_PAGE_ENABLE_ID);
|
|
assert!(HANDSHAKE_GET_TARGETS_ID != RUN_COMMAND_ID);
|
|
assert!(HANDSHAKE_GET_TARGETS_ID != RUN_PAGE_DISABLE_ID);
|
|
assert!(HANDSHAKE_ATTACH_ID != RUN_PAGE_ENABLE_ID);
|
|
assert!(HANDSHAKE_ATTACH_ID != RUN_COMMAND_ID);
|
|
assert!(HANDSHAKE_ATTACH_ID != RUN_PAGE_DISABLE_ID);
|
|
};
|
|
|
|
/// Run one command on a fresh connection and hand back its result.
|
|
pub async fn run_command(
|
|
target: &CdpTarget,
|
|
method: &str,
|
|
params: Value,
|
|
) -> Result<Value, CdpError> {
|
|
let mut connection = target.connect().await?;
|
|
let result = connection.call(RUN_COMMAND_ID, method, params).await;
|
|
connection.close().await;
|
|
result
|
|
}
|
|
|
|
/// Run one command, then wait for the page to finish loading.
|
|
///
|
|
/// Used for anything that might navigate: `Page.navigate` obviously, but also a
|
|
/// click or a script that turns out to follow a link. When nothing navigates,
|
|
/// the wait simply expires and the command's own result is returned — that is
|
|
/// the intended path, not a failure.
|
|
pub async fn run_command_awaiting_load(
|
|
target: &CdpTarget,
|
|
method: &str,
|
|
params: Value,
|
|
timeout_secs: u64,
|
|
) -> Result<Value, CdpError> {
|
|
let mut connection = target.connect().await?;
|
|
|
|
// Page events have to be on before the command runs, or `loadEventFired`
|
|
// for a fast navigation is missed and the wait runs to its full timeout.
|
|
connection
|
|
.call(RUN_PAGE_ENABLE_ID, "Page.enable", serde_json::json!({}))
|
|
.await?;
|
|
connection
|
|
.send_command(RUN_COMMAND_ID, method, params)
|
|
.await?;
|
|
|
|
let mut command_result = None;
|
|
let mut failure = None;
|
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs);
|
|
|
|
loop {
|
|
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
|
if remaining.is_zero() {
|
|
break;
|
|
}
|
|
let text = match tokio::time::timeout(remaining, connection.next_text()).await {
|
|
Ok(Some(Ok(text))) => text,
|
|
Ok(Some(Err(e))) => {
|
|
failure = Some(e);
|
|
break;
|
|
}
|
|
// The peer hung up, or the wait expired. Either way whatever the command
|
|
// already answered is the best result available.
|
|
Ok(None) | Err(_) => break,
|
|
};
|
|
|
|
let response: Value = serde_json::from_str(&text).unwrap_or_default();
|
|
|
|
if response.get("id") == Some(&Value::from(RUN_COMMAND_ID)) {
|
|
if let Some(error) = response.get("error") {
|
|
failure = Some(CdpError::Protocol(error.to_string()));
|
|
break;
|
|
}
|
|
command_result = Some(
|
|
response
|
|
.get("result")
|
|
.cloned()
|
|
.unwrap_or_else(|| serde_json::json!({})),
|
|
);
|
|
}
|
|
|
|
// Flattened remote sessions keep `method` at the top level, so this match
|
|
// is identical on both arms.
|
|
if response.get("method") == Some(&Value::from("Page.loadEventFired")) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
let _ = connection
|
|
.send_command(RUN_PAGE_DISABLE_ID, "Page.disable", serde_json::json!({}))
|
|
.await;
|
|
let closed = connection.closed_error("no response received from CDP");
|
|
connection.close().await;
|
|
|
|
if let Some(error) = failure {
|
|
return Err(error);
|
|
}
|
|
command_result.ok_or(closed)
|
|
}
|
|
|
|
/// Point a browser at a URL and wait for it to settle.
|
|
///
|
|
/// This is what "open a URL in that profile" means once the browser is already
|
|
/// up, wherever it is. A remote session navigates its existing page rather than
|
|
/// opening a tab: a tab opened on a leased host that nobody can see or close is
|
|
/// not a feature, it is litter on hardware the user is paying for by the hour.
|
|
pub async fn navigate(target: &CdpTarget, url: &str, timeout_secs: u64) -> Result<(), CdpError> {
|
|
run_command_awaiting_load(
|
|
target,
|
|
"Page.navigate",
|
|
serde_json::json!({ "url": url }),
|
|
timeout_secs,
|
|
)
|
|
.await
|
|
.map(|_| ())
|
|
}
|
|
|
|
/// Map a WebSocket close code onto what the caller should do about it.
|
|
pub fn classify_close(code: u16, detail: String) -> CdpError {
|
|
match code {
|
|
1008 => CdpError::Unauthorized(detail),
|
|
1013 => CdpError::NotDrivable(detail),
|
|
1009 => CdpError::Transport(format!("{detail} — message too large")),
|
|
1011 => CdpError::Unreachable(detail),
|
|
_ => CdpError::Transport(detail),
|
|
}
|
|
}
|
|
|
|
impl CdpTarget {
|
|
/// Open a conversation with this browser.
|
|
///
|
|
/// Retries a connection that failed for a reason a retry could fix, and never
|
|
/// one that failed because the answer was no.
|
|
pub async fn connect(&self) -> Result<CdpConnection, CdpError> {
|
|
let mut attempt = 0u32;
|
|
loop {
|
|
let error = match self.connect_once().await {
|
|
Ok(connection) => return Ok(connection),
|
|
Err(e) => e,
|
|
};
|
|
attempt += 1;
|
|
if attempt >= CONNECT_ATTEMPTS || !error.is_retryable() {
|
|
return Err(error);
|
|
}
|
|
let delay = CONNECT_RETRY_BASE * 2u32.pow(attempt - 1);
|
|
log::warn!(
|
|
"CDP connect to {} failed ({error}); retrying in {}ms",
|
|
self.describe(),
|
|
delay.as_millis()
|
|
);
|
|
tokio::time::sleep(delay).await;
|
|
}
|
|
}
|
|
|
|
async fn connect_once(&self) -> Result<CdpConnection, CdpError> {
|
|
match self {
|
|
Self::Local { ws_url } => {
|
|
let request = ws_url
|
|
.as_str()
|
|
.into_client_request()
|
|
.map_err(|e| CdpError::Unreachable(format!("invalid CDP endpoint: {e}")))?;
|
|
Ok(CdpConnection::new(dial(request, None).await?))
|
|
}
|
|
Self::Remote {
|
|
ws_url,
|
|
bearer,
|
|
session_id,
|
|
} => {
|
|
let mut connection = dial_relay(ws_url, bearer).await?;
|
|
if let Err(e) = connection.attach_to_page().await {
|
|
log::warn!("Could not attach to a page in remote session {session_id}: {e}");
|
|
return Err(e);
|
|
}
|
|
Ok(connection)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A relay socket with nothing done to it yet.
|
|
pub type RelaySocket = WebSocketStream<MaybeTlsStream<TcpStream>>;
|
|
|
|
/// Open a session's BROWSER-level relay socket and hand it back untouched.
|
|
///
|
|
/// Deliberately skips the page attach that [`CdpTarget::connect`] performs. The
|
|
/// tools in this app all speak `Page.*` and need a page session stamped onto
|
|
/// every message; an external automation client does not, and must not have
|
|
/// one. Playwright's `connectOverCDP` expects the browser endpoint: it drives
|
|
/// `Target.setAutoAttach` and `Target.getTargets` itself and builds its own
|
|
/// session map, so a socket already attached to one page would hide every other
|
|
/// target from it and stamp a session id onto messages it did not address.
|
|
///
|
|
/// This is what makes a remote session usable from outside the app at all. The
|
|
/// relay only accepts the user's cloud credential, which no API consumer holds
|
|
/// and none should — so the socket is opened here, with the credential this
|
|
/// process already has, and proxied to the caller.
|
|
pub async fn open_relay_socket(session_id: &str) -> Result<RelaySocket, CdpError> {
|
|
let endpoint = crate::remote_session::cdp_endpoint(session_id)
|
|
.await
|
|
.map_err(endpoint_lookup_error)?;
|
|
let bearer = crate::remote_session::access_token_for_cdp().map_err(CdpError::Unauthorized)?;
|
|
|
|
let config = relay_socket_config();
|
|
let refused = match dial(relay_request(&endpoint.ws_url, &bearer)?, Some(config)).await {
|
|
Ok(stream) => return Ok(stream),
|
|
Err(CdpError::Unauthorized(reason)) => reason,
|
|
Err(e) => return Err(e),
|
|
};
|
|
|
|
log::info!("The CDP relay refused the stored access token; refreshing and retrying once");
|
|
crate::cloud_auth::CLOUD_AUTH
|
|
.refresh_access_token()
|
|
.await
|
|
.map_err(|e| CdpError::Unauthorized(format!("{refused}; token refresh failed: {e}")))?;
|
|
let token = crate::remote_session::access_token_for_cdp()
|
|
.map_err(|e| CdpError::Unauthorized(format!("{refused}; {e}")))?;
|
|
dial(relay_request(&endpoint.ws_url, &token)?, Some(config)).await
|
|
}
|
|
|
|
/// Why the backend would not say where to attach.
|
|
///
|
|
/// Collapsing this into "unreachable" is what made a session the user had
|
|
/// already stopped answer 502, so an automation client read a finished session
|
|
/// as a broken gateway and retried it. A session that is over, or that is not
|
|
/// the caller's, is a 404: there is no browser at this address.
|
|
fn endpoint_lookup_error(err: crate::remote_session::RemoteSessionError) -> CdpError {
|
|
use crate::remote_session::RemoteSessionError;
|
|
match err {
|
|
RemoteSessionError::NotAuthorised(m) => CdpError::Unauthorized(m),
|
|
RemoteSessionError::Conflict(m) => CdpError::NotDrivable(m),
|
|
RemoteSessionError::NoCapacity(m) => CdpError::Unreachable(m),
|
|
RemoteSessionError::Other(m) => {
|
|
// `Other` carries the backend's own envelope. A 404 for a closed or
|
|
// foreign session arrives here, and it is the common case rather than an
|
|
// exotic one, so it is read back out rather than lumped in with a
|
|
// genuine transport failure.
|
|
if m.contains("REMOTE_SESSION_NOT_FOUND") || m.contains("404") {
|
|
CdpError::NotDrivable(m)
|
|
} else {
|
|
CdpError::Unreachable(m)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Frame limits for a relay socket. Matches the relay's own client-facing cap.
|
|
pub fn relay_socket_config() -> WebSocketConfig {
|
|
WebSocketConfig::default()
|
|
.max_message_size(Some(REMOTE_MAX_MESSAGE_BYTES))
|
|
.max_frame_size(Some(REMOTE_MAX_MESSAGE_BYTES))
|
|
}
|
|
|
|
/// The ceiling a proxied CDP message may reach, so both ends agree.
|
|
pub const MAX_RELAY_MESSAGE_BYTES: usize = REMOTE_MAX_MESSAGE_BYTES;
|
|
|
|
/// Open the relay socket, refreshing the access token once if it is refused.
|
|
///
|
|
/// The access token lives long enough that an app left open overnight still
|
|
/// holds a valid-looking one after it has been rotated. Failing a whole tool
|
|
/// call for that — when the very next HTTP request would have refreshed it
|
|
/// silently — is a bug the user reads as "remote driving is flaky".
|
|
async fn dial_relay(ws_url: &str, bearer: &str) -> Result<CdpConnection, CdpError> {
|
|
let config = relay_socket_config();
|
|
|
|
let refused = match dial(relay_request(ws_url, bearer)?, Some(config)).await {
|
|
Ok(stream) => return Ok(CdpConnection::new(stream)),
|
|
Err(CdpError::Unauthorized(reason)) => reason,
|
|
Err(e) => return Err(e),
|
|
};
|
|
|
|
log::info!("The CDP relay refused the stored access token; refreshing and retrying once");
|
|
crate::cloud_auth::CLOUD_AUTH
|
|
.refresh_access_token()
|
|
.await
|
|
.map_err(|e| CdpError::Unauthorized(format!("{refused}; token refresh failed: {e}")))?;
|
|
let token = crate::remote_session::access_token_for_cdp()
|
|
.map_err(|e| CdpError::Unauthorized(format!("{refused}; {e}")))?;
|
|
let stream = dial(relay_request(ws_url, &token)?, Some(config)).await?;
|
|
Ok(CdpConnection::new(stream))
|
|
}
|
|
|
|
/// Build the upgrade request for the relay.
|
|
///
|
|
/// The credential goes in a header, never the query string: a URL that grants
|
|
/// full control of a live browser must not reach a proxy access log.
|
|
fn relay_request(ws_url: &str, bearer: &str) -> Result<WsRequest, CdpError> {
|
|
let mut request = ws_url
|
|
.into_client_request()
|
|
.map_err(|e| CdpError::Unreachable(format!("invalid relay endpoint: {e}")))?;
|
|
let value = format!("Bearer {bearer}").parse().map_err(|_| {
|
|
CdpError::Unauthorized("the stored access token is not a valid header value".to_string())
|
|
})?;
|
|
request.headers_mut().insert(
|
|
tokio_tungstenite::tungstenite::http::header::AUTHORIZATION,
|
|
value,
|
|
);
|
|
Ok(request)
|
|
}
|
|
|
|
/// Perform the handshake, bounded by [`CONNECT_TIMEOUT`].
|
|
async fn dial(
|
|
request: WsRequest,
|
|
config: Option<WebSocketConfig>,
|
|
) -> Result<WebSocketStream<MaybeTlsStream<TcpStream>>, CdpError> {
|
|
let connect = tokio_tungstenite::connect_async_with_config(request, config, false);
|
|
match tokio::time::timeout(CONNECT_TIMEOUT, connect).await {
|
|
Err(_) => Err(CdpError::Unreachable(format!(
|
|
"the CDP endpoint did not answer within {}s",
|
|
CONNECT_TIMEOUT.as_secs()
|
|
))),
|
|
Ok(Ok((stream, _response))) => Ok(stream),
|
|
Ok(Err(tokio_tungstenite::tungstenite::Error::Http(response))) => {
|
|
Err(classify_handshake_status(response.status().as_u16()))
|
|
}
|
|
Ok(Err(e)) => Err(CdpError::Unreachable(e.to_string())),
|
|
}
|
|
}
|
|
|
|
/// Map a refused upgrade onto the error it means.
|
|
///
|
|
/// A 401 is a credential problem the caller can fix by signing in again; a 404
|
|
/// means the session is not theirs or no longer exists. Surfacing either as
|
|
/// "connection failed" is what makes an automation client retry forever.
|
|
pub fn classify_handshake_status(status: u16) -> CdpError {
|
|
match status {
|
|
401 | 403 => {
|
|
CdpError::Unauthorized(format!("the relay refused the credential (HTTP {status})"))
|
|
}
|
|
404 => CdpError::NotDrivable(
|
|
"no such remote session, or it does not belong to this account".to_string(),
|
|
),
|
|
409 => CdpError::NotDrivable("the remote session is not drivable yet".to_string()),
|
|
429 => CdpError::NotDrivable("too many attachments to this remote session".to_string()),
|
|
other => CdpError::Unreachable(format!("the relay answered HTTP {other}")),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn a_page_target_is_preferred_over_the_devtools_frontend() {
|
|
// Attaching to devtools:// drives the inspector, not the site, and the
|
|
// failure is silent: navigate returns success and nothing moves.
|
|
let targets = serde_json::json!({
|
|
"targetInfos": [
|
|
{ "targetId": "t-devtools", "type": "page", "url": "devtools://devtools/bundled/x.html" },
|
|
{ "targetId": "t-page", "type": "page", "url": "https://example.com/" },
|
|
]
|
|
});
|
|
assert_eq!(pick_remote_page_target(&targets).as_deref(), Some("t-page"));
|
|
}
|
|
|
|
#[test]
|
|
fn service_workers_and_browser_targets_are_not_pages() {
|
|
let targets = serde_json::json!({
|
|
"targetInfos": [
|
|
{ "targetId": "t-sw", "type": "service_worker", "url": "https://example.com/sw.js" },
|
|
{ "targetId": "t-browser", "type": "browser", "url": "" },
|
|
]
|
|
});
|
|
assert!(pick_remote_page_target(&targets).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn a_fresh_browser_showing_only_about_blank_is_still_drivable() {
|
|
// The first thing a remote launch has open is about:blank. Refusing it
|
|
// would leave every session unusable until the user navigated by hand —
|
|
// which they cannot do, because navigating is what needs the attach.
|
|
let targets = serde_json::json!({
|
|
"targetInfos": [{ "targetId": "t-blank", "type": "page", "url": "about:blank" }]
|
|
});
|
|
assert_eq!(
|
|
pick_remote_page_target(&targets).as_deref(),
|
|
Some("t-blank")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_empty_reply_resolves_to_no_target_rather_than_panicking() {
|
|
assert!(pick_remote_page_target(&serde_json::json!({})).is_none());
|
|
assert!(pick_remote_page_target(&serde_json::json!({ "targetInfos": [] })).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn the_local_page_socket_is_read_from_the_json_listing() {
|
|
let targets = vec![
|
|
serde_json::json!({ "type": "background_page", "webSocketDebuggerUrl": "ws://x/bg" }),
|
|
serde_json::json!({ "type": "page", "webSocketDebuggerUrl": "ws://127.0.0.1:1/devtools/page/A" }),
|
|
];
|
|
assert_eq!(
|
|
pick_local_page_socket(&targets).as_deref(),
|
|
Some("ws://127.0.0.1:1/devtools/page/A")
|
|
);
|
|
assert!(pick_local_page_socket(&[]).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn a_remote_frame_addresses_the_page_and_a_local_one_does_not() {
|
|
// A page-level command sent on the relay's BROWSER socket comes back as
|
|
// "'Page.navigate' wasn't found". One missing sessionId on one message is
|
|
// enough to make a single tool fail while every other tool works — a
|
|
// partial failure that reads as a flaky VM.
|
|
let remote = cdp_frame(
|
|
Some("SESSION-42"),
|
|
7,
|
|
"Page.navigate",
|
|
serde_json::json!({ "url": "https://example.com" }),
|
|
);
|
|
assert_eq!(remote["sessionId"], "SESSION-42");
|
|
assert_eq!(remote["id"], 7);
|
|
assert_eq!(remote["method"], "Page.navigate");
|
|
assert_eq!(remote["params"]["url"], "https://example.com");
|
|
|
|
let local = cdp_frame(None, 7, "Page.navigate", serde_json::json!({}));
|
|
assert!(local.get("sessionId").is_none());
|
|
assert_eq!(local["id"], 7);
|
|
}
|
|
|
|
#[test]
|
|
fn a_relay_close_says_what_the_caller_should_do_about_it() {
|
|
// These codes are the relay's entire vocabulary. Collapsing them into one
|
|
// transport failure is how "your session is still provisioning" and "you
|
|
// are signed out" both become "something went wrong".
|
|
assert!(matches!(
|
|
classify_close(1008, "x".into()),
|
|
CdpError::Unauthorized(_)
|
|
));
|
|
assert!(matches!(
|
|
classify_close(1013, "x".into()),
|
|
CdpError::NotDrivable(_)
|
|
));
|
|
assert!(matches!(
|
|
classify_close(1011, "x".into()),
|
|
CdpError::Unreachable(_)
|
|
));
|
|
assert!(matches!(
|
|
classify_close(1009, "x".into()),
|
|
CdpError::Transport(_)
|
|
));
|
|
assert!(matches!(
|
|
classify_close(1000, "x".into()),
|
|
CdpError::Transport(_)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn only_the_failures_a_retry_could_fix_are_retried() {
|
|
assert!(CdpError::Unreachable("x".into()).is_retryable());
|
|
assert!(CdpError::Transport("x".into()).is_retryable());
|
|
assert!(!CdpError::Unauthorized("x".into()).is_retryable());
|
|
assert!(!CdpError::NotDrivable("x".into()).is_retryable());
|
|
assert!(!CdpError::Protocol("x".into()).is_retryable());
|
|
}
|
|
|
|
#[test]
|
|
fn a_refused_upgrade_is_not_reported_as_an_unreachable_browser() {
|
|
assert!(matches!(
|
|
classify_handshake_status(401),
|
|
CdpError::Unauthorized(_)
|
|
));
|
|
assert!(matches!(
|
|
classify_handshake_status(404),
|
|
CdpError::NotDrivable(_)
|
|
));
|
|
assert!(matches!(
|
|
classify_handshake_status(409),
|
|
CdpError::NotDrivable(_)
|
|
));
|
|
assert!(matches!(
|
|
classify_handshake_status(429),
|
|
CdpError::NotDrivable(_)
|
|
));
|
|
assert!(matches!(
|
|
classify_handshake_status(502),
|
|
CdpError::Unreachable(_)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn a_relay_endpoint_carries_the_credential_in_a_header() {
|
|
// In the query string it would reach every proxy log between here and the
|
|
// origin, and this credential grants full control of a live browser.
|
|
let request = relay_request(
|
|
"wss://api.donutbrowser.com/api/remote-sessions/cdp?session_id=s1",
|
|
"secret-token",
|
|
)
|
|
.expect("a wss endpoint must build a request");
|
|
assert_eq!(
|
|
request
|
|
.headers()
|
|
.get("authorization")
|
|
.and_then(|v| v.to_str().ok()),
|
|
Some("Bearer secret-token")
|
|
);
|
|
assert!(!request.uri().to_string().contains("secret-token"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_session_that_is_over_is_not_reported_as_a_broken_gateway() {
|
|
// Observed against the real backend: attaching to a session the user had
|
|
// just stopped answered 502, so a CDP client read "this is finished" as
|
|
// "the gateway is down" and retried it.
|
|
use crate::remote_session::RemoteSessionError;
|
|
assert!(matches!(
|
|
endpoint_lookup_error(RemoteSessionError::Other(
|
|
r#"(404) {"code":"REMOTE_SESSION_NOT_FOUND"}"#.to_string()
|
|
)),
|
|
CdpError::NotDrivable(_)
|
|
));
|
|
assert!(matches!(
|
|
endpoint_lookup_error(RemoteSessionError::Conflict("already open".into())),
|
|
CdpError::NotDrivable(_)
|
|
));
|
|
assert!(matches!(
|
|
endpoint_lookup_error(RemoteSessionError::NotAuthorised("signed out".into())),
|
|
CdpError::Unauthorized(_)
|
|
));
|
|
// A genuine transport failure must still read as one.
|
|
assert!(matches!(
|
|
endpoint_lookup_error(RemoteSessionError::Other(
|
|
"reach backend: connection refused".to_string()
|
|
)),
|
|
CdpError::Unreachable(_)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn a_malformed_endpoint_is_refused_rather_than_dialled() {
|
|
assert!(relay_request("not a url", "t").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn a_target_describes_itself_without_leaking_the_credential() {
|
|
let remote = CdpTarget::Remote {
|
|
ws_url: "wss://api.donutbrowser.com/api/remote-sessions/cdp?session_id=s1".to_string(),
|
|
bearer: "secret-token".to_string(),
|
|
session_id: "s1".to_string(),
|
|
};
|
|
assert!(remote.is_remote());
|
|
let described = remote.describe();
|
|
assert!(described.contains("s1"));
|
|
assert!(!described.contains("secret-token"));
|
|
|
|
let local = CdpTarget::Local {
|
|
ws_url: "ws://127.0.0.1:1/devtools/page/A".to_string(),
|
|
};
|
|
assert!(!local.is_remote());
|
|
}
|
|
|
|
#[test]
|
|
fn a_hasty_probe_tries_once_and_a_patient_one_waits() {
|
|
// The split is what stops a profile running on the fleet from being held
|
|
// behind twenty-five seconds of local retries before anyone looks remote.
|
|
assert_eq!(Patience::Immediate.attempts(10), 1);
|
|
assert_eq!(Patience::WaitForLaunch.attempts(10), 10);
|
|
}
|
|
|
|
// --- Against a real socket -----------------------------------------------
|
|
//
|
|
// Everything above is pure. These drive the client against a WebSocket
|
|
// server that answers the way the relay does, because the failure this whole
|
|
// module exists to fix — page commands sent on a browser-level socket coming
|
|
// back as "'Page.navigate' wasn't found" — cannot be caught by inspecting a
|
|
// JSON value. It only shows up when something actually answers.
|
|
|
|
/// What the fake relay observed.
|
|
#[derive(Debug, Default)]
|
|
struct RelayLog {
|
|
/// The credential the client presented on the upgrade.
|
|
authorization: Option<String>,
|
|
/// Every message the client sent, in order.
|
|
received: Vec<Value>,
|
|
}
|
|
|
|
/// How the fake relay should behave once a client connects.
|
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
enum RelayBehaviour {
|
|
/// Answer the attach handshake, then echo every command back.
|
|
Cooperative,
|
|
/// Hang up the way a session that is not yet up does.
|
|
RefuseAsNotDrivable,
|
|
}
|
|
|
|
/// The CDP session id the fake relay hands out for a flat attach.
|
|
const FAKE_CDP_SESSION: &str = "CDP-SESSION-1";
|
|
|
|
/// A stand-in for the infra relay bridged onto a browser-level socket.
|
|
///
|
|
/// Answers `Target.getTargets` and `Target.attachToTarget` exactly as a real
|
|
/// browser endpoint does, then echoes each command back so the test can read
|
|
/// what was actually on the wire.
|
|
//
|
|
// The large-Err allow is forced by tungstenite's server-callback signature:
|
|
// its `ErrorResponse` is a full `http::Response`, and the callback is the
|
|
// only place the upgrade request's headers are visible.
|
|
#[allow(clippy::result_large_err)]
|
|
async fn fake_relay(behaviour: RelayBehaviour) -> (String, tokio::task::JoinHandle<RelayLog>) {
|
|
use futures_util::sink::SinkExt;
|
|
use futures_util::stream::StreamExt;
|
|
use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode;
|
|
use tokio_tungstenite::tungstenite::protocol::CloseFrame;
|
|
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
|
.await
|
|
.expect("the fake relay must bind");
|
|
let port = listener.local_addr().expect("a bound port").port();
|
|
|
|
let handle = tokio::spawn(async move {
|
|
let mut log = RelayLog::default();
|
|
let Ok((socket, _)) = listener.accept().await else {
|
|
return log;
|
|
};
|
|
|
|
let seen = std::sync::Arc::new(std::sync::Mutex::new(None::<String>));
|
|
let captured = seen.clone();
|
|
let Ok(mut stream) = tokio_tungstenite::accept_hdr_async(
|
|
socket,
|
|
|request: &WsRequest,
|
|
response: tokio_tungstenite::tungstenite::handshake::server::Response| {
|
|
*captured.lock().unwrap() = request
|
|
.headers()
|
|
.get("authorization")
|
|
.and_then(|value| value.to_str().ok())
|
|
.map(str::to_string);
|
|
Ok(response)
|
|
},
|
|
)
|
|
.await
|
|
else {
|
|
return log;
|
|
};
|
|
log.authorization = seen.lock().unwrap().clone();
|
|
|
|
if behaviour == RelayBehaviour::RefuseAsNotDrivable {
|
|
let _ = stream
|
|
.close(Some(CloseFrame {
|
|
code: CloseCode::Library(1013),
|
|
reason: "session is provisioning, not drivable".into(),
|
|
}))
|
|
.await;
|
|
return log;
|
|
}
|
|
|
|
while let Some(Ok(message)) = stream.next().await {
|
|
let Message::Text(text) = message else {
|
|
continue;
|
|
};
|
|
let Ok(request) = serde_json::from_str::<Value>(&text) else {
|
|
continue;
|
|
};
|
|
log.received.push(request.clone());
|
|
|
|
let id = request.get("id").cloned().unwrap_or(Value::Null);
|
|
let reply = match request.get("method").and_then(Value::as_str) {
|
|
Some("Target.getTargets") => serde_json::json!({
|
|
"id": id,
|
|
"result": { "targetInfos": [
|
|
{ "targetId": "page-1", "type": "page", "url": "https://example.com/" }
|
|
]}
|
|
}),
|
|
Some("Target.attachToTarget") => serde_json::json!({
|
|
"id": id,
|
|
"result": { "sessionId": FAKE_CDP_SESSION }
|
|
}),
|
|
// Everything else is handed straight back, so the test can assert on
|
|
// the exact frame the client put on the wire.
|
|
_ => serde_json::json!({
|
|
"id": id,
|
|
"sessionId": request.get("sessionId").cloned().unwrap_or(Value::Null),
|
|
"result": { "echo": request }
|
|
}),
|
|
};
|
|
if stream
|
|
.send(Message::Text(reply.to_string().into()))
|
|
.await
|
|
.is_err()
|
|
{
|
|
break;
|
|
}
|
|
|
|
// A real browser follows a navigation with the load event, flattened
|
|
// onto the same socket. Emitting it here is what proves the wait
|
|
// actually terminates on the event rather than on its timeout.
|
|
if request.get("method").and_then(Value::as_str) == Some("Page.navigate") {
|
|
let loaded = serde_json::json!({
|
|
"method": "Page.loadEventFired",
|
|
"sessionId": request.get("sessionId").cloned().unwrap_or(Value::Null),
|
|
"params": { "timestamp": 1.0 }
|
|
});
|
|
if stream
|
|
.send(Message::Text(loaded.to_string().into()))
|
|
.await
|
|
.is_err()
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
log
|
|
});
|
|
|
|
(format!("ws://127.0.0.1:{port}"), handle)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_relayed_page_command_is_attached_and_stamped_with_its_session() {
|
|
// This is the whole feature. The relay bridges /devtools/browser/<id>, so
|
|
// without the flat attach and the sessionId stamp every existing tool
|
|
// answers "'Page.navigate' wasn't found" and a paid remote session cannot
|
|
// be used for anything.
|
|
let (ws_url, server) = fake_relay(RelayBehaviour::Cooperative).await;
|
|
let target = CdpTarget::Remote {
|
|
ws_url,
|
|
bearer: "user-access-token".to_string(),
|
|
session_id: "sess-1".to_string(),
|
|
};
|
|
|
|
let result = run_command(
|
|
&target,
|
|
"Page.navigate",
|
|
serde_json::json!({ "url": "https://example.com" }),
|
|
)
|
|
.await
|
|
.expect("a relayed navigate must succeed");
|
|
|
|
assert_eq!(result["echo"]["method"], "Page.navigate");
|
|
assert_eq!(result["echo"]["sessionId"], FAKE_CDP_SESSION);
|
|
assert_eq!(result["echo"]["params"]["url"], "https://example.com");
|
|
|
|
let log = server.await.expect("the fake relay must finish");
|
|
assert_eq!(
|
|
log.authorization.as_deref(),
|
|
Some("Bearer user-access-token")
|
|
);
|
|
let methods: Vec<&str> = log
|
|
.received
|
|
.iter()
|
|
.filter_map(|m| m.get("method").and_then(Value::as_str))
|
|
.collect();
|
|
assert_eq!(
|
|
methods,
|
|
vec![
|
|
"Target.getTargets",
|
|
"Target.attachToTarget",
|
|
"Page.navigate"
|
|
]
|
|
);
|
|
// The handshake runs on the BROWSER session and must not be addressed to a
|
|
// page, or the browser answers it with "no such session".
|
|
assert!(log.received[0].get("sessionId").is_none());
|
|
assert!(log.received[1].get("sessionId").is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_relayed_navigation_waits_for_the_page_to_load() {
|
|
// The load wait shares one implementation with the local arm, so a
|
|
// flattened event that failed to match here would strand every navigate
|
|
// for its full timeout.
|
|
let (ws_url, server) = fake_relay(RelayBehaviour::Cooperative).await;
|
|
let target = CdpTarget::Remote {
|
|
ws_url,
|
|
bearer: "t".to_string(),
|
|
session_id: "sess-1".to_string(),
|
|
};
|
|
|
|
let started = std::time::Instant::now();
|
|
navigate(&target, "https://example.com", 30)
|
|
.await
|
|
.expect("a relayed navigate must resolve");
|
|
// It must return on the load event, not by outliving the timeout: a client
|
|
// that always waits the full budget turns every navigation into a stall.
|
|
assert!(
|
|
started.elapsed() < Duration::from_secs(10),
|
|
"navigate waited out its timeout instead of matching the load event"
|
|
);
|
|
|
|
let log = server.await.expect("the fake relay must finish");
|
|
let methods: Vec<&str> = log
|
|
.received
|
|
.iter()
|
|
.filter_map(|m| m.get("method").and_then(Value::as_str))
|
|
.collect();
|
|
assert!(methods.contains(&"Page.enable"));
|
|
assert!(methods.contains(&"Page.navigate"));
|
|
// Every page-domain message must carry the session, not just the command.
|
|
for message in &log.received {
|
|
let method = message.get("method").and_then(Value::as_str).unwrap_or("");
|
|
if method.starts_with("Page.") {
|
|
assert_eq!(
|
|
message.get("sessionId").and_then(Value::as_str),
|
|
Some(FAKE_CDP_SESSION),
|
|
"{method} was not addressed to the attached page"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_session_that_is_not_up_yet_is_reported_as_such_not_as_a_broken_one() {
|
|
// 1013 is the relay saying "come back when it is live". Surfacing it as a
|
|
// transport failure would send an automation client into a retry loop
|
|
// against a session that is doing exactly what it should.
|
|
let (ws_url, _server) = fake_relay(RelayBehaviour::RefuseAsNotDrivable).await;
|
|
let target = CdpTarget::Remote {
|
|
ws_url,
|
|
bearer: "t".to_string(),
|
|
session_id: "sess-1".to_string(),
|
|
};
|
|
|
|
let error = run_command(&target, "Page.navigate", serde_json::json!({}))
|
|
.await
|
|
.expect_err("a refused session must not look like a success");
|
|
assert!(
|
|
matches!(error, CdpError::NotDrivable(_)),
|
|
"expected NotDrivable, got {error:?}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_local_command_skips_the_attach_and_carries_no_session() {
|
|
// The local arm talks to a PAGE socket. Sending it a sessionId, or making
|
|
// it pay for an attach handshake it does not need, would be a regression
|
|
// in the path that already worked.
|
|
let (ws_url, server) = fake_relay(RelayBehaviour::Cooperative).await;
|
|
let target = CdpTarget::Local { ws_url };
|
|
|
|
let result = run_command(
|
|
&target,
|
|
"Runtime.evaluate",
|
|
serde_json::json!({ "expression": "1" }),
|
|
)
|
|
.await
|
|
.expect("a local command must succeed");
|
|
assert_eq!(result["echo"]["method"], "Runtime.evaluate");
|
|
|
|
let log = server.await.expect("the fake relay must finish");
|
|
let methods: Vec<&str> = log
|
|
.received
|
|
.iter()
|
|
.filter_map(|m| m.get("method").and_then(Value::as_str))
|
|
.collect();
|
|
assert_eq!(methods, vec!["Runtime.evaluate"]);
|
|
assert!(log.received[0].get("sessionId").is_none());
|
|
}
|
|
}
|