Skip to main content

cdp_server/
bao_event.rs

1// @trace REQ-CDS-007 [entity:BaoEvent] [entity:ConsoleMessage]
2// Typed Bao event model: replaces raw __BAO_* string-prefix parsing in server.rs
3// with a structured enum, standardized JS→Rust transport, and typed broadcast.
4
5use serde_json::{json, Value};
6
7use crate::EventSender;
8
9// ---------------------------------------------------------------------------
10// §1 BaoEvent enum — 8 typed CDP event variants
11// ---------------------------------------------------------------------------
12
13/// Typed representation of CDP events that originate from the browser engine
14/// (servo/bao_browser) and are forwarded to cdp-server via console messages.
15///
16/// The old protocol used 8 different `__BAO_*__` string prefixes. This enum
17/// replaces that with a single standardized transport format:
18///
19/// ```text
20/// __BAO_EVT__CDP.MethodName\n{json}
21/// ```
22#[derive(Debug, Clone, PartialEq)]
23pub enum BaoEvent {
24    FetchRequestPaused {
25        request_id: String,
26        url: String,
27        method: String,
28        headers: Value,
29        post_data: Option<String>,
30        resource_type: String,
31    },
32    NetworkRequestWillBeSent {
33        request_id: String,
34        url: String,
35        method: String,
36        headers: Value,
37        request: Value,
38        timestamp: f64,
39        resource_type: String,
40    },
41    NetworkResponseReceived {
42        request_id: String,
43        url: String,
44        status: i32,
45        status_text: String,
46        headers: Value,
47        timestamp: f64,
48        resource_type: String,
49    },
50    NetworkLoadingFailed {
51        request_id: String,
52        resource_type: String,
53        error_text: String,
54        timestamp: f64,
55    },
56    DebuggerScriptParsed {
57        script_id: String,
58        url: String,
59        start_line: i32,
60        end_line: i32,
61    },
62    DebuggerPaused {
63        call_frames: Value,
64        reason: String,
65        hit_breakpoints: Value,
66    },
67    RuntimeExceptionThrown {
68        timestamp: f64,
69        text: String,
70        url: String,
71        line: i32,
72        column: i32,
73        stack_trace: Value,
74    },
75    PageLoadEventFired {
76        timestamp: f64,
77    },
78    PageFrameNavigated {
79        frame_id: String,
80        url: String,
81        loader_id: String,
82    },
83    SecurityCertificateError {
84        event_id: i32,
85        error_type: String,
86        url: String,
87    },
88}
89
90// ---------------------------------------------------------------------------
91// §2 ConsoleMessage — discriminates logs from CDP events
92// ---------------------------------------------------------------------------
93
94/// A console message from the browser engine. Either a plain log
95/// (forwarded as Runtime.consoleAPICalled + Log.entryAdded) or a structured
96/// CDP event that maps to a specific BaoEvent variant.
97#[derive(Debug, Clone, PartialEq)]
98pub enum ConsoleMessage {
99    Log { level: String, text: String },
100    Event(BaoEvent),
101}
102
103// ---------------------------------------------------------------------------
104// §3 from_console_text — JS→Rust transport parser
105// ---------------------------------------------------------------------------
106
107/// Parse a console text string into a `ConsoleMessage`.
108///
109/// Transport format for events:
110/// ```text
111/// __BAO_EVT__CDP.MethodName\n{json}
112/// ```
113///
114/// - Lines without the `__BAO_EVT__` prefix → `ConsoleMessage::Log`
115/// - Lines with unknown CDP method → `None`
116/// - Malformed JSON → best-effort parse with defaults
117/// - Missing newline → treat everything after the prefix as JSON
118impl BaoEvent {
119    pub fn from_console_text(text: &str) -> Option<ConsoleMessage> {
120        let rest = match text.strip_prefix("__BAO_EVT__") {
121            Some(r) => r,
122            None => return None,
123        };
124
125        let (method, json_str): (_, String) = match rest.split_once('\n') {
126            Some((m, j)) => (m, j.to_string()),
127            None => {
128                // No newline — try to split at the first '{' to separate
129                // method name from JSON body.
130                match rest.split_once('{') {
131                    Some((m, j_rest)) => (m, format!("{{{j_rest}")),
132                    None => return None,
133                }
134            }
135        };
136
137        let v: Value = serde_json::from_str(&json_str).unwrap_or_default();
138
139        match method {
140            "Fetch.requestPaused" => Some(ConsoleMessage::Event(BaoEvent::FetchRequestPaused {
141                request_id: v["id"].as_str().unwrap_or_default().to_string(),
142                url: v["url"].as_str().unwrap_or_default().to_string(),
143                method: v["method"].as_str().unwrap_or_default().to_string(),
144                headers: v.get("headers").cloned().unwrap_or(json!({})),
145                post_data: v.get("postData").and_then(|p| p.as_str()).map(String::from),
146                resource_type: v
147                    .get("resourceType")
148                    .and_then(|r| r.as_str())
149                    .unwrap_or("Other")
150                    .to_string(),
151            })),
152            "Network.requestWillBeSent" => {
153                Some(ConsoleMessage::Event(BaoEvent::NetworkRequestWillBeSent {
154                    request_id: v["id"].as_str().unwrap_or_default().to_string(),
155                    url: v["url"].as_str().unwrap_or_default().to_string(),
156                    method: v["method"].as_str().unwrap_or_default().to_string(),
157                    headers: v.get("headers").cloned().unwrap_or(json!({})),
158                    request: v.get("request").cloned().unwrap_or_else(|| {
159                        json!({
160                            "url": v["url"],
161                            "method": v["method"],
162                        })
163                    }),
164                    timestamp: v.get("timestamp").and_then(|t| t.as_f64()).unwrap_or(0.0),
165                    resource_type: v
166                        .get("type")
167                        .and_then(|r| r.as_str())
168                        .unwrap_or("Other")
169                        .to_string(),
170                }))
171            }
172            "Network.responseReceived" => {
173                Some(ConsoleMessage::Event(BaoEvent::NetworkResponseReceived {
174                    request_id: v["id"].as_str().unwrap_or_default().to_string(),
175                    url: v["url"].as_str().unwrap_or_default().to_string(),
176                    status: v["status"].as_i64().unwrap_or(0) as i32,
177                    status_text: v
178                        .get("statusText")
179                        .and_then(|s| s.as_str())
180                        .unwrap_or_default()
181                        .to_string(),
182                    headers: v.get("headers").cloned().unwrap_or(json!({})),
183                    timestamp: v.get("timestamp").and_then(|t| t.as_f64()).unwrap_or(0.0),
184                    resource_type: v
185                        .get("type")
186                        .and_then(|r| r.as_str())
187                        .unwrap_or("Other")
188                        .to_string(),
189                }))
190            }
191            "Network.loadingFailed" => {
192                Some(ConsoleMessage::Event(BaoEvent::NetworkLoadingFailed {
193                    request_id: v["id"].as_str().unwrap_or_default().to_string(),
194                    resource_type: v
195                        .get("type")
196                        .and_then(|r| r.as_str())
197                        .unwrap_or("Other")
198                        .to_string(),
199                    error_text: v
200                        .get("errorText")
201                        .and_then(|e| e.as_str())
202                        .unwrap_or("Network error")
203                        .to_string(),
204                    timestamp: v.get("timestamp").and_then(|t| t.as_f64()).unwrap_or(0.0),
205                }))
206            }
207            "Debugger.scriptParsed" => {
208                Some(ConsoleMessage::Event(BaoEvent::DebuggerScriptParsed {
209                    script_id: v["id"].as_str().unwrap_or_default().to_string(),
210                    url: v["url"].as_str().unwrap_or_default().to_string(),
211                    start_line: v.get("startLine").and_then(|l| l.as_i64()).unwrap_or(0) as i32,
212                    end_line: v.get("endLine").and_then(|l| l.as_i64()).unwrap_or(0) as i32,
213                }))
214            }
215            "Debugger.paused" => Some(ConsoleMessage::Event(BaoEvent::DebuggerPaused {
216                call_frames: v.get("callFrames").cloned().unwrap_or(json!([])),
217                reason: v
218                    .get("reason")
219                    .and_then(|r| r.as_str())
220                    .unwrap_or("other")
221                    .to_string(),
222                hit_breakpoints: v.get("hitBreakpoints").cloned().unwrap_or(json!([])),
223            })),
224            "Runtime.exceptionThrown" => {
225                Some(ConsoleMessage::Event(BaoEvent::RuntimeExceptionThrown {
226                    timestamp: v.get("timestamp").and_then(|t| t.as_f64()).unwrap_or(0.0),
227                    text: v
228                        .get("text")
229                        .and_then(|t| t.as_str())
230                        .unwrap_or_default()
231                        .to_string(),
232                    url: v
233                        .get("url")
234                        .and_then(|u| u.as_str())
235                        .unwrap_or_default()
236                        .to_string(),
237                    line: v.get("line").and_then(|l| l.as_i64()).unwrap_or(0) as i32,
238                    column: v.get("column").and_then(|c| c.as_i64()).unwrap_or(0) as i32,
239                    stack_trace: v.get("stackTrace").cloned().unwrap_or(Value::Null),
240                }))
241            }
242            "Page.loadEventFired" => Some(ConsoleMessage::Event(BaoEvent::PageLoadEventFired {
243                timestamp: v.get("timestamp").and_then(|t| t.as_f64()).unwrap_or(0.0),
244            })),
245            "Page.frameNavigated" => Some(ConsoleMessage::Event(BaoEvent::PageFrameNavigated {
246                frame_id: v["frameId"].as_str().unwrap_or("0").to_string(),
247                url: v["url"].as_str().unwrap_or_default().to_string(),
248                loader_id: v["loaderId"].as_str().unwrap_or_default().to_string(),
249            })),
250            "Security.certificateError" => {
251                Some(ConsoleMessage::Event(BaoEvent::SecurityCertificateError {
252                    event_id: v["eventId"].as_i64().unwrap_or(0) as i32,
253                    error_type: v["errorType"].as_str().unwrap_or_default().to_string(),
254                    url: v["url"].as_str().unwrap_or_default().to_string(),
255                }))
256            }
257            _ => None,
258        }
259    }
260}
261
262// ---------------------------------------------------------------------------
263// §4 broadcast — emit CDP events via EventSender
264// ---------------------------------------------------------------------------
265
266/// Broadcast a `BaoEvent` as the correct CDP event(s) through the
267/// `EventSender`. The JSON shapes exactly match the existing server.rs
268/// implementation (lines 140–254).
269impl BaoEvent {
270    pub fn broadcast(&self, sender: &dyn EventSender) {
271        match self {
272            BaoEvent::FetchRequestPaused {
273                request_id,
274                url,
275                method,
276                headers,
277                post_data: _,
278                resource_type,
279            } => {
280                sender.send_event(
281                    "Fetch.requestPaused",
282                    serde_json::json!({
283                        "requestId": request_id,
284                        "request": {
285                            "url": url,
286                            "method": method,
287                            "headers": headers,
288                        },
289                        "resourceType": resource_type,
290                        "networkStage": "Request",
291                    }),
292                );
293            }
294            BaoEvent::NetworkRequestWillBeSent {
295                request_id,
296                request,
297                timestamp,
298                resource_type,
299                ..
300            } => {
301                sender.send_event(
302                    "Network.requestWillBeSent",
303                    serde_json::json!({
304                        "requestId": request_id,
305                        "request": request,
306                        "timestamp": *timestamp,
307                        "type": resource_type,
308                    }),
309                );
310            }
311            BaoEvent::NetworkResponseReceived {
312                request_id,
313                url,
314                status,
315                status_text,
316                headers,
317                timestamp,
318                resource_type,
319            } => {
320                sender.send_event(
321                    "Network.responseReceived",
322                    serde_json::json!({
323                        "requestId": request_id,
324                        "response": {
325                            "url": url,
326                            "status": *status,
327                            "statusText": status_text,
328                            "headers": headers,
329                        },
330                        "timestamp": *timestamp,
331                        "type": resource_type,
332                    }),
333                );
334                sender.send_event(
335                    "Network.loadingFinished",
336                    serde_json::json!({
337                        "requestId": request_id,
338                        "timestamp": *timestamp,
339                    }),
340                );
341            }
342            BaoEvent::NetworkLoadingFailed {
343                request_id,
344                resource_type,
345                error_text,
346                timestamp,
347            } => {
348                sender.send_event(
349                    "Network.loadingFailed",
350                    serde_json::json!({
351                        "requestId": request_id,
352                        "type": resource_type,
353                        "errorText": error_text,
354                        "timestamp": *timestamp,
355                    }),
356                );
357            }
358            BaoEvent::DebuggerScriptParsed {
359                script_id,
360                url,
361                start_line,
362                end_line,
363            } => {
364                sender.send_event(
365                    "Debugger.scriptParsed",
366                    serde_json::json!({
367                        "scriptId": script_id,
368                        "url": url,
369                        "startLine": *start_line,
370                        "endLine": *end_line,
371                    }),
372                );
373            }
374            BaoEvent::DebuggerPaused {
375                call_frames,
376                reason,
377                hit_breakpoints,
378            } => {
379                sender.send_event(
380                    "Debugger.paused",
381                    serde_json::json!({
382                        "callFrames": call_frames,
383                        "reason": reason,
384                        "hitBreakpoints": hit_breakpoints,
385                    }),
386                );
387            }
388            BaoEvent::RuntimeExceptionThrown {
389                timestamp,
390                text,
391                url,
392                line,
393                column,
394                stack_trace,
395            } => {
396                sender.send_event(
397                    "Runtime.exceptionThrown",
398                    serde_json::json!({
399                        "timestamp": *timestamp,
400                        "exceptionDetails": {
401                            "text": text,
402                            "url": url,
403                            "lineNumber": *line,
404                            "columnNumber": *column,
405                            "stackTrace": stack_trace,
406                        },
407                    }),
408                );
409            }
410            BaoEvent::PageLoadEventFired { timestamp } => {
411                sender.send_event(
412                    "Page.loadEventFired",
413                    serde_json::json!({
414                        "timestamp": *timestamp,
415                    }),
416                );
417            }
418            BaoEvent::PageFrameNavigated {
419                frame_id,
420                url,
421                loader_id,
422            } => {
423                sender.send_event(
424                    "Page.frameNavigated",
425                    serde_json::json!({
426                        "frame": {
427                            "id": frame_id,
428                            "url": url,
429                            "loaderId": loader_id,
430                            "mimeType": "text/html",
431                            "securityOrigin": "",
432                            "secureContextType": "Secure",
433                        },
434                    }),
435                );
436            }
437            BaoEvent::SecurityCertificateError {
438                event_id,
439                error_type,
440                url,
441            } => {
442                sender.send_event(
443                    "Security.certificateError",
444                    serde_json::json!({
445                        "eventId": event_id,
446                        "errorType": error_type,
447                        "resourceUrl": url,
448                    }),
449                );
450            }
451        }
452    }
453}
454
455// ---------------------------------------------------------------------------
456// §5 Tests — TDD comprehensive coverage
457// ---------------------------------------------------------------------------
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462    use std::cell::RefCell;
463
464    /// Recording EventSender for broadcast verification.
465    struct RecordingSender {
466        events: RefCell<Vec<(String, Value)>>,
467    }
468
469    impl RecordingSender {
470        fn new() -> Self {
471            RecordingSender {
472                events: RefCell::new(Vec::new()),
473            }
474        }
475
476        fn events(&self) -> Vec<(String, Value)> {
477            self.events.borrow().clone()
478        }
479    }
480
481    impl EventSender for RecordingSender {
482        fn send_event(&self, method: &str, params: Value) {
483            self.events.borrow_mut().push((method.to_string(), params));
484        }
485    }
486
487    // Make RecordingSender Send + Sync (RefCell is single-threaded, tests are
488    // single-threaded).
489    unsafe impl Send for RecordingSender {}
490    unsafe impl Sync for RecordingSender {}
491
492    // ---- from_console_text tests ----
493
494    #[test]
495    fn from_console_text_fetch_request_paused() {
496        let input = "__BAO_EVT__Fetch.requestPaused\n{\"id\":\"r1\",\"url\":\"http://test.com\",\"method\":\"GET\",\"headers\":{\"X-Custom\":\"val\"},\"resourceType\":\"Document\"}";
497        let msg = BaoEvent::from_console_text(input).expect("should parse");
498        match msg {
499            ConsoleMessage::Event(BaoEvent::FetchRequestPaused {
500                request_id,
501                url,
502                method,
503                headers,
504                post_data,
505                resource_type,
506            }) => {
507                assert_eq!(request_id, "r1");
508                assert_eq!(url, "http://test.com");
509                assert_eq!(method, "GET");
510                assert_eq!(headers["X-Custom"], "val");
511                assert!(post_data.is_none());
512                assert_eq!(resource_type, "Document");
513            }
514            other => panic!("expected FetchRequestPaused, got {:?}", other),
515        }
516    }
517
518    #[test]
519    fn from_console_text_fetch_request_paused_with_post_data() {
520        let input = "__BAO_EVT__Fetch.requestPaused\n{\"id\":\"r2\",\"url\":\"http://test.com\",\"method\":\"POST\",\"headers\":{},\"postData\":\"body\",\"resourceType\":\"XHR\"}";
521        let msg = BaoEvent::from_console_text(input).expect("should parse");
522        match msg {
523            ConsoleMessage::Event(BaoEvent::FetchRequestPaused { post_data, .. }) => {
524                assert_eq!(post_data, Some("body".to_string()));
525            }
526            other => panic!("expected FetchRequestPaused, got {:?}", other),
527        }
528    }
529
530    #[test]
531    fn from_console_text_network_request() {
532        let input = "__BAO_EVT__Network.requestWillBeSent\n{\"id\":\"req1\",\"url\":\"http://example.com\",\"method\":\"GET\",\"headers\":{},\"request\":{\"url\":\"http://example.com\",\"method\":\"GET\"},\"timestamp\":12345.0,\"type\":\"Document\"}";
533        let msg = BaoEvent::from_console_text(input).expect("should parse");
534        match msg {
535            ConsoleMessage::Event(BaoEvent::NetworkRequestWillBeSent {
536                request_id,
537                url,
538                method,
539                timestamp,
540                resource_type,
541                ..
542            }) => {
543                assert_eq!(request_id, "req1");
544                assert_eq!(url, "http://example.com");
545                assert_eq!(method, "GET");
546                assert_eq!(timestamp, 12345.0);
547                assert_eq!(resource_type, "Document");
548            }
549            other => panic!("expected NetworkRequestWillBeSent, got {:?}", other),
550        }
551    }
552
553    #[test]
554    fn from_console_text_network_request_defaults_when_request_missing() {
555        let input = "__BAO_EVT__Network.requestWillBeSent\n{\"id\":\"req1\",\"url\":\"http://example.com\",\"method\":\"GET\",\"headers\":{},\"timestamp\":100.0,\"type\":\"Script\"}";
556        let msg = BaoEvent::from_console_text(input).expect("should parse");
557        match msg {
558            ConsoleMessage::Event(BaoEvent::NetworkRequestWillBeSent { request, .. }) => {
559                assert_eq!(request["url"], "http://example.com");
560                assert_eq!(request["method"], "GET");
561            }
562            other => panic!("expected NetworkRequestWillBeSent, got {:?}", other),
563        }
564    }
565
566    #[test]
567    fn from_console_text_network_response() {
568        let input = "__BAO_EVT__Network.responseReceived\n{\"id\":\"req2\",\"url\":\"http://example.com\",\"status\":200,\"statusText\":\"OK\",\"headers\":{\"Content-Type\":\"text/html\"},\"timestamp\":12346.0,\"type\":\"Document\"}";
569        let msg = BaoEvent::from_console_text(input).expect("should parse");
570        match msg {
571            ConsoleMessage::Event(BaoEvent::NetworkResponseReceived {
572                request_id,
573                url,
574                status,
575                status_text,
576                headers,
577                timestamp,
578                resource_type,
579            }) => {
580                assert_eq!(request_id, "req2");
581                assert_eq!(url, "http://example.com");
582                assert_eq!(status, 200);
583                assert_eq!(status_text, "OK");
584                assert_eq!(headers["Content-Type"], "text/html");
585                assert_eq!(timestamp, 12346.0);
586                assert_eq!(resource_type, "Document");
587            }
588            other => panic!("expected NetworkResponseReceived, got {:?}", other),
589        }
590    }
591
592    #[test]
593    fn from_console_text_network_loading_failed() {
594        let input = "__BAO_EVT__Network.loadingFailed\n{\"id\":\"req3\",\"type\":\"XHR\",\"timestamp\":12347.0}";
595        let msg = BaoEvent::from_console_text(input).expect("should parse");
596        match msg {
597            ConsoleMessage::Event(BaoEvent::NetworkLoadingFailed {
598                request_id,
599                resource_type,
600                error_text,
601                timestamp,
602            }) => {
603                assert_eq!(request_id, "req3");
604                assert_eq!(resource_type, "XHR");
605                assert_eq!(error_text, "Network error");
606                assert_eq!(timestamp, 12347.0);
607            }
608            other => panic!("expected NetworkLoadingFailed, got {:?}", other),
609        }
610    }
611
612    #[test]
613    fn from_console_text_network_loading_failed_with_custom_error() {
614        let input = "__BAO_EVT__Network.loadingFailed\n{\"id\":\"req3\",\"type\":\"XHR\",\"errorText\":\"Connection refused\",\"timestamp\":100.0}";
615        let msg = BaoEvent::from_console_text(input).expect("should parse");
616        match msg {
617            ConsoleMessage::Event(BaoEvent::NetworkLoadingFailed { error_text, .. }) => {
618                assert_eq!(error_text, "Connection refused");
619            }
620            other => panic!("expected NetworkLoadingFailed, got {:?}", other),
621        }
622    }
623
624    #[test]
625    fn from_console_text_debugger_script_parsed() {
626        let input = "__BAO_EVT__Debugger.scriptParsed\n{\"id\":\"1\",\"url\":\"test.js\",\"startLine\":0,\"endLine\":10}";
627        let msg = BaoEvent::from_console_text(input).expect("should parse");
628        match msg {
629            ConsoleMessage::Event(BaoEvent::DebuggerScriptParsed {
630                script_id,
631                url,
632                start_line,
633                end_line,
634            }) => {
635                assert_eq!(script_id, "1");
636                assert_eq!(url, "test.js");
637                assert_eq!(start_line, 0);
638                assert_eq!(end_line, 10);
639            }
640            other => panic!("expected DebuggerScriptParsed, got {:?}", other),
641        }
642    }
643
644    #[test]
645    fn from_console_text_debugger_paused() {
646        let input = "__BAO_EVT__Debugger.paused\n{\"callFrames\":[],\"reason\":\"breakpoint\",\"hitBreakpoints\":[]}";
647        let msg = BaoEvent::from_console_text(input).expect("should parse");
648        match msg {
649            ConsoleMessage::Event(BaoEvent::DebuggerPaused {
650                call_frames,
651                reason,
652                hit_breakpoints,
653            }) => {
654                assert_eq!(call_frames, json!([]));
655                assert_eq!(reason, "breakpoint");
656                assert_eq!(hit_breakpoints, json!([]));
657            }
658            other => panic!("expected DebuggerPaused, got {:?}", other),
659        }
660    }
661
662    #[test]
663    fn from_console_text_runtime_exception() {
664        let input = "__BAO_EVT__Runtime.exceptionThrown\n{\"timestamp\":12348.0,\"text\":\"TypeError: x is not a function\",\"url\":\"test.js\",\"line\":10,\"column\":5,\"stackTrace\":null}";
665        let msg = BaoEvent::from_console_text(input).expect("should parse");
666        match msg {
667            ConsoleMessage::Event(BaoEvent::RuntimeExceptionThrown {
668                timestamp,
669                text,
670                url,
671                line,
672                column,
673                stack_trace,
674            }) => {
675                assert_eq!(timestamp, 12348.0);
676                assert_eq!(text, "TypeError: x is not a function");
677                assert_eq!(url, "test.js");
678                assert_eq!(line, 10);
679                assert_eq!(column, 5);
680                assert!(stack_trace.is_null());
681            }
682            other => panic!("expected RuntimeExceptionThrown, got {:?}", other),
683        }
684    }
685
686    #[test]
687    fn from_console_text_page_load() {
688        let input = "__BAO_EVT__Page.loadEventFired\n{\"timestamp\":12345.0}";
689        let msg = BaoEvent::from_console_text(input).expect("should parse");
690        match msg {
691            ConsoleMessage::Event(BaoEvent::PageLoadEventFired { timestamp }) => {
692                assert_eq!(timestamp, 12345.0);
693            }
694            other => panic!("expected PageLoadEventFired, got {:?}", other),
695        }
696    }
697
698    #[test]
699    fn from_console_text_unknown_method_returns_none() {
700        let input = "__BAO_EVT__Unknown.method\n{}";
701        assert!(BaoEvent::from_console_text(input).is_none());
702    }
703
704    #[test]
705    fn from_console_text_no_prefix_returns_none() {
706        let input = "plain text log message";
707        assert!(BaoEvent::from_console_text(input).is_none());
708    }
709
710    #[test]
711    fn from_console_text_malformed_json_still_parses() {
712        // Malformed JSON → serde returns default Value::Null → defaults used
713        let input = "__BAO_EVT__Page.loadEventFired\n{bad json}";
714        let msg = BaoEvent::from_console_text(input).expect("should parse with defaults");
715        match msg {
716            ConsoleMessage::Event(BaoEvent::PageLoadEventFired { timestamp }) => {
717                assert_eq!(timestamp, 0.0);
718            }
719            other => panic!("expected PageLoadEventFired, got {:?}", other),
720        }
721    }
722
723    #[test]
724    fn from_console_text_missing_newline() {
725        // No newline — method + JSON in one string after prefix
726        let input = "__BAO_EVT__Page.loadEventFired{\"timestamp\":1.0}";
727        let msg = BaoEvent::from_console_text(input).expect("should parse without newline");
728        match msg {
729            ConsoleMessage::Event(BaoEvent::PageLoadEventFired { timestamp }) => {
730                assert_eq!(timestamp, 1.0);
731            }
732            other => panic!("expected PageLoadEventFired, got {:?}", other),
733        }
734    }
735
736    // ---- broadcast tests ----
737
738    #[test]
739    fn broadcast_fetch_request_paused() {
740        let sender = RecordingSender::new();
741        let event = BaoEvent::FetchRequestPaused {
742            request_id: "r1".into(),
743            url: "http://test.com".into(),
744            method: "GET".into(),
745            headers: json!({"X-Custom": "val"}),
746            post_data: None,
747            resource_type: "Document".into(),
748        };
749        event.broadcast(&sender);
750        let events = sender.events();
751        assert_eq!(events.len(), 1);
752        assert_eq!(events[0].0, "Fetch.requestPaused");
753        let params = &events[0].1;
754        assert_eq!(params["requestId"], "r1");
755        assert_eq!(params["request"]["url"], "http://test.com");
756        assert_eq!(params["request"]["method"], "GET");
757        assert_eq!(params["request"]["headers"]["X-Custom"], "val");
758        assert_eq!(params["resourceType"], "Document");
759        assert_eq!(params["networkStage"], "Request");
760    }
761
762    #[test]
763    fn broadcast_network_request_will_be_sent() {
764        let sender = RecordingSender::new();
765        let event = BaoEvent::NetworkRequestWillBeSent {
766            request_id: "req1".into(),
767            url: "http://example.com".into(),
768            method: "GET".into(),
769            headers: json!({}),
770            request: json!({"url": "http://example.com", "method": "GET"}),
771            timestamp: 12345.0,
772            resource_type: "Document".into(),
773        };
774        event.broadcast(&sender);
775        let events = sender.events();
776        assert_eq!(events.len(), 1);
777        assert_eq!(events[0].0, "Network.requestWillBeSent");
778        let params = &events[0].1;
779        assert_eq!(params["requestId"], "req1");
780        assert_eq!(params["request"]["url"], "http://example.com");
781        assert_eq!(params["timestamp"], 12345.0);
782        assert_eq!(params["type"], "Document");
783    }
784
785    #[test]
786    fn broadcast_network_response_received_also_emits_loading_finished() {
787        let sender = RecordingSender::new();
788        let event = BaoEvent::NetworkResponseReceived {
789            request_id: "req2".into(),
790            url: "http://example.com".into(),
791            status: 200,
792            status_text: "OK".into(),
793            headers: json!({"Content-Type": "text/html"}),
794            timestamp: 12346.0,
795            resource_type: "Document".into(),
796        };
797        event.broadcast(&sender);
798        let events = sender.events();
799        assert_eq!(events.len(), 2);
800
801        assert_eq!(events[0].0, "Network.responseReceived");
802        let p0 = &events[0].1;
803        assert_eq!(p0["requestId"], "req2");
804        assert_eq!(p0["response"]["url"], "http://example.com");
805        assert_eq!(p0["response"]["status"], 200);
806        assert_eq!(p0["response"]["statusText"], "OK");
807        assert_eq!(p0["response"]["headers"]["Content-Type"], "text/html");
808        assert_eq!(p0["timestamp"], 12346.0);
809        assert_eq!(p0["type"], "Document");
810
811        assert_eq!(events[1].0, "Network.loadingFinished");
812        let p1 = &events[1].1;
813        assert_eq!(p1["requestId"], "req2");
814        assert_eq!(p1["timestamp"], 12346.0);
815    }
816
817    #[test]
818    fn broadcast_network_loading_failed() {
819        let sender = RecordingSender::new();
820        let event = BaoEvent::NetworkLoadingFailed {
821            request_id: "req3".into(),
822            resource_type: "XHR".into(),
823            error_text: "Network error".into(),
824            timestamp: 12347.0,
825        };
826        event.broadcast(&sender);
827        let events = sender.events();
828        assert_eq!(events.len(), 1);
829        assert_eq!(events[0].0, "Network.loadingFailed");
830        let params = &events[0].1;
831        assert_eq!(params["requestId"], "req3");
832        assert_eq!(params["type"], "XHR");
833        assert_eq!(params["errorText"], "Network error");
834        assert_eq!(params["timestamp"], 12347.0);
835    }
836
837    #[test]
838    fn broadcast_debugger_script_parsed() {
839        let sender = RecordingSender::new();
840        let event = BaoEvent::DebuggerScriptParsed {
841            script_id: "1".into(),
842            url: "test.js".into(),
843            start_line: 0,
844            end_line: 10,
845        };
846        event.broadcast(&sender);
847        let events = sender.events();
848        assert_eq!(events.len(), 1);
849        assert_eq!(events[0].0, "Debugger.scriptParsed");
850        let params = &events[0].1;
851        assert_eq!(params["scriptId"], "1");
852        assert_eq!(params["url"], "test.js");
853        assert_eq!(params["startLine"], 0);
854        assert_eq!(params["endLine"], 10);
855    }
856
857    #[test]
858    fn broadcast_debugger_paused() {
859        let sender = RecordingSender::new();
860        let event = BaoEvent::DebuggerPaused {
861            call_frames: json!([]),
862            reason: "breakpoint".into(),
863            hit_breakpoints: json!([]),
864        };
865        event.broadcast(&sender);
866        let events = sender.events();
867        assert_eq!(events.len(), 1);
868        assert_eq!(events[0].0, "Debugger.paused");
869        let params = &events[0].1;
870        assert_eq!(params["callFrames"], json!([]));
871        assert_eq!(params["reason"], "breakpoint");
872        assert_eq!(params["hitBreakpoints"], json!([]));
873    }
874
875    #[test]
876    fn broadcast_runtime_exception_thrown() {
877        let sender = RecordingSender::new();
878        let event = BaoEvent::RuntimeExceptionThrown {
879            timestamp: 12348.0,
880            text: "TypeError: x is not a function".into(),
881            url: "test.js".into(),
882            line: 10,
883            column: 5,
884            stack_trace: Value::Null,
885        };
886        event.broadcast(&sender);
887        let events = sender.events();
888        assert_eq!(events.len(), 1);
889        assert_eq!(events[0].0, "Runtime.exceptionThrown");
890        let params = &events[0].1;
891        assert_eq!(params["timestamp"], 12348.0);
892        let details = &params["exceptionDetails"];
893        assert_eq!(details["text"], "TypeError: x is not a function");
894        assert_eq!(details["url"], "test.js");
895        assert_eq!(details["lineNumber"], 10);
896        assert_eq!(details["columnNumber"], 5);
897        assert!(details["stackTrace"].is_null());
898    }
899
900    #[test]
901    fn broadcast_page_load_event_fired() {
902        let sender = RecordingSender::new();
903        let event = BaoEvent::PageLoadEventFired { timestamp: 12345.0 };
904        event.broadcast(&sender);
905        let events = sender.events();
906        assert_eq!(events.len(), 1);
907        assert_eq!(events[0].0, "Page.loadEventFired");
908        let params = &events[0].1;
909        assert_eq!(params["timestamp"], 12345.0);
910    }
911
912    #[test]
913    fn broadcast_produces_correct_cdp_events() {
914        // End-to-end: from_console_text → broadcast → verify CDP output
915        let sender = RecordingSender::new();
916
917        let input = "__BAO_EVT__Fetch.requestPaused\n{\"id\":\"r1\",\"url\":\"http://test.com\",\"method\":\"GET\",\"headers\":{},\"resourceType\":\"Document\"}";
918        let msg = BaoEvent::from_console_text(input).expect("should parse");
919        if let ConsoleMessage::Event(evt) = msg {
920            evt.broadcast(&sender);
921        }
922
923        let input = "__BAO_EVT__Page.loadEventFired\n{\"timestamp\":999.0}";
924        let msg = BaoEvent::from_console_text(input).expect("should parse");
925        if let ConsoleMessage::Event(evt) = msg {
926            evt.broadcast(&sender);
927        }
928
929        let events = sender.events();
930        assert_eq!(events.len(), 2);
931        assert_eq!(events[0].0, "Fetch.requestPaused");
932        assert_eq!(events[0].1["requestId"], "r1");
933        assert_eq!(events[1].0, "Page.loadEventFired");
934        assert_eq!(events[1].1["timestamp"], 999.0);
935    }
936
937    #[test]
938    fn console_message_log_variant() {
939        let log = ConsoleMessage::Log {
940            level: "info".into(),
941            text: "hello".into(),
942        };
943        match log {
944            ConsoleMessage::Log { level, text } => {
945                assert_eq!(level, "info");
946                assert_eq!(text, "hello");
947            }
948            ConsoleMessage::Event(_) => panic!("expected Log, got Event"),
949        }
950    }
951
952    #[test]
953    fn console_message_event_variant() {
954        let evt = ConsoleMessage::Event(BaoEvent::PageLoadEventFired { timestamp: 1.0 });
955        match evt {
956            ConsoleMessage::Event(BaoEvent::PageLoadEventFired { timestamp }) => {
957                assert_eq!(timestamp, 1.0);
958            }
959            _ => panic!("expected Event(PageLoadEventFired)"),
960        }
961    }
962
963    // ---- PageFrameNavigated tests ----
964
965    #[test]
966    fn from_console_text_page_frame_navigated() {
967        let input = "__BAO_EVT__Page.frameNavigated\n{\"frameId\":\"0\",\"url\":\"https://example.com\",\"loaderId\":\"abc\"}";
968        let msg = BaoEvent::from_console_text(input).expect("should parse");
969        match msg {
970            ConsoleMessage::Event(BaoEvent::PageFrameNavigated {
971                frame_id,
972                url,
973                loader_id,
974            }) => {
975                assert_eq!(frame_id, "0");
976                assert_eq!(url, "https://example.com");
977                assert_eq!(loader_id, "abc");
978            }
979            other => panic!("expected PageFrameNavigated, got {:?}", other),
980        }
981    }
982
983    #[test]
984    fn from_console_text_page_frame_navigated_defaults() {
985        let input = "__BAO_EVT__Page.frameNavigated\n{}";
986        let msg = BaoEvent::from_console_text(input).expect("should parse with defaults");
987        match msg {
988            ConsoleMessage::Event(BaoEvent::PageFrameNavigated {
989                frame_id,
990                url,
991                loader_id,
992            }) => {
993                assert_eq!(frame_id, "0");
994                assert_eq!(url, "");
995                assert_eq!(loader_id, "");
996            }
997            other => panic!("expected PageFrameNavigated, got {:?}", other),
998        }
999    }
1000
1001    #[test]
1002    fn broadcast_page_frame_navigated() {
1003        let sender = RecordingSender::new();
1004        let event = BaoEvent::PageFrameNavigated {
1005            frame_id: "0".into(),
1006            url: "https://example.com".into(),
1007            loader_id: "abc".into(),
1008        };
1009        event.broadcast(&sender);
1010        let events = sender.events();
1011        assert_eq!(events.len(), 1);
1012        assert_eq!(events[0].0, "Page.frameNavigated");
1013        let params = &events[0].1;
1014        assert_eq!(params["frame"]["id"], "0");
1015        assert_eq!(params["frame"]["url"], "https://example.com");
1016        assert_eq!(params["frame"]["loaderId"], "abc");
1017        assert_eq!(params["frame"]["mimeType"], "text/html");
1018        assert_eq!(params["frame"]["securityOrigin"], "");
1019        assert_eq!(params["frame"]["secureContextType"], "Secure");
1020    }
1021
1022    // ---- SecurityCertificateError tests ----
1023
1024    #[test]
1025    fn from_console_text_security_certificate_error() {
1026        let input = "__BAO_EVT__Security.certificateError\n{\"eventId\":1,\"errorType\":\"net::ERR_CERT_AUTHORITY_INVALID\",\"url\":\"https://bad.example.com\"}";
1027        let msg = BaoEvent::from_console_text(input).expect("should parse");
1028        match msg {
1029            ConsoleMessage::Event(BaoEvent::SecurityCertificateError {
1030                event_id,
1031                error_type,
1032                url,
1033            }) => {
1034                assert_eq!(event_id, 1);
1035                assert_eq!(error_type, "net::ERR_CERT_AUTHORITY_INVALID");
1036                assert_eq!(url, "https://bad.example.com");
1037            }
1038            other => panic!("expected SecurityCertificateError, got {:?}", other),
1039        }
1040    }
1041
1042    #[test]
1043    fn from_console_text_security_certificate_error_defaults() {
1044        let input = "__BAO_EVT__Security.certificateError\n{}";
1045        let msg = BaoEvent::from_console_text(input).expect("should parse with defaults");
1046        match msg {
1047            ConsoleMessage::Event(BaoEvent::SecurityCertificateError {
1048                event_id,
1049                error_type,
1050                url,
1051            }) => {
1052                assert_eq!(event_id, 0);
1053                assert_eq!(error_type, "");
1054                assert_eq!(url, "");
1055            }
1056            other => panic!("expected SecurityCertificateError, got {:?}", other),
1057        }
1058    }
1059
1060    #[test]
1061    fn broadcast_security_certificate_error() {
1062        let sender = RecordingSender::new();
1063        let event = BaoEvent::SecurityCertificateError {
1064            event_id: 1,
1065            error_type: "net::ERR_CERT_AUTHORITY_INVALID".into(),
1066            url: "https://bad.example.com".into(),
1067        };
1068        event.broadcast(&sender);
1069        let events = sender.events();
1070        assert_eq!(events.len(), 1);
1071        assert_eq!(events[0].0, "Security.certificateError");
1072        let params = &events[0].1;
1073        assert_eq!(params["eventId"], 1);
1074        assert_eq!(params["errorType"], "net::ERR_CERT_AUTHORITY_INVALID");
1075        assert_eq!(params["resourceUrl"], "https://bad.example.com");
1076    }
1077}