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 request_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 next_action: Option<NextAction>,
#[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 content_file: Option<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
pub content_json_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, PartialEq, Eq, Serialize, Deserialize)]
pub struct NextAction {
pub kind: NextActionKind,
pub recommended_command: String,
pub target_content_verified: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub takeover_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub takeover_url_expires_at_rfc3339: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub takeover_url_ttl_s: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub takeover_url_scope: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NextActionKind {
HumanTakeover,
}
impl NextAction {
fn human_takeover(url: &str) -> Self {
Self {
kind: NextActionKind::HumanTakeover,
recommended_command: format!("afhttp fetch {} --takeover", shell_quote(url)),
target_content_verified: false,
takeover_url: None,
takeover_url_expires_at_rfc3339: None,
takeover_url_ttl_s: None,
takeover_url_scope: None,
}
}
}
#[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(),
request_url: url,
status: 0,
page_kind: None,
next_action: None,
tab_id: None,
trace,
warnings: Vec::new(),
body_file: None,
rendered_html_file: None,
text_file: None,
content_file: None,
content_json_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::Content => self.content_file = Some(path),
Artifact::ContentJson => self.content_json_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),
}
}
pub(crate) fn set_page_kind(&mut self, kind: PageKind) {
self.page_kind = Some(kind);
if matches!(
kind,
PageKind::BotWallDetected | PageKind::SecurityChallengeDetected
) {
self.next_action = Some(NextAction::human_takeover(&self.request_url));
}
}
pub fn attach_takeover(&mut self, takeover_url: String) {
self.attach_takeover_with_context(takeover_url, None, None, None, None, None);
}
pub fn attach_takeover_with_context(
&mut self,
takeover_url: String,
expires_at_rfc3339: Option<String>,
ttl_s: Option<u64>,
scope: Option<String>,
endpoint: Option<&str>,
profile: Option<&str>,
) {
let url = self.request_url.clone();
let tab = self.tab_id.as_ref().map(|t| t.as_str().to_string());
if let Some(next) = self.next_action.as_mut() {
next.takeover_url = Some(takeover_url);
next.takeover_url_expires_at_rfc3339 = expires_at_rfc3339;
next.takeover_url_ttl_s = ttl_s;
next.takeover_url_scope = scope;
if let Some(tab) = tab {
let mut command = format!(
"afhttp fetch {} --takeover --tab {}",
shell_quote(&url),
shell_quote(&tab),
);
if let Some(endpoint) = endpoint {
command.push_str(" --endpoint-url ");
command.push_str(&shell_quote(endpoint));
}
if let Some(profile) = profile {
command.push_str(" --profile ");
command.push_str(&shell_quote(profile));
}
next.recommended_command = command;
}
}
}
#[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::Content => self.content_file.as_ref(),
Artifact::ContentJson => self.content_json_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(),
}
}
}
fn shell_quote(value: &str) -> String {
if !value.is_empty()
&& value.bytes().all(|b| {
b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.' | b'/' | b':' | b',' | b'=')
})
{
return value.to_string();
}
format!("'{}'", value.replace('\'', "'\\''"))
}
#[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);
}
#[test]
fn bot_wall_page_kind_adds_human_takeover_next_action() {
let mut result = FetchResult::new(
RequestId("req-1".into()),
"https://example.com/a b".into(),
Trace::default(),
);
result.set_page_kind(PageKind::BotWallDetected);
let next = result.next_action.expect("next_action");
assert_eq!(next.kind, NextActionKind::HumanTakeover);
assert!(!next.target_content_verified);
assert_eq!(
next.recommended_command,
"afhttp fetch 'https://example.com/a b' --takeover"
);
}
#[test]
fn takeover_next_action_can_include_connection_and_profile_context() {
let mut result = FetchResult::new(
RequestId("req-1".into()),
"https://example.com/login".into(),
Trace::default(),
);
result.tab_id = Some(TabId::new("page 7"));
result.set_page_kind(PageKind::BotWallDetected);
result.attach_takeover_with_context(
"http://127.0.0.1:9222/takeover/panel?handoff=h".into(),
Some("2026-06-11T00:00:00Z".into()),
Some(900),
Some("takeover".into()),
Some("ws://127.0.0.1:9222"),
Some("work profile"),
);
let next = result.next_action.expect("next_action");
assert_eq!(
next.recommended_command,
"afhttp fetch https://example.com/login --takeover --tab 'page 7' --endpoint-url ws://127.0.0.1:9222 --profile 'work profile'"
);
assert_eq!(
next.takeover_url.as_deref(),
Some("http://127.0.0.1:9222/takeover/panel?handoff=h")
);
assert_eq!(next.takeover_url_ttl_s, Some(900));
assert_eq!(next.takeover_url_scope.as_deref(), Some("takeover"));
}
}