mirror of
https://github.com/lexmount/moli.git
synced 2026-10-07 16:01:01 +00:00
4420 lines
144 KiB
Rust
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(())
|
|
}
|