mirror of
https://github.com/Hopiu/lychee.git
synced 2026-05-05 12:24:45 +00:00
Fix: Bring back error output for links (#1553)
With the last lychee release, we simplified the status output for links. While this reduced the visual noise, it also accidentally caused the source of errors to not be printed anymore. This change brings back the additional error information as part of the final report output. Furthermore, it shows the error information in the progress output if verbose mode is activated. Fixes #1487
This commit is contained in:
parent
2cdec324c2
commit
71564344de
16 changed files with 388 additions and 89 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -2412,6 +2412,7 @@ dependencies = [
|
|||
"tokio-stream",
|
||||
"toml",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
"uuid",
|
||||
"wiremock",
|
||||
]
|
||||
|
|
|
|||
10
fixtures/TEST_DETAILED_JSON_OUTPUT_ERROR.md
vendored
10
fixtures/TEST_DETAILED_JSON_OUTPUT_ERROR.md
vendored
|
|
@ -1,8 +1,8 @@
|
|||
# Test detailed JSON output error
|
||||
# Test Detailed JSON Output Error
|
||||
|
||||
This file is used to test if the error details are parsed properly in the json
|
||||
This file is used to test if the error details are parsed properly in the JSON
|
||||
format.
|
||||
|
||||
[The website](https://expired.badssl.com/) produce SSL expired certificate
|
||||
error. Such network error has no status code but it can be identified by error
|
||||
status details.
|
||||
[The website](https://expired.badssl.com/) produces an SSL expired certificate
|
||||
error. Such a network error has no status code, but it can be identified by
|
||||
error status details.
|
||||
|
|
|
|||
27
fixtures/TEST_INVALID_URLS.html
vendored
Normal file
27
fixtures/TEST_INVALID_URLS.html
vendored
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Invalid URLs</title>
|
||||
</head>
|
||||
<body>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://httpbin.org/status/404"
|
||||
>https://httpbin.org/status/404</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://httpbin.org/status/500"
|
||||
>https://httpbin.org/status/500</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://httpbin.org/status/502"
|
||||
>https://httpbin.org/status/502</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -54,6 +54,7 @@ tabled = "0.16.0"
|
|||
tokio = { version = "1.41.0", features = ["full"] }
|
||||
tokio-stream = "0.1.16"
|
||||
toml = "0.8.19"
|
||||
url = "2.5.2"
|
||||
|
||||
[dev-dependencies]
|
||||
assert_cmd = "2.0.16"
|
||||
|
|
|
|||
|
|
@ -336,7 +336,15 @@ fn show_progress(
|
|||
formatter: &dyn ResponseFormatter,
|
||||
verbose: &Verbosity,
|
||||
) -> Result<()> {
|
||||
let out = formatter.format_response(response.body());
|
||||
// In case the log level is set to info, we want to show the detailed
|
||||
// response output. Otherwise, we only show the essential information
|
||||
// (typically the status code and the URL, but this is dependent on the
|
||||
// formatter).
|
||||
let out = if verbose.log_level() >= log::Level::Info {
|
||||
formatter.format_detailed_response(response.body())
|
||||
} else {
|
||||
formatter.format_response(response.body())
|
||||
};
|
||||
|
||||
if let Some(pb) = progress_bar {
|
||||
pb.inc(1);
|
||||
|
|
@ -424,7 +432,7 @@ mod tests {
|
|||
|
||||
assert!(!buf.is_empty());
|
||||
let buf = String::from_utf8_lossy(&buf);
|
||||
assert_eq!(buf, "[200] http://127.0.0.1/ | Cached: OK (cached)\n");
|
||||
assert_eq!(buf, "[200] http://127.0.0.1/ | OK (cached)\n");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
|
|||
|
|
@ -10,10 +10,11 @@ use super::{ResponseFormatter, MAX_RESPONSE_OUTPUT_WIDTH};
|
|||
/// has not explicitly requested raw, uncolored output.
|
||||
pub(crate) struct ColorFormatter;
|
||||
|
||||
impl ResponseFormatter for ColorFormatter {
|
||||
fn format_response(&self, body: &ResponseBody) -> String {
|
||||
// Determine the color based on the status.
|
||||
let status_color = match body.status {
|
||||
impl ColorFormatter {
|
||||
/// Determine the color for formatted output based on the status of the
|
||||
/// response.
|
||||
fn status_color(status: &Status) -> &'static once_cell::sync::Lazy<console::Style> {
|
||||
match status {
|
||||
Status::Ok(_) | Status::Cached(CacheStatus::Ok(_)) => &GREEN,
|
||||
Status::Excluded
|
||||
| Status::Unsupported(_)
|
||||
|
|
@ -21,34 +22,49 @@ impl ResponseFormatter for ColorFormatter {
|
|||
Status::Redirected(_) => &NORMAL,
|
||||
Status::UnknownStatusCode(_) | Status::Timeout(_) => &YELLOW,
|
||||
Status::Error(_) | Status::Cached(CacheStatus::Error(_)) => &PINK,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let status_formatted = format_status(&body.status);
|
||||
/// Format the status code or text for the color formatter.
|
||||
///
|
||||
/// - Numeric status codes are right-aligned.
|
||||
/// - Textual statuses are left-aligned.
|
||||
/// - Padding is taken into account.
|
||||
fn format_status(status: &Status) -> String {
|
||||
let status_code_or_text = status.code_as_string();
|
||||
|
||||
let colored_status = status_color.apply_to(status_formatted);
|
||||
// Calculate the effective padding. Ensure it's non-negative to avoid panic.
|
||||
let padding = MAX_RESPONSE_OUTPUT_WIDTH.saturating_sub(status_code_or_text.len() + 2); // +2 for brackets
|
||||
|
||||
// Construct the output.
|
||||
format!("{} {}", colored_status, body.uri)
|
||||
format!(
|
||||
"{}[{:>width$}]",
|
||||
" ".repeat(padding),
|
||||
status_code_or_text,
|
||||
width = status_code_or_text.len()
|
||||
)
|
||||
}
|
||||
|
||||
/// Color and format the response status.
|
||||
fn format_response_status(status: &Status) -> String {
|
||||
let status_color = ColorFormatter::status_color(status);
|
||||
let formatted_status = ColorFormatter::format_status(status);
|
||||
status_color.apply_to(formatted_status).to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Format the status code or text for the color formatter.
|
||||
///
|
||||
/// Numeric status codes are right-aligned.
|
||||
/// Textual statuses are left-aligned.
|
||||
/// Padding is taken into account.
|
||||
fn format_status(status: &Status) -> String {
|
||||
let status_code_or_text = status.code_as_string();
|
||||
impl ResponseFormatter for ColorFormatter {
|
||||
fn format_response(&self, body: &ResponseBody) -> String {
|
||||
let colored_status = ColorFormatter::format_response_status(&body.status);
|
||||
format!("{} {}", colored_status, body.uri)
|
||||
}
|
||||
|
||||
// Calculate the effective padding. Ensure it's non-negative to avoid panic.
|
||||
let padding = MAX_RESPONSE_OUTPUT_WIDTH.saturating_sub(status_code_or_text.len() + 2); // +2 for brackets
|
||||
|
||||
format!(
|
||||
"{}[{:>width$}]",
|
||||
" ".repeat(padding),
|
||||
status_code_or_text,
|
||||
width = status_code_or_text.len()
|
||||
)
|
||||
/// Provide some more detailed information about the response
|
||||
/// This prints the entire response body, including the exact error message
|
||||
/// (if available).
|
||||
fn format_detailed_response(&self, body: &ResponseBody) -> String {
|
||||
let colored_status = ColorFormatter::format_response_status(&body.status);
|
||||
format!("{colored_status} {body}")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -56,6 +72,12 @@ mod tests {
|
|||
use super::*;
|
||||
use http::StatusCode;
|
||||
use lychee_lib::{ErrorKind, Status, Uri};
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
/// Helper function to strip ANSI color codes for tests
|
||||
fn strip_ansi_codes(s: &str) -> String {
|
||||
console::strip_ansi_codes(s).to_string()
|
||||
}
|
||||
|
||||
// Helper function to create a ResponseBody with a given status and URI
|
||||
fn mock_response_body(status: Status, uri: &str) -> ResponseBody {
|
||||
|
|
@ -65,20 +87,18 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
/// Helper function to strip ANSI color codes for tests
|
||||
fn strip_ansi_codes(s: &str) -> String {
|
||||
console::strip_ansi_codes(s).to_string()
|
||||
#[test]
|
||||
fn test_format_status() {
|
||||
let status = Status::Ok(StatusCode::OK);
|
||||
assert_eq!(ColorFormatter::format_status(&status).trim_start(), "[200]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_response_with_ok_status() {
|
||||
let formatter = ColorFormatter;
|
||||
let body = mock_response_body(Status::Ok(StatusCode::OK), "https://example.com");
|
||||
assert_eq!(
|
||||
strip_ansi_codes(&formatter.format_response(&body)),
|
||||
" [200] https://example.com/"
|
||||
);
|
||||
let formatted_response = strip_ansi_codes(&formatter.format_response(&body));
|
||||
assert_eq!(formatted_response, " [200] https://example.com/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -88,10 +108,8 @@ mod tests {
|
|||
Status::Error(ErrorKind::InvalidUrlHost),
|
||||
"https://example.com/404",
|
||||
);
|
||||
assert_eq!(
|
||||
strip_ansi_codes(&formatter.format_response(&body)),
|
||||
" [ERROR] https://example.com/404"
|
||||
);
|
||||
let formatted_response = strip_ansi_codes(&formatter.format_response(&body));
|
||||
assert_eq!(formatted_response, " [ERROR] https://example.com/404");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -100,7 +118,22 @@ mod tests {
|
|||
let long_uri =
|
||||
"https://example.com/some/very/long/path/to/a/resource/that/exceeds/normal/lengths";
|
||||
let body = mock_response_body(Status::Ok(StatusCode::OK), long_uri);
|
||||
let formatted_response = formatter.format_response(&body);
|
||||
let formatted_response = strip_ansi_codes(&formatter.format_response(&body));
|
||||
assert!(formatted_response.contains(long_uri));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detailed_response_output() {
|
||||
let formatter = ColorFormatter;
|
||||
let body = mock_response_body(
|
||||
Status::Error(ErrorKind::InvalidUrlHost),
|
||||
"https://example.com/404",
|
||||
);
|
||||
|
||||
let response = strip_ansi_codes(&formatter.format_detailed_response(&body));
|
||||
assert_eq!(
|
||||
response,
|
||||
" [ERROR] https://example.com/404 | URL is missing a host"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,9 +8,11 @@ use super::ResponseFormatter;
|
|||
/// visual output.
|
||||
pub(crate) struct EmojiFormatter;
|
||||
|
||||
impl ResponseFormatter for EmojiFormatter {
|
||||
fn format_response(&self, body: &ResponseBody) -> String {
|
||||
let emoji = match body.status {
|
||||
impl EmojiFormatter {
|
||||
/// Determine the color for formatted output based on the status of the
|
||||
/// response.
|
||||
const fn emoji_for_status(status: &Status) -> &'static str {
|
||||
match status {
|
||||
Status::Ok(_) | Status::Cached(CacheStatus::Ok(_)) => "✅",
|
||||
Status::Excluded
|
||||
| Status::Unsupported(_)
|
||||
|
|
@ -18,9 +20,20 @@ impl ResponseFormatter for EmojiFormatter {
|
|||
Status::Redirected(_) => "↪️",
|
||||
Status::UnknownStatusCode(_) | Status::Timeout(_) => "⚠️",
|
||||
Status::Error(_) | Status::Cached(CacheStatus::Error(_)) => "❌",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ResponseFormatter for EmojiFormatter {
|
||||
fn format_response(&self, body: &ResponseBody) -> String {
|
||||
let emoji = EmojiFormatter::emoji_for_status(&body.status);
|
||||
format!("{} {}", emoji, body.uri)
|
||||
}
|
||||
|
||||
fn format_detailed_response(&self, body: &ResponseBody) -> String {
|
||||
let emoji = EmojiFormatter::emoji_for_status(&body.status);
|
||||
format!("{emoji} {body}")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -92,4 +105,18 @@ mod emoji_tests {
|
|||
"⚠️ https://example.com/unknown"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detailed_response_output() {
|
||||
let formatter = EmojiFormatter;
|
||||
let body = mock_response_body(
|
||||
Status::Error(ErrorKind::InvalidUrlHost),
|
||||
"https://example.com/404",
|
||||
);
|
||||
|
||||
// Just assert the output contains the string
|
||||
assert!(formatter
|
||||
.format_detailed_response(&body)
|
||||
.ends_with("| URL is missing a host"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,4 +25,13 @@ pub(crate) const MAX_RESPONSE_OUTPUT_WIDTH: usize = 10;
|
|||
pub(crate) trait ResponseFormatter: Send + Sync {
|
||||
/// Format the response body into a human-readable string
|
||||
fn format_response(&self, body: &ResponseBody) -> String;
|
||||
|
||||
/// Detailed response formatter (defaults to the normal formatter)
|
||||
///
|
||||
/// This can be used for output modes which want to provide more detailed
|
||||
/// information. It is also used if the output is set to verbose mode
|
||||
/// (i.e. `-v`, `-vv` and above).
|
||||
fn format_detailed_response(&self, body: &ResponseBody) -> String {
|
||||
self.format_response(body)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ pub(crate) struct PlainFormatter;
|
|||
|
||||
impl ResponseFormatter for PlainFormatter {
|
||||
fn format_response(&self, body: &ResponseBody) -> String {
|
||||
body.to_string()
|
||||
format!("[{}] {}", body.status.code_as_string(), body)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -51,7 +51,7 @@ mod plain_tests {
|
|||
);
|
||||
assert_eq!(
|
||||
formatter.format_response(&body),
|
||||
"[ERROR] https://example.com/404 | Failed: URL is missing a host"
|
||||
"[ERROR] https://example.com/404 | URL is missing a host"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -59,10 +59,9 @@ mod plain_tests {
|
|||
fn test_format_response_with_excluded_status() {
|
||||
let formatter = PlainFormatter;
|
||||
let body = mock_response_body(Status::Excluded, "https://example.com/not-checked");
|
||||
assert_eq!(formatter.format_response(&body), body.to_string());
|
||||
assert_eq!(
|
||||
formatter.format_response(&body),
|
||||
"[EXCLUDED] https://example.com/not-checked | Excluded"
|
||||
"[EXCLUDED] https://example.com/not-checked"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -73,7 +72,6 @@ mod plain_tests {
|
|||
Status::Redirected(StatusCode::MOVED_PERMANENTLY),
|
||||
"https://example.com/redirect",
|
||||
);
|
||||
assert_eq!(formatter.format_response(&body), body.to_string());
|
||||
assert_eq!(
|
||||
formatter.format_response(&body),
|
||||
"[301] https://example.com/redirect | Redirect (301 Moved Permanently): Moved Permanently"
|
||||
|
|
@ -87,8 +85,6 @@ mod plain_tests {
|
|||
Status::UnknownStatusCode(StatusCode::from_u16(999).unwrap()),
|
||||
"https://example.com/unknown",
|
||||
);
|
||||
assert_eq!(formatter.format_response(&body), body.to_string());
|
||||
// Check the actual string representation of the status code
|
||||
assert_eq!(
|
||||
formatter.format_response(&body),
|
||||
"[999] https://example.com/unknown | Unknown status (999 <unknown status code>)"
|
||||
|
|
|
|||
|
|
@ -40,7 +40,11 @@ impl Display for CompactResponseStats {
|
|||
for (source, responses) in &stats.fail_map {
|
||||
color!(f, BOLD_YELLOW, "[{}]:\n", source)?;
|
||||
for response in responses {
|
||||
writeln!(f, "{}", response_formatter.format_response(response))?;
|
||||
writeln!(
|
||||
f,
|
||||
"{}",
|
||||
response_formatter.format_detailed_response(response)
|
||||
)?;
|
||||
}
|
||||
|
||||
if let Some(suggestions) = &stats.suggestion_map.get(source) {
|
||||
|
|
@ -106,3 +110,76 @@ impl StatsFormatter for Compact {
|
|||
Ok(Some(compact.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::formatters::stats::StatsFormatter;
|
||||
use crate::{options::OutputMode, stats::ResponseStats};
|
||||
use http::StatusCode;
|
||||
use lychee_lib::{InputSource, ResponseBody, Status, Uri};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use url::Url;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_formatter() {
|
||||
// A couple of dummy successes
|
||||
let mut success_map: HashMap<InputSource, HashSet<ResponseBody>> = HashMap::new();
|
||||
|
||||
success_map.insert(
|
||||
InputSource::RemoteUrl(Box::new(Url::parse("https://example.com").unwrap())),
|
||||
HashSet::from_iter(vec![ResponseBody {
|
||||
uri: Uri::from(Url::parse("https://example.com").unwrap()),
|
||||
status: Status::Ok(StatusCode::OK),
|
||||
}]),
|
||||
);
|
||||
|
||||
let err1 = ResponseBody {
|
||||
uri: Uri::try_from("https://github.com/mre/idiomatic-rust-doesnt-exist-man").unwrap(),
|
||||
status: Status::Ok(StatusCode::NOT_FOUND),
|
||||
};
|
||||
|
||||
let err2 = ResponseBody {
|
||||
uri: Uri::try_from("https://github.com/mre/boom").unwrap(),
|
||||
status: Status::Ok(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
};
|
||||
|
||||
let mut fail_map: HashMap<InputSource, HashSet<ResponseBody>> = HashMap::new();
|
||||
let source = InputSource::RemoteUrl(Box::new(Url::parse("https://example.com").unwrap()));
|
||||
fail_map.insert(source, HashSet::from_iter(vec![err1, err2]));
|
||||
|
||||
let stats = ResponseStats {
|
||||
total: 1,
|
||||
successful: 1,
|
||||
errors: 2,
|
||||
unknown: 0,
|
||||
excludes: 0,
|
||||
timeouts: 0,
|
||||
duration_secs: 0,
|
||||
fail_map,
|
||||
suggestion_map: HashMap::default(),
|
||||
unsupported: 0,
|
||||
redirects: 0,
|
||||
cached: 0,
|
||||
success_map,
|
||||
excluded_map: HashMap::default(),
|
||||
detailed_stats: false,
|
||||
};
|
||||
|
||||
let formatter = Compact::new(OutputMode::Plain);
|
||||
|
||||
let result = formatter.format(stats).unwrap().unwrap();
|
||||
|
||||
println!("{result}");
|
||||
|
||||
assert!(result.contains("🔍 1 Total"));
|
||||
assert!(result.contains("✅ 1 OK"));
|
||||
assert!(result.contains("🚫 2 Errors"));
|
||||
|
||||
assert!(result.contains("[https://example.com/]:"));
|
||||
assert!(result
|
||||
.contains("https://github.com/mre/idiomatic-rust-doesnt-exist-man | 404 Not Found"));
|
||||
assert!(result.contains("https://github.com/mre/boom | 500 Internal Server Error"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,7 +55,11 @@ impl Display for DetailedResponseStats {
|
|||
write!(f, "\n\nErrors in {source}")?;
|
||||
|
||||
for response in responses {
|
||||
write!(f, "\n{}", response_formatter.format_response(response))?;
|
||||
write!(
|
||||
f,
|
||||
"\n{}",
|
||||
response_formatter.format_detailed_response(response)
|
||||
)?;
|
||||
|
||||
if let Some(suggestions) = &stats.suggestion_map.get(source) {
|
||||
writeln!(f, "\nSuggestions in {source}")?;
|
||||
|
|
@ -89,3 +93,65 @@ impl StatsFormatter for Detailed {
|
|||
Ok(Some(detailed.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::options::OutputMode;
|
||||
use http::StatusCode;
|
||||
use lychee_lib::{InputSource, ResponseBody, Status, Uri};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use url::Url;
|
||||
|
||||
#[test]
|
||||
fn test_detailed_formatter_github_404() {
|
||||
let err1 = ResponseBody {
|
||||
uri: Uri::try_from("https://github.com/mre/idiomatic-rust-doesnt-exist-man").unwrap(),
|
||||
status: Status::Ok(StatusCode::NOT_FOUND),
|
||||
};
|
||||
|
||||
let err2 = ResponseBody {
|
||||
uri: Uri::try_from("https://github.com/mre/boom").unwrap(),
|
||||
status: Status::Ok(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
};
|
||||
|
||||
let mut fail_map: HashMap<InputSource, HashSet<ResponseBody>> = HashMap::new();
|
||||
let source = InputSource::RemoteUrl(Box::new(Url::parse("https://example.com").unwrap()));
|
||||
fail_map.insert(source, HashSet::from_iter(vec![err1, err2]));
|
||||
|
||||
let stats = ResponseStats {
|
||||
total: 2,
|
||||
successful: 0,
|
||||
errors: 2,
|
||||
unknown: 0,
|
||||
excludes: 0,
|
||||
timeouts: 0,
|
||||
duration_secs: 0,
|
||||
unsupported: 0,
|
||||
redirects: 0,
|
||||
cached: 0,
|
||||
suggestion_map: HashMap::default(),
|
||||
success_map: HashMap::default(),
|
||||
fail_map,
|
||||
excluded_map: HashMap::default(),
|
||||
detailed_stats: true,
|
||||
};
|
||||
|
||||
let formatter = Detailed::new(OutputMode::Plain);
|
||||
let result = formatter.format(stats).unwrap().unwrap();
|
||||
|
||||
// Check for the presence of expected content
|
||||
assert!(result.contains("📝 Summary"));
|
||||
assert!(result.contains("🔍 Total............2"));
|
||||
assert!(result.contains("✅ Successful.......0"));
|
||||
assert!(result.contains("⏳ Timeouts.........0"));
|
||||
assert!(result.contains("🔀 Redirected.......0"));
|
||||
assert!(result.contains("👻 Excluded.........0"));
|
||||
assert!(result.contains("❓ Unknown..........0"));
|
||||
assert!(result.contains("🚫 Errors...........2"));
|
||||
assert!(result.contains("Errors in https://example.com/"));
|
||||
assert!(result
|
||||
.contains("https://github.com/mre/idiomatic-rust-doesnt-exist-man | 404 Not Found"));
|
||||
assert!(result.contains("https://github.com/mre/boom | 500 Internal Server Error"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -186,7 +186,7 @@ mod tests {
|
|||
let markdown = markdown_response(&response).unwrap();
|
||||
assert_eq!(
|
||||
markdown,
|
||||
"* [200] [http://example.com/](http://example.com/) | Cached: OK (cached)"
|
||||
"* [200] [http://example.com/](http://example.com/) | OK (cached)"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -199,7 +199,7 @@ mod tests {
|
|||
let markdown = markdown_response(&response).unwrap();
|
||||
assert_eq!(
|
||||
markdown,
|
||||
"* [400] [http://example.com/](http://example.com/) | Cached: Error (cached)"
|
||||
"* [400] [http://example.com/](http://example.com/) | Error (cached)"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -253,7 +253,7 @@ mod tests {
|
|||
|
||||
### Errors in stdin
|
||||
|
||||
* [404] [http://127.0.0.1/](http://127.0.0.1/) | Cached: Error (cached)
|
||||
* [404] [http://127.0.0.1/](http://127.0.0.1/) | Error (cached)
|
||||
|
||||
## Suggestions per input
|
||||
|
||||
|
|
|
|||
|
|
@ -112,6 +112,50 @@ mod cli {
|
|||
}};
|
||||
}
|
||||
|
||||
/// Test that the default report output format (compact) and mode (color)
|
||||
/// prints the failed URLs as well as their status codes on error. Make
|
||||
/// sure that the status code only occurs once.
|
||||
#[test]
|
||||
fn test_compact_output_format_contains_status() -> Result<()> {
|
||||
let test_path = fixtures_path().join("TEST_INVALID_URLS.html");
|
||||
|
||||
let mut cmd = main_command();
|
||||
cmd.arg("--format")
|
||||
.arg("compact")
|
||||
.arg("--mode")
|
||||
.arg("color")
|
||||
.arg(test_path)
|
||||
.env("FORCE_COLOR", "1")
|
||||
.assert()
|
||||
.failure()
|
||||
.code(2);
|
||||
|
||||
let output = cmd.output()?;
|
||||
|
||||
// Check that the output contains the status code (once) and the URL
|
||||
let output_str = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
// The expected output is as follows:
|
||||
// "Find details below."
|
||||
// [EMPTY LINE]
|
||||
// [path/to/file]:
|
||||
// [400] https://httpbin.org/status/404
|
||||
// [500] https://httpbin.org/status/500
|
||||
// [502] https://httpbin.org/status/502
|
||||
// (the order of the URLs may vary)
|
||||
|
||||
// Check that the output contains the file path
|
||||
assert!(output_str.contains("TEST_INVALID_URLS.html"));
|
||||
|
||||
let re = Regex::new(r"\s{5}\[\d{3}\] https://httpbin\.org/status/\d{3}").unwrap();
|
||||
let matches: Vec<&str> = re.find_iter(&output_str).map(|m| m.as_str()).collect();
|
||||
|
||||
// Check that the status code occurs only once
|
||||
assert_eq!(matches.len(), 3);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// JSON-formatted output should always be valid JSON.
|
||||
/// Additional hints and error messages should be printed to `stderr`.
|
||||
/// See https://github.com/lycheeverse/lychee/issues/1355
|
||||
|
|
@ -156,7 +200,7 @@ mod cli {
|
|||
let site_error_status = &output_json["fail_map"][&test_path.to_str().unwrap()][0]["status"];
|
||||
|
||||
assert_eq!(
|
||||
"error sending request for url (https://expired.badssl.com/)",
|
||||
"error sending request for url (https://expired.badssl.com/) Maybe a certificate error?",
|
||||
site_error_status["details"]
|
||||
);
|
||||
Ok(())
|
||||
|
|
@ -425,7 +469,7 @@ mod cli {
|
|||
.failure()
|
||||
.code(2)
|
||||
.stdout(contains(
|
||||
"[404] https://github.com/mre/idiomatic-rust-doesnt-exist-man"
|
||||
"[404] https://github.com/mre/idiomatic-rust-doesnt-exist-man | Network error: Not Found"
|
||||
))
|
||||
.stderr(contains(
|
||||
"There were issues with GitHub URLs. You could try setting a GitHub token and running lychee again.",
|
||||
|
|
@ -902,11 +946,11 @@ mod cli {
|
|||
// Run again to verify cache behavior
|
||||
cmd.assert()
|
||||
.stderr(contains(format!(
|
||||
"[200] {}/ | Cached: OK (cached)\n",
|
||||
"[200] {}/ | OK (cached)\n",
|
||||
mock_server_ok.uri()
|
||||
)))
|
||||
.stderr(contains(format!(
|
||||
"[404] {}/ | Cached: Error (cached)\n",
|
||||
"[404] {}/ | Error (cached)\n",
|
||||
mock_server_err.uri()
|
||||
)));
|
||||
|
||||
|
|
@ -955,11 +999,11 @@ mod cli {
|
|||
.assert()
|
||||
.stderr(contains(format!("[200] {}/\n", mock_server_ok.uri())))
|
||||
.stderr(contains(format!(
|
||||
"[204] {}/ | OK (204 No Content): No Content\n",
|
||||
"[204] {}/ | 204 No Content: No Content\n",
|
||||
mock_server_no_content.uri()
|
||||
)))
|
||||
.stderr(contains(format!(
|
||||
"[429] {}/ | Failed: Network error: Too Many Requests\n",
|
||||
"[429] {}/ | Network error: Too Many Requests\n",
|
||||
mock_server_too_many_requests.uri()
|
||||
)));
|
||||
|
||||
|
|
@ -1017,11 +1061,11 @@ mod cli {
|
|||
.failure()
|
||||
.code(2)
|
||||
.stdout(contains(format!(
|
||||
"[418] {}/ | Failed: Network error: I\'m a teapot",
|
||||
"[418] {}/ | Network error: I\'m a teapot",
|
||||
mock_server_teapot.uri()
|
||||
)))
|
||||
.stdout(contains(format!(
|
||||
"[500] {}/ | Failed: Network error: Internal Server Error",
|
||||
"[500] {}/ | Network error: Internal Server Error",
|
||||
mock_server_server_error.uri()
|
||||
)));
|
||||
|
||||
|
|
@ -1040,11 +1084,11 @@ mod cli {
|
|||
.assert()
|
||||
.success()
|
||||
.stderr(contains(format!(
|
||||
"[418] {}/ | Cached: OK (cached)",
|
||||
"[418] {}/ | OK (cached)",
|
||||
mock_server_teapot.uri()
|
||||
)))
|
||||
.stderr(contains(format!(
|
||||
"[500] {}/ | Cached: OK (cached)",
|
||||
"[500] {}/ | OK (cached)",
|
||||
mock_server_server_error.uri()
|
||||
)));
|
||||
|
||||
|
|
@ -1080,7 +1124,7 @@ mod cli {
|
|||
.stderr(contains(format!(
|
||||
"[IGNORED] {unsupported_url} | Unsupported: Error creating request client"
|
||||
)))
|
||||
.stderr(contains(format!("[EXCLUDED] {excluded_url} | Excluded\n")));
|
||||
.stderr(contains(format!("[EXCLUDED] {excluded_url}\n")));
|
||||
|
||||
// The cache file should be empty, because the only checked URL is
|
||||
// unsupported and we don't want to cache that. It might be supported in
|
||||
|
|
|
|||
|
|
@ -177,7 +177,15 @@ impl ErrorKind {
|
|||
.to_string(),
|
||||
)
|
||||
} else {
|
||||
Some(utils::reqwest::trim_error_output(e))
|
||||
// Get the relevant details from the specific reqwest error
|
||||
let details = utils::reqwest::trim_error_output(e);
|
||||
|
||||
// Provide support for common error types
|
||||
if e.is_connect() {
|
||||
Some(format!("{details} Maybe a certificate error?"))
|
||||
} else {
|
||||
Some(details)
|
||||
}
|
||||
}
|
||||
}
|
||||
ErrorKind::GithubRequest(e) => {
|
||||
|
|
|
|||
|
|
@ -79,19 +79,24 @@ pub struct ResponseBody {
|
|||
// matching in these cases.
|
||||
impl Display for ResponseBody {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "[{}] {}", self.status.code_as_string(), self.uri)?;
|
||||
// Always write the URI
|
||||
write!(f, "{}", self.uri)?;
|
||||
|
||||
if let Status::Ok(StatusCode::OK) = self.status {
|
||||
// Don't print anything else if the status code is 200.
|
||||
// The output gets too verbose then.
|
||||
// Early return for OK status to avoid verbose output
|
||||
if matches!(self.status, Status::Ok(StatusCode::OK)) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Add a separator between the URI and the additional details below.
|
||||
// Note: To make the links clickable in some terminals,
|
||||
// we add a space before the separator.
|
||||
write!(f, " | {}", self.status)?;
|
||||
// Format status and return early if empty
|
||||
let status_output = self.status.to_string();
|
||||
if status_output.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Write status with separator
|
||||
write!(f, " | {status_output}")?;
|
||||
|
||||
// Add details if available
|
||||
if let Some(details) = self.status.details() {
|
||||
write!(f, ": {details}")
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -45,15 +45,15 @@ pub enum Status {
|
|||
impl Display for Status {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Status::Ok(code) => write!(f, "OK ({code})"),
|
||||
Status::Ok(code) => write!(f, "{code}"),
|
||||
Status::Redirected(code) => write!(f, "Redirect ({code})"),
|
||||
Status::UnknownStatusCode(code) => write!(f, "Unknown status ({code})"),
|
||||
Status::Excluded => f.write_str("Excluded"),
|
||||
Status::Timeout(Some(code)) => write!(f, "Timeout ({code})"),
|
||||
Status::Timeout(None) => f.write_str("Timeout"),
|
||||
Status::Unsupported(e) => write!(f, "Unsupported: {e}"),
|
||||
Status::Error(e) => write!(f, "Failed: {e}"),
|
||||
Status::Cached(status) => write!(f, "Cached: {status}"),
|
||||
Status::Error(e) => write!(f, "{e}"),
|
||||
Status::Cached(status) => write!(f, "{status}"),
|
||||
Status::Excluded => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -310,10 +310,7 @@ mod tests {
|
|||
fn test_status_serialization() {
|
||||
let status_ok = Status::Ok(StatusCode::from_u16(200).unwrap());
|
||||
let serialized_with_code = serde_json::to_string(&status_ok).unwrap();
|
||||
assert_eq!(
|
||||
"{\"text\":\"OK (200 OK)\",\"code\":200}",
|
||||
serialized_with_code
|
||||
);
|
||||
assert_eq!("{\"text\":\"200 OK\",\"code\":200}", serialized_with_code);
|
||||
|
||||
let status_timeout = Status::Timeout(None);
|
||||
let serialized_without_code = serde_json::to_string(&status_timeout).unwrap();
|
||||
|
|
|
|||
Loading…
Reference in a new issue