use std::collections::HashMap;
use serde_json::Value;
use crate::core::engine::TraceEngineEvent;
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum CdpTraceEvent {
FrameNavigated {
child: bool,
url: String,
fragment: Option<String>,
},
Dialog {
dialog_type: String,
message: String,
},
RequestWillBeSent {
id: String,
url: String,
method: String,
},
LoadingFailed { id: String, error_text: String },
Console {
level: String,
args: Vec<(Option<Value>, Option<String>)>,
},
Exception {
text: String,
description: Option<String>,
value: Option<Value>,
},
}
#[derive(Debug, Default)]
pub(crate) struct CdpTraceTranslator {
requests: HashMap<String, (String, String)>,
failures: HashMap<String, String>,
}
fn console_argument(value: &Option<Value>, description: &Option<String>) -> String {
match (value, description) {
(Some(Value::String(text)), _) => text.clone(),
(Some(Value::Null), _) => "null".to_string(),
(Some(value), None) if !value.is_object() && !value.is_array() => value.to_string(),
(_, Some(description)) => description.clone(),
(Some(value), None) => value.to_string(),
(None, None) => "undefined".to_string(),
}
}
fn error_message(description: &str) -> String {
let first = description.lines().next().unwrap_or_default();
match first.split_once(": ") {
Some((name, message)) if !name.is_empty() && !name.contains(char::is_whitespace) => {
message.to_string()
}
_ => first.to_string(),
}
}
impl CdpTraceTranslator {
pub fn translate(&mut self, event: CdpTraceEvent) -> Option<TraceEngineEvent> {
match event {
CdpTraceEvent::FrameNavigated {
child,
url,
fragment,
} => Some(TraceEngineEvent::Navigated {
main_frame: !child,
url: format!("{url}{}", fragment.unwrap_or_default()),
}),
CdpTraceEvent::Dialog {
dialog_type,
message,
} => Some(TraceEngineEvent::Dialog {
dialog_type,
message,
}),
CdpTraceEvent::RequestWillBeSent { id, url, method } => {
if let Some(error_text) = self.failures.remove(&id) {
return Some(TraceEngineEvent::RequestFailed {
url,
method,
error_text: Some(error_text),
});
}
self.requests.insert(id, (url, method));
None
}
CdpTraceEvent::LoadingFailed { id, error_text } => match self.requests.remove(&id) {
Some((url, method)) => Some(TraceEngineEvent::RequestFailed {
url,
method,
error_text: Some(error_text),
}),
None => {
self.failures.insert(id, error_text);
None
}
},
CdpTraceEvent::Console { level, args } => Some(TraceEngineEvent::Console {
level,
text: args
.iter()
.map(|(value, description)| console_argument(value, description))
.collect::<Vec<_>>()
.join(" "),
}),
CdpTraceEvent::Exception {
text,
description,
value,
} => Some(match (description, value) {
(Some(description), _) => TraceEngineEvent::PageError {
message: error_message(&description),
stack: Some(description),
},
(None, Some(value)) => TraceEngineEvent::PageError {
message: console_argument(&Some(value), &None),
stack: None,
},
(None, None) => TraceEngineEvent::PageError {
message: text,
stack: None,
},
}),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn navigations_keep_their_fragment_and_frame() {
let mut translator = CdpTraceTranslator::default();
assert_eq!(
translator.translate(CdpTraceEvent::FrameNavigated {
child: false,
url: "https://example.com/a".into(),
fragment: Some("#top".into()),
}),
Some(TraceEngineEvent::Navigated {
main_frame: true,
url: "https://example.com/a#top".into(),
})
);
}
#[test]
fn failed_requests_name_the_request_in_either_order() {
let mut translator = CdpTraceTranslator::default();
let sent = |id: &str| CdpTraceEvent::RequestWillBeSent {
id: id.into(),
url: format!("https://example.com/{id}"),
method: "GET".into(),
};
let failed = |id: &str| CdpTraceEvent::LoadingFailed {
id: id.into(),
error_text: "net::ERR_FAILED".into(),
};
let expected = |id: &str| {
Some(TraceEngineEvent::RequestFailed {
url: format!("https://example.com/{id}"),
method: "GET".into(),
error_text: Some("net::ERR_FAILED".into()),
})
};
assert_eq!(translator.translate(sent("1")), None);
assert_eq!(translator.translate(failed("1")), expected("1"));
assert_eq!(translator.translate(failed("2")), None);
assert_eq!(translator.translate(sent("2")), expected("2"));
assert!(translator.requests.is_empty() && translator.failures.is_empty());
}
#[test]
fn console_arguments_print_like_playwright() {
let mut translator = CdpTraceTranslator::default();
let event = CdpTraceEvent::Console {
level: "warning".into(),
args: vec![
(Some(json!("count")), None),
(Some(json!(3)), Some("3".into())),
(None, Some("Object".into())),
(Some(Value::Null), None),
(None, None),
],
};
assert_eq!(
translator.translate(event),
Some(TraceEngineEvent::Console {
level: "warning".into(),
text: "count 3 Object null undefined".into(),
})
);
}
#[test]
fn exceptions_split_message_and_stack() {
let mut translator = CdpTraceTranslator::default();
let thrown = CdpTraceEvent::Exception {
text: "Uncaught".into(),
description: Some("TypeError: x is not a function\n at a.js:1:2".into()),
value: None,
};
assert_eq!(
translator.translate(thrown),
Some(TraceEngineEvent::PageError {
message: "x is not a function".into(),
stack: Some("TypeError: x is not a function\n at a.js:1:2".into()),
})
);
let primitive = CdpTraceEvent::Exception {
text: "Uncaught".into(),
description: None,
value: Some(json!("plain")),
};
assert_eq!(
translator.translate(primitive),
Some(TraceEngineEvent::PageError {
message: "plain".into(),
stack: None,
})
);
}
}