1use super::*;
9use std::collections::HashMap;
10use std::sync::Arc;
11use tokio::sync::Mutex;
12
13#[derive(Debug, Clone, serde::Serialize)]
15pub struct NetworkEntry {
16 pub request_id: String,
18 pub method: String,
20 pub url: String,
22 pub status: Option<u16>,
24 pub failure: Option<String>,
26 pub redirect_count: u16,
28}
29
30#[derive(Debug, Clone, serde::Serialize)]
35pub struct NetworkRecording {
36 pub entries: Vec<NetworkEntry>,
38 pub duration_ms: u64,
40 pub dropped_events: u64,
42}
43
44pub struct NetworkRecorder {
56 cdp: CdpClient,
57 session_id: String,
58 events: tokio::sync::broadcast::Receiver<crate::browser::cdp::CdpEventWithParams>,
59 entries: Arc<Mutex<Vec<NetworkEntry>>>,
60 request_indexes: Arc<Mutex<HashMap<String, usize>>>,
61 dropped: Arc<Mutex<u64>>,
62 armed: bool,
63}
64
65impl NetworkRecorder {
66 pub(crate) async fn start(cdp: CdpClient) -> BrowserResult<Self> {
68 let session_id = cdp
69 .current_session_id()
70 .ok_or("network recorder requires an active page session")?;
71 let events = cdp.subscribe_events_with_params();
72 cdp.send_to_session(&session_id, "Network.enable", None)
73 .await?;
74 Ok(Self {
75 cdp,
76 session_id,
77 events,
78 entries: Arc::new(Mutex::new(Vec::new())),
79 request_indexes: Arc::new(Mutex::new(HashMap::new())),
80 dropped: Arc::new(Mutex::new(0)),
81 armed: true,
82 })
83 }
84
85 pub async fn stop(mut self) -> BrowserResult<NetworkRecording> {
87 self.armed = false;
88 disable_network_for(&self.cdp, Some(&self.session_id)).await?;
89 let entries = std::mem::take(&mut *self.entries.lock().await);
90 Ok(NetworkRecording {
91 entries,
92 duration_ms: 0,
93 dropped_events: *self.dropped.lock().await,
94 })
95 }
96
97 pub async fn drain(&mut self) {
103 let mut entries = self.entries.lock().await;
104 let mut indexes = self.request_indexes.lock().await;
105 let mut dropped = self.dropped.lock().await;
106 while let Ok(event) = self.events.try_recv() {
107 if event.method == "Network.requestWillBeSent" {
108 let Some(rid) = event.params["requestId"].as_str() else {
109 continue;
110 };
111 if let Some(idx) = indexes.get(rid).copied() {
112 entries[idx].redirect_count = entries[idx].redirect_count.saturating_add(1);
113 continue;
114 }
115 if entries.len() >= 128 {
116 *dropped = dropped.saturating_add(1);
117 continue;
118 }
119 let req = &event.params["request"];
120 let idx = entries.len();
121 indexes.insert(rid.to_string(), idx);
122 entries.push(NetworkEntry {
123 request_id: rid.to_string(),
124 method: req["method"].as_str().unwrap_or("").to_string(),
125 url: req["url"].as_str().unwrap_or("").to_string(),
126 status: None,
127 failure: None,
128 redirect_count: 0,
129 });
130 } else if event.method == "Network.responseReceived" {
131 if let Some(idx) = event.params["requestId"]
132 .as_str()
133 .and_then(|id| indexes.get(id))
134 .copied()
135 {
136 entries[idx].status = event.params["response"]["status"]
137 .as_u64()
138 .and_then(|s| u16::try_from(s).ok());
139 }
140 } else if event.method == "Network.loadingFailed"
141 && let Some(idx) = event.params["requestId"]
142 .as_str()
143 .and_then(|id| indexes.get(id))
144 .copied()
145 {
146 entries[idx].failure = Some(
147 event.params["errorText"]
148 .as_str()
149 .unwrap_or("unknown")
150 .to_string(),
151 );
152 }
153 }
154 }
155}
156
157impl Drop for NetworkRecorder {
158 fn drop(&mut self) {
159 if self.armed {
160 let cdp = self.cdp.clone();
161 let sid = self.session_id.clone();
162 tokio::spawn(async move {
163 let _ = disable_network_for(&cdp, Some(&sid)).await;
164 });
165 }
166 }
167}
168
169impl BrowserSession {
170 pub async fn start_network_recording(&self) -> BrowserResult<NetworkRecorder> {
175 self.cdp
176 .with_current_route(async { NetworkRecorder::start(self.cdp.clone()).await })
177 .await
178 }
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184
185 #[test]
186 fn network_entry_serializes_to_json() {
187 let entry = NetworkEntry {
188 request_id: "req-1".to_string(),
189 method: "GET".to_string(),
190 url: "https://example.com".to_string(),
191 status: Some(200),
192 failure: None,
193 redirect_count: 0,
194 };
195 let json = serde_json::to_value(&entry).unwrap();
196 assert_eq!(json["request_id"], "req-1");
197 assert_eq!(json["method"], "GET");
198 assert_eq!(json["url"], "https://example.com");
199 assert_eq!(json["status"], 200);
200 assert!(json["failure"].is_null());
201 assert_eq!(json["redirect_count"], 0);
202 }
203
204 #[test]
205 fn network_entry_serializes_failure_when_present() {
206 let entry = NetworkEntry {
207 request_id: "req-2".to_string(),
208 method: "POST".to_string(),
209 url: "https://example.com/api".to_string(),
210 status: None,
211 failure: Some("net::ERR_CONNECTION_REFUSED".to_string()),
212 redirect_count: 0,
213 };
214 let json = serde_json::to_value(&entry).unwrap();
215 assert_eq!(json["failure"], "net::ERR_CONNECTION_REFUSED");
216 assert!(json["status"].is_null());
217 }
218
219 #[test]
220 fn network_recording_serializes_to_json() {
221 let recording = NetworkRecording {
222 entries: vec![],
223 duration_ms: 1500,
224 dropped_events: 3,
225 };
226 let json = serde_json::to_value(&recording).unwrap();
227 assert_eq!(json["duration_ms"], 1500);
228 assert_eq!(json["dropped_events"], 3);
229 assert!(json["entries"].as_array().unwrap().is_empty());
230 }
231
232 #[test]
233 fn network_recording_defaults_start_at_zero() {
234 let recording = NetworkRecording {
236 entries: vec![],
237 duration_ms: 0,
238 dropped_events: 0,
239 };
240 assert_eq!(recording.duration_ms, 0);
241 assert_eq!(recording.dropped_events, 0);
242 assert!(recording.entries.is_empty());
243 }
244
245 #[test]
246 fn network_entry_with_redirects_serializes_count() {
247 let entry = NetworkEntry {
248 request_id: "req-redirect".to_string(),
249 method: "GET".to_string(),
250 url: "https://example.com/redirected".to_string(),
251 status: Some(301),
252 failure: None,
253 redirect_count: 2,
254 };
255 let json = serde_json::to_value(&entry).unwrap();
256 assert_eq!(json["redirect_count"], 2);
257 assert_eq!(json["status"], 301);
258 }
259
260 #[test]
261 fn network_recording_with_entries_serializes_all() {
262 let recording = NetworkRecording {
263 entries: vec![
264 NetworkEntry {
265 request_id: "r1".to_string(),
266 method: "GET".to_string(),
267 url: "/a".to_string(),
268 status: Some(200),
269 failure: None,
270 redirect_count: 0,
271 },
272 NetworkEntry {
273 request_id: "r2".to_string(),
274 method: "POST".to_string(),
275 url: "/b".to_string(),
276 status: None,
277 failure: Some("net::ERR_FAILED".to_string()),
278 redirect_count: 0,
279 },
280 ],
281 duration_ms: 500,
282 dropped_events: 0,
283 };
284 let json = serde_json::to_value(&recording).unwrap();
285 assert_eq!(json["entries"].as_array().unwrap().len(), 2);
286 assert_eq!(json["entries"][0]["method"], "GET");
287 assert_eq!(json["entries"][1]["failure"], "net::ERR_FAILED");
288 }
289}