use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::shared::artifacts::Artifact;
use crate::shared::error::{Error, ErrorCode};
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 page_kind: Option<PageKind>,
#[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,
pub timeout_ms: u64,
pub current_stage: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub navigation_duration_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub wait_mode: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub wait_satisfied_by: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub network_quiet: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dom_stable: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub text_stable: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub capture_reason: Option<String>,
#[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>,
pub stages: Vec<TraceStage>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TraceStage {
pub name: String,
pub status: TraceStageStatus,
pub duration_ms: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TraceStageStatus {
Ok,
Error,
Timeout,
Started,
}
#[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,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PageKind {
BotWallDetected,
SecurityChallengeDetected,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FetchError {
pub error_code: ErrorCode,
#[serde(rename = "error")]
pub detail: String,
pub retryable: bool,
pub trace: Trace,
}
impl FetchError {
#[must_use]
pub fn new(error: Error, trace: Trace) -> Self {
Self {
error_code: error.error_code,
detail: error.detail,
retryable: error.retryable,
trace,
}
}
#[must_use]
pub fn into_error(self) -> Error {
Error {
error_code: self.error_code,
detail: self.detail,
retryable: self.retryable,
}
}
#[must_use]
pub fn as_error(&self) -> Error {
Error {
error_code: self.error_code,
detail: self.detail.clone(),
retryable: self.retryable,
}
}
}
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(crate) fn new(request_id: RequestId, url: String, trace: Trace) -> Self {
Self {
request_id,
final_url: url.clone(),
url,
status: 0,
page_kind: None,
tab_id: None,
trace,
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,
}
}
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::new(
RequestId("req-1".into()),
"https://example.com/".into(),
Trace {
render_decision: RenderDecision::Browser,
render_mode: TraceRenderMode::Always,
render_used: true,
escalation_reason: None,
main_request_observed: true,
duration_ms: 12,
timeout_ms: 30000,
current_stage: "complete".into(),
navigation_duration_ms: Some(8),
wait_mode: Some("load".into()),
wait_satisfied_by: Some("load".into()),
network_quiet: None,
dom_stable: None,
text_stable: None,
capture_reason: Some("wait_satisfied".into()),
cookie_jar_file: None,
cookie_jar_warning: None,
sensitive_capture: Vec::new(),
stages: vec![
TraceStage {
name: "navigate".into(),
status: TraceStageStatus::Ok,
duration_ms: 8,
},
TraceStage {
name: "capture_text".into(),
status: TraceStageStatus::Ok,
duration_ms: 4,
},
],
},
);
result.status = 200;
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);
}
}