use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicU64, Ordering};
use testing_conventions::patch_coverage::check_typescript;
use testing_conventions::run;
fn fixtures_node_modules() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/unit_coverage/typescript/node_modules")
}
struct TempRepo(PathBuf);
impl TempRepo {
fn new(slug: &str) -> Self {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let root = std::env::temp_dir().join(format!(
"tc-patch-cov-ts-{}-{}-{}",
slug,
std::process::id(),
COUNTER.fetch_add(1, Ordering::Relaxed),
));
std::fs::create_dir_all(&root).unwrap();
std::os::unix::fs::symlink(fixtures_node_modules(), root.join("node_modules"))
.expect("symlinking the fixtures' node_modules should succeed");
git(&root, &["init", "-q"]);
git(&root, &["config", "user.email", "test@example.com"]);
git(&root, &["config", "user.name", "Test"]);
TempRepo(root)
}
fn write(&self, rel: &str, contents: &str) {
let path = self.0.join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, contents).unwrap();
}
fn commit(&self, message: &str) {
git(&self.0, &["add", "-A"]);
git(
&self.0,
&["-c", "commit.gpgsign=false", "commit", "-q", "-m", message],
);
}
fn head(&self) -> String {
let out = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(&self.0)
.output()
.expect("git rev-parse should run");
assert!(out.status.success(), "git rev-parse failed");
String::from_utf8(out.stdout).unwrap().trim().to_string()
}
}
impl Drop for TempRepo {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn git(dir: &Path, args: &[&str]) {
let status = Command::new("git")
.args(args)
.current_dir(dir)
.status()
.expect("git should run");
assert!(status.success(), "git {args:?} failed");
}
fn uncovered(repo: &TempRepo, base: &str) -> Vec<String> {
check_typescript(&repo.0, base, &[])
.expect("checking a readable repo should succeed")
.iter()
.map(|u| format!("{}:{}", u.file, u.line))
.collect()
}
fn run_patch_coverage(repo: &TempRepo, base: &str, config: Option<&str>) -> anyhow::Result<i32> {
let mut argv: Vec<OsString> = vec![
"testing-conventions".into(),
"unit".into(),
"patch-coverage".into(),
repo.0.clone().into_os_string(),
"--language".into(),
"typescript".into(),
"--base".into(),
base.into(),
];
if let Some(name) = config {
argv.push("--config".into());
argv.push(repo.0.join(name).into_os_string());
}
run(argv)
}
const WIDGET_TS: &str = r#"export function widget(n: number): string {
if (n > 0) return 'pos';
return 'neg';
}
"#;
const WIDGET_TEST_TS: &str = r#"import { expect, test } from 'vitest';
import { widget } from './widget';
test('widget', () => {
expect(widget(1)).toBe('pos');
expect(widget(-1)).toBe('neg');
});
"#;
const WIDGET_TS_UNCOVERED: &str = r#"export function widget(n: number): string {
if (n > 0) return 'pos';
if (n === 42) {
return 'answer';
}
return 'neg';
}
"#;
#[test]
fn ts_uncovered_changed_line_is_reported() {
let repo = TempRepo::new("uncovered");
repo.write("widget.ts", WIDGET_TS);
repo.write("widget.test.ts", WIDGET_TEST_TS);
repo.commit("base");
let base = repo.head();
repo.write("widget.ts", WIDGET_TS_UNCOVERED);
repo.commit("add an untested branch");
let reported = uncovered(&repo, &base);
assert!(
reported.contains(&"widget.ts:4".to_string()),
"the uncovered new statement should be reported; got: {reported:?}"
);
assert!(
reported.contains(&"widget.ts:3".to_string()),
"the uncovered new branch source should be reported; got: {reported:?}"
);
}
#[test]
fn ts_covered_change_is_clean() {
let repo = TempRepo::new("covered");
repo.write("widget.ts", WIDGET_TS);
repo.write("widget.test.ts", WIDGET_TEST_TS);
repo.commit("base");
let base = repo.head();
repo.write(
"widget.ts",
r#"export function widget(n: number): string {
if (n > 0) return 'positive';
return 'neg';
}
"#,
);
repo.write(
"widget.test.ts",
r#"import { expect, test } from 'vitest';
import { widget } from './widget';
test('widget', () => {
expect(widget(1)).toBe('positive');
expect(widget(-1)).toBe('neg');
});
"#,
);
repo.commit("reword a covered line and update its test");
assert!(
uncovered(&repo, &base).is_empty(),
"a change to a covered line is clean"
);
}
#[test]
fn a_change_touching_no_typescript_is_clean() {
let repo = TempRepo::new("no-ts");
repo.write("widget.ts", WIDGET_TS);
repo.write("widget.test.ts", WIDGET_TEST_TS);
repo.write("README.md", "# project\n");
repo.commit("base");
let base = repo.head();
repo.write("README.md", "# project\n\nnow with docs\n");
repo.commit("docs only");
assert!(
uncovered(&repo, &base).is_empty(),
"a change that touches no TypeScript source is clean"
);
}
#[test]
fn ts_added_untested_file_changed_lines_are_uncovered() {
let repo = TempRepo::new("added");
repo.write("widget.ts", WIDGET_TS);
repo.write("widget.test.ts", WIDGET_TEST_TS);
repo.commit("base");
let base = repo.head();
repo.write(
"lonely.ts",
"export function lonely(): number {\n return 41;\n}\n",
);
repo.commit("add a brand-new untested source");
let reported = uncovered(&repo, &base);
assert!(
reported.contains(&"lonely.ts:2".to_string()),
"the added file's uncovered lines should be reported; got: {reported:?}"
);
}
#[test]
fn ts_changed_comment_line_is_not_a_subject() {
let repo = TempRepo::new("comment");
repo.write("widget.ts", WIDGET_TS);
repo.write("widget.test.ts", WIDGET_TEST_TS);
repo.commit("base");
let base = repo.head();
repo.write(
"widget.ts",
r#"export function widget(n: number): string {
// branch on the sign of n
if (n > 0) return 'pos';
return 'neg';
}
"#,
);
repo.commit("add an explanatory comment");
assert!(
uncovered(&repo, &base).is_empty(),
"a changed comment line has nothing to cover"
);
}
#[test]
fn an_unknown_base_ref_is_an_error() {
let repo = TempRepo::new("bad-base");
repo.write("widget.ts", WIDGET_TS);
repo.write("widget.test.ts", WIDGET_TEST_TS);
repo.commit("base");
assert!(
check_typescript(&repo.0, "no-such-ref", &[]).is_err(),
"an unresolvable base ref must error"
);
}
#[test]
fn ts_subcommand_exits_nonzero_on_an_uncovered_changed_line() {
let repo = TempRepo::new("cli-red");
repo.write("widget.ts", WIDGET_TS);
repo.write("widget.test.ts", WIDGET_TEST_TS);
repo.commit("base");
let base = repo.head();
repo.write("widget.ts", WIDGET_TS_UNCOVERED);
repo.commit("add an untested branch");
assert_eq!(run_patch_coverage(&repo, &base, None).unwrap(), 1);
}
#[test]
fn ts_subcommand_exits_zero_when_the_change_is_covered() {
let repo = TempRepo::new("cli-clean");
repo.write("widget.ts", WIDGET_TS);
repo.write("widget.test.ts", WIDGET_TEST_TS);
repo.commit("base");
let base = repo.head();
repo.write(
"widget.ts",
r#"export function widget(n: number): string {
if (n > 0) return 'positive';
return 'neg';
}
"#,
);
repo.write(
"widget.test.ts",
r#"import { expect, test } from 'vitest';
import { widget } from './widget';
test('widget', () => {
expect(widget(1)).toBe('positive');
expect(widget(-1)).toBe('neg');
});
"#,
);
repo.commit("reword a covered line and update its test");
assert_eq!(run_patch_coverage(&repo, &base, None).unwrap(), 0);
}
#[test]
fn ts_a_coverage_exemption_lifts_an_uncovered_changed_line() {
let repo = TempRepo::new("exempt");
repo.write(
"testing-conventions.toml",
"[[typescript.exempt]]\npath = \"shim.ts\"\nrules = [\"coverage\"]\n\
reason = \"thin launcher; logic lives in tested modules\"\n",
);
repo.write("widget.ts", WIDGET_TS);
repo.write("widget.test.ts", WIDGET_TEST_TS);
repo.write(
"shim.ts",
"export function shim(): number {\n return 0;\n}\n",
);
repo.commit("base");
let base = repo.head();
repo.write(
"shim.ts",
"export function shim(): number {\n return 1;\n}\n",
);
repo.commit("edit the untested launcher");
assert_eq!(run_patch_coverage(&repo, &base, None).unwrap(), 1);
assert_eq!(
run_patch_coverage(&repo, &base, Some("testing-conventions.toml")).unwrap(),
0
);
}