1use std::path::PathBuf;
4
5use serde::{Deserialize, Serialize};
6
7use crate::shared::artifacts::Artifact;
8use crate::shared::error::{Error, ErrorCode};
9use crate::shared::ids::{RequestId, TabId};
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct FetchResult {
15 pub request_id: RequestId,
16 pub url: String,
17 pub final_url: String,
18 pub status: u16,
19 #[serde(skip_serializing_if = "Option::is_none")]
20 pub page_kind: Option<PageKind>,
21 #[serde(skip_serializing_if = "Option::is_none")]
22 pub tab_id: Option<TabId>,
23 pub trace: Trace,
24 #[serde(skip_serializing_if = "Vec::is_empty", default)]
25 pub warnings: Vec<Warning>,
26 #[serde(skip_serializing_if = "Option::is_none")]
27 pub body_file: Option<PathBuf>,
28 #[serde(skip_serializing_if = "Option::is_none")]
29 pub rendered_html_file: Option<PathBuf>,
30 #[serde(skip_serializing_if = "Option::is_none")]
31 pub text_file: Option<PathBuf>,
32 #[serde(skip_serializing_if = "Option::is_none")]
33 pub screenshot_file: Option<PathBuf>,
34 #[serde(skip_serializing_if = "Option::is_none")]
35 pub network_file: Option<PathBuf>,
36 #[serde(skip_serializing_if = "Option::is_none")]
37 pub console_file: Option<PathBuf>,
38 #[serde(skip_serializing_if = "Option::is_none")]
39 pub observation_file: Option<PathBuf>,
40 #[serde(skip_serializing_if = "Option::is_none")]
41 pub storage_file: Option<PathBuf>,
42 #[serde(skip_serializing_if = "Option::is_none")]
43 pub download_file: Option<PathBuf>,
44 #[serde(skip_serializing_if = "Option::is_none")]
45 pub download_bytes: Option<u64>,
46 #[serde(skip_serializing_if = "Option::is_none")]
47 pub download_filename: Option<String>,
48 #[serde(skip_serializing_if = "Option::is_none")]
49 pub download_url: Option<String>,
50 #[serde(skip_serializing_if = "Option::is_none")]
51 pub download_state: Option<String>,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize, Default)]
55pub struct Trace {
56 pub render_decision: RenderDecision,
57 #[serde(default)]
63 pub render_mode: TraceRenderMode,
64 #[serde(default)]
68 pub render_used: bool,
69 #[serde(skip_serializing_if = "Option::is_none")]
70 pub escalation_reason: Option<String>,
71 pub main_request_observed: bool,
72 pub duration_ms: u64,
73 pub timeout_ms: u64,
74 pub current_stage: String,
75 #[serde(skip_serializing_if = "Option::is_none")]
76 pub navigation_duration_ms: Option<u64>,
77 #[serde(skip_serializing_if = "Option::is_none")]
80 pub wait_mode: Option<String>,
81 #[serde(skip_serializing_if = "Option::is_none")]
83 pub wait_satisfied_by: Option<String>,
84 #[serde(skip_serializing_if = "Option::is_none")]
88 pub network_quiet: Option<bool>,
89 #[serde(skip_serializing_if = "Option::is_none")]
90 pub dom_stable: Option<bool>,
91 #[serde(skip_serializing_if = "Option::is_none")]
92 pub text_stable: Option<bool>,
93 #[serde(skip_serializing_if = "Option::is_none")]
95 pub capture_reason: Option<String>,
96 #[serde(skip_serializing_if = "Option::is_none")]
101 pub cookie_jar_file: Option<std::path::PathBuf>,
102 #[serde(skip_serializing_if = "Option::is_none")]
105 pub cookie_jar_warning: Option<String>,
106 #[serde(skip_serializing_if = "Vec::is_empty", default)]
109 pub sensitive_capture: Vec<String>,
110 pub stages: Vec<TraceStage>,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct TraceStage {
115 pub name: String,
116 pub status: TraceStageStatus,
117 pub duration_ms: u64,
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
121#[serde(rename_all = "snake_case")]
122pub enum TraceStageStatus {
123 Ok,
124 Error,
125 Timeout,
126 Started,
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
133#[serde(rename_all = "snake_case")]
134pub enum TraceRenderMode {
135 None,
136 #[default]
137 Auto,
138 Always,
139}
140
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
142#[serde(rename_all = "snake_case")]
143pub enum RenderDecision {
144 #[default]
145 HttpOnly,
146 Browser,
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct Warning {
151 pub artifact: Artifact,
152 pub code: crate::shared::error::ErrorCode,
153 pub detail: String,
154}
155
156#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
159#[serde(rename_all = "snake_case")]
160pub enum PageKind {
161 BotWallDetected,
162 SecurityChallengeDetected,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct FetchError {
169 pub error_code: ErrorCode,
170 #[serde(rename = "error")]
171 pub detail: String,
172 pub retryable: bool,
173 pub trace: Trace,
174}
175
176impl FetchError {
177 #[must_use]
178 pub fn new(error: Error, trace: Trace) -> Self {
179 Self {
180 error_code: error.error_code,
181 detail: error.detail,
182 retryable: error.retryable,
183 trace,
184 }
185 }
186
187 #[must_use]
188 pub fn into_error(self) -> Error {
189 Error {
190 error_code: self.error_code,
191 detail: self.detail,
192 retryable: self.retryable,
193 }
194 }
195
196 #[must_use]
197 pub fn as_error(&self) -> Error {
198 Error {
199 error_code: self.error_code,
200 detail: self.detail.clone(),
201 retryable: self.retryable,
202 }
203 }
204}
205
206pub struct EscalationReason;
217
218impl EscalationReason {
219 pub const EMPTY_HTML_SHELL: &'static str = "empty_html_shell";
221
222 #[must_use]
224 pub fn http_status(status: u16) -> String {
225 format!("http_status_{status}")
226 }
227
228 #[must_use]
230 pub fn http_failed(error_code: &str) -> String {
231 format!("http_failed_{error_code}")
232 }
233}
234
235impl FetchResult {
236 pub(crate) fn new(request_id: RequestId, url: String, trace: Trace) -> Self {
241 Self {
242 request_id,
243 final_url: url.clone(),
244 url,
245 status: 0,
246 page_kind: None,
247 tab_id: None,
248 trace,
249 warnings: Vec::new(),
250 body_file: None,
251 rendered_html_file: None,
252 text_file: None,
253 screenshot_file: None,
254 network_file: None,
255 console_file: None,
256 observation_file: None,
257 storage_file: None,
258 download_file: None,
259 download_bytes: None,
260 download_filename: None,
261 download_url: None,
262 download_state: None,
263 }
264 }
265
266 pub fn set_artifact_file(&mut self, artifact: Artifact, path: PathBuf) {
269 match artifact {
270 Artifact::Body => self.body_file = Some(path),
271 Artifact::RenderedHtml => self.rendered_html_file = Some(path),
272 Artifact::Text => self.text_file = Some(path),
273 Artifact::Screenshot => self.screenshot_file = Some(path),
274 Artifact::Network => self.network_file = Some(path),
275 Artifact::Console => self.console_file = Some(path),
276 Artifact::Observation => self.observation_file = Some(path),
277 Artifact::Storage => self.storage_file = Some(path),
278 }
279 }
280
281 #[must_use]
282 pub fn artifact_file(&self, artifact: Artifact) -> Option<&PathBuf> {
283 match artifact {
284 Artifact::Body => self.body_file.as_ref(),
285 Artifact::RenderedHtml => self.rendered_html_file.as_ref(),
286 Artifact::Text => self.text_file.as_ref(),
287 Artifact::Screenshot => self.screenshot_file.as_ref(),
288 Artifact::Network => self.network_file.as_ref(),
289 Artifact::Console => self.console_file.as_ref(),
290 Artifact::Observation => self.observation_file.as_ref(),
291 Artifact::Storage => self.storage_file.as_ref(),
292 }
293 }
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299
300 #[test]
301 fn fetch_success_json_is_flat_golden() {
302 let mut result = FetchResult::new(
303 RequestId("req-1".into()),
304 "https://example.com/".into(),
305 Trace {
306 render_decision: RenderDecision::Browser,
307 render_mode: TraceRenderMode::Always,
308 render_used: true,
309 escalation_reason: None,
310 main_request_observed: true,
311 duration_ms: 12,
312 timeout_ms: 30000,
313 current_stage: "complete".into(),
314 navigation_duration_ms: Some(8),
315 wait_mode: Some("load".into()),
316 wait_satisfied_by: Some("load".into()),
317 network_quiet: None,
318 dom_stable: None,
319 text_stable: None,
320 capture_reason: Some("wait_satisfied".into()),
321 cookie_jar_file: None,
322 cookie_jar_warning: None,
323 sensitive_capture: Vec::new(),
324 stages: vec![
325 TraceStage {
326 name: "navigate".into(),
327 status: TraceStageStatus::Ok,
328 duration_ms: 8,
329 },
330 TraceStage {
331 name: "capture_text".into(),
332 status: TraceStageStatus::Ok,
333 duration_ms: 4,
334 },
335 ],
336 },
337 );
338 result.status = 200;
339 for (artifact, file) in [
340 (Artifact::Body, "body.html"),
341 (Artifact::RenderedHtml, "rendered.html"),
342 (Artifact::Text, "text.txt"),
343 (Artifact::Screenshot, "page.png"),
344 (Artifact::Network, "network.json"),
345 (Artifact::Console, "console.json"),
346 (Artifact::Observation, "observation.json"),
347 (Artifact::Storage, "storage.json"),
348 ] {
349 result.set_artifact_file(artifact, PathBuf::from("/tmp/afhttp-out/req-1").join(file));
350 }
351 let mut buf = Vec::new();
352 crate::shared::envelope::emit(&mut buf, "fetch", &result).unwrap();
353 let json: serde_json::Value = serde_json::from_slice(&buf).unwrap();
354 assert!(
355 json.get("artifacts").is_none(),
356 "artifacts map must be gone"
357 );
358 let canonical = serde_json::to_string(&json).unwrap();
359 let expected = include_str!("../../../tests/golden/fetch-success.json").trim();
360 assert_eq!(canonical, expected);
361 }
362}