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}