use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::shared::artifacts::Artifact;
use crate::shared::ids::{RequestId, TabId};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FetchResult {
pub request_id: RequestId,
pub url: String,
pub final_url: String,
pub status: u16,
#[serde(skip_serializing_if = "Option::is_none")]
pub tab_id: Option<TabId>,
pub trace: Trace,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub warnings: Vec<Warning>,
#[serde(skip_serializing_if = "Option::is_none")]
pub body_file: Option<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rendered_html_file: Option<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
pub text_file: Option<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
pub screenshot_file: Option<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
pub network_file: Option<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
pub console_file: Option<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
pub observation_file: Option<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
pub storage_file: Option<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
pub download_file: Option<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
pub download_bytes: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub download_filename: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub download_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub download_state: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Trace {
pub render_decision: RenderDecision,
#[serde(default)]
pub render_mode: TraceRenderMode,
#[serde(default)]
pub render_used: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub escalation_reason: Option<String>,
pub main_request_observed: bool,
pub duration_ms: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub navigation_duration_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cookie_jar_file: Option<std::path::PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cookie_jar_warning: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub sensitive_capture: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum TraceRenderMode {
None,
#[default]
Auto,
Always,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum RenderDecision {
#[default]
HttpOnly,
Browser,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Warning {
pub artifact: Artifact,
pub code: crate::shared::error::ErrorCode,
pub detail: String,
}
pub struct EscalationReason;
impl EscalationReason {
pub const EMPTY_HTML_SHELL: &'static str = "empty_html_shell";
#[must_use]
pub fn http_status(status: u16) -> String {
format!("http_status_{status}")
}
#[must_use]
pub fn http_failed(error_code: &str) -> String {
format!("http_failed_{error_code}")
}
}
impl FetchResult {
pub fn set_artifact_file(&mut self, artifact: Artifact, path: PathBuf) {
match artifact {
Artifact::Body => self.body_file = Some(path),
Artifact::RenderedHtml => self.rendered_html_file = Some(path),
Artifact::Text => self.text_file = Some(path),
Artifact::Screenshot => self.screenshot_file = Some(path),
Artifact::Network => self.network_file = Some(path),
Artifact::Console => self.console_file = Some(path),
Artifact::Observation => self.observation_file = Some(path),
Artifact::Storage => self.storage_file = Some(path),
}
}
#[must_use]
pub fn artifact_file(&self, artifact: Artifact) -> Option<&PathBuf> {
match artifact {
Artifact::Body => self.body_file.as_ref(),
Artifact::RenderedHtml => self.rendered_html_file.as_ref(),
Artifact::Text => self.text_file.as_ref(),
Artifact::Screenshot => self.screenshot_file.as_ref(),
Artifact::Network => self.network_file.as_ref(),
Artifact::Console => self.console_file.as_ref(),
Artifact::Observation => self.observation_file.as_ref(),
Artifact::Storage => self.storage_file.as_ref(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fetch_success_json_is_flat_golden() {
let mut result = FetchResult {
request_id: RequestId("req-1".into()),
url: "https://example.com/".into(),
final_url: "https://example.com/".into(),
status: 200,
tab_id: None,
trace: Trace {
render_decision: RenderDecision::Browser,
render_mode: TraceRenderMode::Always,
render_used: true,
escalation_reason: None,
main_request_observed: true,
duration_ms: 12,
navigation_duration_ms: Some(8),
cookie_jar_file: None,
cookie_jar_warning: None,
sensitive_capture: Vec::new(),
},
warnings: Vec::new(),
body_file: None,
rendered_html_file: None,
text_file: None,
screenshot_file: None,
network_file: None,
console_file: None,
observation_file: None,
storage_file: None,
download_file: None,
download_bytes: None,
download_filename: None,
download_url: None,
download_state: None,
};
for (artifact, file) in [
(Artifact::Body, "body.html"),
(Artifact::RenderedHtml, "rendered.html"),
(Artifact::Text, "text.txt"),
(Artifact::Screenshot, "page.png"),
(Artifact::Network, "network.json"),
(Artifact::Console, "console.json"),
(Artifact::Observation, "observation.json"),
(Artifact::Storage, "storage.json"),
] {
result.set_artifact_file(artifact, PathBuf::from("/tmp/afhttp-out/req-1").join(file));
}
let mut buf = Vec::new();
crate::shared::envelope::emit(&mut buf, "fetch", &result).unwrap();
let json: serde_json::Value = serde_json::from_slice(&buf).unwrap();
assert!(
json.get("artifacts").is_none(),
"artifacts map must be gone"
);
let canonical = serde_json::to_string(&json).unwrap();
let expected = include_str!("../../../tests/golden/fetch-success.json").trim();
assert_eq!(canonical, expected);
}
}