use serde_json::{json, Value};
use crate::cdp::client::CdpClient;
use crate::commands;
use crate::session::{self, SessionStore};
pub fn delivery_from_response(client: &CdpClient, obj: &Value) -> crate::verdict::Delivered {
let Some(token) = obj.get("delivery").and_then(Value::as_str) else {
return crate::verdict::Delivered::NOT_PROBED;
};
crate::verdict::Delivered {
how: crate::verdict::Delivery::parse(token),
modal_receiver: obj
.get("intercepted_by")
.and_then(|r| r.get("modal"))
.and_then(Value::as_bool)
.unwrap_or(false),
observed_after_ms: client.ms_since_dispatch(),
}
}
pub fn postcondition_from_response(out: &Value) -> crate::verdict::Postcondition {
let Some(fields) = out.get("values").and_then(Value::as_array) else {
return field_postcondition(out.get("value"));
};
let mut seen = crate::verdict::Postcondition::NotRead;
for field in fields {
match field_postcondition(field.get("value")) {
crate::verdict::Postcondition::Discarded => return crate::verdict::Postcondition::Discarded,
crate::verdict::Postcondition::Rewritten => seen = crate::verdict::Postcondition::Rewritten,
crate::verdict::Postcondition::Kept
if seen == crate::verdict::Postcondition::NotRead =>
{
seen = crate::verdict::Postcondition::Kept;
}
crate::verdict::Postcondition::Kept | crate::verdict::Postcondition::NotRead => {}
}
}
seen
}
fn field_postcondition(value: Option<&Value>) -> crate::verdict::Postcondition {
use crate::verdict::Postcondition;
let Some(value) = value else { return Postcondition::NotRead };
match value.get("verbatim").and_then(Value::as_bool) {
Some(true) => Postcondition::Kept,
None => Postcondition::NotRead,
Some(false) => {
let empty = match value.get("actual_length").and_then(Value::as_u64) {
Some(len) => len == 0,
None => value
.get("actual")
.and_then(Value::as_str)
.is_none_or(str::is_empty),
};
if empty { Postcondition::Discarded } else { Postcondition::Rewritten }
}
}
}
const LOST_VALUE_LIMIT: usize = 10;
pub async fn attach_values_lost(
client: &CdpClient,
uid_map: &std::collections::HashMap<String, crate::element_ref::ElementRef>,
lost: &[commands::diff::LostValue],
out: &mut Value,
) -> usize {
if lost.is_empty() {
return 0;
}
let mut reported = Vec::new();
for entry in lost.iter().take(LOST_VALUE_LIMIT) {
let mut item = json!({"uid": entry.uid, "role": entry.role});
if let Some(name) = &entry.name {
item["name"] = json!(name);
}
if is_secret_field(client, uid_map, &entry.uid).await {
item["redacted"] = json!(true);
} else {
item["was"] = json!(entry.was);
}
reported.push(item);
}
if let Some(obj) = out.as_object_mut() {
obj.insert("values_lost".into(), Value::Array(reported));
if lost.len() > LOST_VALUE_LIMIT {
obj.insert("values_lost_total".into(), json!(lost.len()));
}
}
lost.len()
}
async fn is_secret_field(
client: &CdpClient,
uid_map: &std::collections::HashMap<String, crate::element_ref::ElementRef>,
uid: &str,
) -> bool {
let Ok(resolved) = crate::element::resolve_uid(client, uid_map, uid).await else {
return true;
};
let js = format!(
"function() {{ const el = this; return !!{}; }}",
crate::element::SECRET_FIELD
);
let Ok(result) = client
.call::<_, Value>(
"Runtime.callFunctionOn",
json!({
"objectId": resolved.object_id,
"functionDeclaration": js,
"returnByValue": true,
}),
)
.await
else {
return true;
};
if result.get("exceptionDetails").is_some() {
return true;
}
result
.get("result")
.and_then(|r| r.get("value"))
.and_then(Value::as_bool)
.unwrap_or(true)
}
pub fn attach_verdict_for(
client: &CdpClient,
out: &mut Value,
observation: crate::verdict::Observation,
) -> crate::verdict::Assessment {
let delivered = delivery_from_response(client, out);
let assessment =
crate::verdict::classify(observation, delivered, postcondition_from_response(out));
if assessment.verdict == crate::verdict::Verdict::NoEffect
&& let Some(ms) = delivered.observed_after_ms
&& let Some(map) = out.as_object_mut()
{
map.entry("observed_after_ms").or_insert_with(|| json!(ms));
}
if let Some(ms) = client.take_settle_wait_ms()
&& let Some(map) = out.as_object_mut()
{
map.insert("waited_ms".into(), json!(ms));
}
crate::run_helpers::attach_verdict(out, assessment);
assessment
}
pub fn mutates_page(cmd: &str) -> bool {
matches!(
cmd,
"click" | "tap" | "dblclick" | "double_click" | "double-click"
| "fill" | "type" | "press" | "select" | "check" | "uncheck"
| "upload" | "drag" | "hover" | "scroll"
| "fill-form" | "fill_form" | "fillform"
| "fill_and_submit" | "fill-and-submit"
| "webmcp_call" | "webmcp-call"
)
}
pub async fn attach_change_report(
client: &CdpClient,
store: &mut SessionStore,
browser_name: &str,
page_name: &str,
target_id: &str,
report: crate::run_helpers::ReportPolicy,
old_text: Option<&str>,
stored: Option<(String, String)>,
out: &mut Value,
) {
crate::snapshot::settle(client, 100, 1000).await;
let Ok(snapshot) = commands::inspect::run(client, false, None, None, None).await else {
attach_verdict_for(client, out, crate::verdict::Observation::ReadFailed);
return;
};
let Some(old_text) = old_text else {
if let Some(browser_s) = store.browsers.get_mut(browser_name) {
let page = session::ensure_page(browser_s, page_name, target_id);
page.uid_map = snapshot.uid_map;
page.last_snapshot = Some(snapshot.text);
let (f, l) = snapshot.identity.map_or((None, None), |(f, l)| (Some(f), Some(l)));
page.last_snapshot_frame = f;
page.last_snapshot_loader = l;
}
attach_verdict_for(client, out, crate::verdict::Observation::NoBaseline);
return;
};
let identity = commands::diff::Identity::from_loader(
stored.as_ref().map(|(f, l)| (f.as_str(), l.as_str())),
snapshot.identity.as_ref().map(|(f, l)| (f.as_str(), l.as_str())),
);
let cmp = commands::diff::compare(identity, old_text, &snapshot.text);
let body = if report.budget == 0 {
cmp.text.clone()
} else {
crate::truncate::truncate_str(
cmp.text.trim_end(),
report.budget,
"\n… truncated, send {\"cmd\":\"inspect\"} for the rest",
)
.into_owned()
};
if let Some(obj) = out.as_object_mut() {
obj.insert(
"changed".into(),
json!({
"added": cmp.added,
"removed": cmp.removed,
"changed": cmp.changed,
"unchanged": cmp.unchanged,
"moved": cmp.moved,
"anonymous": cmp.anonymous,
"document_changed": cmp.document_changed,
"identity_known": cmp.identity_known,
}),
);
obj.insert("delta".into(), json!(body));
if cmp.focus_from.is_some() || cmp.focus_to.is_some() {
obj.insert("focus".into(), json!({"from": cmp.focus_from, "to": cmp.focus_to}));
}
if let Some(hint) = cmp.hint {
obj.entry("hint").or_insert_with(|| json!(hint));
}
}
let values_lost = attach_values_lost(client, &snapshot.uid_map, &cmp.values_lost, out).await;
attach_verdict_for(
client,
out,
crate::verdict::Observation::Compared {
document_changed: cmp.document_changed,
identity_known: cmp.identity_known,
edits: cmp.added + cmp.removed + cmp.changed,
moved: cmp.moved,
focus_moved: cmp.focus_from.is_some()
|| (cmp.focus_to.is_some() && !cmp.focus_to_document),
values_lost,
},
);
if let Some(browser_s) = store.browsers.get_mut(browser_name) {
let page = session::ensure_page(browser_s, page_name, target_id);
page.uid_map = snapshot.uid_map;
page.last_snapshot = Some(snapshot.text);
let (f, l) = snapshot.identity.map_or((None, None), |(f, l)| (Some(f), Some(l)));
page.last_snapshot_frame = f;
page.last_snapshot_loader = l;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::verdict::Postcondition;
fn value(requested: &str, actual: Option<&str>) -> Value {
json!({
"requested": requested,
"actual": actual,
"verbatim": actual == Some(requested),
"observed_after_ms": 60,
})
}
#[test]
fn a_fill_the_page_kept_reads_as_kept() {
let out = json!({"ok": true, "value": value("ada@example.com", Some("ada@example.com"))});
assert_eq!(postcondition_from_response(&out), Postcondition::Kept);
}
#[test]
fn an_emptied_field_reads_as_discarded() {
let out = json!({"ok": true, "value": value("hello@example.com", Some(""))});
assert_eq!(postcondition_from_response(&out), Postcondition::Discarded);
let out = json!({"ok": true, "value": value("x", None)});
assert_eq!(postcondition_from_response(&out), Postcondition::Discarded);
}
#[test]
fn a_reformatted_field_reads_as_rewritten() {
let out = json!({"ok": true, "value": value("5551234567", Some("(555) 123-4567"))});
assert_eq!(postcondition_from_response(&out), Postcondition::Rewritten);
}
#[test]
fn a_redacted_secret_is_classified_from_its_lengths_alone() {
let kept = json!({"ok": true, "value": {
"redacted": true, "requested_length": 12, "actual_length": 12, "verbatim": true,
}});
assert_eq!(postcondition_from_response(&kept), Postcondition::Kept);
let emptied = json!({"ok": true, "value": {
"redacted": true, "requested_length": 12, "actual_length": 0, "verbatim": false,
}});
assert_eq!(postcondition_from_response(&emptied), Postcondition::Discarded);
let rewritten = json!({"ok": true, "value": {
"redacted": true, "requested_length": 12, "actual_length": 8, "verbatim": false,
}});
assert_eq!(postcondition_from_response(&rewritten), Postcondition::Rewritten);
}
#[test]
fn a_bulk_fill_is_judged_on_its_worst_field() {
let all_kept = json!({"ok": true, "values": [
{"uid": "n1", "value": value("a", Some("a"))},
{"uid": "n2", "value": value("b", Some("b"))},
]});
assert_eq!(postcondition_from_response(&all_kept), Postcondition::Kept);
let one_masked = json!({"ok": true, "values": [
{"uid": "n1", "value": value("a", Some("a"))},
{"uid": "n2", "value": value("5551234567", Some("(555) 123-4567"))},
]});
assert_eq!(postcondition_from_response(&one_masked), Postcondition::Rewritten);
let one_emptied = json!({"ok": true, "values": [
{"uid": "n1", "value": value("5551234567", Some("(555) 123-4567"))},
{"uid": "n2", "value": value("b", Some(""))},
]});
assert_eq!(postcondition_from_response(&one_emptied), Postcondition::Discarded);
}
#[test]
fn a_response_with_no_read_back_claims_nothing() {
for out in [
json!({"ok": true, "message": "Clicked uid=n12"}),
json!({"ok": true, "value": {"requested": "x", "actual": "x"}}),
json!({"ok": true, "value": "not an object"}),
json!({"ok": true, "values": []}),
json!({"ok": true, "values": [{"uid": "n1"}]}),
] {
assert_eq!(postcondition_from_response(&out), Postcondition::NotRead, "for {out}");
}
}
}