use crate::SelectorKind;
use crate::cdp::{self, CdpError, Connection};
use crate::scripts::{locate_all_script, locate_deep_script, locate_script, popup_scan_script};
use crate::session::{self, BrowserSession, Mode, Paths};
use actl_core::state::unix_ms;
use actl_core::{CtlError, ErrorCode};
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
const LAUNCH_DEFAULT_TIMEOUT_MS: u64 = 15_000;
const OP_DEFAULT_TIMEOUT_MS: u64 = 10_000;
const TEXT_LIMIT: usize = 65_536;
pub struct LaunchSpec {
pub browser_executable: String,
pub url: Option<String>,
pub profile: Option<String>,
pub proxy: Option<String>,
pub headless: bool,
pub disable_extensions: bool,
pub timeout_ms: Option<u64>,
}
fn declare_action(kind: &str, session_tag: &str) -> actl_core::signal_session::ActionGuard {
actl_core::signal_session::ActionGuard::begin(
kind,
actl_core::activity::InputEffects::default(),
actl_core::activity::ActionTarget {
window: Some(session_tag.to_string()),
..Default::default()
},
)
}
fn protocol(message: impl Into<String>) -> CtlError {
CtlError::new(ErrorCode::Protocol, message)
}
fn map_cdp(err: CdpError) -> CtlError {
match err.code {
"CONNECT" => CtlError::with_evidence(
ErrorCode::NotFound,
format!(
"browser devtools endpoint unreachable (browser exited or session expired): {}",
err.message
),
json!({ "cdp_code": err.code }),
),
"TIMEOUT" => CtlError::new(ErrorCode::Timeout, err.message),
_ => CtlError::with_evidence(
ErrorCode::Internal,
format!("CDP layer failure: {err}"),
json!({ "cdp_code": err.code }),
),
}
}
fn dur(timeout_ms: Option<u64>, default: u64) -> Duration {
Duration::from_millis(timeout_ms.filter(|t| *t > 0).unwrap_or(default))
}
fn validate_url(url: &str) -> Result<(), CtlError> {
let ok = url.len() <= 2048
&& ["http://", "https://", "about:blank", "file://"]
.iter()
.any(|p| url.starts_with(p));
if ok {
Ok(())
} else {
Err(protocol(format!(
"url must start with http:// https:// file:// or be about:blank: {url}"
)))
}
}
fn validate_selector(kind: SelectorKind, value: &str) -> Result<(), CtlError> {
if value.is_empty() || value.len() > 4096 {
return Err(protocol(format!(
"{} selector requires 1-4096 characters",
kind.as_str()
)));
}
Ok(())
}
fn validate_proxy(value: &str) -> Result<(), CtlError> {
let Some((host, port)) = value.rsplit_once(':') else {
return Err(protocol(format!(
"--proxy requires HOST:PORT, got: {value}"
)));
};
if host.is_empty()
|| !host
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-' || b == b'[' || b == b']')
{
return Err(protocol(format!(
"--proxy host must be alphanumeric/dots/brackets: {value}"
)));
}
let valid_port = port.parse::<u16>().is_ok_and(|p| (1..=65535).contains(&p));
if !valid_port {
return Err(protocol(format!("--proxy port must be 1-65535: {value}")));
}
Ok(())
}
pub(crate) const READ_PROPERTIES: &[&str] =
&["text", "value", "href", "name", "id", "checked", "tag"];
fn validate_property(property: &str) -> Result<(), CtlError> {
if READ_PROPERTIES.contains(&property) {
Ok(())
} else {
Err(protocol(format!(
"unknown --property {property:?}; allowed: {}",
READ_PROPERTIES.join(", ")
)))
}
}
fn eval_json(
conn: &Connection,
cdp_session: &str,
script: &str,
context_id: Option<u64>,
timeout: Duration,
) -> Result<Value, CtlError> {
let mut params = json!({ "expression": script, "returnByValue": true, "awaitPromise": true });
if let Some(ctx) = context_id {
params["contextId"] = json!(ctx);
}
let res = conn
.call("Runtime.evaluate", params, Some(cdp_session), timeout)
.map_err(map_cdp)?;
if let Some(exc) = res.get("exceptionDetails") {
let text = exc
.get("text")
.and_then(Value::as_str)
.unwrap_or("(no text)");
return Err(CtlError::with_evidence(
ErrorCode::Internal,
format!("page script raised: {text}"),
json!({ "exception": text }),
));
}
let raw = match res.pointer("/result/value").and_then(Value::as_str) {
Some(s) => s.to_string(),
None => return Err(CtlError::internal("page script returned no value")),
};
serde_json::from_str(&raw)
.map_err(|_| CtlError::internal("page script returned non-JSON payload"))
}
fn connect_browser(session: &BrowserSession) -> Result<Connection, CtlError> {
let url = format!(
"ws://127.0.0.1:{}{}",
session.devtools_port, session.browser_ws_path
);
Connection::connect(&url).map_err(map_cdp)
}
fn attach_target(
conn: &Connection,
target_id: &str,
timeout: Duration,
) -> Result<String, CtlError> {
let res = conn
.call(
"Target.attachToTarget",
json!({ "targetId": target_id, "flatten": true }),
None,
timeout,
)
.map_err(map_cdp)?;
res.get("sessionId")
.and_then(Value::as_str)
.map(str::to_string)
.ok_or_else(|| CtlError::internal("attachToTarget returned no sessionId"))
}
fn verify_target_alive(
conn: &Connection,
target_id: &str,
timeout: Duration,
) -> Result<(), CtlError> {
let res = conn.call(
"Target.getTargetInfo",
json!({ "targetId": target_id }),
None,
timeout,
);
match res {
Ok(_) => Ok(()),
Err(e) => Err(CtlError::with_evidence(
ErrorCode::NotFound,
format!(
"bound target no longer exists (new tab took over or browser restarted); \
re-run browser-navigate or browser-launch: {e}"
),
json!({ "target_id": target_id }),
)),
}
}
fn iframe_context(
conn: &Connection,
cdp_session: &str,
timeout: Duration,
) -> Result<(String, u64), CtlError> {
let tree = conn
.call("Page.getFrameTree", json!({}), Some(cdp_session), timeout)
.map_err(map_cdp)?;
let children = tree
.pointer("/frameTree/childFrames")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let child_id = children
.iter()
.find_map(|f| f.pointer("/frame/id").and_then(Value::as_str))
.ok_or_else(|| {
CtlError::new(
ErrorCode::NotFound,
"page has no child iframe to address (--iframe is not applicable here)",
)
})?
.to_string();
conn.call("Runtime.enable", json!({}), Some(cdp_session), timeout)
.map_err(map_cdp)?;
let deadline = Instant::now() + timeout;
loop {
for e in conn.drain_events() {
if e.method != "Runtime.executionContextCreated" {
continue;
}
let frame_ok = e
.params
.pointer("/context/auxData/frameId")
.and_then(Value::as_str)
.is_some_and(|id| id == child_id);
if frame_ok && let Some(ctx) = e.params.pointer("/context/id").and_then(Value::as_u64) {
return Ok((child_id.clone(), ctx));
}
}
if Instant::now() >= deadline {
return Err(CtlError::new(
ErrorCode::NotFound,
format!("no execution context observed for iframe {child_id} within timeout"),
));
}
std::thread::sleep(Duration::from_millis(20));
}
}
fn require_unique_match(located: &Value, kind: SelectorKind, value: &str) -> Result<(), CtlError> {
let matches = located.get("matches").and_then(Value::as_u64).unwrap_or(0);
match matches {
0 => Err(CtlError::with_evidence(
ErrorCode::NotFound,
format!("no element matches the {} selector", kind.as_str()),
json!({ "selector_kind": kind.as_str(), "selector": value, "matches": 0 }),
)),
1 => Ok(()),
n => Err(CtlError::with_evidence(
ErrorCode::Ambiguous,
format!(
"{} elements match the {} selector; tighten it",
n,
kind.as_str()
),
json!({ "selector_kind": kind.as_str(), "selector": value, "matches": n }),
)),
}
}
fn truncate_text(text: &str) -> (String, bool) {
if text.len() <= TEXT_LIMIT {
return (text.to_string(), false);
}
let mut cut = TEXT_LIMIT;
while cut > 0 && !text.is_char_boundary(cut) {
cut -= 1;
}
(text[..cut].to_string(), true)
}
fn located_text_payload(located: &Value) -> Value {
let (text, truncated) = truncate_text(
located
.get("text")
.and_then(Value::as_str)
.unwrap_or_default(),
);
let mut payload = json!({ "text": text, "truncated": truncated });
if let Some(tag) = located.get("tag").and_then(Value::as_str) {
payload["tag"] = Value::String(tag.to_string());
}
if let Some(id) = located.get("id").and_then(Value::as_str) {
payload["id"] = Value::String(id.to_string());
}
payload
}
pub fn launch(paths: &Paths, spec: LaunchSpec) -> Result<Value, CtlError> {
let browser_executable = actl_uia::app_registry::resolve(&spec.browser_executable)
.ok()
.flatten()
.unwrap_or_else(|| spec.browser_executable.clone());
let exe = PathBuf::from(&browser_executable);
if !exe.is_absolute() {
return Err(protocol(format!(
"browser executable path must be absolute (or a registered alias): {}",
browser_executable
)));
}
if !exe.is_file() {
return Err(protocol(format!(
"browser executable not found: {}",
spec.browser_executable
)));
}
let url = spec.url.unwrap_or_else(|| "about:blank".to_string());
validate_url(&url)?;
let mode = match spec.profile {
Some(name) => {
session::validate_profile_name(&name)?;
Mode::Persistent { name }
}
None => Mode::Ephemeral,
};
if let Some(proxy) = &spec.proxy {
validate_proxy(proxy)?;
}
let timeout = dur(spec.timeout_ms, LAUNCH_DEFAULT_TIMEOUT_MS);
let mut action = declare_action(
"browser-launch",
&match &mode {
Mode::Persistent { name } => format!("cdp-profile:{name}"),
Mode::Ephemeral => "cdp:ephemeral".to_string(),
},
);
action.executing();
if let Mode::Persistent { name } = &mode
&& let Some(reused) = reuse_persistent_session(paths, name, timeout)?
{
action.delivered();
return Ok(reused);
}
let session_id = actl_core::new_snapshot_id();
let profile_dir = match &mode {
Mode::Persistent { name } => paths.profiles_dir().join(name),
Mode::Ephemeral => paths.profiles_dir().join("ephemeral").join(&session_id),
};
let profile_dir = std::env::current_dir()
.map(|d| d.join(&profile_dir))
.unwrap_or(profile_dir);
std::fs::create_dir_all(&profile_dir)
.map_err(|e| CtlError::internal(format!("cannot create browser profile dir: {e}")))?;
std::fs::create_dir_all(paths.sessions_dir())
.map_err(|e| CtlError::internal(format!("cannot create browser sessions dir: {e}")))?;
let mut edge_args: Vec<String> = vec![
"--remote-debugging-port=0".to_string(),
format!("--user-data-dir={}", profile_dir.display()),
"--no-first-run".to_string(),
"--no-default-browser-check".to_string(),
];
if spec.headless {
edge_args.insert(0, "--headless=new".to_string());
}
if spec.disable_extensions || matches!(mode, Mode::Ephemeral) {
edge_args.push("--disable-extensions".to_string());
}
if let Some(proxy) = &spec.proxy {
edge_args.push(format!("--proxy-server={proxy}"));
}
edge_args.push("about:blank".to_string());
actl_uia::process_io::clear_stdio_inheritance();
let _ = std::fs::remove_file(profile_dir.join("DevToolsActivePort"));
let mut child = Command::new(&exe)
.args(&edge_args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| CtlError::internal(format!("cannot launch browser: {e}")))?;
let pid = child.id();
let devtools = match wait_devtools_port(&profile_dir, timeout) {
Some(v) => v,
None => {
let _ = child.kill();
let _ = child.wait();
if matches!(mode, Mode::Ephemeral) {
let _ = std::fs::remove_dir_all(&profile_dir);
}
return Err(CtlError::with_evidence(
ErrorCode::Internal,
"browser did not expose a devtools endpoint within timeout; \
launch aborted and the owned process was reclaimed \
(user-data-dir must be non-default for the debug port, docs/72 §2.1)"
.to_string(),
json!({ "pid": pid, "profile_dir": profile_dir.display().to_string() }),
));
}
};
let version = cdp::http_get_json(devtools.0, "/json/version", timeout).map_err(|e| {
reclaim_on_failure(&mut child, &mode, &profile_dir);
map_cdp(e)
})?;
let browser_version = version
.get("Browser")
.and_then(Value::as_str)
.unwrap_or("(unknown)")
.to_string();
let browser_ws_path = version
.get("webSocketDebuggerUrl")
.and_then(Value::as_str)
.and_then(|u| u.split_once("/devtools"))
.map(|(_, rest)| format!("/devtools{rest}"))
.ok_or_else(|| {
CtlError::internal("devtools /json/version returned no browser websocket url")
})?;
let conn = Connection::connect(&format!("ws://127.0.0.1:{}{}", devtools.0, browser_ws_path))
.map_err(|e| {
reclaim_on_failure(&mut child, &mode, &profile_dir);
map_cdp(e)
})?;
let created = conn
.call("Target.createTarget", json!({ "url": url }), None, timeout)
.map_err(|e| {
reclaim_on_failure(&mut child, &mode, &profile_dir);
map_cdp(e)
})?;
let target_id = created
.get("targetId")
.and_then(Value::as_str)
.ok_or_else(|| CtlError::internal("createTarget returned no targetId"))?
.to_string();
let record = BrowserSession {
version: session::SESSION_VERSION,
session_id: session_id.clone(),
browser_executable: spec.browser_executable.clone(),
profile_dir: profile_dir.display().to_string(),
mode: mode.clone(),
headless: spec.headless,
pid,
devtools_port: devtools.0,
browser_ws_path,
target_id: target_id.clone(),
bound_url: url.clone(),
created_unix: unix_ms() / 1000,
};
session::save(paths, &record)?;
action.delivered();
Ok(json!({
"session_id": session_id,
"target_id": target_id,
"browser_pid": pid,
"devtools_port": devtools.0,
"browser_version": browser_version,
"url": url,
"mode": mode,
"headless": spec.headless,
"profile_dir": profile_dir.display().to_string(),
}))
}
fn reuse_persistent_session(
paths: &Paths,
name: &str,
timeout: Duration,
) -> Result<Option<Value>, CtlError> {
let sessions = list(paths)?;
let Some(arr) = sessions.get("sessions").and_then(Value::as_array) else {
return Ok(None);
};
for entry in arr {
let is_same = entry
.pointer("/mode/persistent/name")
.and_then(Value::as_str)
.is_some_and(|n| n == name);
if !is_same {
continue;
}
let id = entry
.get("session_id")
.and_then(Value::as_str)
.unwrap_or_default();
let Ok(record) = session::load(paths, id) else {
continue;
};
let alive = (|| {
let conn = connect_browser(&record).ok()?;
attach_target(&conn, &record.target_id, timeout).ok()?;
verify_target_alive(&conn, &record.target_id, timeout).ok()
})()
.is_some();
if alive {
return Ok(Some(json!({
"session_id": record.session_id,
"target_id": record.target_id,
"browser_pid": record.pid,
"devtools_port": record.devtools_port,
"url": record.bound_url,
"mode": record.mode,
"headless": record.headless,
"profile_dir": record.profile_dir,
"reused": true,
})));
}
let _ = session::remove(paths, id);
}
Ok(None)
}
fn reclaim_on_failure(child: &mut Child, mode: &Mode, profile_dir: &Path) {
let _ = child.kill();
let _ = child.wait();
if matches!(mode, Mode::Ephemeral) {
let _ = std::fs::remove_dir_all(profile_dir);
}
}
fn wait_devtools_port(profile_dir: &Path, timeout: Duration) -> Option<(u16, String)> {
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
if let Ok(text) = std::fs::read_to_string(profile_dir.join("DevToolsActivePort")) {
let mut lines = text.lines();
if let Some(port) = lines.next().and_then(|l| l.trim().parse::<u16>().ok()) {
if cdp::http_get_json(port, "/json/version", Duration::from_millis(1000)).is_ok() {
let ws_path = lines.next().unwrap_or("").trim().to_string();
return Some((port, ws_path));
}
}
}
std::thread::sleep(Duration::from_millis(100));
}
None
}
pub fn navigate(
paths: &Paths,
session_id: &str,
url: &str,
timeout_ms: Option<u64>,
) -> Result<Value, CtlError> {
validate_url(url)?;
let timeout = dur(timeout_ms, OP_DEFAULT_TIMEOUT_MS);
let record = session::load(paths, session_id)?;
let mut action = declare_action("browser-navigate", &format!("cdp:{session_id}"));
let conn = connect_browser(&record)?;
let cdp_session = attach_target(&conn, &record.target_id, timeout)?;
verify_target_alive(&conn, &record.target_id, timeout)?;
action.executing();
conn.call("Page.enable", json!({}), Some(&cdp_session), timeout)
.map_err(map_cdp)?;
let nav = conn
.call(
"Page.navigate",
json!({ "url": url }),
Some(&cdp_session),
timeout,
)
.map_err(map_cdp)?;
if let Some(net_error) = nav.get("errorText").and_then(Value::as_str)
&& !net_error.is_empty()
{
return Err(CtlError::with_evidence(
ErrorCode::Internal,
format!("page load failed at network level: {net_error}"),
json!({ "url": url, "net_error": net_error }),
));
}
let frame_id = nav
.get("frameId")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let deadline = Instant::now() + timeout;
let mut navigated = false;
loop {
for e in conn.drain_events() {
if e.method == "Page.frameNavigated"
&& e.session.as_deref() == Some(cdp_session.as_str())
&& e.params["frame"]["id"].as_str() == Some(frame_id.as_str())
{
navigated = true;
}
}
if navigated || Instant::now() >= deadline {
break;
}
std::thread::sleep(Duration::from_millis(20));
}
if !navigated {
return Err(CtlError::new(
ErrorCode::Timeout,
format!(
"Page.frameNavigated for frame {frame_id} not observed within {}ms",
timeout.as_millis()
),
));
}
let info = eval_json(
&conn,
&cdp_session,
"(function(){return JSON.stringify({url:location.href,title:document.title})})()",
None,
timeout,
)?;
let mut record = record;
record.bound_url = info
.get("url")
.and_then(Value::as_str)
.unwrap_or(url)
.to_string();
session::save(paths, &record)?;
action.delivered();
Ok(json!({
"session_id": session_id,
"url": record.bound_url,
"title": info.get("title").and_then(Value::as_str).unwrap_or_default(),
"frame_id": frame_id,
}))
}
pub struct ReadSpec<'a> {
pub session_id: &'a str,
pub kind: SelectorKind,
pub value: &'a str,
pub property: Option<&'a str>,
pub iframe: bool,
pub timeout_ms: Option<u64>,
pub all: bool,
pub limit: Option<usize>,
pub deep: bool,
pub wait_for: Option<u64>,
}
fn wait_for_matches(
conn: &Connection,
cdp_session: &str,
script: &str,
context_id: Option<u64>,
timeout: Duration,
min: u64,
first: Value,
) -> Result<Value, CtlError> {
let matches_of = |v: &Value| v.get("matches").and_then(Value::as_u64).unwrap_or(0);
if matches_of(&first) >= min {
return Ok(first);
}
let deadline = std::time::Instant::now() + timeout;
let mut current = first;
loop {
if std::time::Instant::now() >= deadline {
return Err(CtlError::with_evidence(
ErrorCode::Timeout,
format!(
"browser-read --wait-for {min}: only {} matches within the timeout (async content may not have settled)",
matches_of(¤t)
),
json!({ "matches": matches_of(¤t), "wait_for": min }),
));
}
std::thread::sleep(Duration::from_millis(500));
current = eval_json(conn, cdp_session, script, context_id, timeout)?;
if matches_of(¤t) >= min {
return Ok(current);
}
}
}
pub fn read(paths: &Paths, spec: ReadSpec<'_>) -> Result<Value, CtlError> {
let ReadSpec {
session_id,
kind,
value,
property,
iframe,
timeout_ms,
all,
limit,
deep,
wait_for,
} = spec;
validate_selector(kind, value)?;
let property = property.unwrap_or("text");
validate_property(property)?;
let timeout = dur(timeout_ms, OP_DEFAULT_TIMEOUT_MS);
let record = session::load(paths, session_id)?;
let conn = connect_browser(&record)?;
let cdp_session = attach_target(&conn, &record.target_id, timeout)?;
verify_target_alive(&conn, &record.target_id, timeout)?;
let context_id = if iframe {
Some(iframe_context(&conn, &cdp_session, timeout)?.1)
} else {
None
};
if all {
const ALL_LIMIT: usize = 50;
const ALL_LIMIT_MAX: usize = 500;
let limit = limit.unwrap_or(ALL_LIMIT);
if !(1..=ALL_LIMIT_MAX).contains(&limit) {
return Err(protocol(format!(
"browser-read --limit must be 1..={ALL_LIMIT_MAX}, got {limit}"
)));
}
let script = if deep {
locate_deep_script(kind, value, Some(property))
} else {
locate_all_script(kind, value, Some(property))
};
let mut located = eval_json(&conn, &cdp_session, &script, context_id, timeout)?;
if let Some(min) = wait_for {
located = wait_for_matches(
&conn,
&cdp_session,
&script,
context_id,
timeout,
min,
located,
)?;
}
let total = located
.get("matches")
.and_then(Value::as_u64)
.ok_or_else(|| CtlError::internal("enumeration script returned no total"))?;
let elements = located
.get("elements")
.and_then(Value::as_array)
.ok_or_else(|| CtlError::internal("enumeration script returned no elements"))?;
let mut out = Vec::with_capacity(elements.len().min(limit));
for element in elements.iter().take(limit) {
let (text, truncated) = truncate_text(
element
.get("text")
.and_then(Value::as_str)
.unwrap_or_default(),
);
let mut item = json!({ "text": text, "truncated": truncated });
if let Some(tag) = element.get("tag").and_then(Value::as_str) {
item["tag"] = Value::String(tag.to_string());
}
if let Some(id) = element.get("id").and_then(Value::as_str) {
item["id"] = Value::String(id.to_string());
}
if property != "text" {
item["property"] = Value::String(property.to_string());
item["value"] = element.get("property").cloned().unwrap_or(Value::Null);
}
out.push(item);
}
let mut payload = json!({
"total": total,
"returned": out.len(),
"truncated_by_limit": total > out.len() as u64,
"selector_kind": kind.as_str(),
"elements": out,
"session_id": session_id,
"frame": if iframe { "iframe" } else { "main" },
});
if deep {
payload["deep"] = Value::Bool(true);
if located.get("traversal_capped").and_then(Value::as_bool) == Some(true) {
payload["traversal_capped"] = Value::Bool(true);
}
}
return Ok(payload);
}
let script = if deep {
locate_deep_script(kind, value, Some(property))
} else {
locate_script(kind, value, Some(property))
};
let mut located = eval_json(&conn, &cdp_session, &script, context_id, timeout)?;
if let Some(min) = wait_for {
located = wait_for_matches(
&conn,
&cdp_session,
&script,
context_id,
timeout,
min,
located,
)?;
}
require_unique_match(&located, kind, value)?;
let unique = if deep {
located
.get("elements")
.and_then(Value::as_array)
.and_then(|elements| elements.first())
.cloned()
.ok_or_else(|| CtlError::internal("deep script returned no element"))?
} else {
located.clone()
};
let mut payload = located_text_payload(&unique);
if deep {
payload["deep"] = Value::Bool(true);
}
if property != "text" {
payload["property"] = Value::String(property.to_string());
payload["value"] = unique.get("property").cloned().unwrap_or(Value::Null);
}
payload["session_id"] = Value::String(session_id.to_string());
payload["frame"] = Value::String(if iframe { "iframe" } else { "main" }.to_string());
Ok(payload)
}
pub fn click(
paths: &Paths,
session_id: &str,
kind: SelectorKind,
value: &str,
iframe: bool,
expect_text: Option<&str>,
timeout_ms: Option<u64>,
) -> Result<Value, CtlError> {
validate_selector(kind, value)?;
if expect_text.is_some_and(str::is_empty) {
return Err(protocol("--expect-text must be non-empty"));
}
actl_core::stop::check_current()?;
let timeout = dur(timeout_ms, OP_DEFAULT_TIMEOUT_MS);
let record = session::load(paths, session_id)?;
let conn = connect_browser(&record)?;
let cdp_session = attach_target(&conn, &record.target_id, timeout)?;
verify_target_alive(&conn, &record.target_id, timeout)?;
let context_id = if iframe {
Some(iframe_context(&conn, &cdp_session, timeout)?.1)
} else {
None
};
let mut action = declare_action("browser-click", &format!("cdp:{session_id}"));
action.executing();
let located = eval_json(
&conn,
&cdp_session,
&locate_script(kind, value, None),
context_id,
timeout,
)?;
require_unique_match(&located, kind, value)?;
let embedded = serde_json::to_string(value).unwrap_or_default();
let click_script = match kind {
SelectorKind::Css => format!(
"(function(){{var els=document.querySelectorAll({embedded});\
if(els.length!==1)return JSON.stringify({{clicked:false,matches:els.length}});\
els[0].click();return JSON.stringify({{clicked:true}});}})()"
),
SelectorKind::Xpath => format!(
"(function(){{var it=document.evaluate({embedded},document,null,\
XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,null);\
if(it.snapshotLength!==1)return JSON.stringify({{clicked:false,matches:it.snapshotLength}});\
it.snapshotItem(0).click();return JSON.stringify({{clicked:true}});}})()"
),
};
let clicked = eval_json(&conn, &cdp_session, &click_script, context_id, timeout)?;
if clicked.get("clicked").and_then(Value::as_bool) != Some(true) {
return Err(CtlError::with_evidence(
ErrorCode::NotActionable,
"element resolved but click did not dispatch (DOM changed between locate and click)"
.to_string(),
json!({ "clicked": clicked }),
));
}
let after = eval_json(
&conn,
&cdp_session,
&locate_script(kind, value, None),
context_id,
timeout,
)?;
let (text_after, truncated) = truncate_text(
after
.get("text")
.and_then(Value::as_str)
.unwrap_or_default(),
);
let state = eval_json(
&conn,
&cdp_session,
"(function(){return JSON.stringify({url:location.href})})()",
None,
timeout,
)?;
let url = state
.get("url")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
if let Some(expected) = expect_text
&& !text_after.contains(expected)
{
return Err(CtlError::with_evidence(
ErrorCode::AssertionFailed,
"post-click text does not contain the expected substring".to_string(),
json!({
"expected_contains": expected,
"actual": text_after,
"truncated": truncated,
}),
));
}
action.delivered();
Ok(json!({
"session_id": session_id,
"clicked": true,
"text_after": text_after,
"truncated": truncated,
"url": url,
"frame": if iframe { "iframe" } else { "main" },
}))
}
pub fn type_text(
paths: &Paths,
session_id: &str,
kind: SelectorKind,
value: &str,
text: &str,
iframe: bool,
timeout_ms: Option<u64>,
) -> Result<Value, CtlError> {
validate_selector(kind, value)?;
if text.is_empty() || text.len() > TEXT_LIMIT {
return Err(protocol("--text requires 1-65536 characters"));
}
actl_core::stop::check_current()?;
let timeout = dur(timeout_ms, OP_DEFAULT_TIMEOUT_MS);
let record = session::load(paths, session_id)?;
let conn = connect_browser(&record)?;
let cdp_session = attach_target(&conn, &record.target_id, timeout)?;
verify_target_alive(&conn, &record.target_id, timeout)?;
let context_id = if iframe {
Some(iframe_context(&conn, &cdp_session, timeout)?.1)
} else {
None
};
let mut action = declare_action("browser-type", &format!("cdp:{session_id}"));
action.executing();
let located = eval_json(
&conn,
&cdp_session,
&locate_script(kind, value, Some("value")),
context_id,
timeout,
)?;
require_unique_match(&located, kind, value)?;
if located
.get("tag")
.and_then(Value::as_str)
.is_none_or(|t| !matches!(t.to_ascii_uppercase().as_str(), "INPUT" | "TEXTAREA"))
{
return Err(CtlError::with_evidence(
ErrorCode::NotActionable,
"browser-type targets input/textarea elements only".to_string(),
json!({ "tag": located.get("tag") }),
));
}
let embedded_sel = serde_json::to_string(value).unwrap_or_default();
let embedded_text = serde_json::to_string(text).unwrap_or_default();
let locate_expr = match kind {
SelectorKind::Css => format!("document.querySelectorAll({embedded_sel})[0]"),
SelectorKind::Xpath => format!(
"document.evaluate({embedded_sel},document,null,XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,null).snapshotItem(0)"
),
};
let script = format!(
"(function(){{var e={locate_expr};\
if(!e)return JSON.stringify({{typed:false,reason:'gone'}});\
var tag=(e.tagName||'').toUpperCase();\
if(tag!=='INPUT'&&tag!=='TEXTAREA')return JSON.stringify({{typed:false,reason:'not_input'}});\
var proto=tag==='INPUT'?window.HTMLInputElement.prototype:window.HTMLTextAreaElement.prototype;\
var d=Object.getOwnPropertyDescriptor(proto,'value');\
if(d&&d.set){{d.set.call(e,{embedded_text});}}else{{e.value={embedded_text};}}\
e.dispatchEvent(new Event('input',{{bubbles:true}}));\
e.dispatchEvent(new Event('change',{{bubbles:true}}));\
return JSON.stringify({{typed:true,value:String(e.value)}});}})()"
);
let typed = eval_json(&conn, &cdp_session, &script, context_id, timeout)?;
if typed.get("typed").and_then(Value::as_bool) != Some(true) {
return Err(CtlError::with_evidence(
ErrorCode::NotActionable,
"element resolved but typing did not dispatch (DOM changed between locate and type)"
.to_string(),
json!({ "typed": typed }),
));
}
let value_after = typed
.get("value")
.and_then(Value::as_str)
.unwrap_or_default();
if value_after != text {
return Err(CtlError::with_evidence(
ErrorCode::AssertionFailed,
"post-type value differs from the requested text (controlled input rewrote it)"
.to_string(),
json!({ "requested_len": text.len(), "actual_len": value_after.len() }),
));
}
action.delivered();
Ok(json!({
"session_id": session_id,
"typed": true,
"value_after_len": value_after.len(),
"frame": if iframe { "iframe" } else { "main" },
}))
}
pub fn close(paths: &Paths, session_id: &str, timeout_ms: Option<u64>) -> Result<Value, CtlError> {
let timeout = dur(timeout_ms, OP_DEFAULT_TIMEOUT_MS);
let record = session::load(paths, session_id)?;
let mut action = declare_action("browser-close", &format!("cdp:{session_id}"));
action.executing();
let mut already_gone = false;
match connect_browser(&record) {
Ok(conn) => {
conn.call("Browser.close", json!({}), None, timeout)
.map_err(map_cdp)?;
let deadline = Instant::now() + timeout;
while conn.is_connected() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(50));
}
let port_dead = !TcpStream::connect_timeout(
&format!("127.0.0.1:{}", record.devtools_port)
.parse()
.map_err(|_| CtlError::internal("bad devtools port in session"))?,
Duration::from_millis(500),
)
.is_ok();
if !conn.is_connected() && port_dead {
finish_close(paths, &record)
} else {
Err(CtlError::with_evidence(
ErrorCode::Internal,
"Browser.close acknowledged but the endpoint still answers; \
session kept for retry",
json!({ "session_id": session_id, "devtools_port": record.devtools_port }),
))
}
}
Err(e) => {
already_gone = true;
let _ = e;
finish_close(paths, &record)
}
}
.map(|mut value| {
action.delivered();
if already_gone {
value["already_unreachable"] = Value::Bool(true);
}
value
})
}
pub enum VerifyMode {
NewestAlive,
NoneActive,
SessionAlive(String),
SessionGone(String),
Selector {
session: String,
kind: SelectorKind,
value: String,
contains: Option<String>,
},
NoPopups {
session: String,
selector: Option<String>,
},
}
pub const DEFAULT_POPUP_SELECTOR: &str =
"dialog[open],[role=dialog],[role=alertdialog],[aria-modal=true]";
pub fn verify(paths: &Paths, mode: VerifyMode, timeout_ms: Option<u64>) -> Result<Value, CtlError> {
let timeout = dur(timeout_ms, OP_DEFAULT_TIMEOUT_MS);
match mode {
VerifyMode::NoneActive => {
let total = list(paths)?
.get("total")
.and_then(Value::as_u64)
.unwrap_or(0);
if total != 0 {
return Err(CtlError::with_evidence(
ErrorCode::AssertionFailed,
format!("{total} browser sessions still active"),
json!({ "total": total }),
));
}
Ok(json!({ "assertion": "none", "passed": true }))
}
VerifyMode::NewestAlive => {
let sessions = list(paths)?;
let newest = sessions.pointer("/sessions/0").cloned().ok_or_else(|| {
CtlError::with_evidence(
ErrorCode::AssertionFailed,
"no active browser session to assert",
json!({ "total": 0 }),
)
})?;
let id = newest["session_id"]
.as_str()
.unwrap_or_default()
.to_string();
let record = session::load(paths, &id)?;
let conn = connect_browser(&record)?;
attach_target(&conn, &record.target_id, timeout)?;
verify_target_alive(&conn, &record.target_id, timeout)?;
Ok(json!({ "assertion": "newest_alive", "passed": true, "session_id": id }))
}
VerifyMode::SessionGone(session_id) => match session::load(paths, &session_id) {
Err(e) if e.code == ErrorCode::NotFound => Ok(json!({
"assertion": "gone",
"passed": true,
"session_id": session_id
})),
Ok(_) => Err(CtlError::with_evidence(
ErrorCode::AssertionFailed,
format!("browser session {session_id} still registered"),
json!({ "session_id": session_id }),
)),
Err(e) => Err(e),
},
VerifyMode::SessionAlive(session_id) => {
let record = session::load(paths, &session_id)?;
let conn = connect_browser(&record)?;
attach_target(&conn, &record.target_id, timeout)?;
verify_target_alive(&conn, &record.target_id, timeout)?;
Ok(json!({ "assertion": "alive", "passed": true, "session_id": session_id }))
}
VerifyMode::Selector {
session,
kind,
value,
contains,
} => {
validate_selector(kind, &value)?;
let record = session::load(paths, &session)?;
let conn = connect_browser(&record)?;
let cdp_session = attach_target(&conn, &record.target_id, timeout)?;
verify_target_alive(&conn, &record.target_id, timeout)?;
let located = eval_json(
&conn,
&cdp_session,
&locate_script(kind, &value, None),
None,
timeout,
)?;
let matches = located.get("matches").and_then(Value::as_u64).unwrap_or(0);
if matches != 1 {
return Err(CtlError::with_evidence(
ErrorCode::AssertionFailed,
format!(
"login-state selector did not resolve uniquely ({matches} matches; expected 1)"
),
json!({
"selector_kind": kind.as_str(),
"selector": value,
"matches": matches,
}),
));
}
let (text, truncated) = truncate_text(
located
.get("text")
.and_then(Value::as_str)
.unwrap_or_default(),
);
if let Some(expected) = &contains
&& !text.contains(expected.as_str())
{
return Err(CtlError::with_evidence(
ErrorCode::AssertionFailed,
"login-state selector text does not contain the expected substring",
json!({
"selector_kind": kind.as_str(),
"selector": value,
"expected_contains": expected,
"actual": text,
"truncated": truncated,
}),
));
}
Ok(json!({
"assertion": "selector",
"passed": true,
"session_id": session,
"text": text,
"truncated": truncated,
}))
}
VerifyMode::NoPopups { session, selector } => {
let sel = selector.unwrap_or_else(|| DEFAULT_POPUP_SELECTOR.to_string());
if sel.is_empty() || sel.chars().count() > 4096 {
return Err(CtlError::protocol("popup selector must be 1-4096 chars"));
}
let record = session::load(paths, &session)?;
let conn = connect_browser(&record)?;
let cdp_session = attach_target(&conn, &record.target_id, timeout)?;
verify_target_alive(&conn, &record.target_id, timeout)?;
let scanned = eval_json(&conn, &cdp_session, &popup_scan_script(&sel), None, timeout)?;
if let Some(err) = scanned.get("error").and_then(Value::as_str) {
return Err(CtlError::protocol(format!(
"popup selector is not valid CSS: {err}"
)));
}
let total = scanned.get("total").and_then(Value::as_u64).unwrap_or(0);
if total > 0 {
return Err(CtlError::with_evidence(
ErrorCode::AssertionFailed,
format!("{total} visible in-page popup surface(s) present"),
json!({
"assertion": "no_popups",
"selector": sel,
"total": total,
"samples": scanned.get("samples").cloned().unwrap_or(Value::Null),
"session_id": session,
}),
));
}
Ok(json!({
"assertion": "no_popups",
"passed": true,
"selector": sel,
"total": 0,
"session_id": session,
}))
}
}
}
pub fn list(paths: &Paths) -> Result<Value, CtlError> {
let dir = paths.sessions_dir();
let mut sessions: Vec<BrowserSession> = Vec::new();
let entries = match std::fs::read_dir(&dir) {
Ok(e) => e,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(json!({ "sessions": [], "total": 0 }));
}
Err(e) => return Err(CtlError::internal(format!("cannot read sessions dir: {e}"))),
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_none_or(|x| x != "json") {
continue;
}
let Some(id) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
if let Ok(s) = session::load(paths, id) {
sessions.push(s);
}
}
sessions.sort_by_key(|s| std::cmp::Reverse(s.created_unix));
let list: Vec<Value> = sessions
.iter()
.map(|s| {
json!({
"session_id": s.session_id,
"target_id": s.target_id,
"url": s.bound_url,
"browser_pid": s.pid,
"devtools_port": s.devtools_port,
"mode": s.mode,
"headless": s.headless,
"profile_dir": s.profile_dir,
"created_unix": s.created_unix,
})
})
.collect();
let total = list.len();
Ok(json!({ "sessions": list, "total": total }))
}
fn finish_close(paths: &Paths, record: &BrowserSession) -> Result<Value, CtlError> {
session::remove(paths, &record.session_id)?;
let (kept, removed) = match &record.mode {
Mode::Persistent { .. } => (true, None),
Mode::Ephemeral => {
let removed = std::fs::remove_dir_all(&record.profile_dir).is_ok();
(false, Some(removed))
}
};
Ok(json!({
"session_id": record.session_id,
"closed": true,
"mode": record.mode,
"profile_kept": kept,
"ephemeral_profile_removed": removed,
"browser_pid": record.pid,
}))
}
use std::net::TcpStream;