use crate::SelectorKind;
use crate::cdp::Connection;
use crate::ops::{
OP_DEFAULT_TIMEOUT_MS, attach_target, connect_browser, dur, eval_json, list, truncate_text,
validate_selector, verify_target_alive,
};
use crate::scripts::{locate_script, popup_scan_script};
use crate::session::{self, Paths};
use actl_core::{CtlError, ErrorCode};
use serde_json::{Value, json};
use std::time::Duration;
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(crate) fn pre_action_popup_gate(
conn: &Connection,
cdp_session: &str,
timeout: Duration,
) -> Result<(), CtlError> {
let scanned = eval_json(
conn,
cdp_session,
&popup_scan_script(DEFAULT_POPUP_SELECTOR),
None,
timeout,
)?;
if let Some(err) = scanned.get("error").and_then(Value::as_str) {
return Err(CtlError::protocol(format!(
"popup gate 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_hint(
ErrorCode::NotActionable,
format!(
"{total} visible in-page popup surface(s) block this write action \
(dismiss them, or pass --allow-popups after judging the samples)"
),
json!({
"gate": "popup",
"selector": DEFAULT_POPUP_SELECTOR,
"total": total,
"samples": scanned.get("samples").cloned().unwrap_or(Value::Null),
}),
"judge the samples: click the popup's own dismiss/close control with \
--allow-popups, then re-check with browser-verify --no-popups; pass \
--allow-popups on the original target only for judged false positives",
));
}
Ok(())
}
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,
}))
}
}
}