Skip to main content

cdp_client/
cdp.rs

1use crate::net::Ws;
2use serde_json::{json, Value};
3use std::time::{Duration, Instant};
4
5pub struct Cdp {
6    ws: Ws,
7    next_id: i64,
8    events: Vec<Value>,
9}
10
11impl Cdp {
12    pub fn connect(url: &str) -> Result<Self, String> {
13        Ok(Self {
14            ws: crate::net::ws_connect(url)?,
15            next_id: 0,
16            events: Vec::new(),
17        })
18    }
19
20    pub fn events(&self) -> &[Value] {
21        &self.events
22    }
23
24    pub fn call(
25        &mut self,
26        method: &str,
27        params: Value,
28        session_id: Option<&str>,
29    ) -> Result<Value, String> {
30        self.call_for(method, params, session_id, Duration::from_secs(30))
31    }
32
33    pub fn call_for(
34        &mut self,
35        method: &str,
36        params: Value,
37        session_id: Option<&str>,
38        timeout: Duration,
39    ) -> Result<Value, String> {
40        self.next_id += 1;
41        let id = self.next_id;
42        let mut message = json!({
43            "id": id,
44            "method": method,
45            "params": params,
46        });
47        if let Some(session_id) = session_id {
48            message["sessionId"] = json!(session_id);
49        }
50        self.ws.send_text(&message.to_string())?;
51        let deadline = Instant::now() + timeout;
52        loop {
53            let text = match self.ws.recv_text(deadline)? {
54                Some(text) => text,
55                None => return Err(format!("{method} timed out\n")),
56            };
57            let value: Value = serde_json::from_str(&text)
58                .map_err(|err| format!("CDP response was not JSON: {err}\n"))?;
59            if value.get("id").and_then(|item| item.as_i64()) == Some(id) {
60                if let Some(error) = value.get("error") {
61                    let text = error
62                        .get("message")
63                        .and_then(|item| item.as_str())
64                        .unwrap_or("CDP error");
65                    return Err(format!("{method}: {text}\n"));
66                }
67                return Ok(value.get("result").cloned().unwrap_or(Value::Null));
68            }
69            if value.get("method").is_some() {
70                self.events.push(value);
71            }
72        }
73    }
74
75    pub fn notify(&mut self, method: &str, params: Value, session_id: Option<&str>) {
76        self.next_id += 1;
77        let id = self.next_id;
78        let mut message = json!({
79            "id": id,
80            "method": method,
81            "params": params,
82        });
83        if let Some(session_id) = session_id {
84            message["sessionId"] = json!(session_id);
85        }
86        let _ = self.ws.send_text(&message.to_string());
87    }
88
89    pub fn wait_load(&mut self, session_id: &str) -> Result<(), String> {
90        if self.events.iter().any(|event| is_load(event, session_id)) {
91            return Ok(());
92        }
93        let probe = self.call_for(
94            "Runtime.evaluate",
95            json!({
96                "expression": "document.readyState",
97                "returnByValue": true,
98            }),
99            Some(session_id),
100            Duration::from_secs(1),
101        );
102        let settled = match &probe {
103            Ok(result) => probe_is_settled(Ok(result
104                .pointer("/result/value")
105                .and_then(|value| value.as_str())))?,
106            Err(err) => probe_is_settled(Err(err.as_str()))?,
107        };
108        if settled {
109            return Ok(());
110        }
111        let deadline = Instant::now() + Duration::from_secs(15);
112        loop {
113            if self.events.iter().any(|event| is_load(event, session_id)) {
114                return Ok(());
115            }
116            if Instant::now() >= deadline {
117                return Ok(());
118            }
119            match self.ws.recv_text(deadline)? {
120                Some(text) => {
121                    if let Ok(value) = serde_json::from_str::<Value>(&text) {
122                        if value.get("method").is_some() {
123                            self.events.push(value);
124                        }
125                    }
126                }
127                None => return Ok(()),
128            }
129        }
130    }
131
132    pub fn wait_load_since(&mut self, session_id: &str, from: usize) -> Result<(), String> {
133        let deadline = Instant::now() + Duration::from_secs(15);
134        loop {
135            if navigation_settled(&self.events, session_id, from) {
136                return Ok(());
137            }
138            if Instant::now() >= deadline {
139                return Ok(());
140            }
141            match self.ws.recv_text(deadline)? {
142                Some(text) => {
143                    if let Ok(value) = serde_json::from_str::<Value>(&text) {
144                        if value.get("method").is_some() {
145                            self.events.push(value);
146                        }
147                    }
148                }
149                None => return Ok(()),
150            }
151        }
152    }
153}
154
155fn probe_is_settled(outcome: Result<Option<&str>, &str>) -> Result<bool, String> {
156    match outcome {
157        Ok(Some("loading")) | Ok(Some("interactive")) => Ok(false),
158        Ok(_) => Ok(true),
159        Err(err)
160            if err.starts_with("Runtime.evaluate timed out")
161                || err.starts_with("Runtime.evaluate:") =>
162        {
163            Ok(true)
164        }
165        Err(err) => Err(err.to_string()),
166    }
167}
168
169fn navigation_settled(events: &[Value], session_id: &str, from: usize) -> bool {
170    let mut committed = false;
171    for event in events.iter().skip(from) {
172        if !committed && is_commit(event, session_id) {
173            committed = true;
174            continue;
175        }
176        if committed && is_load(event, session_id) {
177            return true;
178        }
179    }
180    false
181}
182
183fn is_commit(event: &Value, session_id: &str) -> bool {
184    if event.get("sessionId").and_then(|value| value.as_str()) != Some(session_id) {
185        return false;
186    }
187    if event.get("method").and_then(|value| value.as_str()) != Some("Page.frameNavigated") {
188        return false;
189    }
190    let url = event
191        .pointer("/params/frame/url")
192        .and_then(|value| value.as_str())
193        .unwrap_or("");
194    !url.is_empty() && !url.eq_ignore_ascii_case("about:blank")
195}
196
197fn is_load(event: &Value, session_id: &str) -> bool {
198    if event.get("sessionId").and_then(|v| v.as_str()) != Some(session_id) {
199        return false;
200    }
201    match event.get("method").and_then(|v| v.as_str()) {
202        Some("Page.loadEventFired") => true,
203        Some("Page.lifecycleEvent") => {
204            event.pointer("/params/name").and_then(|v| v.as_str()) == Some("load")
205        }
206        _ => false,
207    }
208}
209
210pub fn attach(cdp: &mut Cdp, target_id: &str) -> Result<String, String> {
211    let result = cdp.call(
212        "Target.attachToTarget",
213        json!({ "targetId": target_id, "flatten": true }),
214        None,
215    )?;
216    result
217        .get("sessionId")
218        .and_then(|v| v.as_str())
219        .map(str::to_string)
220        .ok_or_else(|| "Target.attachToTarget returned no sessionId\n".to_string())
221}
222
223pub fn detach(cdp: &mut Cdp, session_id: &str) {
224    let _ = cdp.call(
225        "Target.detachFromTarget",
226        json!({ "sessionId": session_id }),
227        None,
228    );
229}
230
231#[derive(Clone, Debug, Default)]
232pub struct ActionNote {
233    pub kind: String,
234    pub tab: Option<String>,
235    pub target: Option<String>,
236    pub url: Option<String>,
237    pub key: Option<String>,
238    pub length: Option<u64>,
239    pub method: Option<String>,
240    pub agent: Option<String>,
241}
242
243impl ActionNote {
244    pub fn start_params(&self) -> Value {
245        let mut params = serde_json::Map::new();
246        params.insert("phase".into(), json!("start"));
247        params.insert("kind".into(), json!(self.kind));
248        insert_text(&mut params, "tab", self.tab.as_deref());
249        insert_text(&mut params, "target", self.target.as_deref());
250        if let Some(url) = self.url.as_deref().and_then(public_url) {
251            params.insert("url".into(), json!(url));
252        }
253        insert_text(&mut params, "key", self.key.as_deref());
254        if let Some(length) = self.length {
255            params.insert("length".into(), json!(length));
256        }
257        insert_text(&mut params, "method", self.method.as_deref());
258        insert_text(&mut params, "agent", self.agent.as_deref());
259        Value::Object(params)
260    }
261
262    pub fn end_params(&self, ok: bool, stderr: &str) -> Value {
263        let mut params = serde_json::Map::new();
264        params.insert("phase".into(), json!("end"));
265        params.insert("ok".into(), json!(ok));
266        if !ok {
267            if let Some(error) = action_error(&self.kind, stderr) {
268                params.insert("error".into(), json!(error));
269            }
270        }
271        insert_text(&mut params, "tab", self.tab.as_deref());
272        Value::Object(params)
273    }
274}
275
276fn insert_text(params: &mut serde_json::Map<String, Value>, key: &str, value: Option<&str>) {
277    if let Some(value) = value.filter(|value| !value.is_empty()) {
278        params.insert(key.to_string(), json!(value));
279    }
280}
281
282pub fn first_stderr_line(stderr: &str) -> Option<&str> {
283    stderr.lines().next().filter(|line| !line.is_empty())
284}
285
286fn public_url(raw: &str) -> Option<String> {
287    let raw = raw.trim();
288    if raw.is_empty() {
289        return None;
290    }
291    let without_fragment = raw.split_once('#').map(|(head, _)| head).unwrap_or(raw);
292    let without_query = without_fragment
293        .split_once('?')
294        .map(|(head, _)| head)
295        .unwrap_or(without_fragment);
296    if without_query.is_empty() {
297        return None;
298    }
299    if is_about_blank(without_query) {
300        return Some("about:blank".to_string());
301    }
302    if let Some(rest) = without_query.strip_prefix("//") {
303        let (host, path) = host_and_path(rest)?;
304        return Some(format!("//{host}{path}"));
305    }
306    let (scheme, rest) = without_query.split_once(':')?;
307    if !is_scheme(scheme) || !scheme_allowed(scheme) {
308        return None;
309    }
310    let scheme = scheme.to_ascii_lowercase();
311    let Some(authority) = rest.strip_prefix("//") else {
312        return Some(format!("{scheme}:"));
313    };
314    let (host, path) = host_and_path(authority)?;
315    Some(format!("{scheme}://{host}{path}"))
316}
317
318fn scheme_allowed(scheme: &str) -> bool {
319    matches!(
320        scheme.to_ascii_lowercase().as_str(),
321        "http"
322            | "https"
323            | "ws"
324            | "wss"
325            | "file"
326            | "data"
327            | "javascript"
328            | "blob"
329            | "about"
330            | "chrome"
331            | "chrome-extension"
332            | "view-source"
333            | "ftp"
334    )
335}
336
337fn is_about_blank(url: &str) -> bool {
338    let Some((scheme, rest)) = url.split_once(':') else {
339        return false;
340    };
341    scheme.eq_ignore_ascii_case("about") && rest.eq_ignore_ascii_case("blank")
342}
343
344fn is_scheme(scheme: &str) -> bool {
345    let mut chars = scheme.chars();
346    match chars.next() {
347        Some(ch) if ch.is_ascii_alphabetic() => {}
348        _ => return false,
349    }
350    chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '+' || ch == '-' || ch == '.')
351}
352
353fn host_and_path(rest: &str) -> Option<(&str, &str)> {
354    let (authority, path) = match rest.find('/') {
355        Some(index) => (&rest[..index], &rest[index..]),
356        None => (rest, ""),
357    };
358    let host = match authority.rfind('@') {
359        Some(index) => &authority[index + 1..],
360        None => authority,
361    };
362    if host.is_empty() { None } else { Some((host, path)) }
363}
364
365fn action_error(kind: &str, stderr: &str) -> Option<&'static str> {
366    if kind == "eval" {
367        return None;
368    }
369    let line = first_stderr_line(stderr).unwrap_or("").to_ascii_lowercase();
370    if line.contains("timed out") {
371        return Some("timeout");
372    }
373    if line.contains("websocket closed")
374        || line.contains("websocket connection closed")
375        || line.contains("websocket write failed")
376        || line.contains("websocket read failed")
377        || line.contains("connection reset")
378        || line.contains("broken pipe")
379    {
380        return Some("connection lost");
381    }
382    if line.contains("no snapshot") || line.contains("likely stale") {
383        return Some("no snapshot");
384    }
385    if line.contains("unknown ref")
386        || line.contains("element not found")
387        || line.contains("not an element ref")
388        || line.contains("could not resolve")
389        || line.contains("no backend node")
390    {
391        return Some("element not found");
392    }
393    if line.contains("no open tab is on") {
394        return Some("not allowed");
395    }
396    if line.contains("unknown tab")
397        || line.contains("--tab")
398        || line.contains("no open tabs")
399        || line.contains("explicit tab")
400    {
401        return Some("unknown tab");
402    }
403    if line.starts_with("navigate:") || line.contains("page.navigate") {
404        return Some("navigation failed");
405    }
406    Some("failed")
407}
408
409pub fn slicc_agent() -> Option<String> {
410    std::env::var("SLICC_AGENT")
411        .ok()
412        .filter(|value| !value.is_empty())
413}
414
415pub fn open(start: crate::connect::Start, runtime: Option<&str>) -> Result<Cdp, String> {
416    let socket = match start {
417        crate::connect::Start::Direct(url) => url,
418        crate::connect::Start::Discover(url) => {
419            let response = match crate::net::http_get(&url) {
420                Ok(response) => response,
421                Err(message) => {
422                    if message.starts_with("HTTP ") || message.contains("does not launch Chrome") {
423                        return Err(message);
424                    }
425                    return Err(format!(
426                        "{message}This CLI does not launch Chrome. Pass --cdp or set SLICC_CDP_URL.\n"
427                    ));
428                }
429            };
430            let body = String::from_utf8_lossy(&response.body).to_string();
431            crate::connect::websocket_url_from_version(&body)?
432        }
433    };
434    let socket = crate::connect::append_runtime(&socket, runtime);
435    Cdp::connect(&socket)
436}
437
438#[cfg(test)]
439mod tests {
440    use super::{first_stderr_line, probe_is_settled, ActionNote};
441    use serde_json::json;
442
443    #[test]
444    fn a_finished_document_does_not_wait_for_load() {
445        assert_eq!(probe_is_settled(Ok(Some("complete"))).unwrap(), true);
446        assert_eq!(probe_is_settled(Ok(None)).unwrap(), true);
447        assert_eq!(
448            probe_is_settled(Err("Runtime.evaluate timed out\n")).unwrap(),
449            true
450        );
451        assert_eq!(
452            probe_is_settled(Err("Runtime.evaluate: 'Runtime.evaluate' wasn't found\n")).unwrap(),
453            true
454        );
455    }
456
457    #[test]
458    fn a_blank_load_does_not_settle_the_next_navigation() {
459        let session = "sess";
460        let blank_load = json!({
461            "method": "Page.loadEventFired",
462            "sessionId": session,
463            "params": {}
464        });
465        let blank_frame = json!({
466            "method": "Page.frameNavigated",
467            "sessionId": session,
468            "params": { "frame": { "url": "about:blank" } }
469        });
470        let commit = json!({
471            "method": "Page.frameNavigated",
472            "sessionId": session,
473            "params": { "frame": { "url": "http://a.test/boot" } }
474        });
475        let load = json!({
476            "method": "Page.loadEventFired",
477            "sessionId": session,
478            "params": {}
479        });
480        let raced = vec![blank_frame, blank_load.clone(), commit.clone()];
481        assert!(!super::navigation_settled(&raced, session, 0));
482        let ready = vec![blank_load.clone(), commit.clone(), load.clone()];
483        assert!(super::navigation_settled(&ready, session, 0));
484        let stale = vec![commit, load, blank_load];
485        assert!(!super::navigation_settled(&stale, session, 2));
486    }
487
488    #[test]
489    fn a_loading_document_still_waits() {
490        assert_eq!(probe_is_settled(Ok(Some("loading"))).unwrap(), false);
491        assert_eq!(probe_is_settled(Ok(Some("interactive"))).unwrap(), false);
492    }
493
494    #[test]
495    fn a_closed_socket_is_not_treated_as_loaded() {
496        for err in [
497            "websocket closed 1011 the browser went away\n",
498            "websocket connection closed\n",
499            "websocket write failed: broken pipe\n",
500            "websocket read failed: connection reset\n",
501            "CDP response was not JSON: eof\n",
502        ] {
503            let reported = probe_is_settled(Err(err)).unwrap_err();
504            assert_eq!(reported, err);
505        }
506    }
507
508    #[test]
509    fn action_start_and_end_keep_the_panel_fields() {
510        let click = ActionNote {
511            kind: "click".to_string(),
512            tab: Some("1234".to_string()),
513            target: Some("button \"Sign in\"".to_string()),
514            agent: Some("cone:cone-1".to_string()),
515            ..ActionNote::default()
516        };
517        assert_eq!(
518            click.start_params(),
519            json!({
520                "phase": "start",
521                "kind": "click",
522                "tab": "1234",
523                "target": "button \"Sign in\"",
524                "agent": "cone:cone-1"
525            })
526        );
527        let failed = click.end_params(
528            false,
529            "No snapshot available. Run \"snapshot\" first.\nignored\n",
530        );
531        assert_eq!(
532            failed,
533            json!({
534                "phase": "end",
535                "ok": false,
536                "error": "no snapshot",
537                "tab": "1234"
538            })
539        );
540        assert!(failed.get("agent").is_none());
541        assert!(failed.get("kind").is_none());
542        assert!(failed.get("target").is_none());
543        let open = ActionNote {
544            kind: "open".to_string(),
545            url: Some("http://a.test/".to_string()),
546            ..ActionNote::default()
547        };
548        assert!(open.start_params().get("tab").is_none());
549        assert_eq!(open.start_params()["url"], "http://a.test/");
550        let mut opened = open.clone();
551        opened.tab = Some("TAB1".to_string());
552        assert_eq!(opened.end_params(true, "")["tab"], "TAB1");
553        assert!(opened.end_params(true, "").get("error").is_none());
554    }
555
556    #[test]
557    fn action_params_omit_typed_text_eval_source_and_request_secrets() {
558        let secret = "secret-text";
559        let fill = ActionNote {
560            kind: "fill".to_string(),
561            tab: Some("T".to_string()),
562            target: Some("textbox \"Email\"".to_string()),
563            length: Some(secret.chars().count() as u64),
564            ..ActionNote::default()
565        };
566        let fill_params = fill.start_params();
567        assert_eq!(fill_params["length"], 11);
568        assert!(!fill_params.to_string().contains(secret));
569        assert!(fill_params.get("value").is_none());
570        let typed = "héllo";
571        let type_note = ActionNote {
572            kind: "type".to_string(),
573            length: Some(typed.chars().count() as u64),
574            ..ActionNote::default()
575        };
576        assert_eq!(type_note.start_params()["length"], 5);
577        assert!(!type_note.start_params().to_string().contains(typed));
578        let source = "document.cookie";
579        let eval_note = ActionNote {
580            kind: "eval".to_string(),
581            tab: Some("T".to_string()),
582            ..ActionNote::default()
583        };
584        let eval_params = eval_note.start_params();
585        assert!(!eval_params.to_string().contains(source));
586        assert!(eval_params.get("expression").is_none());
587        let request = ActionNote {
588            kind: "request".to_string(),
589            tab: Some("T1".to_string()),
590            method: Some("POST".to_string()),
591            url: Some("https://app.example/api/me".to_string()),
592            ..ActionNote::default()
593        };
594        let request_params = request.start_params();
595        assert_eq!(
596            request_params,
597            json!({
598                "phase": "start",
599                "kind": "request",
600                "tab": "T1",
601                "method": "POST",
602                "url": "https://app.example/api/me"
603            })
604        );
605        assert!(!request_params.to_string().contains("Authorization"));
606        assert!(!request_params.to_string().contains(secret));
607        let press = ActionNote {
608            kind: "press".to_string(),
609            key: Some("Enter".to_string()),
610            ..ActionNote::default()
611        };
612        assert_eq!(press.start_params()["key"], "Enter");
613        assert!(ActionNote::default().start_params().get("agent").is_none());
614        assert_eq!(first_stderr_line("only-line"), Some("only-line"));
615        assert_eq!(first_stderr_line("\n"), None);
616    }
617
618    #[test]
619    fn action_urls_and_errors_drop_page_secrets() {
620        let secret = "https://user:pass@app.example/form?q=QUERYSECRET&tok=x#FRAGSECRET";
621        let open = ActionNote {
622            kind: "open".to_string(),
623            url: Some(secret.to_string()),
624            ..ActionNote::default()
625        };
626        let started = open.start_params();
627        assert_eq!(started["url"], "https://app.example/form");
628        assert_clean(&started);
629        let goto = ActionNote {
630            kind: "goto".to_string(),
631            url: Some(secret.to_string()),
632            ..ActionNote::default()
633        };
634        assert_eq!(goto.start_params()["url"], "https://app.example/form");
635        let posted = ActionNote {
636            kind: "request".to_string(),
637            method: Some("POST".to_string()),
638            url: Some(secret.to_string()),
639            ..ActionNote::default()
640        };
641        let request = posted.start_params();
642        assert_eq!(request["method"], "POST");
643        assert_eq!(request["url"], "https://app.example/form");
644        assert_clean(&request);
645        let gotten = ActionNote {
646            kind: "request".to_string(),
647            method: Some("GET".to_string()),
648            url: Some("http://user:p%40ss@[::1]:8443/a/b?x=1#y".to_string()),
649            ..ActionNote::default()
650        };
651        assert_eq!(gotten.start_params()["url"], "http://[::1]:8443/a/b");
652        let relative = ActionNote {
653            kind: "request".to_string(),
654            method: Some("GET".to_string()),
655            url: Some("//user:pass@app.example/path?q=QUERYSECRET#FRAGSECRET".to_string()),
656            ..ActionNote::default()
657        };
658        let relative_params = relative.start_params();
659        assert_eq!(relative_params["url"], "//app.example/path");
660        assert_clean(&relative_params);
661        assert_eq!(
662            ActionNote {
663                kind: "open".to_string(),
664                url: Some("about:blank".to_string()),
665                ..ActionNote::default()
666            }
667            .start_params()["url"],
668            "about:blank"
669        );
670        let opaque = [
671            (
672                "goto",
673                "data:text/html,<title>DATASECRET</title>",
674                Some("data:"),
675                "DATASECRET",
676            ),
677            (
678                "open",
679                "data:text/html,DATASECRET2",
680                Some("data:"),
681                "DATASECRET2",
682            ),
683            (
684                "goto",
685                "javascript:void('JSSECRET')",
686                Some("javascript:"),
687                "JSSECRET",
688            ),
689            (
690                "open",
691                "blob:https://example.com/BLOBSECRET",
692                Some("blob:"),
693                "BLOBSECRET",
694            ),
695            ("goto", "about:blank?q=BLANKSECRET", Some("about:blank"), "BLANKSECRET"),
696            ("open", "ABOUT:blank?q=BLANKSECRET", Some("about:blank"), "BLANKSECRET"),
697            ("goto", "About:blank", Some("about:blank"), "BLANKSECRET"),
698            (
699                "open",
700                "DATA:text/html,DATASECRET",
701                Some("data:"),
702                "DATASECRET",
703            ),
704            (
705                "open",
706                "about:srcdoc,ABOUTSECRET",
707                Some("about:"),
708                "ABOUTSECRET",
709            ),
710            ("goto", "file:/tmp/PATHSECRET", Some("file:"), "PATHSECRET"),
711            (
712                "open",
713                "https:example.com/PATHSECRET",
714                Some("https:"),
715                "PATHSECRET",
716            ),
717            ("goto", "example.com/PATHSECRET", None, "PATHSECRET"),
718            ("open", "not a url PATHSECRET", None, "PATHSECRET"),
719            ("goto", "HOSTSECRET3:8080/path", None, "HOSTSECRET3"),
720            ("open", "host:8080/x", None, "host:"),
721            (
722                "goto",
723                "USERSECRET5:PASSSECRET5@127.0.0.1/form",
724                None,
725                "USERSECRET5",
726            ),
727            (
728                "open",
729                "USERSECRET5:PASSSECRET5@127.0.0.1/form",
730                None,
731                "PASSSECRET5",
732            ),
733            ("goto", "user:pw@host/x", None, "user:"),
734            ("open", "foo:bar", None, "foo:"),
735            ("goto", "foo://example.com/FOOSECRET", None, "FOOSECRET"),
736            (
737                "open",
738                "view-source:https://example.com/VIEWSECRET",
739                Some("view-source:"),
740                "VIEWSECRET",
741            ),
742        ];
743        for (kind, raw, expect, secret) in opaque {
744            let note = ActionNote {
745                kind: kind.to_string(),
746                url: Some(raw.to_string()),
747                ..ActionNote::default()
748            };
749            let params = note.start_params();
750            match expect {
751                Some(url) => assert_eq!(params["url"], url, "{raw}"),
752                None => assert!(params.get("url").is_none(), "{raw} {params}"),
753            }
754            assert!(!params.to_string().contains(secret), "{raw} {params}");
755        }
756        let eval_failed = ActionNote {
757            kind: "eval".to_string(),
758            tab: Some("T".to_string()),
759            ..ActionNote::default()
760        };
761        let eval_end = eval_failed.end_params(false, "Error: EVALSECRET-in-error\n");
762        assert_eq!(eval_end["ok"], false);
763        assert!(eval_end.get("error").is_none());
764        assert_clean(&eval_end);
765        let cases = [
766            (
767                "click",
768                "Unknown ref \"e1\". Available: e2\n",
769                "element not found",
770            ),
771            (
772                "screenshot",
773                "No snapshot available. Run \"snapshot\" first.\n",
774                "no snapshot",
775            ),
776            (
777                "screenshot",
778                "screenshot: could not resolve element e1 to a visible box — the snapshot is likely stale\n",
779                "no snapshot",
780            ),
781            (
782                "click",
783                "Error: --tab <targetId> is required. Run 'playwright-cli tab-list' to get tab IDs.\n",
784                "unknown tab",
785            ),
786            (
787                "goto",
788                "navigate: refused https://app.example/form?q=QUERYSECRET#FRAGSECRET\n",
789                "navigation failed",
790            ),
791            ("click", "Page.navigate timed out\n", "timeout"),
792            (
793                "open",
794                "websocket closed 1011 the browser went away\n",
795                "connection lost",
796            ),
797            (
798                "request",
799                "curlwright: no open tab is on https://app.example — pass --tab.\nhttps://other.example/dash?q=QUERYSECRET\n",
800                "not allowed",
801            ),
802            (
803                "request",
804                "curlwright: (22) The requested URL returned error: 500\n",
805                "failed",
806            ),
807        ];
808        for (kind, stderr, code) in cases {
809            let note = ActionNote {
810                kind: kind.to_string(),
811                ..ActionNote::default()
812            };
813            let ended = note.end_params(false, stderr);
814            assert_eq!(ended["error"], code, "{stderr}");
815            assert_clean(&ended);
816        }
817    }
818
819    fn assert_clean(value: &serde_json::Value) {
820        let rendered = value.to_string();
821        for secret in [
822            "QUERYSECRET",
823            "FRAGSECRET",
824            "EVALSECRET",
825            "user:pass",
826            "tok=x",
827            "p%40ss",
828        ] {
829            assert!(!rendered.contains(secret), "{rendered}");
830        }
831    }
832}