#[cfg(test)]
mod cli {
use anyhow::anyhow;
use assert_cmd::{assert::Assert, cargo::cargo_bin_cmd, output::OutputOkExt};
use assert_json_diff::assert_json_include;
use http::{Method, StatusCode};
use lychee_lib::{InputSource, ResponseBody};
use predicates::{
prelude::PredicateBooleanExt,
str::{contains, is_empty},
};
use pretty_assertions::assert_eq;
use regex::Regex;
use serde::Serialize;
use serde_json::{Value, json};
use std::{
collections::{HashMap, HashSet},
error::Error,
fs::{self, File},
io::{BufRead, Write},
ops::Not,
path::Path,
time::{Duration, Instant},
};
use tempfile::{NamedTempFile, tempdir};
use test_utils::{fixtures_path, mock_server, redirecting_mock_server, root_path};
use url::Url;
use uuid::Uuid;
use wiremock::{
Mock, Request, ResponseTemplate,
matchers::{basic_auth, method, path},
};
type Result<T> = std::result::Result<T, Box<dyn Error>>;
const LYCHEE_CACHE_FILE: &str = ".lycheecache";
macro_rules! mock_response {
($body:expr) => {{
let mock_server = wiremock::MockServer::start().await;
let template = wiremock::ResponseTemplate::new(200).set_body_string($body);
wiremock::Mock::given(wiremock::matchers::method("GET"))
.respond_with(template)
.mount(&mock_server)
.await;
mock_server
}};
}
async fn text_fragments_server() -> Result<wiremock::MockServer> {
let mock_server = wiremock::MockServer::start().await;
let fixtures_dir = fixtures_path!().join("text_fragments");
let html_response = |name: &str| -> Result<ResponseTemplate> {
let body = fs::read(fixtures_dir.join(name))?;
Ok(ResponseTemplate::new(200).set_body_raw(body, "text/html"))
};
Mock::given(method("GET"))
.and(path("/index.html"))
.respond_with(html_response("index.html")?)
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/should-match.html"))
.respond_with(html_response("should-match.html")?)
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/should-not-match.html"))
.respond_with(html_response("should-not-match.html")?)
.mount(&mock_server)
.await;
Ok(mock_server)
}
fn path_str(base: &Path, relative_path: &str) -> String {
base.join(relative_path).to_string_lossy().to_string()
}
fn assert_lines_eq<S: AsRef<str> + Ord>(result: Assert, mut expected_lines: Vec<S>) {
let output = &result.get_output().stdout;
let mut actual_lines: Vec<String> = output.lines().map(|line| line.unwrap()).collect();
actual_lines.sort();
expected_lines.sort();
let expected_lines: Vec<&str> = expected_lines.iter().map(|l| l.as_ref()).collect();
assert_eq!(actual_lines, expected_lines);
}
fn stdout_to_json(stdout: &[u8]) -> Value {
let mut output_json =
serde_json::from_slice::<Value>(stdout).expect("stdout is not valid JSON");
remove_nondeterministic_duration(&mut output_json["success_map"]);
remove_nondeterministic_duration(&mut output_json["excluded_map"]);
return output_json;
fn remove_nondeterministic_duration(value: &mut Value) {
let map = value.as_object_mut().expect("Expected object");
map.iter_mut().for_each(|(_, v)| {
v.as_array_mut()
.expect("Expected array of objects")
.iter_mut()
.for_each(|a| {
a.as_object_mut()
.expect("Expected object")
.remove("duration")
.expect("Value of 'duration' not present");
});
});
}
}
macro_rules! test_json_output {
($test_file:expr, $expected:expr $(, $arg:expr)*) => {{
let mut cmd = cargo_bin_cmd!();
let test_path = fixtures_path!().join($test_file);
let outfile = format!("{}.json", uuid::Uuid::new_v4());
let result = cmd$(.arg($arg))*.arg("--output").arg(&outfile).arg("--format").arg("json").arg(test_path).assert();
let output = std::fs::read_to_string(&outfile)?;
std::fs::remove_file(outfile)?;
let actual: Value = serde_json::from_str(&output)?;
let expected: Value = serde_json::to_value(&$expected)?;
result.success();
assert_json_include!(actual: actual, expected: expected);
Ok(())
}};
}
#[derive(Default, Serialize)]
struct MockResponseStats {
detailed_stats: bool,
total: usize,
successful: usize,
unknown: usize,
unsupported: usize,
timeouts: usize,
redirects: usize,
excludes: usize,
errors: usize,
cached: usize,
success_map: HashMap<InputSource, HashSet<ResponseBody>>,
error_map: HashMap<InputSource, HashSet<ResponseBody>>,
suggestion_map: HashMap<InputSource, HashSet<ResponseBody>>,
excluded_map: HashMap<InputSource, HashSet<ResponseBody>>,
}
#[tokio::test]
async fn test_compact_output_format_contains_status() -> Result<()> {
let not_found = mock_server!(StatusCode::NOT_FOUND);
let internal_server_error = mock_server!(StatusCode::INTERNAL_SERVER_ERROR);
let bad_gateway = mock_server!(StatusCode::BAD_GATEWAY);
let contents = format!(
"{} {} {}",
¬_found.uri(),
&internal_server_error.uri(),
&bad_gateway.uri(),
);
let mut cmd = cargo_bin_cmd!();
cmd.write_stdin(contents)
.arg("-")
.arg("--format")
.arg("compact")
.arg("--mode")
.arg("color")
.env("FORCE_COLOR", "1")
.assert()
.failure()
.code(2);
let output = cmd.output()?;
let output_str = String::from_utf8_lossy(&output.stdout);
let re = Regex::new(r"\s{5}\[\d{3}\] http://.* | Rejected status code").unwrap();
let matches: Vec<&str> = re.find_iter(&output_str).map(|m| m.as_str()).collect();
assert_eq!(matches.len(), 3);
Ok(())
}
#[tokio::test]
async fn test_json_output() -> Result<()> {
let mock_server_ok = mock_server!(StatusCode::OK);
let mut cmd = cargo_bin_cmd!();
cmd.arg("--format")
.arg("json")
.arg("-vv")
.arg("--no-progress")
.arg("-")
.write_stdin(mock_server_ok.uri())
.assert()
.success();
let output = cmd.output().unwrap();
let output_json = stdout_to_json(&output.stdout);
assert!(output_json.is_object());
assert!(output_json.get("detailed_stats").is_some());
assert!(output_json.get("success_map").is_some());
assert!(output_json.get("error_map").is_some());
assert!(output_json.get("excluded_map").is_some());
let success_map = &output_json["success_map"];
let mock_url = mock_server_ok.uri();
let expected_success_map = serde_json::json!({
"stdin": [
{
"span": {
"column": 1,
"line": 1,
},
"status": {
"code": 200,
"text": "200 OK"
},
"url": format!("{mock_url}/"),
}
]
});
assert_eq!(
*success_map, expected_success_map,
"Success map doesn't match expected structure"
);
Ok(())
}
#[test]
fn test_valid_json_output_to_stdout_on_error() -> Result<()> {
let test_path = fixtures_path!().join("TEST_GITHUB_404.md");
let mut cmd = cargo_bin_cmd!();
cmd.arg("--format")
.arg("json")
.arg(test_path)
.assert()
.failure()
.code(2);
let output = cmd.output()?;
stdout_to_json(&output.stdout);
Ok(())
}
#[test]
fn test_detailed_json_output_on_error() -> Result<()> {
let test_path = fixtures_path!().join("TEST_DETAILED_JSON_OUTPUT_ERROR.md");
let mut cmd = cargo_bin_cmd!();
cmd.arg("--format")
.arg("json")
.arg(&test_path)
.assert()
.failure()
.code(2);
let output = cmd.output()?;
let output_json = stdout_to_json(&output.stdout);
let site_error_status =
&output_json["error_map"][&test_path.to_str().unwrap()][0]["status"];
assert_eq!(
"SSL certificate expired. Site needs to renew certificate",
site_error_status["details"]
);
Ok(())
}
#[test]
fn test_exclude_all_private() -> Result<()> {
test_json_output!(
"TEST_ALL_PRIVATE.md",
MockResponseStats {
total: 7,
excludes: 7,
..MockResponseStats::default()
},
"--exclude-all-private"
)
}
#[test]
fn test_local_directories() -> Result<()> {
test_json_output!(
"TEST_LOCAL_DIRECTORIES.md",
MockResponseStats {
total: 4,
successful: 4,
..MockResponseStats::default()
}
)
}
#[test]
fn test_email() -> Result<()> {
cargo_bin_cmd!()
.write_stdin("test@example.com idiomatic-rust-doesnt-exist-man@wikipedia.org")
.arg("--include-mail")
.arg("-")
.assert()
.code(2)
.stdout(contains(
"mailto:test@example.com (at 1:1) | No MX records found for domain",
))
.stdout(contains(
"mailto:idiomatic-rust-doesnt-exist-man@wikipedia.org (at 1:18) | Mail server rejects the address",
))
.stdout(contains("2 Errors"));
test_json_output!(
"TEST_EMAIL.md",
MockResponseStats {
total: 3,
successful: 3,
..MockResponseStats::default()
},
"--include-mail"
)
}
#[test]
fn test_exclude_email_by_default() -> Result<()> {
test_json_output!(
"TEST_EMAIL.md",
MockResponseStats {
total: 3,
excludes: 2,
successful: 1,
..MockResponseStats::default()
}
)
}
#[test]
fn test_email_html_with_subject() {
let input = fixtures_path!().join("TEST_EMAIL_QUERY_PARAMS.html");
cargo_bin_cmd!()
.arg("--dump")
.arg(input)
.arg("--include-mail")
.assert()
.success()
.stdout(contains("hello@example.org?subject=%5BHello%5D"));
}
#[test]
fn test_email_markdown_with_subject() {
let input = fixtures_path!().join("TEST_EMAIL_QUERY_PARAMS.md");
cargo_bin_cmd!()
.arg("--dump")
.arg(input)
.arg("--include-mail")
.assert()
.success()
.stdout(contains("hello@example.org?subject=%5BHello%5D"));
}
#[test]
fn test_stylesheet_misinterpreted_as_email() -> Result<()> {
test_json_output!(
"TEST_STYLESHEET_LINK.md",
MockResponseStats {
total: 0,
..MockResponseStats::default()
}
)
}
#[test]
fn test_check_github_no_token() -> Result<()> {
test_json_output!(
"TEST_GITHUB.md",
MockResponseStats {
total: 1,
successful: 1,
..MockResponseStats::default()
}
)
}
#[test]
fn test_unsupported_uri_schemes_are_ignored() {
let test_schemes_path = fixtures_path!().join("TEST_SCHEMES.txt");
cargo_bin_cmd!()
.arg(test_schemes_path)
.arg("--exclude")
.arg("file://")
.env_clear()
.assert()
.success()
.stdout(contains("3 Total"))
.stdout(contains("1 OK"))
.stdout(contains("1 Excluded"));
}
#[test]
fn test_resolve_paths() {
let dir = fixtures_path!().join("resolve_paths");
cargo_bin_cmd!()
.arg("--offline")
.arg("--base-url")
.arg(&dir)
.arg(dir.join("index.html"))
.env_clear()
.assert()
.success()
.stdout(contains("3 Total"))
.stdout(contains("3 OK"));
}
#[test]
fn test_resolve_paths_from_root_dir() {
let dir = fixtures_path!().join("resolve_paths_from_root_dir");
cargo_bin_cmd!()
.arg("--offline")
.arg("--include-fragments")
.arg("--root-dir")
.arg(&dir)
.arg(dir.join("nested").join("index.html"))
.env_clear()
.assert()
.failure()
.stdout(contains("7 Total"))
.stdout(contains("5 OK"))
.stdout(contains("2 Errors"));
cargo_bin_cmd!()
.current_dir(dir.parent().unwrap())
.arg("--offline")
.arg("--include-fragments")
.arg("--root-dir")
.arg(dir.file_name().unwrap())
.arg(dir.join("nested").join("index.html"))
.env_clear()
.assert()
.failure()
.stdout(contains("7 Total"))
.stdout(contains("5 OK"))
.stdout(contains("2 Errors"));
}
#[test]
fn test_resolve_paths_from_root_dir_and_base_url() {
let dir = fixtures_path!();
cargo_bin_cmd!()
.arg("--offline")
.arg("--root-dir")
.arg("/resolve_paths")
.arg("--base-url")
.arg(&dir)
.arg(dir.join("resolve_paths").join("index2.html"))
.env_clear()
.assert()
.success()
.stdout(contains("5 Total"))
.stdout(contains("5 OK"));
}
#[test]
fn test_resolve_paths_from_root_dir_and_local_base_url() {
let dir = fixtures_path!();
cargo_bin_cmd!()
.arg("--dump")
.arg("--root-dir")
.arg("/root")
.arg("--base-url")
.arg("/base/")
.arg(dir.join("resolve_paths").join("index2.html"))
.env_clear()
.assert()
.success()
.stdout(contains("file:///base/root"))
.stdout(contains("file:///base/root/about"))
.stdout(contains("file:///base/resolve_paths/index.html"))
.stdout(contains("file:///base/root/another%20page#y"))
.stdout(contains("file:///base/resolve_paths/same%20folder.html#x"));
}
#[test]
fn test_root_relative_with_remote_base_url_and_root_dir() {
cargo_bin_cmd!()
.arg("-")
.arg("--dump")
.arg("--base-url=https://example.com/docs/")
.arg("--root-dir=/tmp")
.arg("--default-extension=md")
.write_stdin("[a](/page)")
.assert()
.success()
.stdout(contains("https://example.com/page"));
}
#[test]
fn test_nonexistent_root_dir() {
cargo_bin_cmd!()
.arg("--root-dir")
.arg("i don't exist blah blah")
.arg("http://example.com")
.assert()
.success();
let file = NamedTempFile::new().unwrap();
cargo_bin_cmd!()
.arg("--root-dir")
.arg(file.path())
.arg("http://example.com")
.assert()
.success();
}
#[test]
fn test_youtube_quirk() {
let url = "https://www.youtube.com/watch?v=NlKuICiT470&list=PLbWDhxwM_45mPVToqaIZNbZeIzFchsKKQ&index=7";
cargo_bin_cmd!()
.write_stdin(url)
.arg("--verbose")
.arg("--no-progress")
.arg("-")
.assert()
.success()
.stdout(contains("1 Total"))
.stdout(contains("1 OK"));
}
#[test]
fn test_crates_io_quirk() {
let url = "https://crates.io/crates/lychee";
cargo_bin_cmd!()
.write_stdin(url)
.arg("--verbose")
.arg("--no-progress")
.arg("-")
.assert()
.success()
.stdout(contains("1 Total"))
.stdout(contains("1 OK"));
}
#[test]
fn test_ignored_hosts() {
let url = "https://twitter.com/zarfeblong/status/1339742840142872577";
cargo_bin_cmd!()
.write_stdin(url)
.arg("--verbose")
.arg("--no-progress")
.arg("-")
.assert()
.success()
.stdout(contains("1 Total"))
.stdout(contains("1 Excluded"));
}
#[tokio::test]
async fn test_failure_404_link() -> Result<()> {
let mock_server = mock_server!(StatusCode::NOT_FOUND);
let dir = tempfile::tempdir()?;
let file_path = dir.path().join("test.txt");
let mut file = File::create(&file_path)?;
writeln!(file, "{}", mock_server.uri())?;
cargo_bin_cmd!()
.arg(file_path)
.write_stdin(mock_server.uri())
.assert()
.failure()
.code(2);
Ok(())
}
#[test]
fn test_schemes() {
let test_schemes_path = fixtures_path!().join("TEST_SCHEMES.md");
cargo_bin_cmd!()
.arg(test_schemes_path)
.arg("--scheme")
.arg("https")
.arg("--scheme")
.arg("http")
.env_clear()
.assert()
.success()
.stdout(contains("3 Total"))
.stdout(contains("2 OK"))
.stdout(contains("1 Excluded"));
}
#[test]
fn test_failure_github_404_no_token() {
let test_github_404_path = fixtures_path!().join("TEST_GITHUB_404.md");
cargo_bin_cmd!()
.arg(test_github_404_path)
.arg("--no-progress")
.env_clear()
.assert()
.failure()
.code(2)
.stdout(contains(
r#"[404] https://github.com/mre/idiomatic-rust-doesnt-exist-man (at 3:9) | Rejected status code: 404 Not Found (configurable with "accept" option)"#
))
.stderr(contains(
"There were issues with GitHub URLs. You could try setting a GitHub token and running lychee again.",
));
}
#[tokio::test]
async fn test_stdin_input() {
let mock_server = mock_server!(StatusCode::OK);
cargo_bin_cmd!()
.arg("-")
.write_stdin(mock_server.uri())
.assert()
.success();
}
#[tokio::test]
async fn test_stdin_input_failure() {
let mock_server = mock_server!(StatusCode::INTERNAL_SERVER_ERROR);
cargo_bin_cmd!()
.arg("-")
.write_stdin(mock_server.uri())
.assert()
.failure()
.code(2);
}
#[tokio::test]
async fn test_stdin_input_multiple() {
let mock_server_a = mock_server!(StatusCode::OK);
let mock_server_b = mock_server!(StatusCode::OK);
cargo_bin_cmd!()
.arg("-")
.arg("-")
.write_stdin(mock_server_a.uri())
.write_stdin(mock_server_b.uri())
.assert()
.success();
}
#[test]
fn test_fails_if_input_file_missing_even_with_skip_missing() {
let filename = format!("non-existing-file-{}", uuid::Uuid::new_v4());
cargo_bin_cmd!()
.arg(&filename)
.arg("--skip-missing")
.assert()
.failure();
}
#[test]
fn test_skips_hidden_files_by_default() {
cargo_bin_cmd!()
.arg(fixtures_path!().join("hidden/"))
.assert()
.success()
.stdout(contains("0 Total"));
cargo_bin_cmd!()
.arg("--dump")
.arg(fixtures_path!().join("hidden/"))
.assert()
.stdout("")
.success();
cargo_bin_cmd!()
.arg("--dump-inputs")
.arg(fixtures_path!().join("hidden/"))
.assert()
.stdout("")
.success();
}
#[test]
fn test_include_hidden_file() {
cargo_bin_cmd!()
.arg(fixtures_path!().join("hidden/"))
.arg("--hidden")
.assert()
.success()
.stdout(contains("2 Total"));
let result = cargo_bin_cmd!()
.arg("--dump")
.arg("--hidden")
.arg(fixtures_path!().join("hidden/"))
.assert()
.success();
assert_lines_eq(
result,
vec!["https://rust-lang.org/", "https://rust-lang.org/"],
);
}
#[test]
fn test_skips_ignored_files_by_default() {
cargo_bin_cmd!()
.arg(fixtures_path!().join("ignore/"))
.assert()
.success()
.stdout(contains("0 Total"));
cargo_bin_cmd!()
.arg("--dump")
.arg(fixtures_path!().join("ignore/"))
.assert()
.success()
.stdout("");
cargo_bin_cmd!()
.arg("--dump-inputs")
.arg(fixtures_path!().join("ignore/"))
.assert()
.success()
.stdout("");
}
#[test]
fn test_include_ignored_file() {
cargo_bin_cmd!()
.arg(fixtures_path!().join("ignore/"))
.arg("--no-ignore")
.assert()
.success()
.stdout(contains("1 Total"));
cargo_bin_cmd!()
.arg("--dump")
.arg("--no-ignore")
.arg(fixtures_path!().join("ignore/"))
.assert()
.success()
.stdout(contains("wikipedia.org"));
cargo_bin_cmd!()
.arg("--dump-inputs")
.arg("--no-ignore")
.arg(fixtures_path!().join("ignore/"))
.assert()
.success()
.stdout(contains("ignored-file.md"));
}
#[tokio::test]
async fn test_glob() -> Result<()> {
let dir = tempfile::tempdir()?;
let mock_server_a = mock_server!(StatusCode::OK);
let mock_server_b = mock_server!(StatusCode::OK);
let mut file_a = File::create(dir.path().join("a.md"))?;
let mut file_b = File::create(dir.path().join("b.md"))?;
writeln!(file_a, "{}", mock_server_a.uri().as_str())?;
writeln!(file_b, "{}", mock_server_b.uri().as_str())?;
cargo_bin_cmd!()
.arg(dir.path().join("*.md"))
.arg("--verbose")
.assert()
.success()
.stdout(contains("2 Total"));
Ok(())
}
#[cfg(target_os = "linux")] #[tokio::test]
async fn test_glob_ignore_case() -> Result<()> {
let dir = tempfile::tempdir()?;
let mock_server_a = mock_server!(StatusCode::OK);
let mock_server_b = mock_server!(StatusCode::OK);
let mut file_a = File::create(dir.path().join("README.md"))?;
let mut file_b = File::create(dir.path().join("readme.md"))?;
writeln!(file_a, "{}", mock_server_a.uri().as_str())?;
writeln!(file_b, "{}", mock_server_b.uri().as_str())?;
cargo_bin_cmd!()
.arg(dir.path().join("[r]eadme.md"))
.arg("--verbose")
.arg("--glob-ignore-case")
.assert()
.success()
.stdout(contains("2 Total"));
Ok(())
}
#[tokio::test]
async fn test_glob_recursive() -> Result<()> {
let dir = tempfile::tempdir()?;
let subdir_level_1 = dir.path().join("level1");
let subdir_level_2 = subdir_level_1.join("level2");
std::fs::create_dir_all(&subdir_level_2)?;
let mock_server = mock_server!(StatusCode::OK);
let mut file = File::create(subdir_level_2.join("test.md"))?;
writeln!(file, "{}", mock_server.uri().as_str())?;
cargo_bin_cmd!()
.arg(dir.path().join("**/*.md")) .arg("--verbose")
.assert()
.success()
.stdout(contains("1 Total"));
Ok(())
}
#[test]
fn test_glob_skips_hidden_files_by_default() {
let test_dir = fixtures_path!().join("hidden");
cargo_bin_cmd!()
.arg("--dump")
.arg(test_dir.join("**/*"))
.assert()
.success()
.stdout(is_empty());
cargo_bin_cmd!()
.arg("--dump-inputs")
.arg(test_dir.join("**/*"))
.assert()
.success()
.stdout(is_empty());
}
#[test]
fn test_glob_includes_hidden_files_with_flag() {
let test_dir = fixtures_path!().join("hidden");
let result = cargo_bin_cmd!()
.arg("--dump")
.arg("--hidden")
.arg(test_dir.join("**/*"))
.assert()
.success();
assert_lines_eq(
result,
vec!["https://rust-lang.org/", "https://rust-lang.org/"],
);
cargo_bin_cmd!()
.arg("--dump-inputs")
.arg("--hidden")
.arg(test_dir.join("**/*"))
.assert()
.success()
.stdout(contains(".file.md"))
.stdout(contains(".hidden/file.md"));
}
#[test]
fn test_formatted_file_output() -> Result<()> {
test_json_output!(
"TEST.md",
MockResponseStats {
total: 12,
successful: 10,
excludes: 2,
..MockResponseStats::default()
}
)
}
#[test]
fn test_dump_to_file() -> Result<()> {
let test_path = fixtures_path!().join("TEST.md");
let outfile = format!("{}", Uuid::new_v4());
cargo_bin_cmd!()
.arg("--output")
.arg(&outfile)
.arg("--dump")
.arg("--include-mail")
.arg(test_path)
.assert()
.success();
let output = fs::read_to_string(&outfile)?;
assert_eq!(output.lines().count(), 12);
fs::remove_file(outfile)?;
Ok(())
}
#[test]
fn test_exclude_wildcard() {
let test_path = fixtures_path!().join("TEST.md");
cargo_bin_cmd!()
.arg(test_path)
.arg("--exclude")
.arg(".*")
.assert()
.success()
.stdout(contains("12 Excluded"));
}
#[test]
fn test_exclude_multiple_urls() {
let test_path = fixtures_path!().join("TEST.md");
cargo_bin_cmd!()
.arg(test_path)
.arg("--exclude")
.arg("https://en.wikipedia.org/*")
.arg("--exclude")
.arg("https://ldra.com/")
.assert()
.success()
.stdout(contains("4 Excluded"));
}
#[tokio::test]
async fn test_empty_config() {
let mock_server = mock_server!(StatusCode::OK);
let config = fixtures_path!().join("configs").join("empty.toml");
cargo_bin_cmd!()
.arg("--config")
.arg(config)
.arg("-")
.write_stdin(mock_server.uri())
.env_clear()
.assert()
.success()
.stdout(contains("1 Total"))
.stdout(contains("1 OK"));
}
#[test]
fn test_invalid_default_config() {
let test_path = fixtures_path!().join("configs");
let mut cmd = cargo_bin_cmd!();
cmd.current_dir(test_path)
.arg(".")
.assert()
.failure()
.stderr(contains("Cannot load configuration file"));
}
#[tokio::test]
async fn test_include_mail_config() -> Result<()> {
let test_mail_address = "mailto:hello-test@testingabc.io";
let mut config = NamedTempFile::new()?;
writeln!(config, "include_mail = false")?;
cargo_bin_cmd!()
.arg("--config")
.arg(config.path().to_str().unwrap())
.arg("-")
.write_stdin(test_mail_address)
.env_clear()
.assert()
.success()
.stdout(contains("1 Total"))
.stdout(contains("1 Excluded"));
let mut config = NamedTempFile::new()?;
writeln!(config, "include_mail = true")?;
cargo_bin_cmd!()
.arg("--config")
.arg(config.path().to_str().unwrap())
.arg("-")
.write_stdin(test_mail_address)
.env_clear()
.assert()
.failure()
.stdout(contains("1 Total"))
.stdout(contains("1 Error"));
Ok(())
}
#[tokio::test]
async fn test_cache_config() -> Result<()> {
let mock_server = mock_server!(StatusCode::OK);
let config = fixtures_path!().join("configs").join("cache.toml");
cargo_bin_cmd!()
.arg("--config")
.arg(config)
.arg("-")
.write_stdin(mock_server.uri())
.env_clear()
.assert()
.success()
.stdout(contains("1 Total"))
.stdout(contains("1 OK"));
Ok(())
}
#[tokio::test]
async fn test_invalid_config() {
let config = fixtures_path!().join("configs").join("invalid.toml");
cargo_bin_cmd!()
.arg("--config")
.arg(config)
.arg("-")
.env_clear()
.assert()
.failure()
.stderr(contains("Cannot load configuration file"))
.stderr(contains("Failed to parse"))
.stderr(contains("TOML parse error"));
}
#[tokio::test]
async fn test_invalid_config_type_error_shows_details() {
let config = fixtures_path!().join("configs").join("invalid-type.toml");
cargo_bin_cmd!()
.arg("--config")
.arg(config)
.arg("-")
.env_clear()
.assert()
.failure()
.stderr(contains("Cannot load configuration file"))
.stderr(contains("TOML parse error"))
.stderr(contains("invalid type"))
.stderr(contains("threads = 'a'"));
}
#[tokio::test]
async fn test_configs_precedence() {
let path = fixtures_path!().join("configs");
cargo_bin_cmd!()
.current_dir(&path)
.arg(".")
.arg("--config")
.arg("precedence-compact.toml")
.arg("--config")
.arg("precedence-json.toml") .assert()
.success()
.stdout(contains(r#""total": 1,"#))
.stdout(contains("1 Total").not());
cargo_bin_cmd!()
.current_dir(path)
.arg(".")
.arg("--config")
.arg("precedence-json.toml")
.arg("--config")
.arg("precedence-compact.toml") .assert()
.success()
.stdout(contains("1 Total"))
.stdout(contains(r#""total": 1,"#).not());
}
#[tokio::test]
async fn test_cli_option_precedence() {
let path = fixtures_path!().join("configs");
cargo_bin_cmd!()
.current_dir(&path)
.arg(".")
.arg("--format")
.arg("json")
.arg("--config")
.arg("precedence-compact.toml")
.assert()
.success()
.stdout(contains(r#""total": 1,"#))
.stdout(contains("1 Total").not());
}
#[tokio::test]
async fn test_config_merging() {
let path = fixtures_path!().join("configs");
cargo_bin_cmd!()
.current_dir(&path)
.write_stdin("https://a.dev https://b.dev")
.arg("-")
.arg("--config")
.arg("exclude-a.toml")
.arg("--config")
.arg("exclude-b.toml")
.assert()
.success()
.stdout(contains("2 Excluded"));
}
#[tokio::test]
async fn test_config_invalid_keys() {
let mock_server = mock_server!(StatusCode::OK);
let config = fixtures_path!().join("configs").join("invalid-key.toml");
cargo_bin_cmd!()
.arg("--config")
.arg(config)
.arg("-")
.write_stdin(mock_server.uri())
.env_clear()
.assert()
.failure()
.code(3)
.stderr(contains("unknown field `this_is_invalid`, expected one of"));
}
#[tokio::test]
async fn test_missing_config_error() {
let mock_server = mock_server!(StatusCode::OK);
cargo_bin_cmd!()
.arg("--config")
.arg("config.does.not.exist.toml")
.arg("-")
.write_stdin(mock_server.uri())
.env_clear()
.assert()
.failure();
}
#[tokio::test]
async fn test_config_example() {
let config = root_path!().join("lychee.example.toml");
cargo_bin_cmd!()
.current_dir(root_path!())
.arg("--config")
.arg(config)
.arg("-")
.env_clear()
.assert()
.success();
}
#[test]
#[cfg(unix)]
fn test_all_arguments_in_config() -> Result<()> {
let help_cmd = cargo_bin_cmd!()
.env_clear()
.arg("--help")
.assert()
.success();
let help_text = std::str::from_utf8(&help_cmd.get_output().stdout)?;
let regex = test_utils::arg_regex_help!()?;
let excluded = [
"base", "exclude_file", "config", "quiet", "help", "version", ];
let arguments: Vec<String> = help_text
.lines()
.filter_map(|line| {
let captures = regex.captures(line)?;
captures.name("long").map(|m| m.as_str())
})
.map(|arg| arg.replace("-", "_"))
.filter(|arg| !excluded.contains(&arg.as_str()))
.collect();
let config = root_path!().join("lychee.example.toml");
let values: toml::Table = toml::from_str(&std::fs::read_to_string(config)?)?;
for argument in arguments {
if !values.contains_key(&argument) {
panic!(
"Key '{argument}' missing in config.
The config file should contain every possible key for documentation purposes."
)
}
}
Ok(())
}
#[tokio::test]
async fn test_config_smoketest() {
let mock_server = mock_server!(StatusCode::OK);
let config = fixtures_path!().join("configs").join("smoketest.toml");
cargo_bin_cmd!()
.arg("--config")
.arg(config)
.arg("-")
.write_stdin(mock_server.uri())
.env_clear()
.assert()
.success();
}
#[tokio::test]
async fn test_config_accept() {
let mock_server = mock_server!(StatusCode::OK);
let config = fixtures_path!().join("configs").join("accept.toml");
cargo_bin_cmd!()
.arg("--config")
.arg(config)
.arg("-")
.write_stdin(mock_server.uri())
.env_clear()
.assert()
.success();
}
#[tokio::test]
#[cfg(unix)]
async fn test_config_files_from() {
let dir = fixtures_path!().join("configs").join("files_from");
let result = cargo_bin_cmd!()
.current_dir(dir)
.arg("/dev/null") .arg("--dump")
.assert()
.success();
assert_lines_eq(result, vec!["https://wikipedia.org/"]);
}
#[test]
fn test_lycheeignore_file() -> Result<()> {
let test_path = fixtures_path!().join("lycheeignore");
let cmd = cargo_bin_cmd!()
.current_dir(test_path)
.arg("--dump")
.arg("TEST.md")
.assert()
.stdout(contains("https://example.com"))
.stdout(contains("https://example.com/bar"))
.stdout(contains("https://example.net"));
let output = cmd.get_output();
let output = std::str::from_utf8(&output.stdout).unwrap();
assert_eq!(output.lines().count(), 3);
Ok(())
}
#[test]
fn test_lycheeignore_and_exclude_file() -> Result<()> {
let test_path = fixtures_path!().join("lycheeignore");
let excludes_path = test_path.join("normal-exclude-file");
cargo_bin_cmd!()
.current_dir(test_path)
.arg("--insecure")
.arg("TEST.md")
.arg("--exclude-file")
.arg(excludes_path)
.assert()
.success()
.stdout(contains("8 Total"))
.stdout(contains("6 Excluded"));
Ok(())
}
#[tokio::test]
async fn test_lycheecache_file() -> Result<()> {
let dir = tempfile::tempdir()?;
let base_path = dir.path();
let cache_file = base_path.join(LYCHEE_CACHE_FILE);
if cache_file.exists() {
println!("Removing cache file before test: {cache_file:?}");
fs::remove_file(&cache_file)?;
tokio::time::sleep(Duration::from_millis(500)).await;
}
let mock_server_ok = mock_server!(StatusCode::OK);
let mock_server_err = mock_server!(StatusCode::NOT_FOUND);
let mock_server_exclude = mock_server!(StatusCode::OK);
let dir = tempfile::tempdir()?;
let file_path = dir.path().join("c.md");
let mut file = File::create(&file_path)?;
writeln!(file, "{}", mock_server_ok.uri().as_str())?;
writeln!(file, "{}", mock_server_err.uri().as_str())?;
writeln!(file, "{}", mock_server_exclude.uri().as_str())?;
file.sync_all()?;
let mut cmd = cargo_bin_cmd!();
cmd.current_dir(base_path)
.arg(&file_path)
.arg("-vv")
.arg("--no-progress")
.arg("--cache")
.arg("--exclude")
.arg(mock_server_exclude.uri());
let _output = cmd.output()?;
for _ in 0..20 {
if cache_file.exists() {
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
let data = fs::read_to_string(&cache_file)?;
println!("Cache file contents: {data}");
assert!(
data.contains(&format!("{}/,200", mock_server_ok.uri())),
"Missing OK entry in cache"
);
assert!(
!data.contains(&format!("{}/,404", mock_server_err.uri())),
"Error entry should not be cached"
);
cmd.assert()
.stderr(contains(format!(
"[200] {}/ (at 1:1) | OK (cached)\n",
mock_server_ok.uri()
)))
.stderr(contains(format!(
"[404] {}/ (at 2:1) | Rejected status code: 404 Not Found (configurable with \"accept\" option)\n",
mock_server_err.uri()
)));
fs::remove_file(&cache_file).map_err(|e| {
anyhow::anyhow!("Failed to remove cache file: {cache_file:?}, error: {e}")
})?;
Ok(())
}
#[tokio::test]
async fn test_lycheecache_exclude_custom_status_codes() -> Result<()> {
let dir = tempfile::tempdir()?;
let base_path = dir.path();
let cache_file = base_path.join(LYCHEE_CACHE_FILE);
let _ = fs::remove_file(&cache_file);
let mock_server_ok = mock_server!(StatusCode::OK);
let mock_server_no_content = mock_server!(StatusCode::NO_CONTENT);
let mock_server_too_many_requests = mock_server!(StatusCode::TOO_MANY_REQUESTS);
let dir = tempfile::tempdir()?;
let mut file = File::create(dir.path().join("c.md"))?;
writeln!(file, "{}", mock_server_ok.uri().as_str())?;
writeln!(file, "{}", mock_server_no_content.uri().as_str())?;
writeln!(file, "{}", mock_server_too_many_requests.uri().as_str())?;
let mut cmd = cargo_bin_cmd!();
let test_cmd = cmd
.current_dir(base_path)
.arg(dir.path().join("c.md"))
.arg("-vv")
.arg("--max-retries")
.arg("0")
.arg("--no-progress")
.arg("--cache")
.arg("--cache-exclude-status")
.arg("204,429");
assert!(
!cache_file.exists(),
"cache file should not exist before this test"
);
test_cmd
.assert()
.stderr(contains(format!("[200] {}/ (at 1:1)\n", mock_server_ok.uri())))
.stderr(contains(format!(
"[204] {}/ (at 2:1) | 204 No Content\n",
mock_server_no_content.uri()
)))
.stderr(contains(format!(
"[429] {}/ (at 3:1) | Rejected status code: 429 Too Many Requests (configurable with \"accept\" option)",
mock_server_too_many_requests.uri()
)));
let data = fs::read_to_string(&cache_file)?;
if data.is_empty() {
println!("Cache file is empty!");
}
assert!(data.contains(&format!("{}/,200", mock_server_ok.uri())));
assert!(!data.contains(&format!("{}/,204", mock_server_no_content.uri())));
assert!(!data.contains(&format!("{}/,429", mock_server_too_many_requests.uri())));
let _ = fs::remove_file(&cache_file);
Ok(())
}
#[tokio::test]
async fn test_lycheecache_accept_custom_status_codes() -> Result<()> {
let base_path = fixtures_path!().join("cache_accept_custom_status_codes");
let cache_file = base_path.join(LYCHEE_CACHE_FILE);
let _ = fs::remove_file(&cache_file);
let mock_server_ok = mock_server!(StatusCode::OK);
let mock_server_teapot = mock_server!(StatusCode::IM_A_TEAPOT);
let mock_server_server_error = mock_server!(StatusCode::INTERNAL_SERVER_ERROR);
let dir = tempfile::tempdir()?;
let mut file = File::create(dir.path().join("c.md"))?;
writeln!(file, "{}", mock_server_ok.uri().as_str())?;
writeln!(file, "{}", mock_server_teapot.uri().as_str())?;
writeln!(file, "{}", mock_server_server_error.uri().as_str())?;
let mut cmd = cargo_bin_cmd!();
let test_cmd = cmd
.current_dir(base_path)
.arg(dir.path().join("c.md"))
.arg("-vv")
.arg("--cache");
assert!(
!cache_file.exists(),
"cache file should not exist before this test"
);
test_cmd
.assert()
.failure()
.code(2)
.stdout(contains(format!(
r#"[418] {}/ (at 2:1) | Rejected status code: 418 I'm a teapot (configurable with "accept" option)"#,
mock_server_teapot.uri()
)))
.stdout(contains(format!(
r#"[500] {}/ (at 3:1) | Rejected status code: 500 Internal Server Error (configurable with "accept" option)"#,
mock_server_server_error.uri()
)));
let data = fs::read_to_string(&cache_file)?;
assert!(data.contains(&format!("{}/,200", mock_server_ok.uri())));
assert!(!data.contains(&format!("{}/,418", mock_server_teapot.uri())));
assert!(!data.contains(&format!("{}/,500", mock_server_server_error.uri())));
test_cmd
.arg("--no-progress")
.arg("--accept")
.arg("418,500")
.assert()
.success()
.stderr(contains(format!(
"[418] {}/ (at 2:1) | 418 I'm a teapot",
mock_server_teapot.uri()
)))
.stderr(contains(format!(
"[500] {}/ (at 3:1) | 500 Internal Server Error",
mock_server_server_error.uri()
)));
fs::remove_file(&cache_file)?;
Ok(())
}
#[tokio::test]
async fn test_accept_overrides_defaults_not_additive() -> Result<()> {
let mock_server_200 = mock_server!(StatusCode::OK);
cargo_bin_cmd!()
.arg("--accept")
.arg("404") .arg("-")
.write_stdin(mock_server_200.uri())
.assert()
.failure()
.code(2)
.stdout(contains(format!(
r#"[200] {}/ (at 1:1) | Rejected status code: 200 OK (configurable with "accept" option)"#,
mock_server_200.uri()
)));
Ok(())
}
#[tokio::test]
async fn test_accept_timeout() -> Result<()> {
let mock_server_timeout = mock_server!(StatusCode::OK, set_delay(Duration::from_secs(30)));
cargo_bin_cmd!()
.arg("--max-retries=0")
.arg("--timeout=1")
.arg("-")
.write_stdin(mock_server_timeout.uri())
.assert()
.failure()
.code(2)
.stdout(contains(format!(
r#"[TIMEOUT] {}/ (at 1:1)"#,
mock_server_timeout.uri()
)));
cargo_bin_cmd!()
.arg("--max-retries=0")
.arg("--timeout=1")
.arg("--accept-timeouts")
.arg("-")
.write_stdin(mock_server_timeout.uri())
.assert()
.success()
.code(0)
.stdout(contains(format!(
r#"[TIMEOUT] {}/ (at 1:1)"#,
mock_server_timeout.uri()
)));
Ok(())
}
#[tokio::test]
async fn test_skip_cache_unsupported() -> Result<()> {
let dir = tempfile::tempdir()?;
let base_path = dir.path();
let cache_file = base_path.join(LYCHEE_CACHE_FILE);
let _ = fs::remove_file(&cache_file);
let unsupported_url = "slack://user".to_string();
let excluded_url = "https://example.com/";
cargo_bin_cmd!()
.current_dir(base_path)
.write_stdin(format!("{unsupported_url}\n{excluded_url}"))
.arg("--cache")
.arg("--verbose")
.arg("--no-progress")
.arg("--exclude")
.arg(excluded_url)
.arg("-")
.assert()
.stderr(contains(format!(
"[IGNORED] {unsupported_url} (at 1:1) | Unsupported: Failed to create HTTP request client: builder error for url (slack://user)"
)))
.stderr(contains(format!(
"[EXCLUDED] {excluded_url} (at 2:1) | This is due to your 'exclude' values\n"
)));
let buf = fs::read(&cache_file).unwrap();
assert!(buf.is_empty());
fs::remove_file(&cache_file)?;
Ok(())
}
#[tokio::test]
async fn test_cache_unknown_status_code() -> Result<()> {
let dir = tempfile::tempdir()?;
let base_path = dir.path();
let cache_file = base_path.join(LYCHEE_CACHE_FILE);
let mock_server = wiremock::MockServer::start().await;
Mock::given(wiremock::matchers::method("GET"))
.respond_with(ResponseTemplate::new(999))
.mount(&mock_server)
.await;
cargo_bin_cmd!()
.current_dir(base_path)
.write_stdin(mock_server.uri())
.arg("--cache")
.arg("-")
.assert()
.failure();
let buf = fs::read(&cache_file).unwrap();
assert!(
buf.is_empty(),
"cache file should be empty, but was {}",
String::from_utf8_lossy(&buf)
);
Ok(())
}
#[tokio::test]
async fn test_no_duplicate_requests() {
let server = wiremock::MockServer::start().await;
let count = 100; let cached = "99.0%";
wiremock::Mock::given(wiremock::matchers::method("GET"))
.respond_with(|_: &_| {
ResponseTemplate::new(200).set_delay(Duration::from_secs(1))
})
.expect(1)
.mount(&server)
.await;
cargo_bin_cmd!()
.write_stdin(format!("{} ", server.uri()).repeat(count))
.arg("-")
.arg("--host-stats")
.arg("--host-request-interval=1s")
.assert()
.success()
.stdout(contains("100.0% success"))
.stdout(contains(format!("{cached} cached")));
}
#[tokio::test]
async fn test_process_internal_host_caching() -> Result<()> {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.respond_with(ResponseTemplate::new(429))
.up_to_n_times(2)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.respond_with(ResponseTemplate::new(200))
.expect(1)
.mount(&server)
.await;
let temp_dir = tempfile::tempdir()?;
for i in 0..4 {
let test_md1 = temp_dir.path().join(format!("test{i}.md"));
let duplicate_url_content = format!("{}\n{}", server.uri(), server.uri());
fs::write(&test_md1, duplicate_url_content)?;
}
cargo_bin_cmd!()
.arg(temp_dir.path())
.arg("--host-stats")
.assert()
.success()
.stdout(contains("8 Total"))
.stdout(contains("8 OK"))
.stdout(contains("0 Errors"))
.stdout(contains("10 reqs"))
.stdout(contains("80.0% success"))
.stdout(contains("70.0% cached"));
server.verify().await;
Ok(())
}
#[test]
fn test_verbatim_skipped_by_default() {
let input = fixtures_path!().join("TEST_CODE_BLOCKS.md");
cargo_bin_cmd!()
.arg(input)
.arg("--dump")
.assert()
.success()
.stdout(is_empty());
}
#[test]
fn test_include_verbatim() {
let input = fixtures_path!().join("TEST_CODE_BLOCKS.md");
cargo_bin_cmd!()
.arg("--include-verbatim")
.arg(input)
.arg("--dump")
.assert()
.success()
.stdout(contains("http://127.0.0.1/block"))
.stdout(contains("http://127.0.0.1/inline"))
.stdout(contains("http://127.0.0.1/bash"));
}
#[tokio::test]
async fn test_verbatim_skipped_by_default_via_file() {
let file = fixtures_path!().join("TEST_VERBATIM.html");
cargo_bin_cmd!()
.arg("--dump")
.arg(file)
.assert()
.success()
.stdout(is_empty());
}
#[tokio::test]
async fn test_verbatim_skipped_by_default_via_remote_url() {
let file = fixtures_path!().join("TEST_VERBATIM.html");
let body = fs::read_to_string(file).unwrap();
let mock_server = mock_response!(body);
cargo_bin_cmd!()
.arg("--dump")
.arg(mock_server.uri())
.assert()
.success()
.stdout(is_empty());
}
#[tokio::test]
async fn test_include_verbatim_via_remote_url() {
let file = fixtures_path!().join("TEST_VERBATIM.html");
let body = fs::read_to_string(file).unwrap();
let mock_server = mock_response!(body);
cargo_bin_cmd!()
.arg("--include-verbatim")
.arg("--dump")
.arg(mock_server.uri())
.assert()
.success()
.stdout(contains("http://www.example.com/pre"))
.stdout(contains("http://www.example.com/code"))
.stdout(contains("http://www.example.com/samp"))
.stdout(contains("http://www.example.com/kbd"))
.stdout(contains("http://www.example.com/var"))
.stdout(contains("http://www.example.com/script"));
}
#[test]
fn test_require_https() {
let test_path = fixtures_path!().join("TEST_HTTP.html");
cargo_bin_cmd!().arg(&test_path).assert().success();
cargo_bin_cmd!()
.arg("--require-https")
.arg(test_path)
.assert()
.failure()
.stdout(contains(
"Insecure HTTP URL used, where 'https://example.com/' can be used instead",
));
}
#[test]
fn test_absolute_local_links_without_base() {
let offline_dir = fixtures_path!().join("offline");
cargo_bin_cmd!()
.arg("--offline")
.arg(offline_dir.join("index.html"))
.env_clear()
.assert()
.failure()
.stdout(contains("5 Error"))
.stdout(contains("Cannot resolve root-relative link").count(5));
}
#[test]
fn test_inputs_without_scheme() {
cargo_bin_cmd!()
.arg("--dump")
.arg("example.com")
.assert()
.failure()
.stderr(contains(
"Input 'example.com' not found as file and not a valid URL",
));
}
#[cfg(windows)]
#[test]
fn test_windows_absolute_path_accepted_as_file_input() {
use tempfile::tempdir;
let dir = tempdir().unwrap();
let path_str = dir.path().to_str().unwrap().to_owned();
assert!(
path_str.chars().nth(1) == Some(':'),
"Expected an absolute Windows path with a drive letter, got: {path_str}"
);
cargo_bin_cmd!()
.arg("--dump")
.arg(&path_str)
.assert()
.success();
}
#[test]
fn test_print_excluded_links_in_verbose_mode() {
let test_path = fixtures_path!().join("TEST_DUMP_EXCLUDE.txt");
cargo_bin_cmd!()
.arg("--dump")
.arg("--verbose")
.arg("--exclude")
.arg("example.com")
.arg(&test_path)
.assert()
.success()
.stdout(contains(format!(
"https://example.com/ ({}) [excluded]",
test_path.display()
)))
.stdout(contains(format!(
"https://example.org/ ({})",
test_path.display()
)))
.stdout(contains(format!(
"https://example.com/foo/bar ({}) [excluded]",
test_path.display()
)));
}
#[test]
fn test_remap_uri() {
cargo_bin_cmd!()
.arg("--dump")
.arg("--remap")
.arg("https://example.com http://127.0.0.1:8080")
.arg("--remap")
.arg("https://example.org https://staging.example.com")
.arg("-")
.write_stdin("https://example.com\nhttps://example.org\nhttps://example.net\n")
.env_clear()
.assert()
.success()
.stdout(contains("http://127.0.0.1:8080/"))
.stdout(contains("https://staging.example.com/"))
.stdout(contains("https://example.net/"));
}
#[test]
#[ignore = "Skipping test until https://github.com/robinst/linkify/pull/58 is merged"]
fn test_remap_path() {
cargo_bin_cmd!()
.arg("--dump")
.arg("--remap")
.arg("../../issues https://github.com/usnistgov/OSCAL/issues")
.arg("-")
.write_stdin("../../issues\n")
.env_clear()
.assert()
.success()
.stdout(contains("https://github.com/usnistgov/OSCAL/issues"));
}
#[test]
fn test_remap_capture() {
cargo_bin_cmd!()
.arg("--dump")
.arg("--remap")
.arg("https://example.com/(.*) http://example.org/$1")
.arg("-")
.write_stdin("https://example.com/foo\n")
.env_clear()
.assert()
.success()
.stdout(contains("http://example.org/foo"));
}
#[test]
fn test_remap_named_capture() {
cargo_bin_cmd!()
.arg("--dump")
.arg("--remap")
.arg("https://github.com/(?P<org>.*)/(?P<repo>.*) https://gitlab.com/$org/$repo")
.arg("-")
.write_stdin("https://github.com/lycheeverse/lychee\n")
.env_clear()
.assert()
.success()
.stdout(contains("https://gitlab.com/lycheeverse/lychee"));
}
#[test]
fn test_erroneous_remap_with_redirect() {
cargo_bin_cmd!()
.arg("-vv")
.arg("--remap")
.arg("github.com rust-lang.org")
.arg("-")
.write_stdin("http://github.com/lycheeverse\n")
.env_clear()
.assert()
.failure()
.stdout(contains(r#"
[404] http://rust-lang.org/lycheeverse (at 1:1) | Rejected status code: 404 Not Found (configurable with "accept" option) | Remaps: http://github.com/lycheeverse --> http://rust-lang.org/lycheeverse | Followed 1 redirect. Redirects: http://rust-lang.org/lycheeverse --[301]--> https://rust-lang.org/lycheeverse
"#))
.stderr(contains("[DEBUG] Remapping http://github.com/lycheeverse --> http://rust-lang.org/lycheeverse"))
.stderr(contains("[DEBUG] Following redirect to https://rust-lang.org/lycheeverse"));
}
#[test]
fn test_remap_named_invalid() {
cargo_bin_cmd!()
.arg("--remap")
.arg("https://example.com invalid")
.arg("-")
.write_stdin("https://example.com")
.env_clear()
.assert()
.failure()
.stderr(contains("Error checking URL https://example.com/: Invalid remap pattern: the result `invalid/` is not a valid URL"))
.stdout(contains("The given URI is invalid, check URI syntax: https://example.com/"));
}
#[test]
fn test_remap_to_excluded() {
let stdout = cargo_bin_cmd!()
.arg("--remap=aaa bbb")
.arg("--exclude=bbb")
.arg("--format=json")
.arg("-")
.write_stdin("https://aaa.com")
.assert()
.success()
.get_output()
.stdout
.clone();
let json = stdout_to_json(&stdout);
assert_eq!(json["remaps"], 1);
assert_eq!(json["excludes"], 1);
assert_eq!(
json["excluded_map"],
json!({
"stdin": [
{
"url": "https://bbb.com/",
"status": {
"text": "Excluded",
"details": "This is due to your 'exclude' values"
},
"remap": {
"new": {
"url": "https://bbb.com/",
},
"original": {
"url": "https://aaa.com/",
},
},
"span": {
"line": 1,
"column": 1
},
}
]})
);
}
#[test]
fn test_excluded_paths_regex() {
let test_path = fixtures_path!().join("exclude-path");
let excluded_path_1 = "\\/excluded?\\/"; let excluded_path_2 = "(\\.mdx|\\.txt)$"; let result = cargo_bin_cmd!()
.arg("--exclude-path")
.arg(excluded_path_1)
.arg("--exclude-path")
.arg(excluded_path_2)
.arg("--dump")
.arg(&test_path)
.assert()
.success();
assert_lines_eq(
result,
vec![
"https://test.md/to-be-included-outer",
"https://test.md/to-be-included-inner",
],
);
}
#[test]
fn test_handle_relative_paths_as_input() {
let test_path = fixtures_path!();
cargo_bin_cmd!()
.current_dir(&test_path)
.arg("--verbose")
.arg("--exclude")
.arg("example.*")
.arg("./TEST_DUMP_EXCLUDE.txt")
.assert()
.success()
.stdout(contains("3 Total"))
.stdout(contains("3 Excluded"));
}
#[test]
fn test_handle_nonexistent_relative_paths_as_input() {
let test_path = fixtures_path!();
cargo_bin_cmd!()
.current_dir(&test_path)
.arg("--verbose")
.arg("--exclude")
.arg("example.*")
.arg("./NOT-A-REAL-TEST-FIXTURE.md")
.assert()
.failure()
.stderr(contains(
"Input './NOT-A-REAL-TEST-FIXTURE.md' not found as file and not a valid URL",
));
}
#[test]
fn test_prevent_too_many_redirects() {
let url = "https://http.codes/308";
cargo_bin_cmd!()
.write_stdin(url)
.arg("--max-redirects")
.arg("0")
.arg("-")
.assert()
.failure();
}
#[test]
#[ignore = "Skipping test because it is flaky"]
fn test_suggests_url_alternatives() -> Result<()> {
let re = Regex::new(r"http://web\.archive\.org/web/.*google\.com/jobs\.html").unwrap();
for _ in 0..3 {
let mut cmd = cargo_bin_cmd!();
let input = fixtures_path!().join("INTERNET_ARCHIVE.md");
cmd.arg("--no-progress").arg("--suggest").arg(input);
let assert = cmd.assert();
let output = assert.get_output();
if re.is_match(&String::from_utf8_lossy(&output.stdout)) {
return Ok(());
} else {
std::thread::sleep(std::time::Duration::from_secs(1));
}
}
Err("Did not get the expected command output after multiple attempts.".into())
}
#[tokio::test]
async fn test_basic_auth() {
let username = "username";
let password = "password123";
let mock_server = wiremock::MockServer::start().await;
Mock::given(method("GET"))
.and(basic_auth(username, password))
.respond_with(ResponseTemplate::new(200)) .mount(&mock_server)
.await;
Mock::given(method("GET"))
.respond_with(|_: &_| panic!("Received unauthenticated request"))
.mount(&mock_server)
.await;
cargo_bin_cmd!()
.arg("--verbose")
.arg("--basic-auth")
.arg(format!("{} {username}:{password}", mock_server.uri()))
.arg("-")
.write_stdin(mock_server.uri())
.assert()
.success()
.stdout(contains("1 Total"))
.stdout(contains("1 OK"));
cargo_bin_cmd!()
.arg(mock_server.uri())
.arg("--verbose")
.arg("--basic-auth")
.arg(format!("{} {username}:{password}", mock_server.uri()))
.assert()
.success()
.stdout(contains("0 Total")); }
#[tokio::test]
async fn test_multi_basic_auth() {
let username1 = "username";
let password1 = "password123";
let mock_server1 = wiremock::MockServer::start().await;
Mock::given(basic_auth(username1, password1))
.respond_with(ResponseTemplate::new(200))
.mount(&mock_server1)
.await;
let username2 = "admin_user";
let password2 = "admin_pw";
let mock_server2 = wiremock::MockServer::start().await;
Mock::given(basic_auth(username2, password2))
.respond_with(ResponseTemplate::new(200))
.mount(&mock_server2)
.await;
cargo_bin_cmd!()
.arg("--verbose")
.arg("--basic-auth")
.arg(format!("{} {username1}:{password1}", mock_server1.uri()))
.arg("--basic-auth")
.arg(format!("{} {username2}:{password2}", mock_server2.uri()))
.arg("-")
.write_stdin(format!("{}\n{}", mock_server1.uri(), mock_server2.uri()))
.assert()
.success()
.stdout(contains("2 Total"))
.stdout(contains("2 OK"));
}
#[tokio::test]
async fn test_cookie_jar() -> Result<()> {
let cookie_jar = NamedTempFile::new()?;
cargo_bin_cmd!()
.arg("--cookie-jar")
.arg(cookie_jar.path().to_str().unwrap())
.arg("-")
.write_stdin("https://google.com/ncr")
.assert()
.success();
let file = std::fs::File::open(cookie_jar.path()).map(std::io::BufReader::new)?;
let cookie_store = cookie_store::serde::json::load(file)
.map_err(|e| anyhow!("Failed to load cookie jar: {e}"))?;
let all_cookies = cookie_store.iter_any().collect::<Vec<_>>();
assert!(!all_cookies.is_empty());
assert!(all_cookies.iter().all(|c| c.domain() == Some("google.com")));
Ok(())
}
#[test]
fn test_dump_inputs_does_not_include_duplicates() {
let pattern = fixtures_path!().join("dump_inputs/markdown.md");
cargo_bin_cmd!()
.arg("--dump-inputs")
.arg(&pattern)
.arg(&pattern)
.assert()
.success()
.stdout(contains("fixtures/dump_inputs/markdown.md").count(1));
}
#[test]
fn test_dump_inputs_glob_does_not_include_duplicates() {
let pattern1 = fixtures_path!().join("**/markdown.*");
let pattern2 = fixtures_path!().join("**/*.md");
cargo_bin_cmd!()
.arg("--dump-inputs")
.arg(pattern1)
.arg(pattern2)
.assert()
.success()
.stdout(contains("fixtures/dump_inputs/markdown.md").count(1));
}
#[test]
fn test_dump_inputs_glob_md() {
let pattern = fixtures_path!().join("**/*.md");
cargo_bin_cmd!()
.arg("--dump-inputs")
.arg(pattern)
.assert()
.success()
.stdout(contains("fixtures/dump_inputs/subfolder/file2.md"))
.stdout(contains("fixtures/dump_inputs/markdown.md"));
}
#[test]
fn test_dump_inputs_glob_all() {
let pattern = fixtures_path!().join("**/*");
cargo_bin_cmd!()
.arg("--dump-inputs")
.arg(pattern)
.assert()
.success()
.stdout(contains("fixtures/dump_inputs/subfolder/test.html"))
.stdout(contains("fixtures/dump_inputs/subfolder/file2.md"))
.stdout(contains("fixtures/dump_inputs/subfolder"))
.stdout(contains("fixtures/dump_inputs/markdown.md"))
.stdout(contains("fixtures/dump_inputs/some_file.txt"));
}
#[test]
fn test_dump_inputs_glob_exclude_path() {
let pattern = fixtures_path!().join("**/*");
cargo_bin_cmd!()
.arg("--dump-inputs")
.arg(pattern)
.arg("--exclude-path")
.arg(fixtures_path!().join("dump_inputs/subfolder"))
.assert()
.success()
.stdout(contains("fixtures/dump_inputs/subfolder/test.html").not())
.stdout(contains("fixtures/dump_inputs/subfolder/file2.md").not())
.stdout(contains("fixtures/dump_inputs/subfolder").not());
}
#[test]
fn test_dump_inputs_url() {
let result = cargo_bin_cmd!()
.arg("--dump-inputs")
.arg("https://example.com")
.assert()
.success();
assert_lines_eq(result, vec!["https://example.com/"]);
}
#[test]
fn test_dump_inputs_path() {
let result = cargo_bin_cmd!()
.arg("--dump-inputs")
.arg(fixtures_path!().join("dump_inputs"))
.assert()
.success();
let base_path = fixtures_path!().join("dump_inputs");
let expected_lines = [
"some_file.txt",
"subfolder/file2.md",
"subfolder/test.html",
"markdown.md",
]
.iter()
.map(|p| path_str(&base_path, p))
.collect();
assert_lines_eq(result, expected_lines);
}
#[test]
fn test_dump_inputs_with_extensions() {
let test_dir = fixtures_path!().join("dump_inputs");
let output = cargo_bin_cmd!()
.arg("--dump-inputs")
.arg("--extensions")
.arg("md,txt")
.arg(test_dir)
.assert()
.success()
.get_output()
.stdout
.clone();
let mut actual_lines: Vec<String> = output
.lines()
.map(|line| line.unwrap().to_string())
.collect();
actual_lines.sort();
let base_path = fixtures_path!().join("dump_inputs");
let mut expected_lines = vec![
path_str(&base_path, "some_file.txt"),
path_str(&base_path, "subfolder/file2.md"),
path_str(&base_path, "markdown.md"),
];
expected_lines.sort();
assert_eq!(actual_lines, expected_lines);
for line in &actual_lines {
assert!(
!line.contains("example.bin"),
"Should not contain example.bin: {line}"
);
}
}
#[test]
fn test_dump_inputs_skip_hidden() {
let test_dir = fixtures_path!().join("hidden");
cargo_bin_cmd!()
.arg("--dump-inputs")
.arg(&test_dir)
.assert()
.success()
.stdout(is_empty());
cargo_bin_cmd!()
.arg("--dump-inputs")
.arg("--hidden")
.arg(test_dir)
.assert()
.success()
.stdout(contains("hidden/.file.md"))
.stdout(contains("hidden/.hidden/file.md"));
}
#[test]
fn test_dump_inputs_individual_file() {
let test_file = fixtures_path!().join("TEST.md");
cargo_bin_cmd!()
.arg("--dump-inputs")
.arg(&test_file)
.assert()
.success()
.stdout(contains("fixtures/TEST.md"));
}
#[test]
fn test_dump_inputs_stdin() {
cargo_bin_cmd!()
.arg("--dump-inputs")
.arg("-")
.assert()
.success()
.stdout(contains("<stdin>"));
}
#[test]
fn test_fragments_regression() {
let input = fixtures_path!().join("FRAGMENT_REGRESSION.md");
cargo_bin_cmd!()
.arg("--include-fragments")
.arg("--verbose")
.arg(input)
.assert()
.failure();
}
#[test]
fn test_fragments() {
let input = fixtures_path!().join("fragments");
let result = cargo_bin_cmd!()
.arg("--include-fragments")
.arg("--format=json")
.arg("-vv")
.arg(input)
.assert()
.failure();
let output = std::str::from_utf8(&result.get_output().stdout).unwrap();
let json: Value = serde_json::from_str(output).unwrap();
let actual_successes = extract_urls(&json["success_map"]);
let actual_errors = extract_urls(&json["error_map"]);
let expected_successes = vec![
"fixtures/fragments/empty_dir",
"fixtures/fragments/empty_file#fragment", "fixtures/fragments/file1.md#code-heading",
"fixtures/fragments/file1.md#explicit-fragment",
"fixtures/fragments/file1.md#f%C3%BCnf-s%C3%9C%C3%9Fe-%C3%84pfel",
"fixtures/fragments/file1.md#f%C3%BCnf-s%C3%BC%C3%9Fe-%C3%A4pfel",
"fixtures/fragments/file1.md#fragment-1",
"fixtures/fragments/file1.md#fragment-2",
"fixtures/fragments/file1.md#IGNORE-CASING",
"fixtures/fragments/file1.md#kebab-case-fragment",
"fixtures/fragments/file1.md#kebab-case-fragment-1",
"fixtures/fragments/file1.md#lets-wear-a-hat-%C3%AAtre",
"fixtures/fragments/file2.md#",
"fixtures/fragments/file2.md#custom-id",
"fixtures/fragments/file2.md#fragment-1",
"fixtures/fragments/file2.md#top",
"fixtures/fragments/file.html#",
"fixtures/fragments/file.html#a-word",
"fixtures/fragments/file.html#in-the-beginning",
"fixtures/fragments/file.html#tangent%3A-kustomize",
"fixtures/fragments/file.html#top",
"fixtures/fragments/file.html#Upper-%C3%84%C3%96%C3%B6",
"fixtures/fragments/sub_dir",
"fixtures/fragments/zero.bin",
"fixtures/fragments/zero.bin#",
"fixtures/fragments/zero.bin#fragment",
"https://github.com/lycheeverse/lychee#table-of-contents",
"https://raw.githubusercontent.com/lycheeverse/lychee/master/fixtures/fragments/zero.bin",
"https://raw.githubusercontent.com/lycheeverse/lychee/master/fixtures/fragments/zero.bin#",
"https://raw.githubusercontent.com/lycheeverse/lychee/master/fixtures/fragments/zero.bin#fragment",
];
let expected_errors = vec![
"fixtures/fragments/sub_dir_non_existing_1",
"fixtures/fragments/sub_dir#non-existing-fragment-2",
"fixtures/fragments/sub_dir#a-link-inside-index-html-inside-sub-dir",
"fixtures/fragments/empty_dir#non-existing-fragment-3",
"fixtures/fragments/file2.md#missing-fragment",
"fixtures/fragments/sub_dir#non-existing-fragment-1",
"fixtures/fragments/sub_dir_non_existing_2",
"fixtures/fragments/file1.md#missing-fragment",
"fixtures/fragments/empty_dir#non-existing-fragment-4",
"fixtures/fragments/file.html#in-the-end",
"fixtures/fragments/file.html#in-THE-begiNNing",
"https://github.com/lycheeverse/lychee#non-existent-anchor",
];
assert_eq!(actual_successes.len(), expected_successes.len());
assert_eq!(actual_errors.len(), expected_errors.len());
for good_url in expected_successes {
assert!(
actual_successes.iter().any(|url| url.ends_with(good_url)),
"Expected {good_url} to be a success"
);
}
for bad_url in &expected_errors {
assert!(
actual_errors.iter().any(|url| url.ends_with(bad_url)),
"Expected {bad_url} to be an error"
);
}
fn extract_urls(json: &Value) -> HashSet<&str> {
json.as_object()
.unwrap()
.into_iter()
.flat_map(|(_, file)| {
file.as_array()
.unwrap()
.iter()
.map(|o| o.as_object().unwrap()["url"].as_str().unwrap())
})
.collect()
}
}
#[test]
fn test_fragments_when_accept_error_status_codes() {
let input = fixtures_path!().join("TEST_FRAGMENT_ERR_CODE.md");
cargo_bin_cmd!()
.arg("-vv")
.arg("--accept=200,404")
.arg("--include-fragments")
.arg(input)
.assert()
.success()
.stderr(contains(
"https://en.wikipedia.org/wiki/Should404#ignore-fragment",
))
.stdout(contains("0 Errors"))
.stdout(contains("1 OK"))
.stdout(contains("1 Total"));
}
#[test]
fn test_fallback_extensions() {
let input = fixtures_path!().join("fallback-extensions");
cargo_bin_cmd!()
.arg("--verbose")
.arg("--fallback-extensions=htm,html")
.arg(input)
.assert()
.success()
.stdout(contains("0 Errors"));
}
#[test]
fn test_fragments_fallback_extensions() {
let input = fixtures_path!().join("fragments-fallback-extensions");
cargo_bin_cmd!()
.arg("--include-fragments")
.arg("--fallback-extensions=html")
.arg("--no-progress")
.arg("--offline")
.arg("-v")
.arg(input)
.assert()
.failure()
.stdout(contains("3 Total"))
.stdout(contains("1 OK"))
.stdout(contains("2 Errors"));
}
#[tokio::test]
async fn test_resolve_relative_paths_in_subfolder() {
let mock_server = wiremock::MockServer::start().await;
let body = r#"<a href="next.html">next</a>"#;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/test/index.html"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_string(body))
.mount(&mock_server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/test/next.html"))
.respond_with(wiremock::ResponseTemplate::new(200))
.mount(&mock_server)
.await;
cargo_bin_cmd!()
.arg("--verbose")
.arg(format!("{}/test/index.html", mock_server.uri()))
.assert()
.success()
.stdout(contains("1 Total"))
.stdout(contains("0 Errors"));
}
#[tokio::test]
async fn test_json_format_in_config() -> Result<()> {
let mock_server = mock_server!(StatusCode::OK);
let config = fixtures_path!().join("configs").join("format.toml");
let output = cargo_bin_cmd!()
.arg("--config")
.arg(config)
.arg("-")
.write_stdin(mock_server.uri())
.env_clear()
.assert()
.success()
.get_output()
.clone();
let output = std::str::from_utf8(&output.stdout)?;
let json: Value = serde_json::from_str(output)?;
assert_eq!(json["total"], 1);
Ok(())
}
#[tokio::test]
async fn test_redirect_json() {
redirecting_mock_server!(async |redirect_url: Url, _| {
let (json, stderr) = run(&redirect_url, false);
assert!(stderr.contains("[WARN] lychee detected 1 redirect. You might want to consider replacing redirecting URLs"));
assert_eq!(json["total"], 1);
assert_eq!(json["redirects"], 1); assert_eq!(json["successful"], 1); assert_eq!(json["redirect_map"], json!({})); assert_eq!(json["success_map"], json!({})); })
.await;
redirecting_mock_server!(async |redirect_url: Url, ok_url| {
let (json, stderr) = run(&redirect_url, true);
assert!(stderr.contains("WARN").not());
assert_eq!(json["total"], 1);
assert_eq!(json["redirects"], 1);
assert_eq!(
json["redirect_map"],
json!({
"stdin":[{
"origin": redirect_url,
"redirects": [{
"code": 308,
"url": ok_url,
}]
}]})
);
assert_eq!(
json["success_map"],
json!({
"stdin":[{
"redirects": {
"origin": redirect_url,
"redirects": [{
"code": 308,
"url": ok_url,
}]
},
"span": {
"column": 1,
"line": 1,
},
"status": {
"code": 200,
"text": "200 OK",
},
"url": redirect_url
}]})
);
})
.await;
fn run(url: &Url, verbose: bool) -> (Value, String) {
let mut binding = cargo_bin_cmd!();
let mut base = binding.arg("-");
if verbose {
base = base.arg("--verbose");
}
let output = base
.arg("--format")
.arg("json")
.write_stdin(url.as_str())
.env_clear()
.assert()
.success()
.get_output()
.clone()
.unwrap();
let stderr = str::from_utf8(&output.stderr).unwrap().to_string();
(stdout_to_json(&output.stdout), stderr)
}
}
#[tokio::test]
async fn test_retry() {
let mock_server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.respond_with(ResponseTemplate::new(429))
.up_to_n_times(1)
.mount(&mock_server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.respond_with(ResponseTemplate::new(200))
.mount(&mock_server)
.await;
cargo_bin_cmd!()
.arg("-")
.write_stdin(mock_server.uri())
.assert()
.success();
}
#[tokio::test]
async fn test_retry_rate_limit_headers() {
const RETRY_DELAY: Duration = Duration::from_secs(1);
const TOLERANCE: Duration = Duration::from_millis(500);
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.respond_with(
ResponseTemplate::new(429)
.append_header("Retry-After", RETRY_DELAY.as_secs().to_string()),
)
.expect(1)
.up_to_n_times(1)
.mount(&server)
.await;
let start = Instant::now();
wiremock::Mock::given(wiremock::matchers::method("GET"))
.respond_with(move |_: &Request| {
let delta = Instant::now().duration_since(start);
assert!(delta > RETRY_DELAY);
assert!(delta < RETRY_DELAY + TOLERANCE);
ResponseTemplate::new(200)
})
.expect(1)
.mount(&server)
.await;
cargo_bin_cmd!()
.arg("-")
.arg("--retry-wait-time")
.arg("0")
.write_stdin(server.uri())
.assert()
.success();
server.verify().await;
}
#[tokio::test]
async fn test_no_header_set_on_input() {
let server = wiremock::MockServer::start().await;
server
.register(
wiremock::Mock::given(wiremock::matchers::method("GET"))
.respond_with(wiremock::ResponseTemplate::new(200))
.expect(1),
)
.await;
cargo_bin_cmd!()
.arg("--verbose")
.arg(server.uri())
.assert()
.success();
let received_requests = server.received_requests().await.unwrap();
assert_eq!(received_requests.len(), 1);
let received_request = &received_requests[0];
assert_eq!(received_request.method, Method::GET);
assert_eq!(received_request.url.path(), "/");
assert!(!received_request.headers.contains_key("X-Foo"));
}
#[tokio::test]
async fn test_header_set_on_input() {
let server = wiremock::MockServer::start().await;
server
.register(
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::header("X-Foo", "Bar"))
.respond_with(wiremock::ResponseTemplate::new(200))
.expect(1)
.named("GET expecting custom header"),
)
.await;
cargo_bin_cmd!()
.arg("--verbose")
.arg("--header")
.arg("X-Foo: Bar")
.arg(server.uri())
.assert()
.success();
server.verify().await;
}
#[tokio::test]
async fn test_multi_header_set_on_input() {
let server = wiremock::MockServer::start().await;
server
.register(
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::header("X-Foo", "Bar"))
.and(wiremock::matchers::header("X-Bar", "Baz"))
.respond_with(wiremock::ResponseTemplate::new(200))
.expect(1)
.named("GET expecting custom header"),
)
.await;
cargo_bin_cmd!()
.arg("--verbose")
.arg("--header")
.arg("X-Foo: Bar")
.arg("--header")
.arg("X-Bar: Baz")
.arg(server.uri())
.assert()
.success();
server.verify().await;
}
#[tokio::test]
async fn test_header_set_in_config() {
let server = wiremock::MockServer::start().await;
server
.register(
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::header("X-Foo", "Bar"))
.and(wiremock::matchers::header("X-Bar", "Baz"))
.and(wiremock::matchers::header("X-Host-Specific", "Foo"))
.respond_with(wiremock::ResponseTemplate::new(200))
.expect(1)
.named("GET expecting custom header"),
)
.await;
let config = fixtures_path!().join("configs").join("headers.toml");
cargo_bin_cmd!()
.arg("--verbose")
.arg("--config")
.arg(config)
.arg("-")
.write_stdin(server.uri())
.assert()
.success();
server.verify().await;
}
#[tokio::test]
async fn test_user_agent_set_on_remote_input() {
let server = wiremock::MockServer::start().await;
server
.register(
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::header("user-agent", "test-agent/1.0"))
.respond_with(wiremock::ResponseTemplate::new(200))
.expect(1)
.named("GET expecting user-agent header"),
)
.await;
cargo_bin_cmd!()
.arg("--user-agent")
.arg("test-agent/1.0")
.arg(server.uri())
.assert()
.success();
}
#[tokio::test]
async fn test_default_user_agent_set_on_remote_input() {
let server = wiremock::MockServer::start().await;
server
.register(
wiremock::Mock::given(wiremock::matchers::method("GET"))
.respond_with(wiremock::ResponseTemplate::new(200))
.expect(1),
)
.await;
cargo_bin_cmd!().arg(server.uri()).assert().success();
let received_requests = server.received_requests().await.unwrap();
assert_eq!(received_requests.len(), 1);
let received_request = &received_requests[0];
let user_agent = received_request
.headers
.get("user-agent")
.expect("User agent missing")
.to_str()
.unwrap();
assert!(
user_agent.starts_with("lychee/"),
"Expected user-agent to start with 'lychee/', got: {user_agent:?}"
);
}
#[test]
fn test_sorted_error_output() -> Result<()> {
let temp_dir = tempfile::tempdir()?;
let a_md = temp_dir.path().join("a.md");
let b_md = temp_dir.path().join("b.md");
fs::write(&a_md, "https://example.com/a\nhttps://example.com/b")?;
fs::write(&b_md, "https://example.com/1\nhttps://example.com/2")?;
let test_files = ["a.md", "b.md"];
let test_urls = [
"https://example.com/a",
"https://example.com/b",
"https://example.com/1",
"https://example.com/2",
];
let cmd = &mut cargo_bin_cmd!()
.arg("--format")
.arg("compact")
.arg(b_md)
.arg(a_md)
.assert()
.failure()
.code(2);
let output = String::from_utf8_lossy(&cmd.get_output().stdout);
let mut position: usize = 0;
for file in test_files {
assert!(output.contains(file), "{file} not found in lychee output");
let next_position = output.find(file).unwrap();
assert!(next_position > position);
position = next_position;
}
position = 0;
for url in test_urls {
assert!(output.contains(url), "{url} not found in lychee output");
let next_position = output.find(url).unwrap();
assert!(next_position > position);
position = next_position;
}
Ok(())
}
#[test]
fn test_extract_url_ending_with_period_file() {
let test_path = fixtures_path!().join("LINK_PERIOD.html");
cargo_bin_cmd!()
.arg("--dump")
.arg(test_path)
.assert()
.success()
.stdout(contains("https://www.example.com/smth."));
}
#[tokio::test]
async fn test_extract_url_ending_with_period_webserver() {
let body = r#"<a href="https://www.example.com/smth.">link</a>"#;
let mock_server = mock_response!(body);
cargo_bin_cmd!()
.arg("--dump")
.arg(mock_server.uri())
.assert()
.success()
.stdout(contains("https://www.example.com/smth."));
}
#[test]
fn test_wikilink_extract_when_specified() {
let test_path = fixtures_path!().join("TEST_WIKI.md");
cargo_bin_cmd!()
.arg("--dump")
.arg("--include-wikilinks")
.arg("--base-url")
.arg(fixtures_path!())
.arg(test_path)
.assert()
.success()
.stdout(contains("LycheeWikilink"));
}
#[test]
fn test_wikilink_dont_extract_when_not_specified() {
let test_path = fixtures_path!().join("TEST_WIKI.md");
cargo_bin_cmd!()
.arg("--dump")
.arg(test_path)
.assert()
.success()
.stdout(is_empty());
}
#[test]
fn test_index_files_default() {
let input = fixtures_path!().join("filechecker/dir_links.md");
cargo_bin_cmd!()
.arg(&input)
.arg("--verbose")
.assert()
.success();
let dir_links_with_fragment = 2;
cargo_bin_cmd!()
.arg(&input)
.arg("--include-fragments")
.assert()
.failure()
.stdout(contains("Cannot find fragment").count(dir_links_with_fragment))
.stdout(contains("#").count(dir_links_with_fragment));
}
#[test]
fn test_index_files_specified() {
let input = fixtures_path!().join("filechecker/dir_links.md");
let result = cargo_bin_cmd!()
.arg(&input)
.arg("--index-files")
.arg("index.html,index.htm")
.arg("--verbose")
.assert()
.failure();
let empty_dir_links = 2;
let index_dir_links = 2;
result
.stdout(contains("Cannot find index file").count(empty_dir_links))
.stdout(contains("/empty_dir").count(empty_dir_links))
.stdout(contains("(index.html, or index.htm)").count(empty_dir_links))
.stdout(contains(format!("{index_dir_links} OK")));
cargo_bin_cmd!()
.arg(&input)
.arg("--index-files")
.arg(",index.html,,,index.htm,")
.assert()
.failure()
.stdout(contains("(index.html, or index.htm)").count(empty_dir_links));
}
#[test]
fn test_index_files_dot_in_list() {
let input = fixtures_path!().join("filechecker/dir_links.md");
cargo_bin_cmd!()
.arg(&input)
.arg("--index-files")
.arg("index.html,.")
.assert()
.success()
.stdout(contains("4 OK"));
cargo_bin_cmd!()
.arg(&input)
.arg("--index-files")
.arg("index.html,.")
.arg("--include-fragments")
.assert()
.failure()
.stdout(contains("Cannot find fragment").count(1))
.stdout(contains("empty_dir#fragment").count(1))
.stdout(contains("index_dir#fragment").count(0))
.stdout(contains("3 OK"));
}
#[test]
fn test_index_files_empty_list() {
let input = fixtures_path!().join("filechecker/dir_links.md");
let result = cargo_bin_cmd!()
.arg(&input)
.arg("--index-files")
.arg("")
.assert()
.failure();
let num_dir_links = 4;
result
.stdout(contains("Cannot find index file").count(num_dir_links))
.stdout(
contains("Directory links are rejected because index_files is empty")
.count(num_dir_links),
)
.stdout(contains("0 OK"));
cargo_bin_cmd!()
.arg(&input)
.arg("--index-files")
.arg(",,,,,")
.assert()
.failure()
.stdout(
contains("Directory links are rejected because index_files is empty")
.count(num_dir_links),
)
.stdout(contains("0 OK"));
}
#[test]
fn test_skip_binary_input() {
let inputs = fixtures_path!().join("invalid_utf8");
let result = cargo_bin_cmd!()
.arg("-vv")
.arg(&inputs)
.assert()
.success()
.stdout(contains("1 Total"))
.stdout(contains("1 OK"))
.stdout(contains("0 Errors"));
result
.stderr(contains(format!(
"Skipping file with invalid UTF-8 content: {}",
inputs.join("invalid_utf8.txt").display()
)))
.stderr(contains("https://example.com/"));
}
#[test]
fn test_dump_invalid_utf8_inputs() {
let inputs = fixtures_path!().join("invalid_utf8");
cargo_bin_cmd!()
.arg("--dump-inputs")
.arg(inputs)
.assert()
.success()
.stdout(contains("fixtures/invalid_utf8/index.html"))
.stdout(contains("fixtures/invalid_utf8/invalid_utf8.txt"));
}
#[test]
fn test_globbed_files_are_always_checked() {
let input = fixtures_path!().join("glob_dir/**/*.tsx");
cargo_bin_cmd!()
.arg("--verbose")
.arg("--extensions=ts,js,html")
.arg(input)
.assert()
.failure()
.stdout(contains("1 Total"))
.stderr(contains("https://example.com/glob_dir/tsx"));
}
#[test]
fn test_extensions_work_on_glob_files_directory() {
let input = fixtures_path!().join("glob_dir");
cargo_bin_cmd!()
.arg("--verbose")
.arg("--extensions=ts,html")
.arg(input)
.assert()
.failure()
.stdout(contains("2 Total"))
.stderr(contains("https://example.com/glob_dir/ts "))
.stderr(contains("https://example.com/glob_dir/tsx").not())
.stderr(contains("https://example.com/glob_dir/md").not())
.stderr(contains("https://example.com/glob_dir/html"));
}
#[test]
fn test_extensions_apply_to_files_not_globs() {
let glob_input = fixtures_path!().join("glob_dir/**/*.tsx");
let dir_input = fixtures_path!().join("example_dir");
cargo_bin_cmd!()
.arg("--verbose")
.arg("--extensions=html,md")
.arg(glob_input)
.arg(dir_input)
.assert()
.failure()
.stdout(contains("3 Total"))
.stderr(contains("https://example.com/glob_dir/tsx"))
.stderr(contains("https://example.com/glob_dir/ts ").not())
.stderr(contains("https://example.com/glob_dir/md").not())
.stderr(contains("https://example.com/glob_dir/html").not())
.stderr(contains("https://example.com/example_dir/html"))
.stderr(contains("https://example.com/example_dir/md"))
.stderr(contains("https://example.com/example_dir/ts ").not())
.stderr(contains("https://example.com/example_dir/tsx").not());
}
#[test]
fn test_file_inputs_always_get_checked_no_matter_their_extension() {
let ts_input_file = fixtures_path!().join("glob_dir/example.ts");
let md_input_file = fixtures_path!().join("glob_dir/example.md");
cargo_bin_cmd!()
.arg("--verbose")
.arg("--dump")
.arg("--extensions=html,md")
.arg(ts_input_file)
.arg(md_input_file)
.assert()
.success()
.stderr("") .stdout(contains("https://example.com/glob_dir/ts"))
.stdout(contains("https://example.com/glob_dir/md"));
}
#[tokio::test]
async fn test_url_inputs_always_get_checked_no_matter_their_extension() {
let mock_server = wiremock::MockServer::start().await;
let url = "https://example.com";
Mock::given(method("GET"))
.respond_with(
ResponseTemplate::new(200).set_body_string(format!("<a href=\"{url}\">hi</a>")),
)
.mount(&mock_server)
.await;
cargo_bin_cmd!()
.arg("--verbose")
.arg("--dump")
.arg(format!("{}/some.svg", mock_server.uri()))
.assert()
.success()
.stdout(contains(url))
.stderr("");
}
#[test]
fn test_files_from_file() -> Result<()> {
let temp_dir = tempfile::tempdir()?;
let files_list_path = temp_dir.path().join("files.txt");
let test_md = temp_dir.path().join("test.md");
fs::write(&test_md, "# Test\n[link](https://example.com)")?;
fs::write(&files_list_path, test_md.to_string_lossy().as_ref())?;
cargo_bin_cmd!()
.arg("--files-from")
.arg(&files_list_path)
.arg("--dump-inputs")
.assert()
.success()
.stdout(contains(test_md.to_string_lossy().as_ref()));
Ok(())
}
#[test]
fn test_files_from_stdin() -> Result<()> {
let temp_dir = tempfile::tempdir()?;
let test_md = temp_dir.path().join("test.md");
fs::write(&test_md, "# Test\n[link](https://example.com)")?;
cargo_bin_cmd!()
.arg("--files-from")
.arg("-")
.arg("--dump-inputs")
.write_stdin(test_md.to_string_lossy().as_ref())
.assert()
.success()
.stdout(contains(test_md.to_string_lossy().as_ref()));
Ok(())
}
#[test]
fn test_files_from_with_comments_and_empty_lines() -> Result<()> {
let temp_dir = tempfile::tempdir()?;
let files_list_path = temp_dir.path().join("files.txt");
let test_md = temp_dir.path().join("test.md");
fs::write(&test_md, "# Test\n[link](https://example.com)")?;
fs::write(
&files_list_path,
format!(
"# Comment line\n\n{}\n# Another comment\n",
test_md.display()
),
)?;
cargo_bin_cmd!()
.arg("--files-from")
.arg(&files_list_path)
.arg("--dump-inputs")
.assert()
.success()
.stdout(contains(test_md.to_string_lossy().as_ref()));
Ok(())
}
#[test]
fn test_files_from_combined_with_regular_inputs() -> Result<()> {
let temp_dir = tempfile::tempdir()?;
let files_list_path = temp_dir.path().join("files.txt");
let test_md1 = temp_dir.path().join("test1.md");
let test_md2 = temp_dir.path().join("test2.md");
fs::write(&test_md1, "# Test 1")?;
fs::write(&test_md2, "# Test 2")?;
fs::write(&files_list_path, test_md1.to_string_lossy().as_ref())?;
let mut cmd = cargo_bin_cmd!();
cmd.arg("--files-from")
.arg(&files_list_path)
.arg(&test_md2) .arg("--dump-inputs")
.assert()
.success()
.stdout(contains(test_md1.to_string_lossy().as_ref()))
.stdout(contains(test_md2.to_string_lossy().as_ref()));
Ok(())
}
#[test]
fn test_files_from_nonexistent_file_error() {
cargo_bin_cmd!()
.arg("--files-from")
.arg("/nonexistent/file.txt")
.arg("--dump-inputs")
.assert()
.failure()
.stderr(contains("Cannot open --files-from file"));
}
#[test]
fn test_default_extension_option() -> Result<()> {
let mut file_without_ext = NamedTempFile::new()?;
writeln!(file_without_ext, "# Test File")?;
writeln!(file_without_ext, "[Example](https://example.com)")?;
writeln!(file_without_ext, "[Local](local.md)")?;
cargo_bin_cmd!()
.arg("--default-extension")
.arg("md")
.arg("--dump")
.arg(file_without_ext.path())
.assert()
.success()
.stdout(contains("https://example.com"));
let mut html_file_without_ext = NamedTempFile::new()?;
writeln!(html_file_without_ext, "<html><body>")?;
writeln!(
html_file_without_ext,
"<a href=\"https://html-example.com\">HTML Link</a>"
)?;
writeln!(html_file_without_ext, "</body></html>")?;
cargo_bin_cmd!()
.arg("--default-extension")
.arg("html")
.arg("--dump")
.arg(html_file_without_ext.path())
.assert()
.success()
.stdout(contains("https://html-example.com"));
Ok(())
}
#[test]
fn test_default_extension_unknown_value() {
let mut file_without_ext = NamedTempFile::new().unwrap();
writeln!(file_without_ext, "# Test").unwrap();
writeln!(file_without_ext, "Visit https://example.org for more info").unwrap();
cargo_bin_cmd!()
.arg("--default-extension")
.arg("unknown")
.arg("--dump")
.arg(file_without_ext.path())
.assert()
.success()
.stdout(contains("https://example.org")); }
#[test]
fn test_wikilink_fixture_obsidian_style() {
let input = fixtures_path!().join("wiki/obsidian-style.md");
cargo_bin_cmd!()
.arg(&input)
.arg("--include-wikilinks")
.arg("--fallback-extensions")
.arg("md")
.arg("--base-url")
.arg(fixtures_path!())
.assert()
.success()
.stdout(contains("4 OK"));
}
#[test]
fn test_wikilink_fixture_wikilink_non_existent() {
let input = fixtures_path!().join("wiki/Non-existent.md");
cargo_bin_cmd!()
.arg(&input)
.arg("--include-wikilinks")
.arg("--fallback-extensions")
.arg("md")
.arg("--base-url")
.arg(fixtures_path!())
.assert()
.failure()
.stdout(contains("3 Errors"));
}
#[test]
fn test_wikilink_fixture_with_fragments_obsidian_style_fixtures_excluded() {
let input = fixtures_path!().join("wiki/obsidian-style-plus-headers.md");
cargo_bin_cmd!()
.arg(&input)
.arg("--include-wikilinks")
.arg("--fallback-extensions")
.arg("md")
.arg("--base-url")
.arg(fixtures_path!())
.assert()
.success()
.stdout(contains("4 OK"));
}
#[test]
fn test_wikilink_fixture_with_fragments_obsidian_style() {
let input = fixtures_path!().join("wiki/obsidian-style-plus-headers.md");
cargo_bin_cmd!()
.arg(&input)
.arg("--include-wikilinks")
.arg("--include-fragments")
.arg("--fallback-extensions")
.arg("md")
.arg("--base-url")
.arg(fixtures_path!())
.assert()
.success()
.stdout(contains("4 OK"));
}
#[test]
fn test_input_matching_nothing_warns() -> Result<()> {
let empty_dir = tempdir()?;
cargo_bin_cmd!()
.arg(format!("{}", empty_dir.path().to_string_lossy()))
.arg(format!("{}/*", empty_dir.path().to_string_lossy()))
.arg("non-existing-path/*")
.arg("*.non-existing-extension")
.arg("non-existing-file-name???")
.assert()
.success()
.stderr(contains("No files found").count(5));
Ok(())
}
#[test]
fn test_invalid_user_input_source() -> Result<()> {
cargo_bin_cmd!()
.arg("http://website.invalid")
.assert()
.failure()
.code(1)
.stderr(contains("Error: Network error"));
cargo_bin_cmd!()
.arg("-")
.arg("https://a.invalid")
.write_stdin("http://example.com")
.assert()
.failure()
.code(1)
.stderr(contains("Error: Network error"));
cargo_bin_cmd!()
.arg("invalid-glob[")
.assert()
.failure()
.code(1);
Ok(())
}
#[test]
fn test_invalid_glob_fails_parse() {
cargo_bin_cmd!()
.arg("invalid-unmatched-brackets[")
.assert()
.stderr(contains("Cannot parse input"))
.failure()
.code(1); }
#[test]
fn test_pre_cat() {
let file = fixtures_path!().join("TEST.md");
let pre_with_cat = cargo_bin_cmd!()
.arg("--preprocess")
.arg("cat")
.arg("--dump")
.arg(&file)
.assert()
.success();
let no_pre = cargo_bin_cmd!()
.arg("--dump")
.arg(&file)
.assert()
.success()
.get_output()
.stdout
.lines()
.map(|line| line.unwrap().to_string())
.collect();
assert_lines_eq(pre_with_cat, no_pre);
}
#[test]
fn test_pre_invalid_command() {
let file = fixtures_path!().join("TEST.md");
cargo_bin_cmd!()
.arg("--preprocess")
.arg("program does not exist")
.arg(file)
.assert()
.failure()
.code(2)
.stdout(contains("Preprocessor command 'program does not exist' failed with 'could not start: No such file or directory (os error 2)'"));
}
#[test]
fn test_pre_error() {
let file = fixtures_path!().join("TEST.md");
let script = fixtures_path!().join("pre").join("no_error_message.sh");
cargo_bin_cmd!()
.arg("--preprocess")
.arg(&script)
.arg(&file)
.assert()
.failure()
.code(2)
.stdout(contains(format!(
"Preprocessor command '{}' failed with 'exited with non-zero code: <empty stderr>'",
script.as_os_str().to_str().unwrap()
)));
let script = fixtures_path!().join("pre").join("error_message.sh");
cargo_bin_cmd!()
.arg("--preprocess")
.arg(&script)
.arg(file)
.assert()
.failure()
.code(2)
.stdout(contains(format!(
"Preprocessor command '{}' failed with 'exited with non-zero code: Some error message'",
script.as_os_str().to_str().unwrap()
)));
}
#[test]
fn test_mdx_file() {
let file = fixtures_path!().join("mdx").join("test.mdx");
cargo_bin_cmd!()
.arg("--dump")
.arg(&file)
.assert()
.success()
.stdout(contains("https://example.com"));
}
fn escape_url_prefix(base_url: &str) -> String {
let escaped = regex::escape(base_url.trim_end_matches('/'));
format!("^{escaped}($|[/?#])")
}
fn normalise_url_lines(bytes: &[u8], substitutions: &[&str]) -> String {
let mut str = str::from_utf8(bytes).unwrap().to_string();
let mut iter = substitutions.iter();
while let Some(old) = iter.next() {
str = str.replace(old, iter.next().expect("substitutions should be paired!"));
}
let mut lines = str.lines().collect::<Vec<&str>>();
lines.sort();
lines
.into_iter()
.map(|x| format!("{x}\n"))
.collect::<String>()
.trim()
.to_string()
}
#[tokio::test]
#[allow(clippy::format_in_format_args)]
async fn test_mapping_whole_domain_to_local_folder() {
let fixture = fixtures_path!().join("mapping_local_folder");
let root_dir = fixture.join("a/b/ROOT");
let local_file = root_dir.join("whole_domain.md");
let mock_server = mock_response!(fs::read_to_string(fixture.join("remote.md")).unwrap());
let remote_url = format!("{}/server/1/2/file.md", mock_server.uri());
let remote_origin = mock_server.uri();
let proc2 = cargo_bin_cmd!()
.arg("--dump")
.arg(local_file)
.arg(format!("--root-dir={}", root_dir.display()))
.arg(format!(
"--remap={} {}",
escape_url_prefix("https://gist.githubusercontent.com"),
format!("file://{}/", root_dir.display())
))
.assert()
.success();
assert_eq!(
normalise_url_lines(
&proc2.get_output().stdout,
&[&fixture.to_string_lossy(), "/TMP"]
),
"
file:///TMP/a/b/ROOT/
file:///TMP/a/b/ROOT/fully/qualified.html
file:///TMP/a/b/ROOT/fully/qualified/up.html
file:///TMP/a/b/ROOT/relative.md
file:///TMP/a/b/ROOT/root
file:///TMP/a/b/root-up
https://gist.githubusercontent.com-fake/
"
.trim()
);
let proc = cargo_bin_cmd!()
.arg("--dump")
.arg(remote_url)
.arg(format!("--root-dir={}", root_dir.display()))
.arg(format!(
"--remap={} {}",
escape_url_prefix(&remote_origin),
format!("file://{}/", root_dir.display())
))
.assert()
.success();
assert_eq!(
normalise_url_lines(
&proc.get_output().stdout,
&[&fixture.to_string_lossy(), "/TMP"]
),
"
file:///TMP/a/b/ROOT/root
file:///TMP/a/b/ROOT/server/1/2/encoded%24%2A%28%20%29%5B%20%5D.html
file:///TMP/a/b/ROOT/server/1/2/file.md#self
file:///TMP/a/b/ROOT/server/1/2/query.html?boop=20
file:///TMP/a/b/ROOT/server/1/2/relative.html
file:///TMP/a/b/ROOT/server/1/2/sub/dir/index.html
file:///TMP/a/b/ROOT/server/1/up-one.html
file:///TMP/a/b/ROOT/server/up-two.html
file:///TMP/a/b/ROOT/up-up
file:///TMP/a/b/ROOT/up-up-up
"
.trim()
);
}
#[tokio::test]
#[allow(clippy::format_in_format_args)]
async fn test_mapping_subpath_to_local_folder() {
let fixture = fixtures_path!().join("mapping_local_folder");
let root_dir = fixture.join("a/b/ROOT");
let mock_server = mock_response!(fs::read_to_string(fixture.join("remote.md")).unwrap());
let remote_url = format!("{}/server/1/2/file.md", mock_server.uri());
let remote_base = format!("{}/server/1/", mock_server.uri());
let remote_origin = mock_server.uri();
let temp_root_dir_tmpdir = tempdir().unwrap();
let temp_root_dir = temp_root_dir_tmpdir.path().join("a/b/c/ROOT");
let temp_root_subdir = temp_root_dir.join("server/1");
let local_file = temp_root_subdir.join("subpath.md");
std::fs::create_dir_all(temp_root_subdir.parent().unwrap()).unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink(&root_dir, &temp_root_subdir).unwrap();
#[cfg(windows)]
std::os::windows::fs::symlink_dir(&root_dir, &temp_root_subdir).unwrap();
let temp_root_subdir_url = format!("file://{}", temp_root_subdir.to_string_lossy());
let temp_root_dir_url = format!("file://{}", temp_root_dir.to_string_lossy());
let proc2 = cargo_bin_cmd!()
.arg("--dump")
.arg(local_file)
.arg(format!("--root-dir={}", temp_root_dir.display()))
.arg(format!(
"--remap={} {}$1",
escape_url_prefix("https://gist.githubusercontent.com/server/1"),
temp_root_subdir_url
))
.arg(format!(
"--remap={} {}$1",
escape_url_prefix(&temp_root_subdir_url),
temp_root_subdir_url
))
.arg(format!(
"--remap={} {}$1",
escape_url_prefix(&temp_root_dir_url),
"https://gist.githubusercontent.com"
))
.assert()
.success();
assert_eq!(
normalise_url_lines(
&proc2.get_output().stdout,
&[&temp_root_dir_tmpdir.path().to_string_lossy(), "/TMP"]
),
"
file:///TMP/a/b/c/ROOT/server/1/make-me-local
file:///TMP/a/b/c/ROOT/server/1/relative.md
file:///TMP/a/b/c/root-up
file:///TMP/a/b/very-up
https://gist.githubusercontent.com-fake/
https://gist.githubusercontent.com/
https://gist.githubusercontent.com/fully/qualified.html
https://gist.githubusercontent.com/fully/qualified/up.html
https://gist.githubusercontent.com/root
https://gist.githubusercontent.com/server/up
"
.trim()
);
let proc = cargo_bin_cmd!()
.arg("--dump")
.arg(remote_url)
.arg(format!("--root-dir={}", temp_root_dir.display()))
.arg(format!(
"--remap={} {}$1",
escape_url_prefix(&remote_base),
temp_root_subdir_url
))
.arg(format!(
"--remap={} {}$1",
escape_url_prefix(&temp_root_subdir_url),
temp_root_subdir_url
))
.arg(format!(
"--remap={} {}$1",
escape_url_prefix(&temp_root_dir_url),
remote_origin
))
.assert()
.success();
assert_eq!(
normalise_url_lines(
&proc.get_output().stdout,
&[
&temp_root_dir_tmpdir.path().to_string_lossy(),
"/TMP",
&mock_server.uri(),
"[mock-server]"
]
),
"
[mock-server]/root
[mock-server]/server/up-two.html
[mock-server]/up-up
[mock-server]/up-up-up
file:///TMP/a/b/c/ROOT/server/1/2/encoded%24%2A%28%20%29%5B%20%5D.html
file:///TMP/a/b/c/ROOT/server/1/2/file.md#self
file:///TMP/a/b/c/ROOT/server/1/2/query.html?boop=20
file:///TMP/a/b/c/ROOT/server/1/2/relative.html
file:///TMP/a/b/c/ROOT/server/1/2/sub/dir/index.html
file:///TMP/a/b/c/ROOT/server/1/up-one.html
"
.trim()
);
}
#[test]
fn test_local_base_url_bug_1896() -> Result<()> {
let dir = tempdir()?;
cargo_bin_cmd!()
.arg("-")
.arg("--dump")
.arg("--base-url")
.arg(dir.path())
.arg("--default-extension")
.arg("md")
.write_stdin("[a](b.html#a)")
.assert()
.success()
.stdout(contains("b.html#a"));
Ok(())
}
#[test]
fn test_relative_url_parse_errors() -> Result<()> {
let dir = tempdir()?;
cargo_bin_cmd!()
.arg("-")
.arg("--default-extension=md")
.write_stdin("[a](a)")
.assert()
.failure()
.stdout(contains("Cannot parse 'a' into a URL: relative URL without a base: This relative link was found inside an input source that has no base location"));
cargo_bin_cmd!()
.arg("-")
.arg("--default-extension=md")
.write_stdin("[a](/a)")
.assert()
.failure()
.stdout(contains("Cannot resolve root-relative link '/a': To resolve root-relative links in local files, provide a root dir"));
cargo_bin_cmd!()
.arg("-")
.arg("--root-dir")
.arg(dir.path())
.arg("--default-extension=md")
.write_stdin("[a](a)")
.assert()
.failure()
.stdout(contains("Cannot parse 'a' into a URL: relative URL without a base: This relative link was found inside an input source that has no base location"));
cargo_bin_cmd!()
.arg("-")
.arg("--root-dir")
.arg(dir.path())
.arg("--default-extension=md")
.write_stdin("[a](/)")
.assert()
.success();
cargo_bin_cmd!()
.arg("-")
.arg("-vv")
.arg("--base-url=https://lychee.cli.rs/")
.arg("--default-extension=md")
.write_stdin("[a](/)")
.assert()
.success()
.stderr(contains("https://lychee.cli.rs/"));
cargo_bin_cmd!()
.arg("-")
.arg("-vv")
.arg("--base-url=https://lychee.cli.rs/")
.arg("--default-extension=md")
.write_stdin("[a](.)")
.assert()
.success()
.stderr(contains("https://lychee.cli.rs/"));
Ok(())
}
#[test]
fn test_base_url_applies_to_local() {
cargo_bin_cmd!()
.arg("--base-url=https://lychee.cli.rs/")
.arg(fixtures_path!().join("resolve_paths/index.html"))
.assert()
.failure()
.stdout(contains("https://lychee.cli.rs/another%20page"));
}
#[test]
fn test_sitemap_xml() {
cargo_bin_cmd!()
.arg(fixtures_path!().join("sitemap/sitemap.xml"))
.arg("-v")
.assert()
.success()
.stdout(contains("2 Total"))
.stdout(contains("2 OK"))
.stdout(contains("0 Errors"));
}
#[test]
fn test_sitemap_xml_warn() {
cargo_bin_cmd!()
.arg(fixtures_path!().join("sitemap/not-a-sitemap.xml"))
.arg("-v")
.assert()
.success()
.stdout(contains("0 Total"))
.stdout(contains("0 OK"))
.stdout(contains("0 Errors"))
.stderr(contains("[WARN] No URLs found in XML input."));
}
#[test]
fn test_large_file_lazy_download() {
cargo_bin_cmd!()
.arg("-")
.arg("--include-fragments")
.arg("--timeout=5")
.write_stdin(
"
https://proof.ovh.net/files/10Gb.dat
https://proof.ovh.net/files/1Gb.dat
https://proof.ovh.net/files/1Mb.dat
https://lychee.cli.rs/guides/cli/#options
",
)
.assert()
.success();
cargo_bin_cmd!()
.arg("-")
.arg("--timeout=5")
.write_stdin(
"
https://proof.ovh.net/files/10Gb.dat#fragments-ignored
https://proof.ovh.net/files/1Gb.dat#fragments-ignored
https://proof.ovh.net/files/1Mb.dat#fragments-ignored
https://lychee.cli.rs/guides/cli/#fragments-ignored
",
)
.assert()
.success();
}
#[tokio::test]
async fn test_legacy_cache_file_ignores_errors() -> Result<()> {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let dir = tempfile::tempdir()?;
let base_path = dir.path();
let cache_file = base_path.join(LYCHEE_CACHE_FILE);
let mock_server_ok = mock_server!(StatusCode::OK);
fs::write(&cache_file, format!("{},404,{ts}\n", mock_server_ok.uri()))?;
let mut file = File::create(dir.path().join("input.txt"))?;
writeln!(file, "{}", mock_server_ok.uri())?;
cargo_bin_cmd!()
.current_dir(base_path)
.arg("input.txt")
.arg("--cache")
.arg("--verbose")
.assert()
.success()
.stdout(contains("1 OK"))
.stdout(contains("0 Errors"));
Ok(())
}
#[test]
fn test_no_double_count() {
let test_file_1 = fixtures_path!().join("double-count/index.md");
let test_file_2 = fixtures_path!().join("double-count/home.md");
cargo_bin_cmd!()
.arg(&test_file_1)
.arg(&test_file_2)
.assert()
.success()
.stdout(contains("6 Total"))
.stdout(contains("3 Unique"));
cargo_bin_cmd!()
.arg("--dump")
.arg(&test_file_1)
.arg(&test_file_2)
.assert()
.success()
.stdout(contains("resource-1.md").count(2));
}
#[tokio::test]
async fn test_text_fragments() -> Result<()> {
let mock_server = text_fragments_server().await?;
cargo_bin_cmd!()
.arg("--include-fragments")
.arg(format!("{}/should-match.html", mock_server.uri()))
.assert()
.success()
.stdout(contains("5 Total"))
.stdout(contains("0 Errors"));
cargo_bin_cmd!()
.arg("--include-fragments")
.arg(format!("{}/should-not-match.html", mock_server.uri()))
.assert()
.success()
.stdout(contains("4 Total"))
.stdout(contains("0 Errors"));
cargo_bin_cmd!()
.arg("--include-fragments=text-only")
.arg(format!("{}/should-match.html", mock_server.uri()))
.assert()
.success()
.stdout(contains("5 Total"))
.stdout(contains("0 Errors"));
cargo_bin_cmd!()
.arg("--include-fragments=text-only")
.arg(format!("{}/should-not-match.html", mock_server.uri()))
.assert()
.failure()
.stdout(contains("4 Total"))
.stdout(contains("4 Errors"));
Ok(())
}
#[tokio::test]
async fn test_text_fragments_in_files() -> Result<()> {
let fixtures_dir = fixtures_path!().join("text_fragments");
cargo_bin_cmd!()
.arg("--include-fragments=full")
.arg("--root-dir")
.arg(&fixtures_dir)
.arg(fixtures_dir.join("should-match.html"))
.assert()
.success()
.stdout(contains("5 Total"))
.stdout(contains("0 Errors"));
cargo_bin_cmd!()
.arg("--include-fragments=full")
.arg("--root-dir")
.arg(&fixtures_dir)
.arg(fixtures_dir.join("should-not-match.html"))
.assert()
.failure()
.stdout(contains("4 Total"))
.stdout(contains("4 Errors"));
Ok(())
}
#[tokio::test]
async fn test_pyproject_toml() -> Result<()> {
let dir = tempfile::tempdir()?;
let pyproject = dir.path().join("pyproject.toml");
std::fs::write(
&pyproject,
r#"
[tool.lychee]
exclude = ["exclude_test_str"]
"#,
)?;
let stdout = cargo_bin_cmd!()
.current_dir(dir.path())
.arg("-")
.write_stdin("https://exclude/exclude_test_str")
.arg("--format=json")
.assert()
.success()
.get_output()
.stdout
.clone();
assert_eq!(
stdout_to_json(&stdout)["excludes"],
1,
"Config must mark the URL as excluded"
);
Ok(())
}
#[tokio::test]
async fn test_cargo_toml() -> Result<()> {
let dir = tempfile::tempdir()?;
let cargo = dir.path().join("Cargo.toml");
std::fs::write(
&cargo,
r#"
[workspace.metadata.lychee]
exclude = ["cargo_exclude_test_str"]
"#,
)?;
std::fs::write(
dir.path().join("input.txt"),
"https://example.com/cargo_exclude_test_str",
)?;
let mut cmd = cargo_bin_cmd!();
let assert = cmd
.current_dir(dir.path())
.arg("input.txt")
.arg("--offline")
.assert();
let output = String::from_utf8_lossy(&assert.get_output().stdout);
let output_err = String::from_utf8_lossy(&assert.get_output().stderr);
assert!(
output.contains("1 Excluded"),
"Output did not indicate the link was excluded. Stdout: {}, Stderr: {}",
output,
output_err
);
Ok(())
}
#[tokio::test]
async fn test_config_precedence() -> Result<()> {
let dir = tempfile::tempdir()?;
std::fs::write(dir.path().join("exclude_lychee.txt"), "")?;
std::fs::write(dir.path().join("exclude_pyproject.txt"), "")?;
std::fs::write(dir.path().join("exclude_cargo.txt"), "")?;
std::fs::write(
dir.path().join("Cargo.toml"),
r#"
[workspace.metadata.lychee]
exclude_path = ["exclude_cargo.txt"]
"#,
)?;
std::fs::write(
dir.path().join("pyproject.toml"),
r#"
[tool.lychee]
exclude_path = ["exclude_pyproject.txt"]
"#,
)?;
std::fs::write(
dir.path().join("lychee.toml"),
r#"
exclude_path = ["exclude_lychee.txt"]
"#,
)?;
let mut cmd1 = cargo_bin_cmd!();
let assert1 = cmd1
.current_dir(dir.path())
.arg("--dump-inputs")
.arg(".")
.assert();
let output1 = String::from_utf8_lossy(&assert1.get_output().stdout);
assert!(!output1.contains("exclude_lychee.txt"));
assert!(output1.contains("exclude_pyproject.txt"));
assert!(output1.contains("exclude_cargo.txt"));
Ok(())
}
#[tokio::test]
async fn test_explicit_pyproject_config() -> Result<()> {
let dir = tempfile::tempdir()?;
let pyproject = dir.path().join("pyproject.toml");
std::fs::write(
&pyproject,
r#"
[tool.lychee]
exclude_path = ["exclude_pyproject.txt"]
"#,
)?;
std::fs::write(dir.path().join("exclude_pyproject.txt"), "")?;
std::fs::write(dir.path().join("keep.txt"), "")?;
let mut cmd = cargo_bin_cmd!();
let assert = cmd
.current_dir(dir.path())
.arg("--config")
.arg(&pyproject)
.arg("--dump-inputs")
.arg(".")
.assert();
let output = String::from_utf8_lossy(&assert.get_output().stdout);
assert!(!output.contains("exclude_pyproject.txt"));
assert!(output.contains("keep.txt"));
Ok(())
}
#[tokio::test]
async fn test_explicit_config_missing_section() -> Result<()> {
let dir = tempfile::tempdir()?;
let package_json = dir.path().join("package.json");
std::fs::write(
&package_json,
r#"
{
"name": "my-project",
"version": "1.0.0"
}
"#,
)?;
let mut cmd = cargo_bin_cmd!();
let assert = cmd
.current_dir(dir.path())
.arg("--config")
.arg(&package_json)
.arg("https://example.com")
.assert();
let output_err = String::from_utf8_lossy(&assert.get_output().stderr);
assert!(output_err.contains("No valid lychee configuration found in"));
let pyproject_toml = dir.path().join("pyproject.toml");
std::fs::write(
&pyproject_toml,
r#"
[build-system]
requires = ["setuptools", "wheel"]
[tool.black]
line-length = 88
"#,
)?;
let mut cmd = cargo_bin_cmd!();
let assert = cmd
.current_dir(dir.path())
.arg("--config")
.arg(&pyproject_toml)
.arg("https://example.com")
.assert();
let output_err = String::from_utf8_lossy(&assert.get_output().stderr);
assert!(output_err.contains("No valid lychee configuration found in"));
let cargo_toml = dir.path().join("Cargo.toml");
std::fs::write(
&cargo_toml,
r#"
[package]
name = "my-project"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = "1.0"
"#,
)?;
let mut cmd = cargo_bin_cmd!();
let assert = cmd
.current_dir(dir.path())
.arg("--config")
.arg(&cargo_toml)
.arg("https://example.com")
.assert();
let output_err = String::from_utf8_lossy(&assert.get_output().stderr);
assert!(output_err.contains("No valid lychee configuration found in"));
Ok(())
}
#[tokio::test]
async fn test_cargo_toml_preference() -> Result<()> {
let dir = tempfile::tempdir()?;
let cargo = dir.path().join("Cargo.toml");
std::fs::write(
&cargo,
r#"
[workspace.metadata.lychee]
exclude_path = ["exclude_workspace.txt"]
[package.metadata.lychee]
exclude_path = ["exclude_package.txt"]
"#,
)?;
std::fs::write(dir.path().join("exclude_workspace.txt"), "")?;
std::fs::write(dir.path().join("exclude_package.txt"), "")?;
let mut cmd = cargo_bin_cmd!();
let assert = cmd
.current_dir(dir.path())
.arg("--dump-inputs")
.arg(".")
.assert();
let output = String::from_utf8_lossy(&assert.get_output().stdout);
assert!(!output.contains("exclude_package.txt"));
assert!(output.contains("exclude_workspace.txt"));
Ok(())
}
}
#[cfg(unix)]
#[test]
fn test_file_limit_low_concurrency() {
use assert_cmd::cargo::CommandCargoExt;
use std::os::unix::process::CommandExt;
let mut cmd = std::process::Command::cargo_bin("lychee").unwrap();
cmd.arg("-v").arg("https://example.com");
unsafe {
cmd.pre_exec(|| {
let _ = rlimit::setrlimit(rlimit::Resource::NOFILE, 64, 64);
Ok(())
});
}
let mut assert_cmd = assert_cmd::Command::from(cmd);
assert_cmd.assert().stderr(predicates::str::contains(
"System file descriptor limit is 64 which is too low for the requested concurrency of 128. Lowering `max_concurrency` to 44",
));
}
#[test]
fn test_output_invalid_path() {
let mut cmd = assert_cmd::Command::cargo_bin("lychee").unwrap();
cmd.arg("--output")
.arg("does/not/exist")
.arg("https://example.com");
cmd.assert().failure().stderr(predicates::str::contains(
"Output path `does/not/exist` is not writable: parent directory `does/not` does not exist",
));
}