Files
moli/moli/tests/fetch_cli.rs

4420 lines
144 KiB
Rust

use moli_test_support as support;
use anyhow::Result;
use axum::{
Router,
extract::State,
http::{StatusCode, Uri},
response::{Html, IntoResponse, Response},
routing::get,
};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use clap::Parser;
use moli::{
app,
cli::{Cli, normalize_args_for_compat},
config::AppConfig,
};
use moli_browser_profile::{
BrowserProfilePaths, load_cookie_cache as load_profile_cookie_cache, load_profile_manifest,
};
use parking_lot::Mutex;
use serde_json::Value;
use std::{
io::Write,
path::PathBuf,
process::Command,
sync::{
Arc,
atomic::{AtomicUsize, Ordering},
},
time::{SystemTime, UNIX_EPOCH},
};
use strip_ansi_escapes::strip;
use support::FixtureServer;
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::TcpListener,
task::JoinHandle,
};
use tracing_subscriber::fmt::MakeWriter;
#[path = "fetch_cli/anubis_deferred_module.rs"]
mod anubis_deferred_module;
#[path = "fetch_cli/disable_css.rs"]
mod disable_css;
#[path = "fetch_cli/disable_css_boundaries.rs"]
mod disable_css_boundaries;
#[path = "fetch_cli/disable_js.rs"]
mod disable_js;
#[path = "fetch_cli/eval.rs"]
mod eval;
#[path = "fetch_cli/markdown.rs"]
mod markdown;
#[path = "fetch_cli/readiness.rs"]
mod readiness;
#[path = "fetch_cli/redirect_chain.rs"]
mod redirect_chain;
struct Output {
status: OutputStatus,
stdout: Vec<u8>,
stderr: Vec<u8>,
}
struct OutputStatus {
success: bool,
}
impl OutputStatus {
fn success(&self) -> bool {
self.success
}
}
#[derive(Clone)]
struct CapturedStderr {
buffer: Arc<Mutex<Vec<u8>>>,
}
struct CapturedStderrWriter {
buffer: Arc<Mutex<Vec<u8>>>,
}
impl<'a> MakeWriter<'a> for CapturedStderr {
type Writer = CapturedStderrWriter;
fn make_writer(&'a self) -> Self::Writer {
CapturedStderrWriter {
buffer: Arc::clone(&self.buffer),
}
}
}
impl Write for CapturedStderrWriter {
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
self.buffer.lock().extend_from_slice(bytes);
Ok(bytes.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
fn run_fetch_cli(url: &str) -> Result<Output> {
run_fetch_cli_with_wait_until(url, "load")
}
fn run_fetch_cli_with_args(url: &str, args: &[&str]) -> Result<Output> {
run_fetch_cli_with_dump_and_args(url, "html", args)
}
fn run_fetch_cli_with_log_level(url: &str, log_level: &str) -> Result<Output> {
run_fetch_cli_with_log_level_and_args(url, log_level, &[])
}
fn run_fetch_cli_with_log_level_and_args(
url: &str,
log_level: &str,
args: &[&str],
) -> Result<Output> {
let mut cli_args = vec![
"moli",
"fetch",
"--log-level",
log_level,
"--http-no-proxy",
"*",
"--wait-until",
"load",
"--dump",
"html",
];
cli_args.extend_from_slice(args);
cli_args.push(url);
run_moli(cli_args)
}
fn run_fetch_cli_with_dump_and_args(url: &str, dump: &str, args: &[&str]) -> Result<Output> {
let mut cli_args = vec![
"moli",
"fetch",
"--log-level",
"error",
"--http-no-proxy",
"*",
"--wait-until",
"load",
"--dump",
dump,
];
cli_args.extend_from_slice(args);
cli_args.push(url);
run_moli(cli_args)
}
fn run_fetch_cli_with_default_wait_and_dump(url: &str, dump: &str) -> Result<Output> {
run_moli([
"moli",
"fetch",
"--log-level",
"error",
"--http-no-proxy",
"*",
"--dump",
dump,
url,
])
}
fn run_fetch_cli_without_dump(url: &str) -> Result<Output> {
run_fetch_cli_without_dump_with_args(url, &[])
}
fn run_fetch_cli_without_dump_with_args(url: &str, args: &[&str]) -> Result<Output> {
let mut cli_args = vec![
"moli",
"fetch",
"--log-level",
"error",
"--http-no-proxy",
"*",
];
cli_args.extend_from_slice(args);
cli_args.push(url);
run_moli(cli_args)
}
fn run_moli<I, S>(args: I) -> Result<Output>
where
I: IntoIterator<Item = S>,
S: Into<std::ffi::OsString> + Clone,
{
let cli = Cli::try_parse_from(normalize_args_for_compat(args))?;
let config = AppConfig::from_cli(&cli)?;
let stderr = Arc::new(Mutex::new(Vec::new()));
let subscriber = tracing_subscriber::fmt()
.with_env_filter(config.log_filter.clone())
.with_target(false)
.with_writer(CapturedStderr {
buffer: Arc::clone(&stderr),
})
.finish();
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
let mut stdout = Vec::new();
let _ = tracing::subscriber::set_global_default(subscriber);
let result = runtime.block_on(app::run_cli_with_config(cli, config, &mut stdout));
if let Err(error) = &result {
let mut stderr = stderr.lock();
app::write_error_report(&mut *stderr, error)?;
}
let stderr = stderr.lock().clone();
Ok(Output {
status: OutputStatus {
success: result.is_ok(),
},
stdout,
stderr,
})
}
fn run_fetch_cli_with_wait_until(url: &str, wait_until: &str) -> Result<Output> {
run_fetch_cli_with_wait_until_and_dump(url, wait_until, "html")
}
fn run_fetch_cli_with_wait_until_and_dump(
url: &str,
wait_until: &str,
dump: &str,
) -> Result<Output> {
run_moli([
"moli",
"fetch",
"--log-level",
"error",
"--http-no-proxy",
"*",
"--wait-until",
wait_until,
"--dump",
dump,
url,
])
}
fn clean_output(bytes: &[u8]) -> String {
String::from_utf8_lossy(&strip(bytes)).into_owned()
}
fn assert_json_dump_shape(payload: &Value, expected_url: &str, expected_status: u64) {
let Some(object) = payload.as_object() else {
panic!("expected top-level JSON object, got {payload}");
};
let mut keys = object.keys().cloned().collect::<Vec<_>>();
keys.sort();
assert_eq!(
keys,
vec![
"final_url",
"headers",
"html",
"redirect_chain",
"status",
"title",
]
);
assert_eq!(payload["final_url"], expected_url);
assert_eq!(payload["status"], expected_status);
assert!(
payload["title"].is_string() || payload["title"].is_null(),
"payload={payload}"
);
assert!(payload["headers"].is_array(), "payload={payload}");
assert!(payload["redirect_chain"].is_array(), "payload={payload}");
assert!(payload["html"].is_string(), "payload={payload}");
}
fn assert_raw_json_dump_shape(
payload: &Value,
expected_url: &str,
expected_status: u64,
expected_body: &[u8],
) {
let Some(object) = payload.as_object() else {
panic!("expected top-level JSON object, got {payload}");
};
let mut keys = object.keys().cloned().collect::<Vec<_>>();
keys.sort();
assert_eq!(
keys,
vec![
"body_base64",
"final_url",
"headers",
"html",
"redirect_chain",
"status",
"title",
]
);
assert_eq!(payload["final_url"], expected_url);
assert_eq!(payload["status"], expected_status);
assert!(payload["title"].is_null(), "payload={payload}");
assert!(payload["headers"].is_array(), "payload={payload}");
assert!(payload["redirect_chain"].is_array(), "payload={payload}");
assert!(payload["html"].is_null(), "payload={payload}");
let encoded = payload["body_base64"]
.as_str()
.expect("raw JSON body must be a base64 string");
assert_eq!(
BASE64_STANDARD
.decode(encoded)
.expect("raw JSON body must contain valid base64"),
expected_body
);
}
fn unique_temp_dir(name: &str) -> Result<PathBuf> {
let nonce = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
let dir = std::env::temp_dir().join(format!("moli-{name}-{}-{nonce}", std::process::id()));
std::fs::create_dir_all(&dir)?;
Ok(dir)
}
fn unique_temp_file_path(name: &str, file_name: &str) -> Result<PathBuf> {
Ok(unique_temp_dir(name)?.join(file_name))
}
struct CacheableFixtureServer {
url: String,
hits: Arc<AtomicUsize>,
task: JoinHandle<()>,
}
const PDF_FIXTURE_BODY: &[u8] =
b"%PDF-1.7\n% moli raw document fixture\n1 0 obj\n<<>>\nendobj\n%%EOF\n";
const VIDEO_FIXTURE_BODY: &[u8] = b"\0\0\0\x18ftypmp42\xff\0moli-video-fixture";
const PNG_FIXTURE_BODY: &[u8] = &[
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x08, 0x1d, 0x63, 0xf8, 0xcf, 0xc0, 0xf0,
0x1f, 0x00, 0x05, 0x80, 0x02, 0x3f, 0x49, 0xc2, 0xfa, 0x39, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45,
0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
];
struct BinaryDocumentFixtureServer {
base_url: String,
task: JoinHandle<()>,
}
async fn spawn_stalled_raw_document_server() -> Result<(String, JoinHandle<()>)> {
let listener = TcpListener::bind("127.0.0.1:0").await?;
let address = listener.local_addr()?;
let server = tokio::spawn(async move {
let Ok((mut stream, _)) = listener.accept().await else {
return;
};
let mut request = [0_u8; 4096];
let Ok(read) = stream.read(&mut request).await else {
return;
};
if read == 0 {
return;
}
if stream
.write_all(
b"HTTP/1.1 200 OK\r\nContent-Type: video/mp4\r\nContent-Length: 1048576\r\nConnection: keep-alive\r\n\r\n",
)
.await
.is_err()
{
return;
}
std::future::pending::<()>().await;
});
Ok((format!("http://{address}/stalled.mp4"), server))
}
struct BlockingStylesheetTimeoutFixtureServer {
base_url: String,
task: JoinHandle<()>,
}
const OPTIONAL_RESOURCE_PATHS: [&str; 5] = [
"/optional-audio.mp3",
"/optional-font.woff2",
"/optional-image.png",
"/optional-track.vtt",
"/optional-video.mp4",
];
const OPTIONAL_RESOURCE_FLAG_CASES: [(&str, Option<&str>); 6] = [
("--image", Some("/optional-image.png")),
("--font", Some("/optional-font.woff2")),
("--audio", Some("/optional-audio.mp3")),
("--video", Some("/optional-video.mp4")),
("--media", None),
("--text-track", Some("/optional-track.vtt")),
];
const TRACK_TERMINAL_WAIT_SCRIPT: &str = "document.getElementById('track').readyState === 2";
struct OptionalResourceFixtureServer {
base_url: String,
requests: Arc<Mutex<Vec<String>>>,
task: JoinHandle<()>,
}
impl OptionalResourceFixtureServer {
async fn spawn() -> Result<Self> {
async fn preload_page() -> Html<&'static str> {
Html(
r#"<!doctype html>
<html>
<head>
<link rel="preload" as="image" href="/optional-image.png">
<link rel="preload" as="font" href="/optional-font.woff2" crossorigin="anonymous">
<link rel="preload" as="audio" href="/optional-audio.mp3">
<link rel="preload" as="video" href="/optional-video.mp4">
<link rel="preload" as="track" href="/optional-track.vtt">
</head>
<body><main id="ready">preload fixture ready</main></body>
</html>"#,
)
}
async fn element_page() -> Html<&'static str> {
Html(
r#"<!doctype html>
<html>
<head>
<link rel="stylesheet" href="/optional-resources.css">
</head>
<body>
<img id="image" src="/optional-image.png">
<audio id="audio" preload="auto" src="/optional-audio.mp3"></audio>
<video id="video" preload="auto" src="/optional-video.mp4">
<track id="track" default src="/optional-track.vtt">
</video>
</body>
</html>"#,
)
}
async fn resource(State(requests): State<Arc<Mutex<Vec<String>>>>, uri: Uri) -> Response {
let path = uri.path().to_owned();
requests.lock().push(path.clone());
let (content_type, body): (&str, Vec<u8>) = match path.as_str() {
"/optional-resources.css" => (
"text/css; charset=utf-8",
br#"
@font-face {
font-family: OptionalFixture;
src: url("/optional-font.woff2") format("woff2");
}
body { font-family: OptionalFixture; }
"#
.to_vec(),
),
"/optional-image.png" => (
"image/png",
vec![
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x04, 0x00, 0x00, 0x00, 0xb5, 0x1c, 0x0c, 0x02, 0x00, 0x00, 0x00,
0x0b, 0x49, 0x44, 0x41, 0x54, 0x78, 0xda, 0x63, 0xfc, 0xff, 0x1f, 0x00,
0x03, 0x03, 0x02, 0x00, 0xef, 0xbf, 0x6b, 0xab, 0x00, 0x00, 0x00, 0x00,
0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
],
),
"/optional-font.woff2" => ("font/woff2", b"fixture-font".to_vec()),
"/optional-audio.mp3" => ("audio/mpeg", b"fixture-audio".to_vec()),
"/optional-video.mp4" => ("video/mp4", b"fixture-video".to_vec()),
"/optional-track.vtt" => (
"text/vtt; charset=utf-8",
b"WEBVTT\n\n00:00.000 --> 00:01.000\nfixture\n".to_vec(),
),
_ => {
return (
StatusCode::NOT_FOUND,
[("content-type", "text/plain; charset=utf-8")],
b"not found".to_vec(),
)
.into_response();
}
};
(
StatusCode::OK,
[
("content-type", content_type),
("cache-control", "no-store"),
],
body,
)
.into_response()
}
let requests = Arc::new(Mutex::new(Vec::new()));
let listener = TcpListener::bind("127.0.0.1:0").await?;
let addr = listener.local_addr()?;
let app = Router::new()
.route("/preloads.html", get(preload_page))
.route("/elements.html", get(element_page))
.route("/optional-resources.css", get(resource))
.route("/optional-image.png", get(resource))
.route("/optional-font.woff2", get(resource))
.route("/optional-audio.mp3", get(resource))
.route("/optional-video.mp4", get(resource))
.route("/optional-track.vtt", get(resource))
.with_state(Arc::clone(&requests));
let task = tokio::spawn(async move {
axum::serve(listener, app)
.await
.expect("optional resource fixture server should serve");
});
Ok(Self {
base_url: format!("http://{addr}"),
requests,
task,
})
}
fn url(&self, path: &str) -> String {
format!("{}{}", self.base_url, path)
}
fn clear_requests(&self) {
self.requests.lock().clear();
}
fn optional_requests(&self) -> Vec<String> {
let mut requests = self
.requests
.lock()
.iter()
.filter(|path| OPTIONAL_RESOURCE_PATHS.contains(&path.as_str()))
.cloned()
.collect::<Vec<_>>();
requests.sort();
requests
}
fn request_count(&self, path: &str) -> usize {
self.requests
.lock()
.iter()
.filter(|request| request.as_str() == path)
.count()
}
async fn shutdown(self) {
self.task.abort();
let _ = self.task.await;
}
}
impl BinaryDocumentFixtureServer {
async fn spawn() -> Result<Self> {
async fn inline_pdf() -> impl IntoResponse {
(
[("content-type", "application/pdf")],
PDF_FIXTURE_BODY.to_vec(),
)
}
async fn attachment_pdf() -> impl IntoResponse {
(
[
("content-type", "text/html; charset=utf-8"),
("content-disposition", "attachment; filename=report.html"),
],
PDF_FIXTURE_BODY.to_vec(),
)
}
async fn inline_video() -> impl IntoResponse {
(
[("content-type", "video/mp4; codecs=\"avc1.42E01E\"")],
VIDEO_FIXTURE_BODY.to_vec(),
)
}
async fn inline_png() -> impl IntoResponse {
([("content-type", "image/png")], PNG_FIXTURE_BODY.to_vec())
}
let listener = TcpListener::bind("127.0.0.1:0").await?;
let addr = listener.local_addr()?;
let app = Router::new()
.route("/inline.pdf", get(inline_pdf))
.route("/clip.mp4", get(inline_video))
.route("/pixel.png", get(inline_png))
.route("/attachment.html", get(attachment_pdf));
let task = tokio::spawn(async move {
axum::serve(listener, app)
.await
.expect("binary fixture server should serve");
});
Ok(Self {
base_url: format!("http://{addr}"),
task,
})
}
fn url(&self, path: &str) -> String {
format!("{}{}", self.base_url, path)
}
async fn shutdown(self) {
self.task.abort();
let _ = self.task.await;
}
}
impl BlockingStylesheetTimeoutFixtureServer {
async fn spawn() -> Result<Self> {
async fn page() -> Html<&'static str> {
Html(
r#"<!doctype html>
<html>
<head>
<link rel="stylesheet" href="/slow.css">
<script>document.documentElement.dataset.parserResumed = "true";</script>
</head>
<body></body>
</html>"#,
)
}
async fn slow_css() -> impl IntoResponse {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
([("content-type", "text/css; charset=utf-8")], "html {}")
}
let listener = TcpListener::bind("127.0.0.1:0").await?;
let addr = listener.local_addr()?;
let app = Router::new()
.route("/", get(page))
.route("/slow.css", get(slow_css));
let task = tokio::spawn(async move {
axum::serve(listener, app)
.await
.expect("blocking stylesheet timeout fixture should serve");
});
Ok(Self {
base_url: format!("http://{addr}"),
task,
})
}
fn url(&self) -> String {
format!("{}/", self.base_url)
}
async fn shutdown(self) {
self.task.abort();
let _ = self.task.await;
}
}
impl CacheableFixtureServer {
async fn spawn() -> Result<Self> {
async fn cacheable(
State(hits): State<Arc<AtomicUsize>>,
) -> ([(&'static str, &'static str); 1], Html<String>) {
let hit = hits.fetch_add(1, Ordering::SeqCst) + 1;
(
[("cache-control", "max-age=60")],
Html(format!(
"<!doctype html><html><body><main>http-cache-hit={hit}</main></body></html>"
)),
)
}
let hits = Arc::new(AtomicUsize::new(0));
let listener = TcpListener::bind("127.0.0.1:0").await?;
let addr = listener.local_addr()?;
let app = Router::new()
.route("/cacheable", get(cacheable))
.with_state(hits.clone());
let task = tokio::spawn(async move {
axum::serve(listener, app)
.await
.expect("cacheable fixture server should serve");
});
Ok(Self {
url: format!("http://{addr}/cacheable"),
hits,
task,
})
}
fn hits(&self) -> usize {
self.hits.load(Ordering::SeqCst)
}
async fn shutdown(self) {
self.task.abort();
let _ = self.task.await;
}
}
fn assert_cli_dump_html_callback_error_contract(output: &Output, expected_message: &str) {
assert!(
output.status.success(),
"moli fetch failed: stdout={}\nstderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
let stderr = clean_output(&output.stderr);
assert!(stdout.contains("<main id=\"after\">after</main>"));
assert!(!stdout.contains("host callback threw"));
assert!(!stdout.contains("Uncaught Error:"));
assert!(!stdout.contains("<unknown>:"));
assert!(
!stderr.contains(expected_message),
"stderr start: {stderr} stderr end"
);
assert!(
!stderr.contains("host callback threw"),
"stderr start: {stderr} stderr end"
);
assert!(
!stderr.contains("v8 message listener"),
"stderr start: {stderr} stderr end"
);
assert!(
!stderr.contains("source_line="),
"stderr start: {stderr} stderr end"
);
assert!(
!stderr.contains("backtrace:"),
"stderr start: {stderr} stderr end"
);
}
fn assert_single_fetch_failure_reason(stderr: &str, url: &str, reason: &str) {
assert!(
stderr.contains(&format!("Error: failed to fetch `{url}`")),
"stderr={stderr}"
);
assert!(
stderr.contains(&format!("Reason: {reason}")),
"stderr={stderr}"
);
assert_eq!(stderr.matches("Reason:").count(), 1, "stderr={stderr}");
assert!(!stderr.contains("Caused by:"), "stderr={stderr}");
}
fn assert_cli_dump_html_debug_callback_error_contract(output: &Output, expected_message: &str) {
assert!(
output.status.success(),
"moli fetch failed: stdout={}\nstderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
let stderr = clean_output(&output.stderr);
assert!(stdout.contains("<main id=\"after\">after</main>"));
assert!(!stdout.contains("simple event listener threw"));
assert!(!stdout.contains("Uncaught Error:"));
assert!(
stderr.contains(expected_message),
"stderr start: {stderr} stderr end"
);
assert!(
stderr.contains("simple event listener threw"),
"stderr start: {stderr} stderr end"
);
assert!(
!stderr.contains("v8 message listener"),
"stderr start: {stderr} stderr end"
);
assert!(
stderr.contains("source_line="),
"stderr start: {stderr} stderr end"
);
assert!(
stderr.contains("backtrace:"),
"stderr start: {stderr} stderr end"
);
}
fn assert_callback_error_page(path: &str, expected_message: &str, wait_until: &str) -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url(path);
let output = run_fetch_cli_with_wait_until(&url, wait_until)?;
runtime.block_on(server.shutdown());
assert_cli_dump_html_callback_error_contract(&output, expected_message);
Ok(())
}
fn assert_debug_callback_error_page_after_script(
path: &str,
expected_message: &str,
completion_script: &str,
) -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url(path);
let output = run_fetch_cli_with_log_level_and_args(
&url,
"debug",
&["--wait-script", completion_script],
)?;
runtime.block_on(server.shutdown());
assert_cli_dump_html_debug_callback_error_contract(&output, expected_message);
Ok(())
}
fn assert_optional_resource_fetch_succeeded(output: &Output, scenario: &str) {
assert!(
output.status.success(),
"{scenario} failed: stdout={}\nstderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
fn assert_optional_resource_request_case(
server: &OptionalResourceFixtureServer,
page_path: &str,
flags: &[&str],
expected_paths: &[&str],
scenario: &str,
) -> Result<()> {
server.clear_requests();
let mut args = flags.to_vec();
if page_path == "/elements.html" {
args.extend(["--wait-script", TRACK_TERMINAL_WAIT_SCRIPT]);
}
let output = run_fetch_cli_with_args(&server.url(page_path), &args)?;
assert_optional_resource_fetch_succeeded(&output, scenario);
let mut expected = expected_paths
.iter()
.map(|path| (*path).to_owned())
.collect::<Vec<_>>();
expected.sort();
assert_eq!(
server.optional_requests(),
expected,
"{scenario} produced the wrong physical request union"
);
for path in OPTIONAL_RESOURCE_PATHS {
assert_eq!(
server.request_count(path),
usize::from(expected.iter().any(|expected| expected == path)),
"{scenario} produced the wrong request count for {path}"
);
}
if page_path == "/elements.html" {
assert_eq!(
server.request_count("/optional-resources.css"),
1,
"{scenario} must still fetch the required stylesheet exactly once"
);
}
Ok(())
}
#[test]
fn fetch_cli_default_sends_no_optional_resource_requests_to_a_real_web_server() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(OptionalResourceFixtureServer::spawn())?;
let preload_output = run_fetch_cli_with_args(&server.url("/preloads.html"), &[])?;
assert_optional_resource_fetch_succeeded(&preload_output, "default preload page");
assert!(
server.optional_requests().is_empty(),
"default preload page sent optional requests: {:?}",
server.optional_requests()
);
server.clear_requests();
let element_output = run_fetch_cli_with_args(
&server.url("/elements.html"),
&["--wait-script", TRACK_TERMINAL_WAIT_SCRIPT],
)?;
assert_optional_resource_fetch_succeeded(&element_output, "default element page");
assert!(
server.optional_requests().is_empty(),
"default element page sent optional requests: {:?}",
server.optional_requests()
);
assert_eq!(
server.request_count("/optional-resources.css"),
1,
"the required stylesheet must still be fetched so the negative assertion is meaningful"
);
runtime.block_on(server.shutdown());
Ok(())
}
#[test]
fn fetch_cli_individual_flags_send_only_matching_real_preload_and_element_requests() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(OptionalResourceFixtureServer::spawn())?;
for page_path in ["/preloads.html", "/elements.html"] {
for (flag, expected_path) in OPTIONAL_RESOURCE_FLAG_CASES {
let expected = expected_path.map_or_else(Vec::new, |path| vec![path]);
assert_optional_resource_request_case(
&server,
page_path,
&[flag],
&expected,
&format!("{page_path} {flag}"),
)?;
}
}
runtime.block_on(server.shutdown());
Ok(())
}
#[test]
fn fetch_cli_representative_flag_subsets_send_exact_real_request_unions() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(OptionalResourceFixtureServer::spawn())?;
let cases: [(&str, &[&str], &[&str]); 4] = [
(
"image+font",
&["--image", "--font"],
&["/optional-image.png", "/optional-font.woff2"],
),
(
"audio+video",
&["--audio", "--video"],
&["/optional-audio.mp3", "/optional-video.mp4"],
),
(
"font+media+track",
&["--font", "--media", "--text-track"],
&["/optional-font.woff2", "/optional-track.vtt"],
),
(
"image+audio+video+track",
&["--image", "--audio", "--video", "--text-track"],
&[
"/optional-image.png",
"/optional-audio.mp3",
"/optional-video.mp4",
"/optional-track.vtt",
],
),
];
for page_path in ["/preloads.html", "/elements.html"] {
for (name, flags, expected_paths) in cases {
assert_optional_resource_request_case(
&server,
page_path,
flags,
expected_paths,
&format!("{page_path} {name}"),
)?;
}
}
runtime.block_on(server.shutdown());
Ok(())
}
#[test]
fn fetch_cli_all_resource_flag_enables_real_preload_and_element_resource_paths() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(OptionalResourceFixtureServer::spawn())?;
for page_path in ["/preloads.html", "/elements.html"] {
assert_optional_resource_request_case(
&server,
page_path,
&["--resource"],
&OPTIONAL_RESOURCE_PATHS,
&format!("all-resource {page_path}"),
)?;
}
runtime.block_on(server.shutdown());
Ok(())
}
#[test]
fn cli_fetch_about_blank_materializes_empty_document() -> Result<()> {
let output = run_fetch_cli_with_dump_and_args("about:blank", "json", &[])?;
assert!(
output.status.success(),
"moli fetch failed: stdout={}\nstderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
let payload: Value = serde_json::from_str(&stdout)?;
assert_json_dump_shape(&payload, "about:blank", 200);
assert_eq!(payload["html"], "<html><head></head><body></body></html>");
Ok(())
}
#[test]
fn cli_dump_screenshot_writes_png_bytes() -> Result<()> {
let output = run_fetch_cli_with_dump_and_args("about:blank", "screenshot", &["--layout"])?;
assert!(
output.status.success(),
"moli fetch failed: stdout_bytes={}\nstderr={}",
output.stdout.len(),
String::from_utf8_lossy(&output.stderr)
);
assert!(output.stdout.starts_with(b"\x89PNG\r\n\x1a\n"));
assert!(output.stdout.ends_with(b"IEND\xaeB`\x82"));
Ok(())
}
#[test]
fn cli_dump_screenshot_is_quiet_by_default() -> Result<()> {
let output = Command::new(env!("CARGO_BIN_EXE_moli"))
.args(["fetch", "--dump", "screenshot", "--layout", "about:blank"])
.output()?;
assert!(
output.status.success(),
"moli fetch failed: stdout_bytes={}\nstderr={}",
output.stdout.len(),
String::from_utf8_lossy(&output.stderr)
);
assert!(output.stdout.starts_with(b"\x89PNG\r\n\x1a\n"));
assert!(
output.stderr.is_empty(),
"--dump should be quiet by default: stderr={}",
String::from_utf8_lossy(&output.stderr)
);
Ok(())
}
#[test]
fn cli_fetch_implicit_html_dump_is_quiet_by_default() -> Result<()> {
let output = Command::new(env!("CARGO_BIN_EXE_moli"))
.args(["fetch", "about:blank"])
.output()?;
assert!(
output.status.success(),
"moli fetch failed: stdout={}\nstderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(
clean_output(&output.stdout),
"<html><head></head><body></body></html>"
);
assert!(
output.stderr.is_empty(),
"fetch should be quiet by default: stderr={}",
String::from_utf8_lossy(&output.stderr)
);
Ok(())
}
#[test]
fn cli_dump_full_screenshot_writes_png_bytes() -> Result<()> {
let output = run_fetch_cli_with_dump_and_args("about:blank", "screenshot_full", &["--layout"])?;
assert!(
output.status.success(),
"moli fetch failed: stdout_bytes={}\nstderr={}",
output.stdout.len(),
String::from_utf8_lossy(&output.stderr)
);
assert!(output.stdout.starts_with(b"\x89PNG\r\n\x1a\n"));
assert!(output.stdout.ends_with(b"IEND\xaeB`\x82"));
Ok(())
}
#[test]
fn cli_dump_pdf_writes_pdf_bytes() -> Result<()> {
let output = run_fetch_cli_with_dump_and_args("about:blank", "pdf", &["--layout"])?;
assert!(
output.status.success(),
"moli fetch failed: stdout_bytes={}\nstderr={}",
output.stdout.len(),
String::from_utf8_lossy(&output.stderr)
);
assert!(output.stdout.starts_with(b"%PDF-1.7\n"));
assert!(
output
.stdout
.windows(b"/Type /Pages".len())
.any(|window| window == b"/Type /Pages")
);
assert!(output.stdout.ends_with(b"%%EOF\n"));
Ok(())
}
#[test]
fn binary_dump_modes_require_layout() -> Result<()> {
for dump in ["screenshot", "screenshot_full", "pdf"] {
let output = run_fetch_cli_with_dump_and_args("about:blank", dump, &[])?;
assert!(
!output.status.success(),
"--dump {dump} unexpectedly worked"
);
assert!(
output.stdout.is_empty(),
"--dump {dump} wrote partial output"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains(&format!("--dump {dump} requires --layout")),
"stderr={stderr}"
);
}
Ok(())
}
#[test]
fn cli_fetch_without_dump_writes_pdf_verbatim() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(BinaryDocumentFixtureServer::spawn())?;
let url = server.url("/inline.pdf");
let output = run_fetch_cli_without_dump(&url)?;
runtime.block_on(server.shutdown());
assert!(
output.status.success(),
"moli fetch failed: stdout={}\nstderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(output.stdout, PDF_FIXTURE_BODY);
Ok(())
}
#[test]
fn cli_fetch_without_dump_writes_video_verbatim() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(BinaryDocumentFixtureServer::spawn())?;
let url = server.url("/clip.mp4");
let output = run_fetch_cli_without_dump(&url)?;
runtime.block_on(server.shutdown());
assert!(
output.status.success(),
"moli fetch failed: stdout={:?}\nstderr={}",
output.stdout,
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(output.stdout, VIDEO_FIXTURE_BODY);
Ok(())
}
#[test]
fn cli_fetch_without_dump_writes_attachment_verbatim() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(BinaryDocumentFixtureServer::spawn())?;
let url = server.url("/attachment.html");
let output = run_fetch_cli_without_dump(&url)?;
runtime.block_on(server.shutdown());
assert!(
output.status.success(),
"moli fetch failed: stdout={}\nstderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(output.stdout, PDF_FIXTURE_BODY);
Ok(())
}
#[test]
fn cli_dump_json_encodes_raw_documents_losslessly() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(BinaryDocumentFixtureServer::spawn())?;
for (path, expected_body) in [
("/pixel.png", PNG_FIXTURE_BODY),
("/inline.pdf", PDF_FIXTURE_BODY),
("/clip.mp4", VIDEO_FIXTURE_BODY),
("/attachment.html", PDF_FIXTURE_BODY),
] {
let url = server.url(path);
let output = run_fetch_cli_with_dump_and_args(&url, "json", &[])?;
assert!(
output.status.success(),
"path={path} stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let payload: Value = serde_json::from_slice(&output.stdout)?;
assert_raw_json_dump_shape(&payload, &url, 200, expected_body);
}
runtime.block_on(server.shutdown());
Ok(())
}
#[test]
fn cli_dump_html_rejects_raw_documents_without_partial_output() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(BinaryDocumentFixtureServer::spawn())?;
for path in ["/inline.pdf", "/clip.mp4", "/attachment.html"] {
let url = server.url(path);
let output = run_fetch_cli_with_dump_and_args(&url, "html", &[])?;
let stdout = clean_output(&output.stdout);
let stderr = clean_output(&output.stderr);
assert!(!output.status.success(), "path={path} stdout={stdout}");
assert!(stdout.is_empty(), "path={path} stdout={stdout}");
assert_single_fetch_failure_reason(
&stderr,
&url,
"--dump html requires a renderable HTML document, not a raw download",
);
}
runtime.block_on(server.shutdown());
Ok(())
}
#[test]
fn cli_dump_html_rejects_from_raw_headers_without_waiting_for_body() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let (url, server) = runtime.block_on(spawn_stalled_raw_document_server())?;
let output = run_moli([
"moli",
"fetch",
"--log-level",
"error",
"--http-no-proxy",
"*",
"--http-timeout",
"5000",
"--timeout",
"500",
"--dump",
"html",
&url,
])?;
server.abort();
let _ = runtime.block_on(server);
let stdout = clean_output(&output.stdout);
let stderr = clean_output(&output.stderr);
assert!(!output.status.success(), "stdout={stdout}\nstderr={stderr}");
assert!(stdout.is_empty(), "stdout={stdout}");
assert_single_fetch_failure_reason(
&stderr,
&url,
"--dump html requires a renderable HTML document, not a raw download",
);
assert!(!stderr.contains("timed out"), "stderr={stderr}");
Ok(())
}
#[test]
fn cli_fetch_raw_document_with_page_wait_uses_fetch_failure_report() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(BinaryDocumentFixtureServer::spawn())?;
let url = server.url("/inline.pdf");
let output = run_fetch_cli_without_dump_with_args(&url, &["--wait-script", "true"])?;
runtime.block_on(server.shutdown());
let stdout = clean_output(&output.stdout);
let stderr = clean_output(&output.stderr);
assert!(!output.status.success(), "stdout={stdout}\nstderr={stderr}");
assert!(stdout.is_empty(), "stdout={stdout}");
assert_single_fetch_failure_reason(
&stderr,
&url,
"raw non-HTML document fetch does not support page wait options",
);
Ok(())
}
#[test]
fn cli_dump_html_keeps_uncaught_script_errors_out_of_stdout() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/compat/uncaught-script-error");
let output = run_fetch_cli(&url)?;
runtime.block_on(server.shutdown());
assert!(
output.status.success(),
"moli fetch failed: stdout={}\nstderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
let stderr = clean_output(&output.stderr);
assert!(stdout.contains("<main id=\"after\">after</main>"));
assert!(!stdout.contains("Uncaught Error: boom"));
assert!(!stdout.contains("<unknown>:"));
assert!(
stderr.contains("Uncaught Error: boom"),
"stderr start: {stderr} stderr end"
);
assert!(
stderr.contains("source_line="),
"stderr start: {stderr} stderr end"
);
assert!(
stderr.contains("backtrace:"),
"stderr start: {stderr} stderr end"
);
Ok(())
}
#[test]
fn cli_http_cache_dir_reuses_cached_response_across_processes() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(CacheableFixtureServer::spawn())?;
let url = server.url.clone();
let cache_dir = unique_temp_dir("http-cache-cli-e2e")?;
let cache_dir_arg = cache_dir.to_string_lossy().into_owned();
let first =
run_fetch_cli_with_dump_and_args(&url, "json", &["--http-cache-dir", &cache_dir_arg])?;
assert!(
first.status.success(),
"first moli fetch failed: stdout={}\nstderr={}",
String::from_utf8_lossy(&first.stdout),
String::from_utf8_lossy(&first.stderr)
);
let first_payload: Value = serde_json::from_slice(&first.stdout)?;
assert_json_dump_shape(&first_payload, &url, 200);
assert!(
first_payload["html"]
.as_str()
.is_some_and(|html| html.contains("http-cache-hit=1")),
"payload={first_payload}"
);
assert_eq!(server.hits(), 1);
let second =
run_fetch_cli_with_dump_and_args(&url, "json", &["--http-cache-dir", &cache_dir_arg])?;
assert!(
second.status.success(),
"second moli fetch failed: stdout={}\nstderr={}",
String::from_utf8_lossy(&second.stdout),
String::from_utf8_lossy(&second.stderr)
);
let second_payload: Value = serde_json::from_slice(&second.stdout)?;
assert_json_dump_shape(&second_payload, &url, 200);
assert!(
second_payload["html"]
.as_str()
.is_some_and(|html| html.contains("http-cache-hit=1")),
"payload={second_payload}"
);
assert_eq!(server.hits(), 1);
assert!(
std::fs::read_dir(&cache_dir)?.next().is_some(),
"cache dir should contain persisted response files"
);
runtime.block_on(server.shutdown());
Ok(())
}
#[test]
fn cli_http_cache_dir_skips_cache_when_request_headers_are_present() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(CacheableFixtureServer::spawn())?;
let cache_dir = unique_temp_dir("http-cache-cli-headers")?;
let cache_dir_arg = cache_dir.to_string_lossy().into_owned();
let first = run_fetch_cli_with_dump_and_args(
&server.url,
"json",
&["--http-cache-dir", &cache_dir_arg, "-H", "X-Test: one"],
)?;
assert!(
first.status.success(),
"first moli fetch failed: stdout={}\nstderr={}",
String::from_utf8_lossy(&first.stdout),
String::from_utf8_lossy(&first.stderr)
);
let first_payload: Value = serde_json::from_slice(&first.stdout)?;
assert_json_dump_shape(&first_payload, &server.url, 200);
assert!(
first_payload["html"]
.as_str()
.is_some_and(|html| html.contains("http-cache-hit=1")),
"payload={first_payload}"
);
let second = run_fetch_cli_with_dump_and_args(
&server.url,
"json",
&["--http-cache-dir", &cache_dir_arg, "-H", "X-Test: one"],
)?;
assert!(
second.status.success(),
"second moli fetch failed: stdout={}\nstderr={}",
String::from_utf8_lossy(&second.stdout),
String::from_utf8_lossy(&second.stderr)
);
let second_payload: Value = serde_json::from_slice(&second.stdout)?;
assert_json_dump_shape(&second_payload, &server.url, 200);
assert!(
second_payload["html"]
.as_str()
.is_some_and(|html| html.contains("http-cache-hit=2")),
"payload={second_payload}"
);
assert_eq!(server.hits(), 2);
runtime.block_on(server.shutdown());
Ok(())
}
#[test]
fn cli_cookie_file_imports_netscape_cookie_before_fetch() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/cookie");
let cookie_file = unique_temp_file_path("cookie-file-import", "cookies.txt")?;
let cookie_file_arg = cookie_file.to_string_lossy().into_owned();
let host = url::Url::parse(&url)?
.host_str()
.expect("fixture URL should have host")
.to_owned();
std::fs::write(
&cookie_file,
format!(
"# Netscape HTTP Cookie File\n{host}\tFALSE\t/\tFALSE\t2147483647\tsession\tfixture\n"
),
)?;
let output =
run_fetch_cli_with_dump_and_args(&url, "html", &["--cookie-file", &cookie_file_arg])?;
runtime.block_on(server.shutdown());
assert!(
output.status.success(),
"moli fetch failed: stdout={}\nstderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
assert!(
stdout.contains("<main>cookie=seen</main>"),
"stdout={stdout}"
);
assert!(
!stdout.contains("<main>cookie=missing</main>"),
"stdout={stdout}"
);
Ok(())
}
#[test]
fn cli_profile_dir_persists_cookies_after_successful_fetch() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/cookie");
let profile_dir = unique_temp_dir("profile-cookie-persist")?;
let profile_dir_arg = profile_dir.to_string_lossy().into_owned();
let first =
run_fetch_cli_with_dump_and_args(&url, "html", &["--profile-dir", &profile_dir_arg])?;
assert!(
first.status.success(),
"moli fetch failed: stdout={}\nstderr={}",
String::from_utf8_lossy(&first.stdout),
String::from_utf8_lossy(&first.stderr)
);
let first_stdout = clean_output(&first.stdout);
assert!(
first_stdout.contains("<main>cookie=missing</main>"),
"stdout={first_stdout}"
);
let second =
run_fetch_cli_with_dump_and_args(&url, "html", &["--profile-dir", &profile_dir_arg])?;
runtime.block_on(server.shutdown());
assert!(
second.status.success(),
"moli fetch failed: stdout={}\nstderr={}",
String::from_utf8_lossy(&second.stdout),
String::from_utf8_lossy(&second.stderr)
);
let second_stdout = clean_output(&second.stdout);
assert!(
second_stdout.contains("<main>cookie=seen</main>"),
"stdout={second_stdout}"
);
let profile_paths = BrowserProfilePaths::new(&profile_dir);
let manifest = load_profile_manifest(&profile_paths)?;
assert_eq!(
manifest.version,
moli_browser_profile::PROFILE_MANIFEST_VERSION
);
assert_eq!(manifest.partitions.len(), 1);
Ok(())
}
#[test]
fn cli_cookie_file_with_profile_dir_imports_then_persists_to_profile() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/cookie");
let profile_dir = unique_temp_dir("cookie-file-profile-import")?;
let profile_dir_arg = profile_dir.to_string_lossy().into_owned();
let cookie_file = unique_temp_file_path("cookie-file-profile-import-source", "cookies.txt")?;
let cookie_file_arg = cookie_file.to_string_lossy().into_owned();
let host = url::Url::parse(&url)?
.host_str()
.expect("fixture URL should have host")
.to_owned();
let cookie_file_contents = format!(
"# Netscape HTTP Cookie File\n{host}\tFALSE\t/\tFALSE\t2147483647\tsession\tfixture\n"
);
std::fs::write(&cookie_file, &cookie_file_contents)?;
let first = run_fetch_cli_with_dump_and_args(
&url,
"html",
&[
"--profile-dir",
&profile_dir_arg,
"--cookie-file",
&cookie_file_arg,
],
)?;
assert!(
first.status.success(),
"moli fetch failed: stdout={}\nstderr={}",
String::from_utf8_lossy(&first.stdout),
String::from_utf8_lossy(&first.stderr)
);
let first_stdout = clean_output(&first.stdout);
assert!(
first_stdout.contains("<main>cookie=seen</main>"),
"stdout={first_stdout}"
);
assert_eq!(std::fs::read_to_string(&cookie_file)?, cookie_file_contents);
let profile_paths = BrowserProfilePaths::new(&profile_dir);
let profile_cookies = load_profile_cookie_cache(&profile_paths.cookies_path)?;
assert!(
profile_cookies
.iter()
.any(|cookie| cookie.name == "session" && cookie.value == "fixture"),
"profile cookies={profile_cookies:?}"
);
let second =
run_fetch_cli_with_dump_and_args(&url, "html", &["--profile-dir", &profile_dir_arg])?;
runtime.block_on(server.shutdown());
assert!(
second.status.success(),
"moli fetch failed: stdout={}\nstderr={}",
String::from_utf8_lossy(&second.stdout),
String::from_utf8_lossy(&second.stderr)
);
let second_stdout = clean_output(&second.stdout);
assert!(
second_stdout.contains("<main>cookie=seen</main>"),
"stdout={second_stdout}"
);
assert_eq!(std::fs::read_to_string(&cookie_file)?, cookie_file_contents);
Ok(())
}
#[test]
fn cli_dump_html_keeps_load_listener_errors_out_of_stdout() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/compat/load-listener-error");
let output = run_fetch_cli(&url)?;
runtime.block_on(server.shutdown());
assert!(
output.status.success(),
"moli fetch failed: stdout={}\nstderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
let stderr = clean_output(&output.stderr);
assert!(stdout.contains("<main id=\"after\">after</main>"));
assert!(stdout.contains("data-load-first=\"before-throw\""));
assert!(stdout.contains("data-load-second=\"after-throw\""));
assert!(!stdout.contains("<unknown>:"));
assert!(!stdout.contains("host event listener threw"));
assert!(!stdout.contains("Uncaught Error: load boom"));
assert!(
!stderr.contains("load boom"),
"stderr start: {stderr} stderr end"
);
assert!(
!stderr.contains("host event listener threw"),
"stderr start: {stderr} stderr end"
);
assert!(
!stderr.contains("v8 message listener"),
"stderr start: {stderr} stderr end"
);
assert!(
!stderr.contains("source_line="),
"stderr start: {stderr} stderr end"
);
assert!(
!stderr.contains("backtrace:"),
"stderr start: {stderr} stderr end"
);
Ok(())
}
#[test]
fn cli_dump_html_can_debug_log_load_listener_errors() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/compat/load-listener-error");
let output = run_fetch_cli_with_log_level(&url, "debug")?;
runtime.block_on(server.shutdown());
assert!(
output.status.success(),
"moli fetch failed: stdout={}\nstderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
let stderr = clean_output(&output.stderr);
assert!(stdout.contains("<main id=\"after\">after</main>"));
assert!(!stdout.contains("host event listener threw"));
assert!(
stderr.contains("load boom"),
"stderr start: {stderr} stderr end"
);
assert!(
stderr.contains("host event listener threw"),
"stderr start: {stderr} stderr end"
);
assert!(
stderr.contains("source_line="),
"stderr start: {stderr} stderr end"
);
assert!(
stderr.contains("backtrace:"),
"stderr start: {stderr} stderr end"
);
Ok(())
}
#[test]
fn cli_dump_html_keeps_handled_promise_rejections_out_of_stderr() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/compat/handled-promise-rejection");
let output = run_fetch_cli(&url)?;
runtime.block_on(server.shutdown());
assert!(
output.status.success(),
"moli fetch failed: stdout={}\nstderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
let stderr = clean_output(&output.stderr);
assert!(stdout.contains("<main id=\"status\">handled</main>"));
assert!(
!stderr.contains("handled boom"),
"stderr start: {stderr} stderr end"
);
assert!(
!stderr.contains("unhandled promise rejection"),
"stderr start: {stderr} stderr end"
);
Ok(())
}
#[test]
fn cli_dump_html_keeps_unhandled_promise_rejections_out_of_stdout() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/compat/unhandled-promise-rejection");
let output = run_fetch_cli(&url)?;
runtime.block_on(server.shutdown());
assert!(
output.status.success(),
"moli fetch failed: stdout={}\nstderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
let stderr = clean_output(&output.stderr);
assert!(stdout.contains("<main id=\"after\">after</main>"));
assert!(!stdout.contains("unhandled promise rejection"));
assert!(!stdout.contains("<unknown>:"));
assert!(
stderr.contains("promise boom"),
"stderr start: {stderr} stderr end"
);
assert!(
stderr.contains("unhandled promise rejection"),
"stderr start: {stderr} stderr end"
);
assert!(
stderr.contains("source_line="),
"stderr start: {stderr} stderr end"
);
assert!(
stderr.contains("backtrace:"),
"stderr start: {stderr} stderr end"
);
Ok(())
}
#[test]
fn cli_dump_html_keeps_caught_dynamic_bare_import_rejections_out_of_stderr() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/compat/caught-dynamic-bare-import");
let output = run_fetch_cli(&url)?;
runtime.block_on(server.shutdown());
assert!(
output.status.success(),
"moli fetch failed: stdout={}\nstderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
let stderr = clean_output(&output.stderr);
assert!(stdout.contains("<main id=\"status\">caught</main>"));
assert!(
!stderr.contains("failed to resolve bare module specifier `_`"),
"stderr start: {stderr} stderr end"
);
assert!(
!stderr.contains("unhandled promise rejection"),
"stderr start: {stderr} stderr end"
);
Ok(())
}
#[test]
fn cli_dump_html_keeps_message_port_callback_errors_out_of_stdout() -> Result<()> {
assert_callback_error_page(
"/compat/message-port-callback-error",
"message port boom",
"load",
)
}
#[test]
fn cli_dump_html_can_debug_log_callback_errors() -> Result<()> {
assert_debug_callback_error_page_after_script(
"/compat/message-port-callback-error",
"message port boom",
"window.__messagePortCallbackCompleted === true",
)
}
#[test]
fn cli_dump_html_keeps_file_reader_callback_errors_out_of_stdout() -> Result<()> {
assert_callback_error_page(
"/compat/file-reader-callback-error",
"file reader boom",
"load",
)
}
#[test]
fn cli_dump_html_keeps_mutation_observer_callback_errors_out_of_stdout() -> Result<()> {
assert_callback_error_page(
"/compat/mutation-observer-callback-error",
"mutation observer boom",
"load",
)
}
#[test]
fn cli_dump_html_keeps_resize_observer_callback_errors_out_of_stdout() -> Result<()> {
assert_callback_error_page(
"/compat/resize-observer-callback-error",
"resize observer boom",
"load",
)
}
#[test]
fn cli_dump_html_keeps_xhr_callback_errors_out_of_stdout() -> Result<()> {
assert_callback_error_page(
"/compat/xhr-callback-error",
"xhr readyState boom",
"networkidle",
)
}
#[test]
fn cli_dump_html_keeps_abort_signal_callback_errors_out_of_stdout() -> Result<()> {
assert_callback_error_page(
"/compat/abort-signal-callback-error",
"abort signal boom",
"load",
)
}
#[test]
fn cli_dump_html_serializes_runtime_dom_snapshot_contract() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/compat/dump-dom-snapshot");
let output = run_fetch_cli(&url)?;
runtime.block_on(server.shutdown());
assert!(
output.status.success(),
"moli fetch failed: stdout={}\nstderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
assert!(stdout.starts_with("<!DOCTYPE html>"), "stdout={stdout}");
assert!(stdout.contains("data-snapshot=\"yes\""), "stdout={stdout}");
assert!(
stdout.contains("<main id=\"before\">after-script</main>"),
"stdout={stdout}"
);
assert!(
stdout.contains("<section id=\"after\">runtime</section>"),
"stdout={stdout}"
);
assert!(
!stdout.to_ascii_lowercase().contains("<meta charset"),
"stdout={stdout}"
);
Ok(())
}
#[test]
fn cli_fetch_repeats_custom_request_headers() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/net/echo");
let output = run_fetch_cli_with_args(&url, &["--header", "X-Test: one", "-H", "X-Trace: two"])?;
runtime.block_on(server.shutdown());
assert!(
output.status.success(),
"moli fetch failed: stdout={}\nstderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
assert!(stdout.contains(r#""x-test":"one""#), "stdout={stdout}");
assert!(stdout.contains(r#""x-trace":"two""#), "stdout={stdout}");
Ok(())
}
#[test]
fn cli_fetch_keeps_unicode_header_values_as_utf8_bytes() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let listener = runtime.block_on(TcpListener::bind("127.0.0.1:0"))?;
let url = format!("http://{}/", listener.local_addr()?);
let server = runtime.spawn(async move {
let (mut stream, _) = listener.accept().await?;
let mut request = Vec::new();
while !request.windows(4).any(|part| part == b"\r\n\r\n") {
let mut chunk = [0; 1024];
let count = stream.read(&mut chunk).await?;
anyhow::ensure!(count != 0 && request.len() < 64 * 1024, "incomplete request");
request.extend_from_slice(&chunk[..count]);
}
stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok").await?;
Ok::<_, anyhow::Error>(request)
});
let output = run_fetch_cli_with_args(&url, &["-H", "X-Utf8: é中"])?;
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let request = runtime.block_on(async {
tokio::time::timeout(std::time::Duration::from_secs(5), server).await
})???;
assert!(
request
.windows("X-Utf8: é中\r\n".len())
.any(|part| part == "X-Utf8: é中\r\n".as_bytes())
);
Ok(())
}
#[test]
fn cli_fetch_preserves_header_values_with_embedded_colons_and_empty_values() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/net/echo");
let output = run_fetch_cli_with_args(
&url,
&[
"--header",
"Authorization: Bearer a:b:c",
"--header",
"X-Empty:",
],
)?;
runtime.block_on(server.shutdown());
assert!(
output.status.success(),
"moli fetch failed: stdout={}\nstderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
assert!(
stdout.contains(r#""authorization":"Bearer a:b:c""#),
"stdout={stdout}"
);
assert!(stdout.contains(r#""x-empty":"""#), "stdout={stdout}");
Ok(())
}
#[test]
fn cli_fetch_scopes_custom_headers_to_main_document_request() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/net/header-scope");
let output = run_fetch_cli_with_args(
&url,
&[
"--header",
"X-Test: one",
"--header",
"X-Trace: two",
"--wait-selector",
"#subrequest",
],
)?;
runtime.block_on(server.shutdown());
assert!(
output.status.success(),
"moli fetch failed: stdout={}\nstderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
assert!(
stdout.contains(r#"data-nav-x-test="one""#),
"stdout={stdout}"
);
assert!(
stdout.contains(r#"data-nav-x-trace="two""#),
"stdout={stdout}"
);
assert!(
stdout.contains(
r#"<pre id="subrequest">{"method":"GET","received":true,"x-test":"","x-trace":"","authorization":"","x-empty":""}</pre>"#
),
"stdout={stdout}"
);
Ok(())
}
#[test]
fn cli_dump_json_renders_body_stylesheet_with_charset_and_curly_quote_meta() -> Result<()> {
let html = concat!(
"<!doctype html><head><title>Parser regression</title>",
"<meta name=\"viewport\" content=\"initial-scale=0.4\">",
"<meta name=“viewport“ content=“width=device-width, initial-scale=1“>",
"</head><body>",
"<link rel=stylesheet href='data:text/css,body%7Bcolor:rgb(1,2,3)%7D' charset=utf-8>",
"\n<script>document.body.setAttribute('data-color', getComputedStyle(document.body).color);</script>",
"<main id=after>café</main></body>",
);
let url = format!(
"data:text/html;charset=utf-8;base64,{}",
BASE64_STANDARD.encode(html),
);
let output = run_fetch_cli_with_wait_until_and_dump(&url, "domcontentloaded", "json")?;
assert!(
output.status.success(),
"moli fetch failed: {}",
clean_output(&output.stderr),
);
let payload: Value = serde_json::from_slice(&output.stdout)?;
assert_json_dump_shape(&payload, &url, 200);
assert_eq!(payload["title"], "Parser regression");
let rendered = payload["html"].as_str().expect("rendered HTML");
assert!(rendered.contains("name=\"“viewport“\""), "{rendered}");
assert!(
rendered.contains("data-color=\"rgb(1, 2, 3)\""),
"{rendered}"
);
assert!(
rendered.contains("<main id=\"after\">café</main>"),
"{rendered}"
);
Ok(())
}
#[test]
fn cli_dump_json_emits_final_url_status_and_html() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/static");
let output = run_fetch_cli_with_dump_and_args(&url, "json", &[])?;
runtime.block_on(server.shutdown());
assert!(
output.status.success(),
"moli fetch failed: stdout={}\nstderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
let payload: Value = serde_json::from_str(&stdout)?;
assert_json_dump_shape(&payload, &url, 200);
assert!(
payload["html"]
.as_str()
.is_some_and(|html| html.contains("<main>fixture static</main>"))
);
Ok(())
}
#[test]
fn cli_dump_json_trace_network_includes_subresource_summary() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/wait-until-delayed-fetch");
let output = run_moli([
"moli",
"fetch",
"--log-level",
"error",
"--http-no-proxy",
"*",
"--wait-until",
"networkidle",
"--dump",
"json",
"--trace-network",
&url,
])?;
runtime.block_on(server.shutdown());
assert!(
output.status.success(),
"moli fetch failed: stdout={}\nstderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
let payload: Value = serde_json::from_str(&stdout)?;
assert_eq!(payload["final_url"], url);
assert_eq!(payload["status"], 200);
assert_eq!(payload["network"]["main_document"]["status"], 200);
let subresources = payload["network"]["subresources"]
.as_array()
.expect("network.subresources should be an array");
let record = subresources
.iter()
.find(|record| {
record["url"]
.as_str()
.is_some_and(|url| url.ends_with("/wait-until-data"))
})
.unwrap_or_else(|| panic!("missing delayed fetch record: {subresources:?}"));
assert_eq!(record["resource_type"], "Fetch");
assert_eq!(record["method"], "GET");
assert_eq!(record["ok"], true);
assert_eq!(record["status"], 200);
assert_eq!(record["content_type"], "text/plain; charset=utf-8");
assert_eq!(record["body_length"], "settled".len());
Ok(())
}
#[test]
fn cli_wait_response_regexes_match_url_body_and_json_without_networkidle() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/wait-until-delayed-json-fetch");
let output = run_moli([
"moli",
"fetch",
"--log-level",
"error",
"--http-no-proxy",
"*",
"--wait-until",
"load",
"--wait-response-url-regex",
r"/wait-until-(json|xml)-data$",
"--wait-response-body-regex",
"SUCC[A-Z]+",
"--wait-response-json-regex",
r"data.url=^/item/\d+$",
"--dump",
"json",
"--trace-network",
&url,
])?;
runtime.block_on(server.shutdown());
assert!(
output.status.success(),
"moli fetch failed: stdout={}\nstderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
let payload: Value = serde_json::from_str(&stdout)?;
assert!(
payload["html"]
.as_str()
.is_some_and(|html| html.contains(r#"id="late-json""#) && html.contains("/item/42")),
"stdout={stdout}"
);
let subresources = payload["network"]["subresources"]
.as_array()
.expect("network.subresources should be an array");
let record = subresources
.iter()
.find(|record| {
record["url"]
.as_str()
.is_some_and(|url| url.ends_with("/wait-until-json-data"))
})
.unwrap_or_else(|| panic!("missing JSON fetch record: {subresources:?}"));
assert_eq!(record["resource_type"], "Fetch");
assert_eq!(record["ok"], true);
assert_eq!(record["content_type"], "application/json");
assert_eq!(record["json_summary"]["api"], "fixture.detail");
assert_eq!(record["json_summary"]["data.url"], "/item/42");
assert_eq!(
payload["network"]["matched_response"]["url"], record["url"],
"matched_response should identify the response that satisfied wait-response criteria"
);
assert_eq!(
payload["network"]["matched_response"]["json_summary"]["data.url"],
"/item/42"
);
assert!(
payload["network"]["matched_response"]
.get("body_text")
.is_none(),
"matched response body should be omitted unless explicitly requested"
);
Ok(())
}
#[test]
fn cli_readiness_plan_combines_early_response_selector_and_script() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/wait-until-readiness-plan");
let output = run_fetch_cli_with_dump_and_args(
&url,
"html",
&[
"--timeout",
"1600",
"--wait-response-url",
"/wait-until-json-data",
"--wait-response-body",
r#""ret":["SUCCESS"]"#,
"--wait-response-json",
"data.url=/item/42",
"--wait-selector",
"#readiness-selector",
"--wait-script",
"globalThis.readinessScriptReady === true",
],
)?;
runtime.block_on(server.shutdown());
assert!(
output.status.success(),
"combined readiness plan failed: stdout={}\nstderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
assert!(
stdout.contains("data-readiness-response=\"SUCCESS\""),
"the response completed while the parser-blocking script delayed Load and must remain matchable: {stdout}"
);
assert!(
stdout.contains("id=\"readiness-selector\""),
"stdout={stdout}"
);
assert!(
stdout.contains("data-readiness-script=\"true\""),
"stdout={stdout}"
);
assert!(
stdout.contains("data-readiness-order=\"response,selector,script\""),
"stdout={stdout}"
);
Ok(())
}
#[test]
fn cli_readiness_plan_does_not_restart_timeout_for_script() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/wait-until-readiness-plan");
let output = run_fetch_cli_with_dump_and_args(
&url,
"html",
&[
"--timeout",
"700",
"--wait-response-url",
"/wait-until-json-data",
"--wait-selector",
"#readiness-selector",
"--wait-script",
"globalThis.readinessScriptReady === true",
],
)?;
runtime.block_on(server.shutdown());
assert!(
!output.status.success(),
"a fresh 700 ms script timeout would incorrectly reach the 500 ms post-Load flag: stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
let stderr = clean_output(&output.stderr);
assert!(stdout.is_empty(), "stdout={stdout}");
assert!(
stderr.contains(
"fetch readiness timed out after 700 ms while waiting for a script to become truthy"
),
"the shared deadline should expire during the final plan phase: stderr={stderr}"
);
Ok(())
}
#[test]
fn cli_readiness_timeout_identifies_response_selector_and_script_phases() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/static");
let cases: [(&[&str], &str); 4] = [
(
&["--wait-response-url", "/response-that-never-arrives"],
"fetch readiness timed out after 350 ms while waiting for a subresource response",
),
(
&["--wait-selector", "#selector-that-never-appears"],
"fetch readiness timed out after 350 ms while waiting for a selector",
),
(
&["--wait-script", "globalThis.scriptThatNeverBecomesTruthy"],
"fetch readiness timed out after 350 ms while waiting for a script to become truthy",
),
(
&["--wait-script", "() => false"],
"fetch readiness timed out after 350 ms while waiting for a script to become truthy",
),
];
for (args, expected_phase) in cases {
let mut args = args.to_vec();
args.extend(["--timeout", "350"]);
let output = run_fetch_cli_with_dump_and_args(&url, "html", &args)?;
let stdout = clean_output(&output.stdout);
let stderr = clean_output(&output.stderr);
assert!(
!output.status.success(),
"phase={expected_phase} stdout={stdout}"
);
assert!(stdout.is_empty(), "phase={expected_phase} stdout={stdout}");
assert!(
stderr.contains(expected_phase),
"expected phase `{expected_phase}` in stderr={stderr}"
);
}
runtime.block_on(server.shutdown());
Ok(())
}
#[test]
fn cli_trace_network_can_include_matched_response_body_text() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/wait-until-delayed-json-fetch");
let output = run_moli([
"moli",
"fetch",
"--log-level",
"error",
"--http-no-proxy",
"*",
"--wait-until",
"load",
"--wait-response-url",
"/wait-until-json-data",
"--wait-response-body",
"SUCCESS",
"--dump",
"json",
"--trace-network",
"--trace-matched-response-body",
&url,
])?;
runtime.block_on(server.shutdown());
assert!(
output.status.success(),
"moli fetch failed: stdout={}\nstderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
let payload: Value = serde_json::from_str(&stdout)?;
let matched = &payload["network"]["matched_response"];
assert_eq!(
matched["url"]
.as_str()
.map(|url| url.ends_with("/wait-until-json-data")),
Some(true)
);
assert!(
matched["body_text"]
.as_str()
.is_some_and(|body| body.contains("SUCCESS") && body.contains("/item/42")),
"matched response body text missing from payload: {matched}"
);
let subresources = payload["network"]["subresources"]
.as_array()
.expect("network.subresources should be an array");
assert!(
subresources
.iter()
.all(|record| record.get("body_text").is_none()),
"body_text should only be included on matched_response: {subresources:?}"
);
Ok(())
}
#[test]
fn cli_wait_response_survives_xhr_callback_location_replace() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/wait-until-xhr-location-replace");
let output = run_moli([
"moli",
"fetch",
"--log-level",
"error",
"--http-no-proxy",
"*",
"--wait-until",
"load",
"--wait-response-url",
"/wait-until-data",
"--dump",
"json",
"--trace-network",
&url,
])?;
runtime.block_on(server.shutdown());
assert!(
output.status.success(),
"moli fetch failed: stdout={}\nstderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
let payload: Value = serde_json::from_str(&stdout)?;
let subresources = payload["network"]["subresources"]
.as_array()
.expect("network.subresources should be an array");
let record = subresources
.iter()
.find(|record| {
record["url"]
.as_str()
.is_some_and(|url| url.ends_with("/wait-until-data"))
})
.unwrap_or_else(|| panic!("missing XHR record: {subresources:?}"));
assert_eq!(record["resource_type"], "XHR");
assert_eq!(
payload["network"]["matched_response"]["url"], record["url"],
"matched_response should remain available when the response callback queues navigation"
);
Ok(())
}
#[test]
fn cli_trace_network_reports_cookie_summary_for_matched_response() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/wait-until-cookie-fetch");
let output = run_moli([
"moli",
"fetch",
"--log-level",
"error",
"--http-no-proxy",
"*",
"--wait-until",
"load",
"--wait-response-url",
"/wait-until-cookie-data",
"--wait-response-json",
"cookie=present",
"--dump",
"json",
"--trace-network",
&url,
])?;
runtime.block_on(server.shutdown());
assert!(
output.status.success(),
"moli fetch failed: stdout={}\nstderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
let payload: Value = serde_json::from_str(&stdout)?;
assert!(
payload["html"]
.as_str()
.is_some_and(|html| html.contains(r#"id="late-cookie""#) && html.contains("present")),
"stdout={stdout}"
);
let matched = &payload["network"]["matched_response"];
assert_eq!(
matched["url"]
.as_str()
.map(|url| url.ends_with("/wait-until-cookie-data")),
Some(true)
);
assert_eq!(matched["cookies"]["request"]["included"], 1);
assert_eq!(matched["cookies"]["request"]["excluded"], 0);
assert_eq!(matched["cookies"]["request"]["access_enabled"], true);
assert_eq!(matched["cookies"]["request"]["store_available"], true);
assert_eq!(matched["cookies"]["response"]["accepted"], 1);
assert_eq!(matched["cookies"]["response"]["rejected"], 0);
assert!(
matched["cookies"]["request"]
.get("included_names")
.is_none(),
"trace must not expose cookie names or values: {matched}"
);
Ok(())
}
#[test]
fn cli_dump_json_reports_redirected_final_url() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/location-nav/assign-source");
let final_url = server.url("/location-nav/target?from=assign");
let output = run_fetch_cli_with_dump_and_args(&url, "json", &[])?;
runtime.block_on(server.shutdown());
assert!(
output.status.success(),
"moli fetch failed: stdout={}\nstderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
let payload: Value = serde_json::from_str(&stdout)?;
assert_json_dump_shape(&payload, &final_url, 200);
assert!(
payload["final_url"]
.as_str()
.is_some_and(|final_url| final_url.ends_with("/location-nav/target?from=assign")),
"stdout={stdout}"
);
assert!(
payload["html"]
.as_str()
.is_some_and(|html| html.contains("location-target=assign")),
"stdout={stdout}"
);
Ok(())
}
#[test]
fn cli_dump_json_reports_redirect_chain_after_script_navigation() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/location-nav/http-redirect-source");
let redirect_url = server.url("/redirect");
let final_url = server.url("/static");
let output = run_fetch_cli_with_dump_and_args(&url, "json", &[])?;
runtime.block_on(server.shutdown());
assert!(
output.status.success(),
"moli fetch failed: stdout={}\nstderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
let payload: Value = serde_json::from_str(&stdout)?;
assert_json_dump_shape(&payload, &final_url, 200);
let redirects = payload["redirect_chain"]
.as_array()
.expect("redirect_chain must be an array");
assert_eq!(redirects.len(), 1, "stdout={stdout}");
assert_eq!(redirects[0]["from_url"], redirect_url);
assert_eq!(redirects[0]["to_url"], final_url);
assert_eq!(redirects[0]["status"], 307);
Ok(())
}
#[test]
fn cli_domstable_waits_for_late_content() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/wait-until-delayed-fetch");
let output = run_fetch_cli_with_wait_until(&url, "domstable")?;
runtime.block_on(server.shutdown());
assert!(
output.status.success(),
"moli fetch failed: stdout={}\nstderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
assert!(stdout.contains("id=\"late\""), "stdout={stdout}");
assert!(stdout.contains(">settled<"), "stdout={stdout}");
Ok(())
}
#[test]
fn cli_networkidle_timeout_logs_warning_and_returns_best_effort_output() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/wait-until-interval-fetch");
let output = run_moli([
"moli",
"fetch",
"--log-level",
"warn",
"--http-no-proxy",
"*",
"--wait-until",
"networkidle",
"--timeout",
"1200",
"--dump",
"html",
&url,
])?;
runtime.block_on(server.shutdown());
assert!(
output.status.success(),
"moli fetch failed: stdout={}\nstderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
let stderr = clean_output(&output.stderr);
assert!(stdout.contains("data-ping="), "stdout={stdout}");
assert!(
stderr.contains("fetch readiness wait timed out; returning best-effort page"),
"stderr={stderr}"
);
assert!(stderr.contains("wait_until=NetworkIdle"), "stderr={stderr}");
assert!(stderr.contains("timeout_ms=1200"), "stderr={stderr}");
Ok(())
}
fn assert_cli_best_effort_stage_uses_remaining_deadline(
wait_until: &str,
path: &str,
expected_html: &str,
) -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
// The main response consumes 500 ms. A fresh one-second stability timer
// would make this take roughly 1.5 s; one shared deadline must return a
// usable best-effort Page at roughly the one-second mark.
let started = std::time::Instant::now();
let output = run_moli([
"moli",
"fetch",
"--log-level",
"warn",
"--http-no-proxy",
"*",
"--wait-until",
wait_until,
"--timeout",
"1000",
"--dump",
"html",
&server.url(path),
])?;
let elapsed = started.elapsed();
runtime.block_on(server.shutdown());
assert!(
output.status.success(),
"wait_until={wait_until} stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
let stderr = clean_output(&output.stderr);
assert!(stdout.contains(expected_html), "stdout={stdout}");
assert!(
stderr.contains("fetch readiness wait timed out; returning best-effort page"),
"stderr={stderr}"
);
assert!(stderr.contains("timeout_ms=1000"), "stderr={stderr}");
assert!(stderr.contains("remaining_ms="), "stderr={stderr}");
assert!(
elapsed < std::time::Duration::from_millis(1_300),
"wait_until={wait_until} appears to have restarted its timeout: {elapsed:?}"
);
Ok(())
}
#[test]
fn cli_networkidle_uses_only_the_deadline_left_after_a_slow_main_response() -> Result<()> {
assert_cli_best_effort_stage_uses_remaining_deadline(
"networkidle",
"/wait-until-slow-interval-fetch",
"data-state=\"init\"",
)
}
#[test]
fn cli_domstable_uses_only_the_deadline_left_after_a_slow_main_response() -> Result<()> {
assert_cli_best_effort_stage_uses_remaining_deadline(
"domstable",
"/wait-until-slow-interval-dom-mutation",
"id=\"mutation-count\"",
)
}
#[test]
fn cli_best_effort_stage_does_not_soften_a_slow_base_lifecycle_timeout() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/wait-until-slow-static");
for wait_until in ["networkidle", "domstable"] {
// The response takes 500 ms, so at 200 ms no live Page exists yet.
// This is a hard base-stage failure, not a best-effort stability exit.
let started = std::time::Instant::now();
let output = run_moli([
"moli",
"fetch",
"--log-level",
"warn",
"--http-no-proxy",
"*",
"--wait-until",
wait_until,
"--timeout",
"200",
"--dump",
"html",
&url,
])?;
let elapsed = started.elapsed();
let stdout = clean_output(&output.stdout);
let stderr = clean_output(&output.stderr);
assert!(!output.status.success(), "stdout={stdout}\nstderr={stderr}");
assert!(stdout.is_empty(), "stdout={stdout}");
assert!(stderr.contains("timed out after 200 ms"), "stderr={stderr}");
assert!(
!stderr.contains("returning best-effort page"),
"a base lifecycle timeout must not be softened: stderr={stderr}"
);
assert!(
elapsed < std::time::Duration::from_millis(400),
"wait_until={wait_until} extended the hard base-stage timeout: {elapsed:?}"
);
}
runtime.block_on(server.shutdown());
Ok(())
}
#[test]
fn cli_zero_deadline_cannot_enter_a_best_effort_page_stage() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/wait-until-slow-static");
for wait_until in ["networkidle", "domstable"] {
let started = std::time::Instant::now();
let output = run_moli([
"moli",
"fetch",
"--log-level",
"warn",
"--http-no-proxy",
"*",
"--wait-until",
wait_until,
"--timeout",
"0",
"--dump",
"html",
&url,
])?;
let stdout = clean_output(&output.stdout);
let stderr = clean_output(&output.stderr);
assert!(!output.status.success(), "stdout={stdout}\nstderr={stderr}");
assert!(stdout.is_empty(), "stdout={stdout}");
assert!(stderr.contains("timed out after 0 ms"), "stderr={stderr}");
assert!(
!stderr.contains("returning best-effort page"),
"zero budget cannot produce a Page to return: stderr={stderr}"
);
assert!(
started.elapsed() < std::time::Duration::from_millis(200),
"wait_until={wait_until} did not fail its zero deadline immediately"
);
}
runtime.block_on(server.shutdown());
Ok(())
}
fn assert_cli_quiet_page_uses_remaining_best_effort_budget(wait_until: &str) -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/wait-until-slow-static");
// After the 500 ms response, 250 ms remains. That is shorter than either
// stability window, although a freshly started timer would let this
// otherwise quiet page settle successfully.
let output = run_moli([
"moli",
"fetch",
"--log-level",
"warn",
"--http-no-proxy",
"*",
"--wait-until",
wait_until,
"--timeout",
"750",
"--dump",
"html",
&url,
])?;
runtime.block_on(server.shutdown());
assert!(
output.status.success(),
"wait_until={wait_until} stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
let stderr = clean_output(&output.stderr);
assert!(stdout.contains("slow-main=ready"), "stdout={stdout}");
assert!(
stderr.contains("fetch readiness wait timed out; returning best-effort page"),
"wait_until={wait_until} should use only the remaining budget: stderr={stderr}"
);
Ok(())
}
#[test]
fn cli_quiet_page_networkidle_uses_remaining_best_effort_budget() -> Result<()> {
assert_cli_quiet_page_uses_remaining_best_effort_budget("networkidle")
}
#[test]
fn cli_quiet_page_domstable_uses_remaining_best_effort_budget() -> Result<()> {
assert_cli_quiet_page_uses_remaining_best_effort_budget("domstable")
}
#[test]
fn cli_post_readiness_wait_cannot_restart_an_exhausted_best_effort_deadline() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/wait-until-slow-interval-fetch");
let started = std::time::Instant::now();
let output = run_moli([
"moli",
"fetch",
"--log-level",
"warn",
"--http-no-proxy",
"*",
"--wait-until",
"networkidle",
"--wait-selector",
"#never-appears",
"--timeout",
"1000",
"--dump",
"html",
&url,
])?;
let elapsed = started.elapsed();
runtime.block_on(server.shutdown());
let stdout = clean_output(&output.stdout);
let stderr = clean_output(&output.stderr);
assert!(!output.status.success(), "stdout={stdout}\nstderr={stderr}");
assert!(stdout.is_empty(), "stdout={stdout}");
assert!(
stderr.contains("returning best-effort page"),
"stderr={stderr}"
);
assert!(
stderr.contains("fetch readiness timed out after 1000 ms while waiting for a selector"),
"stderr={stderr}"
);
assert!(
elapsed < std::time::Duration::from_millis(1_300),
"the selector appears to have received a fresh timeout: {elapsed:?}"
);
Ok(())
}
#[test]
fn cli_domstable_waits_for_inflight_slow_fetch_content() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/wait-until-complete-slow-fetch");
let output = run_fetch_cli_with_wait_until(&url, "domstable")?;
runtime.block_on(server.shutdown());
assert!(
output.status.success(),
"moli fetch failed: stdout={}\nstderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
assert!(stdout.contains("id=\"late-slow-fetch\""), "stdout={stdout}");
assert!(stdout.contains(">settled-very-slow<"), "stdout={stdout}");
Ok(())
}
#[test]
fn cli_domstable_waits_for_slow_post_domcontentloaded_runtime_script() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/wait-until-domcontentloaded-runtime-script-very-slow");
let output = run_fetch_cli_with_wait_until(&url, "domstable")?;
runtime.block_on(server.shutdown());
assert!(
output.status.success(),
"moli fetch failed: stdout={}\nstderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
assert!(
stdout.contains("id=\"late-dcl-script-very-slow\""),
"stdout={stdout}"
);
assert!(
stdout.contains(">script-loaded-very-slow<"),
"stdout={stdout}"
);
Ok(())
}
#[test]
fn cli_load_waits_for_slow_post_domcontentloaded_runtime_script() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/wait-until-domcontentloaded-runtime-script-slow");
let output = run_fetch_cli_with_wait_until(&url, "load")?;
runtime.block_on(server.shutdown());
assert!(
output.status.success(),
"moli fetch failed: stdout={}\nstderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
assert!(
stdout.contains("id=\"late-dcl-script-slow\""),
"stdout={stdout}"
);
assert!(stdout.contains(">script-loaded-slow<"), "stdout={stdout}");
Ok(())
}
#[test]
fn cli_default_redirect_wait_follows_client_navigation_from_terminal_302() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/wait-until-redirect-status-navigation");
let output = run_fetch_cli_with_dump_and_args(&url, "json", &[])?;
runtime.block_on(server.shutdown());
assert!(
output.status.success(),
"stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
let payload: Value = serde_json::from_str(&stdout)?;
assert_json_dump_shape(&payload, &url, 200);
let html = payload["html"].as_str().unwrap_or_default();
assert!(
html.contains("id=\"redirect-status-navigation-target\""),
"stdout={stdout}"
);
assert!(
!html.contains("id=\"redirect-status-challenge\""),
"the successor Document must replace the terminal 302 body: stdout={stdout}"
);
Ok(())
}
#[test]
fn cli_terminal_302_without_navigation_waits_then_returns_its_body() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/wait-until-redirect-status-no-navigation");
let output = run_fetch_cli_with_dump_and_args(&url, "json", &[])?;
runtime.block_on(server.shutdown());
assert!(
output.status.success(),
"stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
let payload: Value = serde_json::from_str(&stdout)?;
assert_json_dump_shape(&payload, &url, 302);
let html = payload["html"].as_str().unwrap_or_default();
assert!(
html.contains("id=\"terminal-redirect-status\""),
"stdout={stdout}"
);
assert!(
html.contains("id=\"redirect-status-wait-marker\""),
"the default redirect wait must keep the terminal 302 Page live: stdout={stdout}"
);
Ok(())
}
#[test]
fn cli_zero_redirect_wait_returns_terminal_302_before_delayed_page_work() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/wait-until-redirect-status-no-navigation");
let output = run_fetch_cli_with_dump_and_args(&url, "json", &["--redirect-wait-ms", "0"])?;
runtime.block_on(server.shutdown());
assert!(
output.status.success(),
"stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
let payload: Value = serde_json::from_str(&stdout)?;
assert_json_dump_shape(&payload, &url, 302);
let html = payload["html"].as_str().unwrap_or_default();
assert!(
html.contains("id=\"terminal-redirect-status\""),
"stdout={stdout}"
);
assert!(
!html.contains("id=\"redirect-status-wait-marker\""),
"a zero redirect wait must not run delayed Page work: stdout={stdout}"
);
Ok(())
}
#[test]
fn cli_slow_streaming_terminal_302_does_not_add_a_post_load_wait() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/wait-until-redirect-status-slow-streaming-302");
for wait_until in ["domcontentloaded", "load", "done"] {
let output = run_fetch_cli_with_wait_until_and_dump(&url, wait_until, "json")?;
assert!(
output.status.success(),
"wait_until={wait_until} stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
let payload: Value = serde_json::from_str(&stdout)?;
assert_json_dump_shape(&payload, &url, 302);
let html = payload["html"].as_str().unwrap_or_default();
assert!(
html.contains("id=\"slow-redirect-status-head\""),
"wait_until={wait_until} stdout={stdout}"
);
assert!(
html.contains("id=\"slow-redirect-status-tail\""),
"the delayed terminal 302 body must be complete: wait_until={wait_until} stdout={stdout}"
);
assert!(
!html.contains("id=\"slow-redirect-status-post-load\""),
"time spent loading the body must consume the minimum redirect wait: wait_until={wait_until} stdout={stdout}"
);
}
runtime.block_on(server.shutdown());
Ok(())
}
#[test]
fn cli_http_redirect_with_location_does_not_consume_redirect_wait() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/redirect");
let final_url = server.url("/static");
let output = run_fetch_cli_with_dump_and_args(
&url,
"json",
&["--timeout", "4000", "--redirect-wait-ms", "10000"],
)?;
runtime.block_on(server.shutdown());
assert!(
output.status.success(),
"an HTTP redirect with Location must finish at the network destination without a renderer grace: stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
let payload: Value = serde_json::from_str(&stdout)?;
assert_json_dump_shape(&payload, &final_url, 200);
Ok(())
}
#[test]
fn cli_dump_json_includes_title_headers_and_redirect_chain() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/redirect-cookie-chain/start");
let middle_url = server.url("/redirect-cookie-chain/middle");
let final_url = server.url("/redirect-cookie-chain/final");
let output = run_fetch_cli_with_dump_and_args(&url, "json", &[])?;
runtime.block_on(server.shutdown());
assert!(
output.status.success(),
"moli fetch failed: stdout={}\nstderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
let payload: Value = serde_json::from_str(&stdout)?;
assert_json_dump_shape(&payload, &final_url, 200);
assert_eq!(payload["title"], "Cookie chain final");
assert!(
payload["headers"].as_array().is_some_and(|headers| {
headers.iter().any(|header| {
header["name"]
.as_str()
.is_some_and(|name| name.eq_ignore_ascii_case("content-type"))
})
}),
"stdout={stdout}"
);
let redirects = payload["redirect_chain"]
.as_array()
.expect("redirect_chain must be an array");
assert_eq!(redirects.len(), 2, "stdout={stdout}");
assert_eq!(redirects[0]["from_url"], url);
assert_eq!(redirects[0]["to_url"], middle_url);
assert_eq!(redirects[0]["status"], 307);
assert_eq!(redirects[1]["from_url"], middle_url);
assert_eq!(redirects[1]["to_url"], final_url);
assert_eq!(redirects[1]["status"], 307);
let first_hop_set_cookies = redirects[0]["headers"]
.as_array()
.expect("redirect headers must be an array")
.iter()
.filter(|header| {
header["name"]
.as_str()
.is_some_and(|name| name.eq_ignore_ascii_case("set-cookie"))
})
.count();
assert_eq!(first_hop_set_cookies, 2, "stdout={stdout}");
Ok(())
}
#[test]
fn cli_domcontentloaded_recovers_from_delayed_403_navigation_at_same_stage() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/wait-until-http-error-navigation");
let output = run_fetch_cli_with_wait_until(&url, "domcontentloaded")?;
runtime.block_on(server.shutdown());
assert!(
output.status.success(),
"moli fetch failed: stdout={}\nstderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
assert!(
stdout.contains("id=\"http-error-navigation-target\""),
"stdout={stdout}"
);
assert!(
stdout.contains("data-reached-dcl=\"true\""),
"stdout={stdout}"
);
assert!(!stdout.contains("id=\"challenge\""), "stdout={stdout}");
Ok(())
}
#[test]
fn cli_load_recovers_from_delayed_403_navigation_at_same_stage() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/wait-until-http-error-navigation");
let output = run_fetch_cli_with_wait_until(&url, "load")?;
runtime.block_on(server.shutdown());
assert!(
output.status.success(),
"moli fetch failed: stdout={}\nstderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
assert!(
stdout.contains("id=\"http-error-navigation-target\""),
"stdout={stdout}"
);
assert!(
stdout.contains("data-reached-load=\"true\""),
"stdout={stdout}"
);
assert!(
stdout.contains("id=\"http-error-navigation-load-tail\""),
"load must include the replacement load tail: stdout={stdout}"
);
Ok(())
}
#[test]
fn cli_load_follows_five_same_url_replacements_after_403() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/wait-until-http-error-five-navigations");
let output = run_fetch_cli_with_wait_until(&url, "load")?;
runtime.block_on(server.shutdown());
assert!(
output.status.success(),
"moli fetch failed: stdout={}\nstderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
assert!(
stdout.contains("id=\"http-error-five-navigation-target\""),
"stdout={stdout}"
);
assert!(
stdout.contains("id=\"http-error-five-navigation-load-tail\""),
"the fifth replacement must still reach Load: stdout={stdout}"
);
assert!(!stdout.contains("id=\"challenge\""), "stdout={stdout}");
Ok(())
}
#[test]
fn cli_load_uses_one_timeout_across_403_replacement_navigation() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/wait-until-http-error-navigation");
let output = run_fetch_cli_with_dump_and_args(&url, "html", &["--timeout", "250"])?;
runtime.block_on(server.shutdown());
assert!(!output.status.success());
let stdout = clean_output(&output.stdout);
let stderr = clean_output(&output.stderr);
assert!(stdout.is_empty(), "stdout={stdout}");
assert!(
stderr.contains("fetch readiness timed out after 250 ms while waiting for load"),
"stderr={stderr}"
);
Ok(())
}
#[test]
fn cli_default_done_recovers_from_delayed_403_navigation_at_load() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/wait-until-http-error-navigation");
let output = run_fetch_cli_with_default_wait_and_dump(&url, "json")?;
runtime.block_on(server.shutdown());
assert!(
output.status.success(),
"moli fetch failed: stdout={}\nstderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
let payload: Value = serde_json::from_str(&stdout)?;
assert_json_dump_shape(&payload, &url, 200);
let html = payload["html"].as_str().unwrap_or_default();
assert!(
html.contains("id=\"http-error-navigation-target\""),
"stdout={stdout}"
);
assert!(
html.contains("id=\"http-error-navigation-load-tail\""),
"stdout={stdout}"
);
Ok(())
}
#[test]
fn cli_explicit_load_returns_404_body_without_retrying() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/net/upstream/xhr/404-then-200");
let output = run_fetch_cli_with_dump_and_args(&url, "json", &[])?;
runtime.block_on(server.shutdown());
assert!(
output.status.success(),
"an HTTP error document with a body must be returned: stdout={}\nstderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
let payload: Value = serde_json::from_str(&stdout)?;
assert_json_dump_shape(&payload, &url, 404);
let html = payload["html"].as_str().unwrap_or_default();
assert!(html.contains("first-hit-404"), "stdout={stdout}");
assert!(!html.contains("second-hit-200"), "stdout={stdout}");
Ok(())
}
#[test]
fn cli_explicit_domcontentloaded_returns_500_body() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/net/upstream/xhr/500");
let output = run_fetch_cli_with_wait_until(&url, "domcontentloaded")?;
runtime.block_on(server.shutdown());
assert!(
output.status.success(),
"stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
assert!(stdout.contains("Internal Server Error"), "stdout={stdout}");
Ok(())
}
#[test]
fn cli_explicit_load_returns_error_page_when_navigation_starts_after_the_minimum() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/wait-until-http-error-late-navigation");
let output = run_fetch_cli_with_wait_until(&url, "load")?;
runtime.block_on(server.shutdown());
assert!(
output.status.success(),
"stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
assert!(stdout.contains("id=\"challenge\""), "stdout={stdout}");
assert!(stdout.contains("late-challenge"), "stdout={stdout}");
assert!(!stdout.contains("http-error-navigation-target"));
Ok(())
}
#[test]
fn cli_redirect_wait_can_extend_http_error_navigation_grace() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/wait-until-http-error-late-navigation");
let output = run_fetch_cli_with_dump_and_args(&url, "html", &["--redirect-wait-ms", "1500"])?;
runtime.block_on(server.shutdown());
assert!(
output.status.success(),
"moli fetch failed: stdout={}\nstderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
assert!(
stdout.contains("id=\"http-error-navigation-target\""),
"stdout={stdout}"
);
assert!(
stdout.contains("id=\"http-error-navigation-load-tail\""),
"the configured grace must still preserve the requested Load stage: stdout={stdout}"
);
Ok(())
}
#[test]
fn cli_readiness_plan_continues_after_http_error_replacement_navigation() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/wait-until-readiness-http-error-navigation");
let output = run_fetch_cli_with_dump_and_args(
&url,
"html",
&[
"--timeout",
"1800",
"--wait-response-url",
"/wait-until-json-data",
"--wait-response-json",
"data.url=/item/42",
"--wait-selector",
"#readiness-navigation-selector",
"--wait-script",
"globalThis.readinessNavigationScriptReady === true",
],
)?;
runtime.block_on(server.shutdown());
assert!(
output.status.success(),
"replacement readiness plan failed: stdout={}\nstderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
assert!(
stdout.contains("id=\"readiness-navigation-target\""),
"stdout={stdout}"
);
assert!(
stdout.contains("data-readiness-navigation-response=\"SUCCESS\""),
"stdout={stdout}"
);
assert!(
stdout.contains("id=\"readiness-navigation-selector\""),
"stdout={stdout}"
);
assert!(
stdout.contains("data-readiness-navigation-script=\"true\""),
"stdout={stdout}"
);
assert!(!stdout.contains("id=\"readiness-challenge\""));
Ok(())
}
#[test]
fn cli_http_error_replacement_and_post_waits_share_one_deadline() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/wait-until-readiness-http-error-navigation");
let output = run_fetch_cli_with_dump_and_args(
&url,
"html",
&[
"--timeout",
"850",
"--wait-response-url",
"/wait-until-json-data",
"--wait-selector",
"#readiness-navigation-selector",
"--wait-script",
"globalThis.readinessNavigationScriptReady === true",
],
)?;
runtime.block_on(server.shutdown());
assert!(
!output.status.success(),
"a fresh post-navigation script timeout would incorrectly succeed: stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
let stderr = clean_output(&output.stderr);
assert!(stdout.is_empty(), "stdout={stdout}");
assert!(
stderr.contains(
"fetch readiness timed out after 850 ms while waiting for a script to become truthy"
),
"the replacement and earlier post waits should leave the script phase with only the remaining budget: stderr={stderr}"
);
Ok(())
}
#[test]
fn cli_zero_redirect_wait_accepts_navigation_already_pending_at_stage() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/wait-until-http-error-immediate-navigation");
let output = run_fetch_cli_with_dump_and_args(&url, "html", &["--redirect-wait-ms", "0"])?;
runtime.block_on(server.shutdown());
assert!(
output.status.success(),
"a replacement already pending at the 403 Load stage must not need a grace timer: stdout={}\nstderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
assert!(
stdout.contains("id=\"http-error-navigation-target\""),
"stdout={stdout}"
);
assert!(
stdout.contains("id=\"http-error-navigation-load-tail\""),
"stdout={stdout}"
);
Ok(())
}
#[test]
fn cli_zero_redirect_wait_returns_the_current_error_page() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/wait-until-http-error-navigation");
let output = run_fetch_cli_with_dump_and_args(&url, "html", &["--redirect-wait-ms", "0"])?;
runtime.block_on(server.shutdown());
assert!(
output.status.success(),
"stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
assert!(stdout.contains("id=\"challenge\""), "stdout={stdout}");
assert!(!stdout.contains("http-error-navigation-target"));
Ok(())
}
#[test]
fn cli_same_document_navigation_returns_the_current_http_error_page() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/wait-until-http-error-same-document-navigation");
let output = run_fetch_cli_with_dump_and_args(&url, "json", &[])?;
runtime.block_on(server.shutdown());
assert!(
output.status.success(),
"stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
let payload: Value = serde_json::from_str(&stdout)?;
assert_eq!(payload["status"], 403);
let html = payload["html"].as_str().unwrap_or_default();
assert!(html.contains("id=\"challenge\""), "stdout={stdout}");
assert!(
html.contains("http-error-navigation=same-document"),
"stdout={stdout}"
);
Ok(())
}
#[test]
fn cli_explicit_load_returns_a_replacement_error_document() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/wait-until-http-error-navigation-to-error");
let output = run_fetch_cli_with_dump_and_args(&url, "json", &[])?;
runtime.block_on(server.shutdown());
assert!(
output.status.success(),
"stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
let payload: Value = serde_json::from_str(&stdout)?;
assert_json_dump_shape(&payload, &url, 500);
let html = payload["html"].as_str().unwrap_or_default();
assert!(
html.contains("id=\"http-error-navigation-target\""),
"stdout={stdout}"
);
Ok(())
}
#[test]
fn cli_non_lifecycle_wait_modes_keep_http_error_dump_behavior() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/net/upstream/xhr/404");
for wait_until in ["networkidle", "domstable"] {
let output = run_fetch_cli_with_wait_until(&url, wait_until)?;
assert!(
output.status.success(),
"wait_until={wait_until} stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(
clean_output(&output.stdout).contains("Not Found"),
"wait_until={wait_until} stdout={}",
clean_output(&output.stdout)
);
}
runtime.block_on(server.shutdown());
Ok(())
}
#[test]
fn cli_default_done_returns_http_error_status_and_body() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/net/upstream/xhr/404");
let output = run_fetch_cli_with_default_wait_and_dump(&url, "json")?;
runtime.block_on(server.shutdown());
let stdout = clean_output(&output.stdout);
assert!(
output.status.success(),
"default done must return an HTTP response with its body: stdout={stdout} stderr={}",
clean_output(&output.stderr)
);
let payload: Value = serde_json::from_str(&stdout)?;
assert_json_dump_shape(&payload, &url, 404);
assert!(
payload["html"]
.as_str()
.unwrap_or_default()
.contains("Not Found"),
"stdout={stdout}"
);
Ok(())
}
#[test]
fn cli_default_done_http_error_wait_does_not_retry_request() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/net/upstream/xhr/404-then-200");
let output = run_fetch_cli_with_default_wait_and_dump(&url, "json")?;
runtime.block_on(server.shutdown());
let stdout = clean_output(&output.stdout);
assert!(
output.status.success(),
"stderr={}",
clean_output(&output.stderr)
);
let payload: Value = serde_json::from_str(&stdout)?;
assert_json_dump_shape(&payload, &url, 404);
let html = payload["html"].as_str().unwrap_or_default();
assert!(html.contains("first-hit-404"), "stdout={stdout}");
assert!(
!html.contains("second-hit-200"),
"a hidden HTTP retry would incorrectly receive the fixture's second 200 response: stdout={stdout}"
);
Ok(())
}
#[test]
fn cli_default_done_returns_raw_http_error_status_and_body() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/net/upstream/xhr/binary-404");
let output = run_fetch_cli_with_default_wait_and_dump(&url, "json")?;
runtime.block_on(server.shutdown());
assert!(
output.status.success(),
"stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let payload: Value = serde_json::from_slice(&output.stdout)?;
assert_raw_json_dump_shape(&payload, &url, 404, b"raw-error-body");
Ok(())
}
#[test]
fn cli_slow_streaming_500_returns_complete_body_without_an_additive_wait() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/wait-until-http-error-slow-streaming-500");
for wait_until in ["domcontentloaded", "load", "done"] {
let output = run_fetch_cli_with_wait_until_and_dump(&url, wait_until, "json")?;
assert!(
output.status.success(),
"wait_until={wait_until} stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
let payload: Value = serde_json::from_str(&stdout)?;
assert_json_dump_shape(&payload, &url, 500);
let html = payload["html"].as_str().unwrap_or_default();
assert!(
html.contains("id=\"slow-http-error-head\""),
"wait_until={wait_until} stdout={stdout}"
);
assert!(
html.contains("id=\"slow-http-error-tail\""),
"the delayed main-response tail must be present: wait_until={wait_until} stdout={stdout}"
);
assert!(
!html.contains("id=\"slow-http-error-post-load-early\""),
"a fresh post-Load wait would run the 400 ms marker: wait_until={wait_until} stdout={stdout}"
);
assert!(
!html.contains("id=\"slow-http-error-post-load-late\""),
"wait_until={wait_until} stdout={stdout}"
);
}
runtime.block_on(server.shutdown());
Ok(())
}
#[test]
fn cli_slow_streaming_500_fallback_remains_live_for_page_waits() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/wait-until-http-error-slow-streaming-500");
let output = run_fetch_cli_with_dump_and_args(
&url,
"json",
&["--wait-selector", "#slow-http-error-post-load-early"],
)?;
runtime.block_on(server.shutdown());
assert!(
output.status.success(),
"stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
let payload: Value = serde_json::from_str(&stdout)?;
assert_json_dump_shape(&payload, &url, 500);
let html = payload["html"].as_str().unwrap_or_default();
assert!(
html.contains("id=\"slow-http-error-tail\""),
"stdout={stdout}"
);
assert!(
html.contains("id=\"slow-http-error-post-load-early\""),
"the fallback Page must keep running for explicit page waits: stdout={stdout}"
);
assert!(
!html.contains("id=\"slow-http-error-post-load-late\""),
"the selector wait should not turn into an open-ended delay: stdout={stdout}"
);
Ok(())
}
#[test]
fn cli_slow_streaming_500_and_page_wait_share_one_total_timeout() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/wait-until-http-error-slow-streaming-500");
let output = run_fetch_cli_with_dump_and_args(
&url,
"json",
&[
"--timeout",
"4000",
"--wait-selector",
"#slow-http-error-post-load-after-total-deadline",
],
)?;
runtime.block_on(server.shutdown());
assert!(
!output.status.success(),
"a fresh selector timeout would incorrectly succeed: stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
let stderr = clean_output(&output.stderr);
assert!(stdout.is_empty(), "stdout={stdout}");
assert!(
stderr.contains("fetch readiness timed out after 4000 ms while waiting for a selector"),
"stderr={stderr}"
);
Ok(())
}
#[test]
fn cli_slow_streaming_500_returns_complete_body_for_best_effort_modes() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/wait-until-http-error-slow-streaming-500");
for wait_until in ["networkidle", "domstable"] {
let output = run_fetch_cli_with_wait_until_and_dump(&url, wait_until, "json")?;
assert!(
output.status.success(),
"wait_until={wait_until} stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
let payload: Value = serde_json::from_str(&stdout)?;
assert_json_dump_shape(&payload, &url, 500);
let html = payload["html"].as_str().unwrap_or_default();
assert!(
html.contains("id=\"slow-http-error-head\""),
"wait_until={wait_until} stdout={stdout}"
);
assert!(
html.contains("id=\"slow-http-error-tail\""),
"wait_until={wait_until} stdout={stdout}"
);
}
runtime.block_on(server.shutdown());
Ok(())
}
#[test]
fn cli_slow_streaming_403_returns_before_a_post_load_navigation_by_default() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/wait-until-http-error-slow-streaming-navigation");
let output = run_fetch_cli_with_dump_and_args(&url, "json", &[])?;
runtime.block_on(server.shutdown());
assert!(
output.status.success(),
"stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
let payload: Value = serde_json::from_str(&stdout)?;
assert_json_dump_shape(&payload, &url, 403);
let html = payload["html"].as_str().unwrap_or_default();
assert!(
html.contains("id=\"slow-http-error-navigation-tail\""),
"the 1.25 s main-response tail must be returned: stdout={stdout}"
);
assert!(
!html.contains("slow-http-error-navigation-target"),
"the default minimum elapsed before Load and must not become an extra post-Load grace: stdout={stdout}"
);
Ok(())
}
#[test]
fn cli_slow_streaming_403_uses_remaining_configured_wait_for_navigation() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/wait-until-http-error-slow-streaming-navigation");
let output = run_fetch_cli_with_dump_and_args(&url, "json", &["--redirect-wait-ms", "3000"])?;
runtime.block_on(server.shutdown());
assert!(
output.status.success(),
"stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
let payload: Value = serde_json::from_str(&stdout)?;
assert_json_dump_shape(&payload, &url, 200);
let html = payload["html"].as_str().unwrap_or_default();
assert!(
html.contains("id=\"slow-http-error-navigation-target\""),
"the unconsumed minimum must catch the post-Load navigation: stdout={stdout}"
);
assert!(
!html.contains("slow-http-error-navigation-tail"),
"the replacement Document must supersede the original 403 body: stdout={stdout}"
);
Ok(())
}
#[test]
fn cli_http_error_replacement_to_slow_streaming_500_returns_complete_body() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/wait-until-http-error-navigation-to-slow-streaming-500");
let output = run_fetch_cli_with_dump_and_args(&url, "json", &[])?;
runtime.block_on(server.shutdown());
assert!(
output.status.success(),
"stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
let payload: Value = serde_json::from_str(&stdout)?;
assert_json_dump_shape(&payload, &url, 500);
let html = payload["html"].as_str().unwrap_or_default();
assert!(
html.contains("id=\"slow-http-error-head\""),
"stdout={stdout}"
);
assert!(
html.contains("id=\"slow-http-error-tail\""),
"the replacement 500 main-response tail must be complete: stdout={stdout}"
);
Ok(())
}
#[test]
fn cli_slow_streaming_http_error_still_obeys_the_total_timeout() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/wait-until-http-error-slow-streaming-500");
let output = run_fetch_cli_with_dump_and_args(&url, "json", &["--timeout", "400"])?;
runtime.block_on(server.shutdown());
assert!(!output.status.success());
let stdout = clean_output(&output.stdout);
let stderr = clean_output(&output.stderr);
assert!(stdout.is_empty(), "stdout={stdout}");
assert!(stderr.contains("timed out after 400 ms"), "stderr={stderr}");
assert!(
!stderr.contains("resident renderer page entry must retain an active PageVm"),
"the partial error Page must tear down cleanly: stderr={stderr}"
);
Ok(())
}
#[test]
fn cli_slow_streaming_raw_404_returns_complete_status_and_body() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/wait-until-http-error-slow-streaming-raw-404");
let output = run_fetch_cli_with_default_wait_and_dump(&url, "json")?;
runtime.block_on(server.shutdown());
assert!(
output.status.success(),
"stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let payload: Value = serde_json::from_slice(&output.stdout)?;
assert_raw_json_dump_shape(&payload, &url, 404, b"slow-raw-404-head|slow-raw-404-tail");
Ok(())
}
#[test]
fn cli_dump_json_keeps_transport_failures_as_process_errors() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let listener = runtime.block_on(TcpListener::bind("127.0.0.1:0"))?;
let addr = listener.local_addr()?;
let disconnect = runtime.spawn(async move {
let (stream, _) = listener
.accept()
.await
.expect("transport failure fixture should accept");
drop(stream);
});
let url = format!("http://{addr}/transport-failure");
let output = run_fetch_cli_with_dump_and_args(&url, "json", &[])?;
runtime.block_on(disconnect)?;
assert!(
!output.status.success(),
"expected transport failure to keep non-zero exit: stdout={}\nstderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
let stderr = clean_output(&output.stderr);
assert!(stdout.is_empty(), "stdout={stdout}");
assert!(stderr.contains("failed to fetch"), "stderr={stderr}");
Ok(())
}
#[test]
fn cli_connection_failure_reports_curl_code_and_cause() -> Result<()> {
let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
let address = listener.local_addr()?;
drop(listener);
let url = format!("http://{address}/connection-refused");
let output = run_fetch_cli(&url)?;
let stdout = clean_output(&output.stdout);
let stderr = clean_output(&output.stderr);
assert!(!output.status.success(), "stdout={stdout}\nstderr={stderr}");
assert!(stdout.is_empty(), "stdout={stdout}");
assert_single_fetch_failure_reason(&stderr, &url, "curl request failed");
assert!(stderr.contains("[7]"), "stderr={stderr}");
assert!(stderr.contains("Failed to connect"), "stderr={stderr}");
Ok(())
}
#[test]
fn cli_timeout_reports_waiting_for_response_headers_as_one_reason() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let listener = runtime.block_on(TcpListener::bind("127.0.0.1:0"))?;
let addr = listener.local_addr()?;
let hanging_server = runtime.spawn(async move {
let (mut stream, _) = listener
.accept()
.await
.expect("header timeout fixture should accept");
let mut request = [0_u8; 1024];
let _ = stream.read(&mut request).await;
std::future::pending::<()>().await;
});
let url = format!("http://{addr}/headers-never-arrive");
let output = run_moli([
"moli",
"fetch",
"--log-level",
"error",
"--http-no-proxy",
"*",
"--http-timeout",
"5000",
"--wait-until",
"domcontentloaded",
"--timeout",
"300",
"--dump",
"html",
&url,
])?;
hanging_server.abort();
let _ = runtime.block_on(hanging_server);
let stdout = clean_output(&output.stdout);
let stderr = clean_output(&output.stderr);
assert!(!output.status.success(), "stdout={stdout}\nstderr={stderr}");
assert!(stdout.is_empty(), "stdout={stdout}");
assert_single_fetch_failure_reason(
&stderr,
&url,
"fetch readiness timed out after 300 ms while waiting for response headers",
);
Ok(())
}
#[test]
fn cli_streaming_page_creation_timeout_exits_cleanly_for_every_wait_mode() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/streaming/slow-html-tail");
// Run a real child process so this regression distinguishes the expected
// exit code from SIGABRT. The response has committed its headers and first
// HTML chunk, but page creation remains parked on the delayed parser tail
// when the shared readiness deadline expires.
for wait_until in [
"domcontentloaded",
"load",
"networkidle",
"domstable",
"done",
] {
let output = Command::new(env!("CARGO_BIN_EXE_moli"))
.args([
"fetch",
"--log-level",
"error",
"--http-no-proxy",
"*",
"--wait-until",
wait_until,
"--timeout",
"400",
"--dump",
"html",
&url,
])
.output()?;
let stdout = clean_output(&output.stdout);
let stderr = clean_output(&output.stderr);
assert_eq!(
output.status.code(),
Some(1),
"wait_until={wait_until} must report a normal fetch failure instead of aborting: stdout={stdout}\nstderr={stderr}"
);
assert!(stdout.is_empty(), "wait_until={wait_until} stdout={stdout}");
assert!(
stderr.contains("timed out after 400 ms"),
"wait_until={wait_until} stderr={stderr}"
);
assert_single_fetch_failure_reason(
&stderr,
&url,
"fetch readiness timed out after 400 ms while streaming main body",
);
assert!(
!stderr.contains("resident renderer page entry must retain an active PageVm"),
"wait_until={wait_until} leaked the renderer teardown invariant: stderr={stderr}"
);
}
runtime.block_on(server.shutdown());
Ok(())
}
#[test]
fn cli_meta_refresh_shutdown_exits_without_renderer_abort() -> Result<()> {
let output = Command::new(env!("CARGO_BIN_EXE_moli"))
.args([
"fetch",
"--log-level",
"error",
"--wait-until",
"load",
"--dump",
"html",
"data:text/html,<meta http-equiv='refresh' content='0;url=about:blank'>",
])
.output()?;
let stdout = clean_output(&output.stdout);
let stderr = clean_output(&output.stderr);
assert_eq!(
output.status.code(),
Some(0),
"meta refresh shutdown must not abort the CLI: stdout={stdout}\nstderr={stderr}"
);
assert!(
stdout.contains("http-equiv=\"refresh\""),
"the source document should still be emitted: stdout={stdout}"
);
assert!(
!stderr.contains("PageVm must retain a live ScriptVm until drop"),
"renderer teardown invariant leaked to the CLI: stderr={stderr}"
);
Ok(())
}
#[test]
fn cli_timeout_reports_parser_blocking_stylesheet_as_one_reason() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(BlockingStylesheetTimeoutFixtureServer::spawn())?;
let url = server.url();
let output = run_moli([
"moli",
"fetch",
"--log-level",
"error",
"--http-no-proxy",
"*",
"--http-timeout",
"5000",
"--wait-until",
"domcontentloaded",
"--timeout",
"500",
"--dump",
"html",
&url,
])?;
runtime.block_on(server.shutdown());
let stdout = clean_output(&output.stdout);
let stderr = clean_output(&output.stderr);
assert!(!output.status.success(), "stdout={stdout}\nstderr={stderr}");
assert!(stdout.is_empty(), "stdout={stdout}");
assert_single_fetch_failure_reason(
&stderr,
&url,
"fetch readiness timed out after 500 ms while waiting for parser-blocking stylesheet",
);
Ok(())
}
#[test]
fn cli_domcontentloaded_exit_stays_stable_with_inflight_background_fetch_tail() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/wait-until-domcontentloaded-runtime-script-slow");
// This reproduces the CLI shape that used to be risky: return at DCL while
// a post-DCL external script fetch is still in flight, then let the fetch
// subprocess tear down the page and browser normally.
for attempt in 0..12 {
let output = run_fetch_cli_with_wait_until(&url, "domcontentloaded")?;
assert!(
output.status.success(),
"attempt={attempt} moli fetch failed: stdout={}\nstderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = clean_output(&output.stdout);
assert!(
stdout.contains("runtimeOwnedDclInjectedDclOrder"),
"attempt={attempt} stdout={stdout}"
);
assert!(
!stdout.contains("id=\"late-dcl-script-slow\""),
"attempt={attempt} stdout should not include post-DCL slow script load tail: {stdout}"
);
}
runtime.block_on(server.shutdown());
Ok(())
}
const ROBOTS_FIXTURE_PAGE: &str =
"<!doctype html><html><body><main id=\"ready\">robots fixture page</main></body></html>";
#[derive(Clone)]
struct RobotsFixtureState {
robots_status: StatusCode,
robots_body: &'static str,
robots_hits: Arc<AtomicUsize>,
page_hits: Arc<AtomicUsize>,
}
struct RobotsFixtureServer {
base_url: String,
robots_hits: Arc<AtomicUsize>,
page_hits: Arc<AtomicUsize>,
task: JoinHandle<()>,
}
impl RobotsFixtureServer {
async fn spawn(robots_status: StatusCode, robots_body: &'static str) -> Result<Self> {
async fn robots_txt(State(state): State<RobotsFixtureState>) -> impl IntoResponse {
state.robots_hits.fetch_add(1, Ordering::SeqCst);
(
state.robots_status,
[("content-type", "text/plain; charset=utf-8")],
state.robots_body,
)
}
async fn page(State(state): State<RobotsFixtureState>) -> Html<&'static str> {
state.page_hits.fetch_add(1, Ordering::SeqCst);
Html(ROBOTS_FIXTURE_PAGE)
}
let robots_hits = Arc::new(AtomicUsize::new(0));
let page_hits = Arc::new(AtomicUsize::new(0));
let state = RobotsFixtureState {
robots_status,
robots_body,
robots_hits: Arc::clone(&robots_hits),
page_hits: Arc::clone(&page_hits),
};
let listener = TcpListener::bind("127.0.0.1:0").await?;
let addr = listener.local_addr()?;
let app = Router::new()
.route("/robots.txt", get(robots_txt))
.route("/allowed", get(page))
.route("/private/secret", get(page))
.with_state(state);
let task = tokio::spawn(async move {
axum::serve(listener, app)
.await
.expect("robots fixture server should serve");
});
Ok(Self {
base_url: format!("http://{addr}"),
robots_hits,
page_hits,
task,
})
}
fn url(&self, path: &str) -> String {
format!("{}{path}", self.base_url)
}
fn robots_hits(&self) -> usize {
self.robots_hits.load(Ordering::SeqCst)
}
fn page_hits(&self) -> usize {
self.page_hits.load(Ordering::SeqCst)
}
async fn shutdown(self) {
self.task.abort();
let _ = self.task.await;
}
}
const ROBOTS_DISALLOWING_PRIVATE: &str = "User-agent: *\nDisallow: /private\n";
#[test]
fn cli_obey_robots_refuses_a_disallowed_url_without_requesting_it() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(RobotsFixtureServer::spawn(
StatusCode::OK,
ROBOTS_DISALLOWING_PRIVATE,
))?;
let url = server.url("/private/secret");
let output = run_fetch_cli_with_args(&url, &["--obey-robots"])?;
assert!(
!output.status.success(),
"moli fetch should have been refused: stdout={}",
clean_output(&output.stdout)
);
let stderr = clean_output(&output.stderr);
assert!(stderr.contains("is disallowed by"), "stderr={stderr}");
assert!(stderr.contains("/robots.txt"), "stderr={stderr}");
assert_eq!(server.robots_hits(), 1, "robots.txt should be read once");
assert_eq!(
server.page_hits(),
0,
"a disallowed URL must never be requested"
);
runtime.block_on(server.shutdown());
Ok(())
}
#[test]
fn cli_obey_robots_allows_a_permitted_url() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(RobotsFixtureServer::spawn(
StatusCode::OK,
ROBOTS_DISALLOWING_PRIVATE,
))?;
let url = server.url("/allowed");
let output = run_fetch_cli_with_args(&url, &["--obey-robots"])?;
assert!(
output.status.success(),
"moli fetch failed: stdout={}\nstderr={}",
clean_output(&output.stdout),
clean_output(&output.stderr)
);
assert!(
clean_output(&output.stdout).contains("robots fixture page"),
"stdout={}",
clean_output(&output.stdout)
);
assert_eq!(server.robots_hits(), 1);
assert_eq!(server.page_hits(), 1);
runtime.block_on(server.shutdown());
Ok(())
}
#[test]
fn cli_without_obey_robots_never_reads_robots_txt() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(RobotsFixtureServer::spawn(
StatusCode::OK,
ROBOTS_DISALLOWING_PRIVATE,
))?;
let url = server.url("/private/secret");
let output = run_fetch_cli_with_args(&url, &[])?;
assert!(
output.status.success(),
"moli fetch failed: stdout={}\nstderr={}",
clean_output(&output.stdout),
clean_output(&output.stderr)
);
assert_eq!(
server.robots_hits(),
0,
"the default fetch must not pay for a robots.txt request"
);
assert_eq!(server.page_hits(), 1);
runtime.block_on(server.shutdown());
Ok(())
}
#[test]
fn cli_obey_robots_allows_everything_when_robots_txt_is_absent() -> Result<()> {
// RFC 9309 §2.3.1.3: a 4xx means the origin published no rules.
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(RobotsFixtureServer::spawn(StatusCode::NOT_FOUND, ""))?;
let url = server.url("/private/secret");
let output = run_fetch_cli_with_args(&url, &["--obey-robots"])?;
assert!(
output.status.success(),
"moli fetch failed: stdout={}\nstderr={}",
clean_output(&output.stdout),
clean_output(&output.stderr)
);
assert_eq!(server.page_hits(), 1);
runtime.block_on(server.shutdown());
Ok(())
}
#[test]
fn cli_obey_robots_refuses_everything_when_robots_txt_is_unreachable() -> Result<()> {
// RFC 9309 §2.3.1.4: a 5xx hides rules that may exist, so nothing is safe.
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(RobotsFixtureServer::spawn(
StatusCode::SERVICE_UNAVAILABLE,
"",
))?;
let url = server.url("/allowed");
let output = run_fetch_cli_with_args(&url, &["--obey-robots"])?;
assert!(
!output.status.success(),
"moli fetch should have been refused: stdout={}",
clean_output(&output.stdout)
);
let stderr = clean_output(&output.stderr);
assert!(stderr.contains("could not be read"), "stderr={stderr}");
assert_eq!(server.page_hits(), 0);
runtime.block_on(server.shutdown());
Ok(())
}
const ROBOTS_NAMING_ONE_AGENT: &str =
"User-agent: MoliTestBot\nDisallow: /private\n\nUser-agent: *\nAllow: /\n";
#[test]
fn cli_obey_robots_applies_the_group_naming_the_configured_user_agent() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(RobotsFixtureServer::spawn(
StatusCode::OK,
ROBOTS_NAMING_ONE_AGENT,
))?;
let url = server.url("/private/secret");
let named =
run_fetch_cli_with_args(&url, &["--obey-robots", "--user-agent", "MoliTestBot/1.0"])?;
assert!(
!named.status.success(),
"the named group must refuse: stdout={}",
clean_output(&named.stdout)
);
assert_eq!(server.page_hits(), 0);
// The same URL is fine for a user agent the file does not name, which is
// what proves the group was selected rather than merged.
let unnamed = run_fetch_cli_with_args(&url, &["--obey-robots"])?;
assert!(
unnamed.status.success(),
"the wildcard group must allow: stdout={}\nstderr={}",
clean_output(&unnamed.stdout),
clean_output(&unnamed.stderr)
);
assert_eq!(server.page_hits(), 1);
runtime.block_on(server.shutdown());
Ok(())
}