#![allow(clippy::unwrap_used, clippy::expect_used)]
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use git2::{Repository, Signature};
use tempfile::TempDir;
#[allow(dead_code)]
#[path = "../src/cli/doc_fields.rs"]
mod doc_fields;
struct Fixture {
dir: TempDir,
base: String,
}
impl Fixture {
fn new() -> Self {
let dir = tempfile::tempdir().unwrap();
let repo = Repository::init(dir.path()).unwrap();
let base = commit(&repo, "base", &[("a.rs", "fn a() {}\n")], &[]);
commit(
&repo,
"head",
&[
(
"a.rs",
"fn a() {}\nfn b() {}\nfn c() {}\nfn d() {}\nfn e() {}\n",
),
("b.rs", "fn f() {}\n"),
],
&[base],
);
Self {
dir,
base: base.to_string(),
}
}
fn root(&self) -> &Path {
self.dir.path()
}
fn file(&self, name: &str, contents: &str) -> PathBuf {
let path = self.root().join(name);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, contents).unwrap();
path
}
fn report(&self) -> PathBuf {
self.file(
"head.lcov",
"SF:a.rs\nDA:2,1\nDA:3,1\nDA:4,0\nDA:5,0\nend_of_record\n",
)
}
fn diff(&self, args: &[&str]) -> Output {
run(self.root(), &["diff", "--base-ref", &self.base], args)
}
}
fn commit(
repo: &Repository,
message: &str,
files: &[(&str, &str)],
parents: &[git2::Oid],
) -> git2::Oid {
let root = repo.workdir().unwrap();
let mut index = repo.index().unwrap();
index.clear().unwrap();
for (name, contents) in files {
std::fs::write(root.join(name), contents).unwrap();
index.add_path(Path::new(name)).unwrap();
}
index.write().unwrap();
let tree = repo.find_tree(index.write_tree().unwrap()).unwrap();
let signature = Signature::now("Test User", "test@example.com").unwrap();
let parents: Vec<_> = parents
.iter()
.map(|id| repo.find_commit(*id).unwrap())
.collect();
let parents: Vec<_> = parents.iter().collect();
repo.commit(
Some("HEAD"),
&signature,
&signature,
message,
&tree,
&parents,
)
.unwrap()
}
fn run(root: &Path, head: &[&str], args: &[&str]) -> Output {
command(root)
.arg("-C")
.arg(root)
.args(head)
.args(args)
.output()
.unwrap()
}
fn command(cwd: &Path) -> Command {
let mut command = Command::new(env!("CARGO_BIN_EXE_patchcov"));
command
.env_remove("PATCHCOV_CONFIG_DIR")
.env_remove("PATCHCOV_ERROR_FORMAT")
.env("GIT_CEILING_DIRECTORIES", cwd.parent().unwrap())
.current_dir(cwd);
command
}
fn code(output: &Output) -> i32 {
output
.status
.code()
.expect("the process was killed by a signal")
}
fn stderr(output: &Output) -> String {
String::from_utf8(output.stderr.clone()).unwrap()
}
fn path(path: &Path) -> &str {
path.to_str().unwrap()
}
#[test]
fn success_is_0() {
let fx = Fixture::new();
let report = fx.report();
let output = fx.diff(&["--report", path(&report), "--fail-under-patch", "50"]);
assert_eq!(code(&output), 0, "{}", stderr(&output));
let output = fx.diff(&["--report", path(&report), "--branch-coverage"]);
assert_eq!(
code(&output),
0,
"lcov supports --branch-coverage: {}",
stderr(&output)
);
}
#[test]
fn a_failed_gate_is_1_and_still_prints_the_report() {
let fx = Fixture::new();
let report = fx.report();
for (flag, value) in [("--fail-under-patch", "80"), ("--fail-under-lines", "100")] {
let output = fx.diff(&["--report", path(&report), flag, value]);
assert_eq!(code(&output), 1, "{flag}: {}", stderr(&output));
assert!(
!output.stdout.is_empty(),
"{flag}: the report is printed first"
);
assert!(stderr(&output).contains(flag), "{}", stderr(&output));
}
}
#[test]
fn an_unmeasured_touched_file_is_1() {
let fx = Fixture::new();
let report = fx.report();
let output = fx.diff(&["--report", path(&report), "--fail-on-unmeasured", "b.rs"]);
assert_eq!(code(&output), 1, "{}", stderr(&output));
assert!(stderr(&output).contains("b.rs"), "{}", stderr(&output));
}
#[test]
fn every_failed_gate_is_still_named_in_one_error() {
let fx = Fixture::new();
let report = fx.report();
let output = fx.diff(&[
"--report",
path(&report),
"--fail-under-patch",
"80",
"--fail-under-lines",
"100",
]);
assert_eq!(code(&output), 1);
let text = stderr(&output);
assert!(text.contains("--fail-under-patch") && text.contains("--fail-under-lines"));
}
#[test]
fn usage_errors_are_2() {
let fx = Fixture::new();
assert_eq!(code(&fx.diff(&[])), 2);
let report = fx.report();
for (flag, value) in [
("--fail-under-patch", "nan"),
("--fail-under-lines", "inf"),
("--fail-under-patch", "-5"),
("--fail-under-lines", "-0.1"),
("--fail-under-patch", "-inf"),
] {
let output = fx.diff(&["--report", path(&report), flag, value]);
assert_eq!(code(&output), 2, "{flag} {value}: {}", stderr(&output));
let joined = format!("{flag}={value}");
let output = fx.diff(&["--report", path(&report), &joined]);
assert_eq!(code(&output), 2, "{joined}: {}", stderr(&output));
}
let go = fx.file("go.cover", "mode: set\nexample.com/m/a.go:1.1,2.2 1 1\n");
let output = fx.diff(&["--report", path(&go), "--branch-coverage"]);
assert_eq!(code(&output), 2, "{}", stderr(&output));
let output = run(fx.root(), &["merge", "-o", "json"], &[path(&report)]);
assert_eq!(code(&output), 2, "{}", stderr(&output));
assert!(!fx.root().join("json").exists());
}
#[test]
fn negative_threshold_usage_errors_explain_the_value() {
let fx = Fixture::new();
for flag in ["--fail-under-patch", "--fail-under-lines"] {
for (value, reason) in [
("-inf", "must be a finite number"),
("-nan", "must be a finite number"),
("-.5", "must not be negative"),
] {
let joined = format!("{flag}={value}");
for spelling in [vec![flag, value], vec![joined.as_str()]] {
for format in ["text", "json"] {
let mut argv = vec!["--report", "missing.lcov", "--error-format", format];
argv.extend(&spelling);
let output = fx.diff(&argv);
assert_eq!(code(&output), 2, "{}", stderr(&output));
assert!(output.stdout.is_empty());
let text = stderr(&output);
let message = if format == "json" {
let report: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(report["code"], 2);
assert_eq!(report["kind"], "usage");
report["message"].as_str().unwrap().to_owned()
} else {
text
};
assert!(
message.contains(&format!("invalid value '{value}'")),
"{message}"
);
assert!(message.contains(flag), "{message}");
assert!(message.contains(reason), "{message}");
}
}
}
let output = fx.diff(&["--report", "missing.lcov", flag, "--head-ref", "HEAD"]);
assert_eq!(code(&output), 2);
assert!(stderr(&output).contains(&format!("a value is required for '{flag} <PCT>'")));
}
}
#[test]
fn an_unusable_report_is_3() {
let fx = Fixture::new();
let missing = fx.root().join("missing.lcov");
let empty = fx.file("empty.lcov", "");
let garbage = fx.file("garbage.lcov", "this is not a coverage report\n");
for report in [&missing, &empty, &garbage] {
let output = fx.diff(&["--report", path(report)]);
assert_eq!(
code(&output),
3,
"{}: {}",
report.display(),
stderr(&output)
);
}
let good = fx.report();
let no_lines = fx.file("no-lines.lcov", "SF:a.rs\nend_of_record\n");
let output = fx.diff(&["--report", path(&good), "--report", path(&no_lines)]);
assert_eq!(code(&output), 3, "{}", stderr(&output));
let output = run(fx.root(), &["merge", "-o", "out.lcov"], &[path(&missing)]);
assert_eq!(code(&output), 3, "{}", stderr(&output));
}
#[test]
fn a_malformed_marker_is_4() {
let fx = Fixture::new();
let report = fx.report();
let marker = format!(
"// {} ignore reason=\"unclosed\"\n",
patchcov::markers::INTRODUCER
);
fx.file("a.rs", &format!("{marker}fn a() {{}}\n"));
let output = fx.diff(&["--report", path(&report)]);
assert_eq!(code(&output), 4, "{}", stderr(&output));
let output = run(fx.root(), &["lint-markers"], &["a.rs"]);
assert_eq!(code(&output), 4, "{}", stderr(&output));
assert!(stderr(&output).contains("coverage marker lint failed"));
}
#[test]
fn bad_config_is_5() {
let fx = Fixture::new();
let report = fx.report();
fx.file(".patchcov/config.yaml", "diff: [unclosed\n");
let output = fx.diff(&["--report", path(&report)]);
assert_eq!(code(&output), 5, "{}", stderr(&output));
let output = run(fx.root(), &["lint-markers"], &[]);
assert_eq!(code(&output), 5, "{}", stderr(&output));
std::fs::remove_dir_all(fx.root().join(".patchcov")).unwrap();
fx.file(
".patchcov/config.yaml",
"diff:\n path-mappings:\n - from: x\n to: ../escape\n",
);
let output = fx.diff(&["--report", path(&report)]);
assert_eq!(code(&output), 5, "{}", stderr(&output));
std::fs::remove_dir_all(fx.root().join(".patchcov")).unwrap();
let output = fx.diff(&["--report", path(&report), "--ignore-filename-regex", "("]);
assert_eq!(code(&output), 5, "{}", stderr(&output));
let output = fx.diff(&["--report", path(&report), "--fail-on-unmeasured", "["]);
assert_eq!(code(&output), 5, "{}", stderr(&output));
let output = run(fx.root(), &["lint-markers", "--include", "["], &[]);
assert_eq!(code(&output), 5, "{}", stderr(&output));
}
#[test]
fn git_failures_are_6() {
let fx = Fixture::new();
let report = fx.report();
let output = run(
fx.root(),
&["diff", "--base-ref", "no-such-ref"],
&["--report", path(&report)],
);
assert_eq!(code(&output), 6, "{}", stderr(&output));
let elsewhere = tempfile::tempdir().unwrap();
let output = run(elsewhere.path(), &["diff"], &["--report", path(&report)]);
assert_eq!(code(&output), 6, "{}", stderr(&output));
let output = run(elsewhere.path(), &["lint-markers"], &[]);
assert_eq!(code(&output), 6, "{}", stderr(&output));
}
#[test]
fn a_path_mismatch_is_7_unless_allowed() {
let fx = Fixture::new();
let report = fx.file(
"elsewhere.lcov",
"SF:nowhere/else.rs\nDA:1,1\nend_of_record\n",
);
let output = fx.diff(&["--report", path(&report)]);
assert_eq!(code(&output), 7, "{}", stderr(&output));
let output = fx.diff(&["--report", path(&report), "--allow-path-mismatch"]);
assert_eq!(
code(&output),
0,
"a warning, not a failure: {}",
stderr(&output)
);
assert!(stderr(&output).contains("warning:"), "{}", stderr(&output));
}
#[test]
fn another_runtime_failure_is_8() {
let fx = Fixture::new();
let report = fx.report();
let output = run(
fx.root(),
&["merge", "-o", "no-such-dir/merged.lcov"],
&[path(&report)],
);
assert_eq!(code(&output), 8, "{}", stderr(&output));
}
#[test]
fn the_message_is_unchanged_by_the_code() {
let fx = Fixture::new();
let missing = fx.root().join("missing.lcov");
let output = fx.diff(&["--report", path(&missing)]);
let text = stderr(&output);
let last = text.lines().last().unwrap();
assert!(
last.starts_with("Error: could not read coverage report "),
"{text}"
);
assert!(last.contains("missing.lcov: "), "{text}");
}
fn report(output: &Output) -> serde_json::Value {
let text = stderr(output);
let last = text.lines().last().unwrap_or_default();
serde_json::from_str(last).unwrap_or_else(|err| panic!("not JSON ({err}): {text}"))
}
fn assert_json_error(fx: &Fixture, args: &[&str], code: i32, kind: &str) {
let plain = run(fx.root(), args, &[]);
let mut with_flag: Vec<&str> = args.to_vec();
with_flag.extend(["--error-format", "json"]);
let json = run(fx.root(), &with_flag, &[]);
assert_eq!(self::code(&plain), code, "{args:?}: {}", stderr(&plain));
assert_eq!(self::code(&json), code, "{args:?}: {}", stderr(&json));
assert_eq!(plain.stdout, json.stdout, "{args:?}: stdout is unchanged");
let value = report(&json);
assert_eq!(value["code"], code, "{args:?}: {value}");
assert_eq!(value["kind"], kind, "{args:?}: {value}");
let object = value.as_object().unwrap();
let expected: &[&str] = if kind == "gate" {
&["chain", "code", "gates", "kind", "level", "message"]
} else {
&["chain", "code", "kind", "level", "message"]
};
assert_eq!(object.keys().collect::<Vec<_>>(), expected, "{args:?}");
let chain: Vec<&str> = value["chain"]
.as_array()
.unwrap()
.iter()
.map(|cause| cause.as_str().unwrap())
.collect();
let message = value["message"].as_str().unwrap();
assert!(!message.is_empty(), "{args:?}");
let mut joined = vec![message];
joined.extend(chain);
let plain_text = stderr(&plain);
if kind == "usage" && !plain_text.contains("Error: ") {
assert!(plain_text.contains(message), "{args:?}: {plain_text}");
} else {
let expected = format!("Error: {}", joined.join(": "));
assert!(
plain_text.trim_end().ends_with(&expected),
"{args:?}: {plain_text}"
);
}
assert!(
!stderr(&json).contains("Error: "),
"{args:?}: {}",
stderr(&json)
);
}
#[test]
fn json_errors_name_each_class() {
let fx = Fixture::new();
let report = fx.report();
let report = path(&report);
let base = fx.base.clone();
let diff = |extra: &[&'static str]| -> Vec<String> {
let mut args = vec!["diff".to_string(), "--base-ref".into(), base.clone()];
args.extend(extra.iter().map(ToString::to_string));
args
};
let check = |args: Vec<String>, code: i32, kind: &str| {
let args: Vec<&str> = args.iter().map(String::as_str).collect();
assert_json_error(&fx, &args, code, kind);
};
let mut args = diff(&["--fail-under-patch", "80"]);
args.extend(["--report".into(), report.to_string()]);
check(args, 1, "gate");
check(diff(&[]), 2, "usage");
let go = fx.file("go.cover", "mode: set\nexample.com/m/a.go:1.1,2.2 1 1\n");
let mut args = diff(&["--branch-coverage"]);
args.extend(["--report".into(), path(&go).to_string()]);
check(args, 2, "usage");
let mut args = diff(&[]);
args.extend(["--report".into(), "missing.lcov".to_string()]);
check(args, 3, "report");
let marker = format!(
"// {} ignore reason=\"unclosed\"\n",
patchcov::markers::INTRODUCER
);
fx.file("m.rs", &format!("{marker}fn m() {{}}\n"));
check(vec!["lint-markers".into(), "m.rs".into()], 4, "marker");
let mut args = diff(&["--ignore-filename-regex", "("]);
args.extend(["--report".into(), report.to_string()]);
check(args, 5, "config");
let mut args = vec![
"diff".to_string(),
"--base-ref".into(),
"no-such-ref".into(),
];
args.extend(["--report".into(), report.to_string()]);
check(args, 6, "git");
let elsewhere = fx.file(
"elsewhere.lcov",
"SF:nowhere/else.rs\nDA:1,1\nend_of_record\n",
);
let mut args = diff(&[]);
args.extend(["--report".into(), path(&elsewhere).to_string()]);
check(args, 7, "path-mismatch");
let args = vec![
"merge".to_string(),
report.to_string(),
"-o".into(),
"no-such-dir/merged.lcov".into(),
];
check(args, 8, "other");
}
#[test]
fn a_json_error_carries_the_causes() {
let fx = Fixture::new();
let missing = fx.root().join("missing.lcov");
let output = fx.diff(&["--report", path(&missing), "--error-format", "json"]);
let value = report(&output);
assert!(
value["message"]
.as_str()
.unwrap()
.starts_with("could not read coverage report "),
"{value}"
);
assert!(
value["chain"][0].as_str().unwrap().contains("os error 2"),
"{value}"
);
}
#[test]
fn a_gate_failure_still_prints_the_report_to_stdout() {
let fx = Fixture::new();
let report = fx.report();
let output = fx.diff(&[
"--report",
path(&report),
"--fail-under-patch",
"80",
"--error-format=json",
]);
assert_eq!(code(&output), 1);
assert!(!output.stdout.is_empty());
assert!(self::report(&output)["message"]
.as_str()
.unwrap()
.contains("--fail-under-patch"));
}
#[test]
fn a_json_gate_error_has_the_gates_as_data() {
let fx = Fixture::new();
let lcov = fx.report();
let output = fx.diff(&[
"--report",
path(&lcov),
"--fail-under-patch",
"80",
"--fail-under-lines",
"75.5",
"--fail-on-unmeasured",
"b.rs",
"--error-format",
"json",
]);
assert_eq!(code(&output), 1, "{}", stderr(&output));
let value = report(&output);
assert_eq!(value["kind"], "gate", "{value}");
let emitted: Vec<_> = value["gates"]
.as_array()
.expect("gate error must have a gates array")
.iter()
.map(|gate| gate["gate"].as_str().expect("gate name must be a string"))
.collect();
let documented: Vec<_> = doc_fields::gate_rows(doc_fields::reference_section("Error output"))
.into_iter()
.map(|(name, _)| name)
.collect();
assert_eq!(
emitted, documented,
"gate order must match docs/reference.md: {value}"
);
assert_eq!(
value["gates"],
serde_json::json!([
{"gate": "fail-under-patch", "threshold": 80.0, "measured": 50.0},
{"gate": "fail-under-lines", "threshold": 75.5, "measured": 50.0},
{"gate": "fail-on-unmeasured", "files": ["b.rs"]},
]),
"{value}"
);
let text = fx.diff(&[
"--report",
path(&lcov),
"--fail-under-patch",
"80",
"--fail-under-lines",
"75.5",
"--fail-on-unmeasured",
"b.rs",
]);
assert_eq!(
stderr(&text),
format!("Error: {}\n", value["message"].as_str().unwrap())
);
assert!(value["chain"].as_array().unwrap().is_empty());
}
#[test]
fn an_unmeasurable_line_total_is_a_null_measured_value() {
let fx = Fixture::new();
let lcov = fx.report();
let output = fx.diff(&[
"--report",
path(&lcov),
"--ignore-filename-regex",
r"a\.rs",
"--fail-under-lines",
"1",
"--error-format",
"json",
]);
assert_eq!(code(&output), 1, "{}", stderr(&output));
assert_eq!(
report(&output)["gates"],
serde_json::json!([{"gate": "fail-under-lines", "threshold": 1.0, "measured": null}])
);
}
#[test]
fn only_the_gates_that_failed_are_listed() {
let fx = Fixture::new();
let lcov = fx.report();
let output = fx.diff(&[
"--report",
path(&lcov),
"--fail-under-patch",
"50",
"--fail-under-lines",
"60",
"--error-format",
"json",
]);
assert_eq!(code(&output), 1, "{}", stderr(&output));
let gates = report(&output)["gates"].clone();
assert_eq!(
gates,
serde_json::json!([{"gate": "fail-under-lines", "threshold": 60.0, "measured": 50.0}])
);
}
#[test]
fn the_environment_variable_sets_the_format_and_the_flag_wins() {
let fx = Fixture::new();
let missing = fx.root().join("missing.lcov");
let args = ["diff", "--report", path(&missing)];
let with_env = |value: &str, extra: &[&str]| {
Command::new(env!("CARGO_BIN_EXE_patchcov"))
.env("PATCHCOV_ERROR_FORMAT", value)
.env("GIT_CEILING_DIRECTORIES", fx.root().parent().unwrap())
.arg("-C")
.arg(fx.root())
.args(args)
.args(extra)
.output()
.unwrap()
};
let output = with_env("json", &[]);
assert_eq!(code(&output), 3);
assert_eq!(report(&output)["kind"], "report");
let output = with_env("json", &["--error-format", "text"]);
assert!(
stderr(&output).starts_with("Error: "),
"{}",
stderr(&output)
);
let output = with_env("xml", &[]);
assert_eq!(code(&output), 3);
let text = stderr(&output);
assert!(
text.contains("warning: ignoring PATCHCOV_ERROR_FORMAT=xml"),
"{text}"
);
assert!(
text.lines().last().unwrap().starts_with("Error: "),
"{text}"
);
let output = with_env("", &[]);
assert_eq!(code(&output), 3);
assert!(
stderr(&output).starts_with("Error: "),
"{}",
stderr(&output)
);
}
#[test]
fn the_flag_is_accepted_before_the_subcommand() {
let fx = Fixture::new();
let missing = fx.root().join("missing.lcov");
let output = run(
fx.root(),
&["--error-format=json", "diff", "--report", path(&missing)],
&[],
);
assert_eq!(report(&output)["kind"], "report");
}
#[test]
fn argument_parser_errors_are_json_too() {
let fx = Fixture::new();
let report_missing = fx.diff(&["--error-format", "json"]);
assert_eq!(code(&report_missing), 2);
let value = report(&report_missing);
assert_eq!(value["kind"], "usage");
assert_eq!(value["chain"].as_array().unwrap().len(), 0);
let message = value["message"].as_str().unwrap();
assert!(message.contains("--report"), "{message}");
assert!(!message.contains("Usage:"), "{message}");
assert_eq!(stderr(&report_missing).lines().count(), 1);
for args in [
&["nonsense", "--error-format=json"][..],
&["--error-format", "json", "nonsense"][..],
] {
let output = run(fx.root(), args, &[]);
assert_eq!(code(&output), 2);
assert_eq!(report(&output)["kind"], "usage", "{args:?}");
}
let output = Command::new(env!("CARGO_BIN_EXE_patchcov"))
.env("PATCHCOV_ERROR_FORMAT", "json")
.arg("nonsense")
.output()
.unwrap();
assert_eq!(code(&output), 2);
assert_eq!(report(&output)["kind"], "usage");
let output = run(fx.root(), &["diff", "--error-format", "xml"], &[]);
assert_eq!(code(&output), 2);
assert!(
stderr(&output).starts_with("error: "),
"{}",
stderr(&output)
);
}
#[test]
fn help_and_version_are_not_errors() {
let fx = Fixture::new();
for flag in ["--help", "--version"] {
let output = run(fx.root(), &[flag, "--error-format=json"], &[]);
assert_eq!(code(&output), 0, "{flag}");
assert!(!output.stdout.is_empty(), "{flag}");
assert!(output.stderr.is_empty(), "{flag}: {}", stderr(&output));
}
}
fn text_warnings(output: &Output) -> Vec<String> {
stderr(output)
.lines()
.filter_map(|line| line.strip_prefix("warning: "))
.map(str::to_owned)
.collect()
}
fn json_warnings(output: &Output) -> Vec<serde_json::Value> {
let text = stderr(output);
assert!(!text.contains("warning: "), "a text warning: {text}");
text.lines()
.filter(|line| line.starts_with('{'))
.map(|line| serde_json::from_str(line).unwrap_or_else(|err| panic!("{err}: {line}")))
.filter(|object: &serde_json::Value| object["level"] == "warning")
.collect()
}
fn assert_json_warning(
fx: &Fixture,
head: &[&str],
args: &[&str],
kind: &str,
fields: serde_json::Value,
) {
let plain = run(fx.root(), head, args);
let mut flagged: Vec<&str> = head.to_vec();
flagged.extend(["--error-format", "json"]);
let json = run(fx.root(), &flagged, args);
assert_eq!(code(&plain), 0, "{head:?}: {}", stderr(&plain));
assert_eq!(code(&json), 0, "{head:?}: {}", stderr(&json));
let text = text_warnings(&plain);
let objects = json_warnings(&json);
assert_eq!(text.len(), 1, "{head:?}: {}", stderr(&plain));
assert_eq!(objects.len(), 1, "{head:?}: {}", stderr(&json));
assert_eq!(objects[0]["kind"], kind, "{head:?}");
assert_eq!(objects[0]["message"], text[0], "{head:?}");
let mut expected = serde_json::json!({
"level": "warning",
"kind": kind,
"message": text[0],
});
expected
.as_object_mut()
.unwrap()
.extend(fields.as_object().unwrap().clone());
assert_eq!(objects[0], expected, "{head:?}");
}
#[test]
fn an_allowed_path_mismatch_is_a_json_warning() {
let fx = Fixture::new();
let report = fx.file(
"elsewhere.lcov",
"SF:nowhere/else.rs\nDA:1,1\nend_of_record\n",
);
assert_json_warning(
&fx,
&["diff", "--base-ref", &fx.base],
&["--report", path(&report), "--allow-path-mismatch"],
"path-mismatch",
serde_json::json!({
"report": path(&report),
"file_count": 1,
"unmatched": ["nowhere/else.rs"],
}),
);
}
fn warning_in(cwd: &Path, args: &[&str]) -> (serde_json::Value, String) {
let text = command(cwd).args(args).output().unwrap();
let json = command(cwd)
.args(args)
.args(["--error-format", "json"])
.output()
.unwrap();
assert_eq!(code(&text), 0, "{args:?}: {}", stderr(&text));
assert_eq!(code(&json), 0, "{args:?}: {}", stderr(&json));
let (mut objects, mut lines) = (json_warnings(&json), text_warnings(&text));
assert_eq!(objects.len(), 1, "{args:?}: {}", stderr(&json));
assert_eq!(lines.len(), 1, "{args:?}: {}", stderr(&text));
let (object, line) = (objects.remove(0), lines.remove(0));
assert_eq!(object["message"], line, "{args:?}");
(object, line)
}
fn input_label_cases(fx: &Fixture) -> Vec<(&Path, Vec<&str>)> {
vec![
(fx.root(), vec![]),
(fx.root(), vec!["-C", fx.root().to_str().unwrap()]),
(
fx.root().parent().unwrap(),
vec!["-C", fx.root().file_name().unwrap().to_str().unwrap()],
),
]
}
#[test]
fn report_warning_labels_preserve_input_arguments() {
let fx = Fixture::new();
fx.report();
let foreign = fx.file(
"reports/foreign.lcov",
"SF:nowhere/else.rs\nDA:1,1\nend_of_record\n",
);
let absolute = foreign.to_str().unwrap();
for baseline in [false, true] {
for (cwd, global) in input_label_cases(&fx) {
for input in ["reports/foreign.lcov", "./reports/foreign.lcov", absolute] {
let mut args = global.clone();
args.extend(["diff", "--base-ref", &fx.base, "--allow-path-mismatch"]);
if baseline {
args.extend(["--report", "head.lcov", "--baseline-report", input]);
} else {
args.extend(["--report", "head.lcov", "--report", input]);
}
let (object, line) = warning_in(cwd, &args);
assert_eq!(object["report"], input, "{args:?}");
assert!(
line.starts_with(&format!("coverage report {input}: ")),
"{args:?}: {line}"
);
}
}
}
}
#[test]
fn shard_warning_labels_preserve_input_arguments() {
let fx = Fixture::new();
let foreign = fx.file(
"reports/two.lcov",
"SF:/other/runner/a.rs\nDA:1,1\nend_of_record\n",
);
for (cwd, global) in input_label_cases(&fx) {
for input in [
"reports/two.lcov",
"./reports/two.lcov",
foreign.to_str().unwrap(),
] {
let mut args = global.clone();
args.extend([
"merge",
"--strip-prefix",
"/ci/workspace",
"-o",
"merged.lcov",
input,
]);
let (object, line) = warning_in(cwd, &args);
assert_eq!(object["shard"], input, "{args:?}");
assert!(
line.starts_with(&format!("coverage shard {input}: ")),
"{args:?}: {line}"
);
}
}
}
#[test]
fn empty_shard_errors_preserve_the_input_label() {
let fx = Fixture::new();
fx.report();
fx.file("empty.lcov", "SF:a.rs\nend_of_record\n");
for (cwd, global) in input_label_cases(&fx) {
for subcommand in ["diff", "merge"] {
for format in ["text", "json"] {
let mut args = global.clone();
args.push(subcommand);
if subcommand == "diff" {
args.extend([
"--base-ref",
&fx.base,
"--report",
"head.lcov",
"--report",
"./empty.lcov",
]);
} else {
args.extend(["-o", "merged.lcov", "./empty.lcov"]);
}
args.extend(["--error-format", format]);
let output = command(cwd).args(&args).output().unwrap();
assert_eq!(code(&output), 3, "{args:?}: {}", stderr(&output));
assert!(
stderr(&output).contains("coverage shard ./empty.lcov has no executable lines"),
"{args:?}: {}",
stderr(&output)
);
assert!(!fx.root().join("merged.lcov").exists());
}
}
}
}
#[test]
fn strict_path_mismatch_preserves_the_input_label() {
let fx = Fixture::new();
fx.file(
"foreign.lcov",
"SF:nowhere/else.rs\nDA:1,1\nend_of_record\n",
);
for (cwd, global) in input_label_cases(&fx) {
for format in ["text", "json"] {
let mut args = global.clone();
args.extend([
"diff",
"--base-ref",
&fx.base,
"--report",
"foreign.lcov",
"--error-format",
format,
]);
let output = command(cwd).args(&args).output().unwrap();
assert_eq!(code(&output), 7, "{args:?}: {}", stderr(&output));
let text = stderr(&output);
let message = if format == "json" {
let object: serde_json::Value = serde_json::from_str(&text).unwrap();
object["message"].as_str().unwrap().to_owned()
} else {
text.strip_prefix("Error: ").unwrap().to_owned()
};
assert!(
message.starts_with("coverage report foreign.lcov: "),
"{args:?}: {message}"
);
}
}
}
#[test]
fn a_deprecated_flag_is_a_json_warning() {
let fx = Fixture::new();
let report = fx.report();
let cases = [
(
&["--format", "markdown"][..],
serde_json::json!({"flag": "--format", "replacement": "-o/--output"}),
),
(
&["--fail-on-path-mismatch"],
serde_json::json!({"flag": "--fail-on-path-mismatch", "replacement": null}),
),
];
for (flag, fields) in cases {
let mut args = vec!["--report", path(&report)];
args.extend(flag);
assert_json_warning(
&fx,
&["diff", "--base-ref", &fx.base],
&args,
"deprecated",
fields,
);
}
}
#[test]
fn a_shard_under_another_root_is_a_json_warning() {
let fx = Fixture::new();
let shard = fx.file(
"shard.lcov",
"SF:/other/runner/a.rs\nDA:1,1\nend_of_record\n",
);
assert_json_warning(
&fx,
&[
"merge",
"--strip-prefix",
"/ci/workspace",
"-o",
"merged.lcov",
],
&[path(&shard)],
"shard-root",
serde_json::json!({
"shard": path(&shard),
"strip_prefix": "/ci/workspace",
"absolute_paths": 1,
}),
);
}
#[test]
fn a_glob_that_matches_nothing_is_a_json_warning() {
let fx = Fixture::new();
assert_json_warning(
&fx,
&["lint-markers", "--include", "**/*.nomatch"],
&[],
"glob-no-match",
serde_json::json!({"globs": ["**/*.nomatch"], "origin": "--include"}),
);
}
#[test]
fn globs_from_the_config_say_so() {
let fx = Fixture::new();
fx.file(
".patchcov/config.yaml",
"lint-markers:\n include:\n - \"**/*.nomatch\"\n - \"*.also\"\n",
);
assert_json_warning(
&fx,
&["lint-markers"],
&[],
"glob-no-match",
serde_json::json!({
"globs": ["**/*.nomatch", "*.also"],
"origin": "lint-markers.include",
}),
);
}
#[test]
fn the_error_follows_the_warnings_and_is_told_apart_by_level() {
let fx = Fixture::new();
let measured = fx.report();
let output = fx.diff(&[
"--report",
path(&measured),
"--format",
"markdown",
"--fail-on-path-mismatch",
"--fail-under-patch",
"80",
"--error-format",
"json",
]);
assert_eq!(code(&output), 1, "{}", stderr(&output));
let text = stderr(&output);
let levels: Vec<_> = text
.lines()
.map(|line| {
let object: serde_json::Value =
serde_json::from_str(line).unwrap_or_else(|err| panic!("not JSON ({err}): {line}"));
object["level"].clone()
})
.collect();
assert_eq!(levels, ["warning", "warning", "error"], "{text}");
let error = report(&output);
assert_eq!(error["kind"], "gate");
}
#[test]
fn the_environment_variable_turns_warnings_into_json_too() {
let fx = Fixture::new();
let output = Command::new(env!("CARGO_BIN_EXE_patchcov"))
.env_remove("PATCHCOV_CONFIG_DIR")
.env("PATCHCOV_ERROR_FORMAT", "json")
.env("GIT_CEILING_DIRECTORIES", fx.root().parent().unwrap())
.args([
"-C",
path(fx.root()),
"lint-markers",
"--include",
"*.nomatch",
])
.output()
.unwrap();
assert_eq!(code(&output), 0, "{}", stderr(&output));
assert_eq!(json_warnings(&output).len(), 1, "{}", stderr(&output));
}
#[test]
fn an_invalid_format_variable_warns_in_text() {
let fx = Fixture::new();
let output = Command::new(env!("CARGO_BIN_EXE_patchcov"))
.env_remove("PATCHCOV_CONFIG_DIR")
.env("PATCHCOV_ERROR_FORMAT", "xml")
.env("GIT_CEILING_DIRECTORIES", fx.root().parent().unwrap())
.args(["-C", path(fx.root()), "lint-markers"])
.output()
.unwrap();
assert_eq!(code(&output), 0, "{}", stderr(&output));
assert!(
stderr(&output).starts_with("warning: ignoring PATCHCOV_ERROR_FORMAT=xml"),
"{}",
stderr(&output)
);
}
#[test]
fn the_merge_summary_is_a_json_info_line() {
let fx = Fixture::new();
let report = fx.report();
let out = fx.root().join("merged.lcov");
let output = run(
fx.root(),
&["merge", "--error-format", "json", "-o", path(&out)],
&[path(&report)],
);
assert_eq!(code(&output), 0, "{}", stderr(&output));
let text = stderr(&output);
assert_eq!(text.lines().count(), 1, "{text}");
let summary: serde_json::Value = serde_json::from_str(text.trim_end()).unwrap();
assert_eq!(summary["level"], "info");
assert_eq!(summary["kind"], "merge-summary");
assert_eq!(summary["inputs"], 1);
assert_eq!(summary["files"], 1);
assert_eq!(summary["total_lines"], 4);
assert_eq!(summary["covered_lines"], 2);
assert_eq!(summary["percent"], 50.0);
assert!(summary["output"].as_str().unwrap().ends_with("merged.lcov"));
let plain = run(fx.root(), &["merge", "-o", path(&out)], &[path(&report)]);
let line = stderr(&plain);
assert!(
line.starts_with("merged 1 report(s) into ") && line.contains("2 of 4 lines covered (50"),
"{line}"
);
assert_eq!(
line.trim_end(),
summary["message"].as_str().unwrap(),
"the object's message is the text line"
);
}
#[test]
fn diff_discovers_from_subdirectories_and_keeps_report_paths() {
let fx = Fixture::new();
let report = fx.report();
fx.file("base.lcov", "SF:a.rs\nDA:1,1\nend_of_record\n");
fx.file(
".patchcov/config.yaml",
"diff:\n require-measured: ['b.rs']\n",
);
let sub = fx.root().join("sub");
std::fs::create_dir(&sub).unwrap();
let cases = [
(sub.as_path(), vec![]),
(fx.root(), vec!["-C", "sub"]),
(fx.root(), vec!["-C", path(&sub)]),
];
for (cwd, global) in cases {
for head in ["../head.lcov", path(&report)] {
let output = command(cwd)
.env("GIT_CEILING_DIRECTORIES", fx.root().parent().unwrap())
.args(&global)
.args([
"diff",
"--base-ref",
&fx.base,
"--report",
head,
"--baseline-report",
"../base.lcov",
"--output",
"json",
])
.output()
.unwrap();
assert_eq!(code(&output), 1, "{global:?}: {}", stderr(&output));
assert!(stderr(&output).contains("b.rs"), "{}", stderr(&output));
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(json["patch_coverage"]["covered"], 2);
assert_eq!(json["patch_coverage"]["total"], 4);
assert!(json.get("project_delta").is_some());
}
}
}
#[test]
fn diff_subdirectory_go_module_and_warning_labels_use_their_own_roots() {
let fx = Fixture::new();
fx.file("go.mod", "module example.com/project\n");
fx.file(
"sub/head.cover",
"mode: set\nexample.com/project/a.rs:2.1,3.1 1 1\n",
);
fx.file(
"sub/base.cover",
"mode: set\nexample.com/project/a.rs:1.1,1.10 1 1\n",
);
fx.file(
"sub/mismatch.lcov",
"SF:missing.rs\nDA:1,1\nend_of_record\n",
);
let sub = fx.root().join("sub");
for (cwd, global) in [
(sub.as_path(), vec![]),
(fx.root(), vec!["-C", "sub"]),
(fx.root(), vec!["-C", path(&sub)]),
] {
let output = command(cwd)
.env("GIT_CEILING_DIRECTORIES", fx.root().parent().unwrap())
.args(&global)
.args([
"diff",
"--base-ref",
&fx.base,
"--report",
"head.cover",
"--baseline-report",
"base.cover",
"--output",
"json",
])
.output()
.unwrap();
assert_eq!(code(&output), 0, "{global:?}: {}", stderr(&output));
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(json["patch_coverage"]["covered"], 2);
assert_eq!(json["project_delta"]["total_before"], 100.0);
let output = command(cwd)
.env("GIT_CEILING_DIRECTORIES", fx.root().parent().unwrap())
.args(&global)
.args([
"diff",
"--base-ref",
&fx.base,
"--report",
"mismatch.lcov",
"--allow-path-mismatch",
"--error-format",
"json",
])
.output()
.unwrap();
assert_eq!(code(&output), 0, "{}", stderr(&output));
let warnings = json_warnings(&output);
assert_eq!(warnings.len(), 1);
let label = "mismatch.lcov";
assert_eq!(warnings[0]["report"], label);
assert!(warnings[0]["message"]
.as_str()
.unwrap()
.starts_with(&format!("coverage report {label}: ")));
}
}