mirror of
https://github.com/unclebob/crapper.git
synced 2026-11-06 18:13:50 +00:00
434 lines
18 KiB
Python
434 lines
18 KiB
Python
import json
|
|
from pathlib import Path
|
|
|
|
from crapper.discover import is_test_file
|
|
from crapper.runners import (
|
|
_clean_clojure,
|
|
_clean_dir,
|
|
_coverage_report,
|
|
_ensure_python_module,
|
|
_ensure_vitest_coverage,
|
|
_manifest_snapshot,
|
|
_prepare_report,
|
|
_python_executable,
|
|
_python_kind,
|
|
_record_python_lcov,
|
|
_restore_manifests,
|
|
_rust_kind,
|
|
_vitest_version,
|
|
python_coverage_commands,
|
|
python_roots,
|
|
python_sources,
|
|
run_coverage,
|
|
run_shell,
|
|
rust_coverage_command,
|
|
rust_modules,
|
|
typescript_command,
|
|
typescript_packages,
|
|
uses_pytest,
|
|
)
|
|
|
|
|
|
def _write(root: Path, relative: str, source: str) -> Path:
|
|
path = root / relative
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(source, encoding="utf-8")
|
|
return path
|
|
|
|
|
|
def test_vitest_uses_its_own_coverage_instead_of_c8(tmp_path):
|
|
_write(
|
|
tmp_path,
|
|
"package.json",
|
|
json.dumps({"scripts": {"test": "vitest run"}}),
|
|
)
|
|
_write(tmp_path, "vitest.config.ts", "export default {}\n")
|
|
source = _write(tmp_path, "src/book.ts", "export function plan() { return 1 }\n")
|
|
_write(tmp_path, "src/book.test.ts", "import { plan } from './book'\n")
|
|
report_dir = tmp_path / "target" / "coverage" / "typescript"
|
|
command = typescript_command(tmp_path, [source, tmp_path / "src" / "book.test.ts"], report_dir)
|
|
assert command is not None
|
|
assert "c8" not in command
|
|
assert "vitest run --coverage" in command
|
|
assert "--coverage.reporter=lcov" in command
|
|
assert f"--coverage.reportsDirectory={report_dir}" in command
|
|
assert "--coverage.include=src/book.ts" in command
|
|
assert "book.test.ts" not in command
|
|
|
|
|
|
def test_existing_coverage_script_is_left_alone(tmp_path):
|
|
_write(
|
|
tmp_path,
|
|
"package.json",
|
|
json.dumps({"scripts": {"coverage": "vitest run --coverage", "test": "vitest run"}}),
|
|
)
|
|
command = typescript_command(tmp_path, [], tmp_path / "coverage")
|
|
assert command == "npm run coverage"
|
|
|
|
|
|
def test_node_test_script_still_uses_c8(tmp_path):
|
|
_write(tmp_path, "package.json", json.dumps({"scripts": {"test": "node --test"}}))
|
|
report_dir = tmp_path / "target" / "coverage" / "typescript"
|
|
command = typescript_command(tmp_path, [], report_dir)
|
|
assert command == f"npx --yes c8 --reporter=lcov --reports-dir {report_dir} npm test"
|
|
|
|
|
|
def test_typescript_package_is_found_from_a_source_file(tmp_path):
|
|
_write(tmp_path, "web/package.json", json.dumps({"scripts": {"test": "vitest run"}}))
|
|
source = _write(tmp_path, "web/src/app.ts", "export const n = 1\n")
|
|
assert typescript_packages(tmp_path, [source]) == [(tmp_path / "web").resolve()]
|
|
|
|
|
|
def test_rust_module_is_the_nearest_cargo_package(tmp_path):
|
|
_write(tmp_path, "src-tauri/Cargo.toml", '[package]\nname = "bookwriter"\nversion = "0.1.0"\n')
|
|
lib = _write(tmp_path, "src-tauri/src/lib.rs", "pub fn read_text() {}\n")
|
|
assert rust_modules(tmp_path, [lib]) == [(tmp_path / "src-tauri").resolve()]
|
|
|
|
|
|
def test_manifests_are_restored_after_a_coverage_install(tmp_path):
|
|
package_json = tmp_path / "package.json"
|
|
lock = tmp_path / "package-lock.json"
|
|
package_json.write_bytes(b'{"name":"demo"}\n')
|
|
lock.write_bytes(b'{"lockfileVersion":3}\n')
|
|
snapshot = _manifest_snapshot(tmp_path)
|
|
package_json.write_bytes(b'{"name":"changed"}\n')
|
|
lock.unlink()
|
|
shrink = tmp_path / "npm-shrinkwrap.json"
|
|
shrink.write_text("{}\n", encoding="utf-8")
|
|
_restore_manifests(snapshot)
|
|
assert package_json.read_bytes() == b'{"name":"demo"}\n'
|
|
assert lock.read_bytes() == b'{"lockfileVersion":3}\n'
|
|
assert not shrink.exists()
|
|
|
|
|
|
def test_python_project_uses_pytest_and_coverage_lcov(tmp_path):
|
|
_write(tmp_path, "pyproject.toml", "[tool.pytest.ini_options]\ntestpaths = ['tests']\n")
|
|
source = _write(tmp_path, "src/demo/box.py", "def choose(x):\n return x\n")
|
|
_write(tmp_path, "tests/test_box.py", "def test_choose():\n assert True\n")
|
|
assert python_roots(tmp_path, [source]) == [tmp_path.resolve()]
|
|
assert uses_pytest(tmp_path)
|
|
assert python_sources(tmp_path, [source, tmp_path / "tests" / "test_box.py"]) == "src"
|
|
assert is_test_file(tmp_path / "tests" / "test_box.py")
|
|
data = tmp_path / "target" / "coverage" / "python" / ".coverage"
|
|
report = data.parent / "lcov.info"
|
|
run, lcov = python_coverage_commands("python3", "pytest", data, report, "src")
|
|
assert run == (
|
|
f"python3 -m coverage run --data-file={data} --source=src -m pytest"
|
|
)
|
|
assert lcov == f"python3 -m coverage lcov --data-file={data} -o {report}"
|
|
|
|
|
|
def test_python_package_without_pytest_uses_unittest(tmp_path):
|
|
_write(tmp_path, "setup.py", "from setuptools import setup\nsetup(name='demo')\n")
|
|
source = _write(tmp_path, "demo/app.py", "def run():\n return 1\n")
|
|
assert not uses_pytest(tmp_path)
|
|
run, _lcov = python_coverage_commands("python3", "unittest", Path("data"), Path("out"), "demo")
|
|
assert "-m unittest discover -s ." in run
|
|
assert python_roots(tmp_path, [source]) == [tmp_path.resolve()]
|
|
|
|
|
|
def test_rust_lcov_path_is_absolute():
|
|
report = Path("/tmp/bookwriter/target/coverage/rust/src-tauri/lcov.info")
|
|
command = rust_coverage_command("llvm-cov", report)
|
|
assert command == f"cargo llvm-cov --lcov --output-path {report}"
|
|
|
|
|
|
def test_vitest_version_and_provider_install(tmp_path, monkeypatch):
|
|
assert _vitest_version(tmp_path) is None
|
|
_write(tmp_path, "node_modules/vitest/package.json", "{not json")
|
|
assert _vitest_version(tmp_path) is None
|
|
_write(tmp_path, "node_modules/vitest/package.json", '{"version": "4.1.11"}\n')
|
|
assert _vitest_version(tmp_path) == "4.1.11"
|
|
provider = tmp_path / "node_modules" / "@vitest" / "coverage-v8"
|
|
provider.mkdir(parents=True)
|
|
assert _ensure_vitest_coverage(tmp_path) is True
|
|
provider.rmdir()
|
|
monkeypatch.setattr("crapper.runners.run_shell", lambda command, cwd: 1)
|
|
assert _ensure_vitest_coverage(tmp_path) is False
|
|
|
|
|
|
def test_clean_clojure_keeps_other_language_reports(tmp_path):
|
|
kept = tmp_path / "target" / "coverage" / "python"
|
|
kept.mkdir(parents=True)
|
|
(kept / "lcov.info").write_text("keep", encoding="utf-8")
|
|
stale = tmp_path / "target" / "coverage" / "lcov.info"
|
|
stale.write_text("old", encoding="utf-8")
|
|
_clean_clojure(tmp_path)
|
|
assert (kept / "lcov.info").is_file()
|
|
assert not stale.exists()
|
|
|
|
|
|
def test_python_executable_prefers_a_project_venv(tmp_path):
|
|
binary = tmp_path / ".venv" / "bin" / "python"
|
|
binary.parent.mkdir(parents=True)
|
|
binary.write_text("", encoding="utf-8")
|
|
assert _python_executable(tmp_path) == str(binary)
|
|
|
|
|
|
def test_rust_kind_installs_llvm_cov_when_it_is_missing(monkeypatch):
|
|
installed = {"llvm": False}
|
|
|
|
def which(name):
|
|
if name == "cargo-llvm-cov" and installed["llvm"]:
|
|
return "/bin/cargo-llvm-cov"
|
|
if name == "rustup":
|
|
return "/bin/rustup"
|
|
return None
|
|
|
|
def shell(command, cwd):
|
|
if "cargo install" in command:
|
|
installed["llvm"] = True
|
|
return 0
|
|
|
|
monkeypatch.setattr("crapper.runners.shutil.which", which)
|
|
monkeypatch.setattr("crapper.runners.run_shell", shell)
|
|
assert _rust_kind() == "llvm-cov"
|
|
installed["llvm"] = False
|
|
monkeypatch.setattr("crapper.runners.run_shell", lambda command, cwd: 1)
|
|
assert _rust_kind() is None
|
|
|
|
|
|
def test_coverage_commands_are_issued_per_language(tmp_path, monkeypatch):
|
|
commands: list[str] = []
|
|
|
|
def shell(command, cwd):
|
|
commands.append(command)
|
|
if "--data-file=" in command:
|
|
data = Path(command.split("--data-file=", 1)[1].split()[0])
|
|
data.parent.mkdir(parents=True, exist_ok=True)
|
|
data.write_text("x", encoding="utf-8")
|
|
return 1
|
|
if command.startswith("clj") or command.startswith("mvn") or command.startswith("go "):
|
|
return 1
|
|
if "vitest" in command or command.startswith("cargo "):
|
|
return 1
|
|
return 0
|
|
|
|
monkeypatch.setattr("crapper.runners.run_shell", shell)
|
|
monkeypatch.setattr(
|
|
"crapper.runners.shutil.which",
|
|
lambda name: "/bin/cargo-llvm-cov" if name == "cargo-llvm-cov" else None,
|
|
)
|
|
_write(tmp_path, "deps.edn", "{}\n")
|
|
_write(tmp_path, "src/demo/core.clj", "(ns demo.core)\n(defn x [])\n")
|
|
_write(tmp_path, "pom.xml", "<project></project>\n")
|
|
_write(tmp_path, "src/Board.java", "class Board { int place(){ return 1; } }\n")
|
|
(tmp_path / "target" / "jacoco.exec").parent.mkdir(parents=True)
|
|
(tmp_path / "target" / "jacoco.exec").write_text("x", encoding="utf-8")
|
|
_write(tmp_path, "go.mod", "module example.com/demo\n")
|
|
_write(tmp_path, "board.go", "package demo\nfunc Place() int { return 1 }\n")
|
|
profile = tmp_path / "target" / "coverage" / "go" / "coverage.out"
|
|
profile.parent.mkdir(parents=True)
|
|
profile.write_text("old", encoding="utf-8")
|
|
_write(tmp_path, "package.json", json.dumps({"scripts": {"test": "vitest run"}}))
|
|
_write(tmp_path, "src/ui.ts", "export function view(){ return 1 }\n")
|
|
(tmp_path / "node_modules" / "@vitest" / "coverage-v8").mkdir(parents=True)
|
|
_write(tmp_path, "pyproject.toml", "[tool.pytest.ini_options]\n")
|
|
_write(tmp_path, "src/app.py", "def run():\n return 1\n")
|
|
_write(tmp_path, "Cargo.toml", '[package]\nname = "demo"\nversion = "0.1.0"\n')
|
|
_write(tmp_path, "src/lib.rs", "pub fn open() {}\n")
|
|
files = [
|
|
tmp_path / "src/demo/core.clj",
|
|
tmp_path / "src/Board.java",
|
|
tmp_path / "board.go",
|
|
tmp_path / "src/ui.ts",
|
|
tmp_path / "src/app.py",
|
|
tmp_path / "src/lib.rs",
|
|
]
|
|
run_coverage(tmp_path, files, None)
|
|
text = "\n".join(commands)
|
|
assert "clj -M:cov --lcov" in text
|
|
assert "clj -M:cov\n" in text or text.endswith("clj -M:cov") or "clj -M:cov" in text
|
|
assert "mvn " in text
|
|
assert "go test" in text
|
|
assert "vitest" in text
|
|
assert "coverage run" in text
|
|
assert "cargo llvm-cov" in text
|
|
run_coverage(tmp_path, files, "echo custom")
|
|
assert commands[-1] == "echo custom"
|
|
|
|
|
|
def test_missing_build_files_skip_that_language(tmp_path, monkeypatch):
|
|
monkeypatch.setattr("crapper.runners.run_shell", lambda command, cwd: 0)
|
|
files = [
|
|
_write(tmp_path, "src/App.java", "class App {}\n"),
|
|
_write(tmp_path, "main.go", "package main\n"),
|
|
_write(tmp_path, "src/app.ts", "export const n = 1\n"),
|
|
_write(tmp_path, "src/lib.rs", "pub fn open() {}\n"),
|
|
]
|
|
run_coverage(tmp_path, files, None)
|
|
|
|
|
|
def test_run_shell_returns_the_child_status_and_reports_a_failed_start(tmp_path, monkeypatch, capsys):
|
|
assert run_shell("exit 3", tmp_path) == 3
|
|
|
|
def boom(*_args, **_kwargs):
|
|
raise OSError("nope")
|
|
|
|
monkeypatch.setattr("crapper.runners.subprocess.run", boom)
|
|
assert run_shell("exit 3", tmp_path) == 127
|
|
assert "failed to start" in capsys.readouterr().err
|
|
|
|
|
|
def test_clean_removes_a_stale_directory_and_ignores_a_missing_one(tmp_path):
|
|
stale = tmp_path / "target" / "coverage" / "old"
|
|
stale.mkdir(parents=True)
|
|
(stale / "lcov.info").write_text("x", encoding="utf-8")
|
|
kept = tmp_path / "target" / "coverage" / "python"
|
|
kept.mkdir()
|
|
_clean_clojure(tmp_path)
|
|
assert not stale.exists()
|
|
assert kept.is_dir()
|
|
_clean_dir(tmp_path / "target" / "coverage")
|
|
assert not (tmp_path / "target" / "coverage").exists()
|
|
_clean_clojure(tmp_path)
|
|
_clean_dir(tmp_path / "missing")
|
|
|
|
|
|
def test_package_json_without_a_test_script_has_no_coverage_command(tmp_path):
|
|
assert typescript_command(tmp_path, [], tmp_path / "cov") is None
|
|
_write(tmp_path, "package.json", "{")
|
|
assert typescript_command(tmp_path, [], tmp_path / "cov") is None
|
|
_write(tmp_path, "package.json", "[]")
|
|
assert typescript_command(tmp_path, [], tmp_path / "cov") is None
|
|
_write(tmp_path, "package.json", '{"scripts": ["nope"]}')
|
|
assert typescript_command(tmp_path, [], tmp_path / "cov") is None
|
|
|
|
|
|
def test_vitest_include_skips_a_file_outside_the_package(tmp_path):
|
|
from crapper.runners import _vitest_includes
|
|
|
|
package = tmp_path / "web"
|
|
_write(package, "src/app.ts", "export const n = 1\n")
|
|
outside = Path("/tmp/crapper-not-in-package.ts")
|
|
assert _vitest_includes(package, [package / "src" / "app.ts", outside]) == ["src/app.ts"]
|
|
|
|
|
|
def test_nested_module_coverage_report_is_slugged(tmp_path):
|
|
module = tmp_path / "src-tauri"
|
|
module.mkdir()
|
|
report = _coverage_report(tmp_path, module, "rust")
|
|
assert report == tmp_path / "target" / "coverage" / "rust" / "src-tauri" / "lcov.info"
|
|
|
|
|
|
def test_prepare_report_replaces_an_existing_directory(tmp_path):
|
|
report = tmp_path / "target" / "coverage" / "typescript" / "lcov.info"
|
|
report.parent.mkdir(parents=True)
|
|
(report.parent / "old.info").write_text("old", encoding="utf-8")
|
|
_prepare_report(report)
|
|
assert report.parent.is_dir()
|
|
assert not (report.parent / "old.info").exists()
|
|
|
|
|
|
def test_pytest_detection_and_the_unittest_fallback(tmp_path, monkeypatch, capsys):
|
|
assert uses_pytest(tmp_path) is False
|
|
_write(tmp_path, "conftest.py", "")
|
|
assert uses_pytest(tmp_path) is True
|
|
monkeypatch.setattr("crapper.runners.run_shell", lambda *_args: 0)
|
|
assert _python_kind("python3", tmp_path) == "pytest"
|
|
bare = tmp_path / "bare"
|
|
bare.mkdir()
|
|
assert _python_kind("python3", bare) == "unittest"
|
|
monkeypatch.setattr("crapper.runners._ensure_python_module", lambda *_args: False)
|
|
assert _python_kind("python3", tmp_path) == "unittest"
|
|
assert "Falling back to unittest" in capsys.readouterr().err
|
|
|
|
|
|
def test_python_sources_ignore_a_file_outside_the_package(tmp_path):
|
|
inside = _write(tmp_path, "src/app.py", "def run():\n return 1\n")
|
|
assert python_sources(tmp_path, [inside, Path("/tmp/crapper-outside.py")]) == "src"
|
|
|
|
|
|
def test_missing_python_module_is_installed(tmp_path, monkeypatch):
|
|
commands = []
|
|
|
|
def shell(command, _cwd):
|
|
commands.append(command)
|
|
if command.startswith("python3 -c"):
|
|
return 1
|
|
return 0
|
|
|
|
monkeypatch.setattr("crapper.runners.run_shell", shell)
|
|
assert _ensure_python_module("python3", tmp_path, "coverage") is True
|
|
assert any("pip install" in command for command in commands)
|
|
commands.clear()
|
|
|
|
def already(_command, _cwd):
|
|
commands.append("probe")
|
|
return 0
|
|
|
|
monkeypatch.setattr("crapper.runners.run_shell", already)
|
|
assert _ensure_python_module("python3", tmp_path, "coverage") is True
|
|
assert commands == ["probe"]
|
|
|
|
|
|
def test_python_lcov_warnings(tmp_path, monkeypatch, capsys):
|
|
data = tmp_path / ".coverage"
|
|
monkeypatch.setattr("crapper.runners.run_shell", lambda *_args: 1)
|
|
_record_python_lcov(tmp_path, 1, data, "lcov")
|
|
assert "Python coverage exited 1" in capsys.readouterr().err
|
|
_record_python_lcov(tmp_path, 0, data, "lcov")
|
|
assert capsys.readouterr().err == ""
|
|
data.write_text("x", encoding="utf-8")
|
|
_record_python_lcov(tmp_path, 0, data, "lcov")
|
|
assert "coverage lcov exited 1" in capsys.readouterr().err
|
|
monkeypatch.setattr("crapper.runners.run_shell", lambda *_args: 0)
|
|
_record_python_lcov(tmp_path, 7, data, "lcov")
|
|
assert "Coverage was still recorded" in capsys.readouterr().err
|
|
|
|
|
|
def test_custom_coverage_command_failure_is_reported(tmp_path, capsys):
|
|
run_coverage(tmp_path, [], "exit 9")
|
|
assert "exited 9" in capsys.readouterr().err
|
|
|
|
|
|
def test_clojure_without_a_build_file_is_skipped(tmp_path, monkeypatch, capsys):
|
|
monkeypatch.setattr("crapper.runners.run_shell", lambda *_args: 0)
|
|
source = _write(tmp_path, "src/demo/core.clj", "(ns demo.core)\n(defn x [] 1)\n")
|
|
run_coverage(tmp_path, [source], None)
|
|
assert "skipping Clojure coverage" in capsys.readouterr().err
|
|
|
|
|
|
def test_rust_kind_none_skips_rust_coverage(tmp_path, monkeypatch, capsys):
|
|
monkeypatch.setattr("crapper.runners._rust_kind", lambda: None)
|
|
monkeypatch.setattr("crapper.runners.run_shell", lambda *_args: 0)
|
|
source = _write(tmp_path, "src/lib.rs", "pub fn open() {}\n")
|
|
_write(tmp_path, "Cargo.toml", '[package]\nname = "demo"\nversion = "0.1.0"\n')
|
|
run_coverage(tmp_path, [source], None)
|
|
assert "cargo" not in capsys.readouterr().err
|
|
|
|
|
|
def test_tarpaulin_command_and_kind(monkeypatch):
|
|
monkeypatch.setattr(
|
|
"crapper.runners.shutil.which",
|
|
lambda name: "/bin/cargo-tarpaulin" if name == "cargo-tarpaulin" else None,
|
|
)
|
|
assert _rust_kind() == "tarpaulin"
|
|
report = Path("/tmp/book/target/coverage/rust/lcov.info")
|
|
assert "tarpaulin" in rust_coverage_command("tarpaulin", report)
|
|
|
|
|
|
def test_typescript_without_a_test_script_is_skipped(tmp_path, monkeypatch, capsys):
|
|
monkeypatch.setattr("crapper.runners.run_shell", lambda *_args: 0)
|
|
_write(tmp_path, "package.json", '{"scripts": {}}')
|
|
source = _write(tmp_path, "src/app.ts", "export const n = 1\n")
|
|
run_coverage(tmp_path, [source], None)
|
|
assert "No package.json test script" in capsys.readouterr().err
|
|
|
|
|
|
def test_vitest_provider_failure_skips_typescript(tmp_path, monkeypatch, capsys):
|
|
monkeypatch.setattr("crapper.runners.run_shell", lambda *_args: 1)
|
|
_write(tmp_path, "package.json", '{"scripts": {"test": "vitest run"}}')
|
|
source = _write(tmp_path, "src/app.ts", "export const n = 1\n")
|
|
run_coverage(tmp_path, [source], None)
|
|
assert "coverage provider is missing" in capsys.readouterr().err
|
|
|
|
|
|
def test_python_coverage_module_missing_is_reported(tmp_path, monkeypatch, capsys):
|
|
monkeypatch.setattr("crapper.runners._ensure_python_module", lambda *_args: False)
|
|
monkeypatch.setattr("crapper.runners.run_shell", lambda *_args: 0)
|
|
_write(tmp_path, "pyproject.toml", "[tool.pytest.ini_options]\n")
|
|
source = _write(tmp_path, "src/app.py", "def run():\n return 1\n")
|
|
run_coverage(tmp_path, [source], None)
|
|
assert "coverage is missing" in capsys.readouterr().err
|