diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..443cee7 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,12 @@ +"""Keep a mutant from launching coverage against the project under test. + +`run()` falls through into `run_coverage` when a mutant skips the help return. +That would delete `target/coverage` and recurse into this suite. +""" + +import pytest + + +@pytest.fixture(autouse=True) +def do_not_launch_project_coverage(monkeypatch): + monkeypatch.setattr("crapper.cli.run_coverage", lambda *_args, **_kwargs: None) diff --git a/tests/test_cli.py b/tests/test_cli.py index 48b62a2..22b14ba 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,7 +1,10 @@ import subprocess +import sys from pathlib import Path -from crapper.cli import run +import pytest + +from crapper.cli import _changed_files, _positionals, _take, main, parse_args, run from crapper.discover import language_of @@ -163,5 +166,125 @@ def test_selects_directories_filters_and_changed_files(tmp_path, monkeypatch, ca def test_run_asks_for_coverage_when_it_is_enabled(tmp_path, monkeypatch): _write(tmp_path, "src/demo/core.clj", "(ns demo.core)\n\n(defn choose [x]\n x)\n") - monkeypatch.setattr("crapper.cli.run_coverage", lambda *args: None) + called = [] + monkeypatch.setattr("crapper.cli.run_coverage", lambda *args: called.append(args)) assert run(["--root", str(tmp_path), "--coverage-command", "true"]) == 0 + assert called + + +def test_empty_option_value_is_rejected(): + with pytest.raises(ValueError, match="requires a value"): + _take(["ok", "--opt", ""], 1, "--opt") + with pytest.raises(ValueError, match="requires a value"): + _take(["--opt", "-1"], 0, "--opt") + + +def test_parse_args_reads_the_process_arguments_after_the_program(monkeypatch): + monkeypatch.setattr(sys, "argv", ["crapper", "--threshold", "5"]) + options = parse_args() + assert options.threshold == 5.0 + assert options.positionals == [] + assert options.action == "analyze" + + +def test_flags_that_only_store_a_boolean(): + assert parse_args(["--use-existing-coverage"]).use_existing_coverage is True + assert parse_args(["--changed"]).changed is True + assert parse_args(["--no-coverage"]).no_coverage is True + conflict = parse_args(["--no-coverage", "--coverage-command", "true"]) + assert conflict.exit_code == 1 + + +def test_changed_files_follow_git_status(tmp_path, monkeypatch, capsys): + seen = {} + + class Result: + returncode = 0 + stdout = "\nM x\n?? sources/keep.py\n" + stderr = "" + + def fake(*_args, **kwargs): + seen.update(kwargs) + return Result() + + monkeypatch.setattr("crapper.cli.subprocess.run", fake) + found = _changed_files(tmp_path) + assert seen["check"] is False + assert seen["capture_output"] is True + assert seen["text"] is True + assert found == [ + (tmp_path / "x").resolve(), + (tmp_path / "sources" / "keep.py").resolve(), + ] + + class Failed: + returncode = 1 + stdout = "?? sources/keep.py\n" + stderr = "git failed" + + monkeypatch.setattr("crapper.cli.subprocess.run", lambda *_a, **_k: Failed()) + assert _changed_files(tmp_path) == [] + assert capsys.readouterr().err.strip() == "git failed" + + class Silent: + returncode = 1 + stdout = "" + stderr = "" + + monkeypatch.setattr("crapper.cli.subprocess.run", lambda *_a, **_k: Silent()) + assert _changed_files(tmp_path) == [] + assert capsys.readouterr().err.strip() == "git status failed" + + +def test_relative_paths_are_resolved_under_the_project_root(tmp_path): + _write(tmp_path, "src/demo/core.clj", "(ns demo.core)\n\n(defn choose [x]\n (if x 1 0))\n") + existing, filters = _positionals(tmp_path, ["src/demo/core.clj"]) + assert existing == [(tmp_path / "src/demo/core.clj").resolve()] + assert filters == [] + assert run(["--root", str(tmp_path), "--no-coverage", "src/demo/core.clj"]) == 0 + text = (tmp_path / ".metrics" / "crap.edn").read_text(encoding="utf-8") + assert ':name "choose"' in text + assert "parse_args" not in text + + +def test_changed_selection_ignores_files_git_did_not_name(tmp_path, monkeypatch, capsys): + _write(tmp_path, "src/kept.clj", "(ns demo.kept)\n\n(defn kept [] 1)\n") + _write(tmp_path, "src/other.clj", "(ns demo.other)\n\n(defn other [] 1)\n") + + class Result: + returncode = 0 + stdout = "?? src/kept.clj\n" + stderr = "" + + monkeypatch.setattr("crapper.cli.subprocess.run", lambda *_a, **_k: Result()) + assert run(["--root", str(tmp_path), "--no-coverage", "--changed"]) == 0 + text = (tmp_path / ".metrics" / "crap.edn").read_text(encoding="utf-8") + assert ':name "kept"' in text + assert "other" not in text + assert "No source files" not in capsys.readouterr().out + + +def test_threshold_equal_to_the_score_is_allowed(tmp_path): + _write(tmp_path, "src/demo/core.clj", "(ns demo.core)\n\n(defn choose [] 1)\n") + lcov = tmp_path / "target" / "coverage" / "lcov.info" + lcov.parent.mkdir(parents=True) + lcov.write_text("SF:src/demo/core.clj\nDA:1,1\nDA:3,1\nend_of_record\n", encoding="utf-8") + code = run( + ["--root", str(tmp_path), "--use-existing-coverage", "--threshold", "1", "src/demo/core.clj"] + ) + assert code == 0 + + +def test_threshold_with_no_scores_does_not_call_max_on_an_empty_list(tmp_path): + _write(tmp_path, "src/demo/core.clj", "(ns demo.core)\n\n(defn choose [] 1)\n") + assert run(["--root", str(tmp_path), "--no-coverage", "--threshold", "1"]) == 0 + + +def test_main_exits_with_the_status(monkeypatch): + monkeypatch.setattr("crapper.cli.run", lambda _argv=None: 4) + try: + main(["--help"]) + except SystemExit as exc: + assert exc.code == 4 + else: + raise AssertionError("main did not exit") diff --git a/tests/test_crap.py b/tests/test_crap.py index dcc781e..3fce93e 100644 --- a/tests/test_crap.py +++ b/tests/test_crap.py @@ -37,6 +37,16 @@ def test_unscored_rows_sort_after_scored_rows(): assert [row.name for row in rows] == ["worst", "mid", "missing"] +def test_a_low_score_still_sorts_ahead_of_an_unscored_row(): + rows = sort_entries( + [ + entry(name="plain", crap=1.0, complexity=1), + entry(name="large", crap=None, coverage=None, complexity=8), + ] + ) + assert [row.name for row in rows] == ["plain", "large"] + + def test_unscored_rows_sort_by_complexity(): rows = sort_entries( [ @@ -67,6 +77,20 @@ def test_edn_uses_crap4clj_keys(): assert text.endswith("}\n") +def test_edn_of_no_entries_is_an_empty_vector(): + assert render_edn([]) == "{:entries []}\n" + + +def test_metrics_directory_can_be_written_twice(tmp_path): + from crapper.metrics import write_metrics + + entry_row = entry() + first = write_metrics([entry_row], tmp_path) + second = write_metrics([entry_row], tmp_path) + assert first == second + assert first.is_file() + + def test_edn_writes_nil_for_missing_coverage(): text = render_edn([entry(coverage=None, crap=None)]) assert ":coverage nil" in text diff --git a/tests/test_edges.py b/tests/test_edges.py new file mode 100644 index 0000000..1c16193 --- /dev/null +++ b/tests/test_edges.py @@ -0,0 +1,258 @@ +"""Branches the first mutation run left unexecuted.""" + +import runpy +from pathlib import Path + +import pytest + +from crapper.analyze import analyze_files +from crapper.coverage import ( + CoverageBundle, + coverage_percent, + go_percent, + load_bundle, + normalize_path, + parse_form_coverage, + parse_go_profile, + parse_jacoco_index, + parse_lcov, + percent_for_range, + suffix_match, +) +from crapper.discover import is_test_file, iter_source_files +from crapper.languages.clojure import _extract_top_level_defns, extract_functions +from crapper.languages.golang import functions_in_source as go_functions +from crapper.languages.java import functions_in_source as java_functions +from crapper.languages.python import functions_in_source as python_functions +from crapper.languages.rust import functions_in_source as rust_functions +from crapper.languages.treesitter import child_of_type, end_line, parse +from crapper.languages.typescript import functions_in_source as ts_functions +from crapper.model import Function + + +def function(**kwargs): + defaults = dict( + name="place", + namespace="demo.Board", + complexity=1, + start_line=4, + end_line=6, + path="board.go", + language="go", + jacoco_class=None, + ) + defaults.update(kwargs) + return Function(**defaults) + + +def test_normalize_path_strips_url_and_dot_prefixes(): + assert normalize_path("file:./src//demo/core.clj") == "src/demo/core.clj" + assert normalize_path("%2E%2Fsrc%2Fcore.clj") == "src/core.clj" + + +def test_suffix_match_rejects_a_longer_suffix(): + assert suffix_match("src/demo/core.clj", "demo/core.clj") is True + assert suffix_match("core.clj", "src/demo/core.clj") is False + assert suffix_match("src/demo/core.clj", "") is False + + +def test_coverage_percent_is_zero_when_there_is_nothing_to_count(): + assert coverage_percent(0, 0) == 0.0 + assert percent_for_range({}, 1, 3) == 0.0 + assert percent_for_range({1: (1, 1)}, 4, 5) == 0.0 + + +def test_form_coverage_ignores_a_span_with_no_forms(): + html = ' 4 ' + assert parse_form_coverage(html) == {} + + +def test_lcov_keeps_a_file_that_has_no_end_record(): + parsed = parse_lcov("SF:a.clj\nDA:1,1\nSF:b.clj\nDA:2,0\n") + assert parsed["a.clj"][1] == (1, 1) + assert parsed["b.clj"][2] == (0, 1) + + +def test_go_profile_rejects_a_broken_line(): + with pytest.raises(ValueError, match="invalid coverage segment"): + parse_go_profile("mode: set\nnot a segment\n") + + +def test_go_percent_is_zero_when_no_segment_overlaps_the_function(): + profile = parse_go_profile("mode: set\nboard.go:1.1,2.2 1 1\n") + assert go_percent(profile, "board.go", 10, 12) == 0.0 + + +def test_go_percent_does_not_accept_the_first_unrelated_file(): + profile = parse_go_profile( + "mode: set\nother.go:4.1,6.2 2 0\nboard.go:4.1,6.2 2 1\n" + ) + assert go_percent(profile, "board.go", 4, 6) == 100.0 + + +def test_jacoco_miss_is_none_and_a_missing_counter_field_is_zero(): + xml = """ + + + + """ + found = parse_jacoco_index(xml) + assert found["demo.Board#place"][0].missed == 0 + assert found["demo.Board#place"][0].percent == 100.0 + bundle = CoverageBundle(jacoco=found) + assert bundle.percent_for(function(language="java", name="missing", namespace="demo.Board")) is None + assert CoverageBundle().percent_for(function(language="java", namespace="demo.Board")) is None + + +def test_jacoco_uses_the_nearest_method_when_the_line_does_not_match(): + xml = """ + + + + + + + """ + bundle = CoverageBundle(jacoco=parse_jacoco_index(xml)) + assert bundle.percent_for(function(language="java", name="place", namespace="demo.Board", start_line=18)) == 0.0 + + +def test_go_coverage_is_read_from_the_bundle(): + profile = parse_go_profile("mode: set\nboard.go:4.1,6.2 2 1\n") + bundle = CoverageBundle(go_profile=profile) + assert bundle.percent_for(function()) == 100.0 + + +def test_suffix_lookup_finds_a_report_keyed_by_a_longer_path(): + bundle = CoverageBundle(lcov={"proj/src/demo/core.clj": {3: (1, 1), 4: (0, 1)}}) + fn = function( + language="clojure", + path="src/demo/core.clj", + start_line=3, + end_line=4, + namespace="demo.core", + name="choose", + ) + assert bundle.percent_for(fn) == 50.0 + + +def test_load_bundle_on_an_empty_tree_has_no_profiles(tmp_path): + bundle = load_bundle(tmp_path) + assert bundle.go_profile is None + assert bundle.jacoco is None + assert bundle.lcov == {} + + +def test_conftest_and_a_tests_directory_are_tests(tmp_path): + assert is_test_file("conftest.py") is True + assert is_test_file(tmp_path / "tests" / "test_board.py") is True + source = tmp_path / "src" / "app.py" + source.parent.mkdir(parents=True) + source.write_text("def run():\n return 1\n", encoding="utf-8") + hidden = tmp_path / "tests" / "hidden.py" + hidden.parent.mkdir() + hidden.write_text("def hidden():\n return 1\n", encoding="utf-8") + skipped = tmp_path / "target" / "gen.py" + skipped.parent.mkdir() + skipped.write_text("def gen():\n return 1\n", encoding="utf-8") + assert iter_source_files([tmp_path]) == [source.resolve()] + assert iter_source_files([tmp_path / "missing"]) == [] + assert iter_source_files([source]) == [source.resolve()] + + +def test_files_outside_the_project_keep_an_absolute_path(tmp_path): + outside = tmp_path / "board.go" + outside.write_text("package demo\nfunc Place() int { return 1 }\n", encoding="utf-8") + entries = analyze_files([outside], tmp_path / "other", None) + assert entries[0].name == "Place" + assert entries[0].path == str(outside.resolve()) + assert analyze_files([tmp_path / "notes.md"], tmp_path, None) == [] + + +def test_module_entry_point_calls_main(monkeypatch): + called = {} + monkeypatch.setattr("crapper.cli.main", lambda: called.setdefault("ran", True)) + runpy.run_module("crapper.__main__", run_name="__main__") + assert called["ran"] is True + + +def test_end_line_of_a_trailing_newline_stays_on_the_last_source_line(): + _data, tree = parse("def choose():\n return 1\n", "python") + assert end_line(tree.root_node) == 2 + assert child_of_type(tree.root_node, "not_a_node") is None + + +def test_clojure_scanner_survives_end_of_file(): + assert extract_functions('(defn foo [] \\') == [] + assert extract_functions("(defn foo [] \\abc") == [] + assert [item["name"] for item in extract_functions("(defn foo [] ;; comment")] == [] + assert extract_functions('(defn foo [] "hello') == [] + found = _extract_top_level_defns('(defn foo []\n ;; comment\n "hello")') + assert found[0]["name"] == "foo" + assert found[0]["text"].endswith(")") + + +def test_go_edges(tmp_path): + assert [(fn.namespace, fn.name) for fn in go_functions("func Orphan() int { return 1 }\n", "board.go")] == [ + ("", "Orphan") + ] + source = "package p\nfunc Declared(x int)\nfunc Real() int { return 1 }\nfunc () Run() {}\nfunc (w *Widget) () {}\n" + assert [(fn.namespace, fn.name) for fn in go_functions(source, "board.go")] == [("p", "Real")] + (tmp_path / "go.mod").write_text("module example.com/demo\n", encoding="utf-8") + path = tmp_path / "board.go" + path.write_text("package demo\nfunc Place() int { return 1 }\n", encoding="utf-8") + rooted = go_functions(path.read_text(encoding="utf-8"), str(path), str(tmp_path)) + assert rooted[0].namespace == "example.com/demo" + + +def test_a_method_inside_a_lambda_is_not_an_entry(): + source = """ + class Sample { + Runnable r = () -> { + class Local { + int score() { if (true) return 1; return 0; } + } + return null; + }; + int outer() { return 1; } + } + """ + assert [method.name for method in java_functions(source, "Sample.java")] == ["outer"] + + +def test_typescript_namespaces_and_declarations_without_bodies(): + body = "export function choose(): number { return 1 }\n" + assert ts_functions(body, "/app/src/demo/box.ts", "/app")[0].namespace == "demo.box" + assert ts_functions(body, "./demo/box.ts", None)[0].namespace == "demo.box" + assert ts_functions(body, "/app/pkg/src/demo/box.mts", None)[0].namespace == "demo.box" + source = """ + function choose(): void; + class Box { open(): void; } + const plain = 1; + const arrow = (n: number) => n; + """ + functions = ts_functions(source, "src/demo/box.ts", "/proj") + assert [(fn.namespace, fn.name) for fn in functions] == [("demo.box", "arrow")] + object_method = "const obj = { open() { return 1 } }\n" + assert ts_functions(object_method, "src/demo/box.ts", "/proj") == [] + computed = "class Box { [name]() { return 1 } }\n" + assert ts_functions(computed, "src/demo/box.ts", "/proj") == [] + + +def test_python_function_without_a_name_is_skipped(): + source = "def choose(x):\n return x\n" + assert [fn.name for fn in python_functions(source, "src/app.py", "/proj")] == ["choose"] + + +def test_rust_relative_path_and_a_trait_method(tmp_path): + (tmp_path / "Cargo.toml").write_text( + '[package]\nname = "demo"\nversion = "0.1.0"\n', + encoding="utf-8", + ) + bare = rust_functions("pub fn bare() {}\n", "notes.rs", None) + assert bare[0].name == "bare" + assert bare[0].namespace == "crate::notes" + source = "trait Foo {\n fn draw(&self) {}\n}\n" + path = tmp_path / "src" / "lib.rs" + functions = rust_functions(source, str(path), str(tmp_path)) + assert [(fn.namespace, fn.name) for fn in functions] == [("demo", "draw")] diff --git a/tests/test_runners.py b/tests/test_runners.py index 8eb5cef..d6308fb 100644 --- a/tests/test_runners.py +++ b/tests/test_runners.py @@ -4,9 +4,15 @@ 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, @@ -14,6 +20,7 @@ from crapper.runners import ( python_roots, python_sources, run_coverage, + run_shell, rust_coverage_command, rust_modules, typescript_command, @@ -250,3 +257,177 @@ def test_missing_build_files_skip_that_language(tmp_path, monkeypatch): _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