mirror of
https://github.com/unclebob/mutator.git
synced 2026-11-06 18:23:50 +00:00
Each worker keeps a private copy of the file under test and links the rest of the project, so the original tree stays put. The default is one worker per core, and --max-workers can set a lower cap.
854 lines
31 KiB
Python
854 lines
31 KiB
Python
"""Boundaries the first mutation run left uncovered or alive.
|
|
|
|
A site on a line coverage.py never emits stays uncovered; these tests do not
|
|
reformat production code to expose those lines. Equivalent mutants are left
|
|
alone when no input can tell the operators apart.
|
|
"""
|
|
|
|
import hashlib
|
|
import sys
|
|
import threading
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from mutator.cli import (
|
|
_changed_files,
|
|
_is_source,
|
|
_prepare_coverage,
|
|
_take,
|
|
parse_args,
|
|
run,
|
|
select_files,
|
|
)
|
|
from mutator.coverage import _candidates, _jacoco_index, _suffix_hit, covered_lines
|
|
from mutator.crapper_link import ensure_crapper
|
|
from mutator.edn import loads
|
|
from mutator.engine import (
|
|
_backup,
|
|
_carry_forward,
|
|
_covered,
|
|
_drop_bytecode,
|
|
_forms,
|
|
_restore_if_dirty,
|
|
mutate_file,
|
|
restore_backups,
|
|
scan_file,
|
|
)
|
|
from mutator.functions import (
|
|
_mutation_field,
|
|
_owner,
|
|
digest,
|
|
form_spans,
|
|
is_private,
|
|
mutation_file,
|
|
)
|
|
from mutator.metrics import (
|
|
_drop_stale,
|
|
_form_file,
|
|
_form_file_only,
|
|
_mentions_file,
|
|
_outcomes_of,
|
|
_remove_empty_dirs,
|
|
_write_namespace,
|
|
load_history,
|
|
snapshot_path,
|
|
write_results,
|
|
)
|
|
from mutator.model import FormResult, History, PriorForm, Site
|
|
from mutator.report import format_results, format_scan
|
|
from mutator.runner import CommandResult, CommandRunner, _clojure_command
|
|
from mutator.runner import test_plan as command_for
|
|
from mutator.sites import _grammar
|
|
|
|
|
|
def _form(namespace, name, file_name, digest_text, killed=1, private=False, line=2):
|
|
form_id = f"defn-/{name}" if private else f"defn/{name}"
|
|
return FormResult(
|
|
id=form_id,
|
|
namespace=namespace,
|
|
name=name,
|
|
private=private,
|
|
file=file_name,
|
|
line=line,
|
|
end_line=line + 2,
|
|
digest=digest_text,
|
|
killed=killed,
|
|
survived=0,
|
|
uncovered=0,
|
|
sites=1,
|
|
)
|
|
|
|
|
|
def _site(**overrides) -> Site:
|
|
values = dict(
|
|
file="src/demo.py",
|
|
namespace="demo",
|
|
form_id="defn/place",
|
|
name="place",
|
|
line=2,
|
|
start=0,
|
|
end=1,
|
|
original=">",
|
|
mutant=">=",
|
|
category="comparison",
|
|
mutation_id="m",
|
|
)
|
|
values.update(overrides)
|
|
return Site(**values)
|
|
|
|
|
|
def _pairs(tmp_path: Path, relative: str, source: str):
|
|
from mutator.functions import sites_in_file
|
|
|
|
path = tmp_path / relative
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(source, encoding="utf-8")
|
|
sites = sites_in_file(source, path, tmp_path, relative)
|
|
return sites, {(site.original, site.mutant) for site in sites}
|
|
|
|
|
|
class _Fn:
|
|
def __init__(self, name, start, end):
|
|
self.name = name
|
|
self.start_line = start
|
|
self.end_line = end
|
|
|
|
|
|
class _Ok:
|
|
def __init__(self, seconds=0.01):
|
|
self.verbose = False
|
|
self.seconds = seconds
|
|
self.timeouts = []
|
|
self._lock = threading.Lock()
|
|
|
|
def run(self, command, cwd, timeout):
|
|
with self._lock:
|
|
self.timeouts.append(timeout)
|
|
code = 0 if timeout is None else 1
|
|
return CommandResult(code=code, timed_out=False, seconds=self.seconds, output="")
|
|
|
|
|
|
def test_score_and_description_follow_the_counts():
|
|
even = _form("demo", "place", "a.py", "d", killed=1)
|
|
even.survived = 1
|
|
assert even.score == 0.5
|
|
blank = _form("demo", "place", "a.py", "d", killed=0)
|
|
assert blank.score is None
|
|
perfect = _form("demo", "place", "a.py", "d", killed=1)
|
|
assert perfect.score == 1.0
|
|
assert _site(original=">", mutant=">=").description == "> -> >="
|
|
assert _site(original="not", mutant="").description == "delete not"
|
|
|
|
|
|
def test_report_text_states_the_score_the_total_and_coverage():
|
|
assert format_results([]) == "No functions to mutate.\n"
|
|
mixed = _form("demo", "place", "a.py", "d", killed=1)
|
|
mixed.survived = 1
|
|
mixed.sites = 2
|
|
text = format_results([mixed])
|
|
assert "50.0%" in next(line for line in text.splitlines() if "place" in line)
|
|
total = next(line for line in text.splitlines() if line.startswith("Total"))
|
|
assert total.split() == ["Total", "1", "1", "0", "2", "50.0%"]
|
|
|
|
perfect = _form("demo", "place", "a.py", "d", killed=1)
|
|
perfect_total = next(line for line in format_results([perfect]).splitlines() if line.startswith("Total"))
|
|
assert perfect_total.split()[-1] == "100.0%"
|
|
idle = _form("demo", "place", "a.py", "d", killed=0)
|
|
idle.sites = 2
|
|
idle.uncovered = 2
|
|
idle_total = next(line for line in format_results([idle]).splitlines() if line.startswith("Total"))
|
|
assert idle_total.split()[-1] == "n/a"
|
|
|
|
covered = format_scan("src/demo.py", [_site()], set(), {"m": True})
|
|
missing = format_scan("src/demo.py", [_site()], set(), {})
|
|
bare = format_scan("src/demo.py", [_site()], set(), {"m": False})
|
|
assert "uncovered" not in covered
|
|
assert "uncovered" not in missing
|
|
assert "uncovered" in bare
|
|
|
|
|
|
def test_edn_errors_comments_sets_and_nil_keys():
|
|
with pytest.raises(ValueError, match="empty EDN"):
|
|
loads("")
|
|
with pytest.raises(ValueError, match="empty EDN"):
|
|
loads("; hi")
|
|
with pytest.raises(ValueError, match="unexpected end of EDN"):
|
|
loads("{")
|
|
with pytest.raises(ValueError, match="unterminated string"):
|
|
loads('"hi')
|
|
with pytest.raises(ValueError, match="unterminated string escape"):
|
|
loads('"\\')
|
|
with pytest.raises(ValueError, match="empty keyword"):
|
|
loads(":")
|
|
assert loads("; kept\n12") == 12
|
|
assert loads("#{1 2}") == {1, 2}
|
|
assert loads("{nil 1}") == {None: 1}
|
|
|
|
|
|
def test_privacy_headers_digests_owners_and_mutation_fields(tmp_path):
|
|
assert is_private("python", "__init__", "def __init__(self):\n pass\n", 1, 2) is False
|
|
assert is_private("python", "_hide", "def _hide():\n return 1\n", 1, 2) is True
|
|
assert is_private("python", "__x", "def __x():\n return 1\n", 1, 2) is True
|
|
assert is_private("rust", "place", "fn place() {}\n", 1, 1) is True
|
|
assert is_private("rust", "place", "#[inline]\npub fn place() {}\n", 1, 2) is False
|
|
assert is_private("typescript", "#place", "#place() {}\n", 1, 1) is True
|
|
assert is_private("typescript", "place", "private place() {}\n", 1, 1) is True
|
|
assert is_private("typescript", "place", "function place() {}\n", 1, 1) is False
|
|
assert is_private("cobol", "place", "PLACE.\n", 1, 1) is False
|
|
|
|
source = "def place(x):\n return x > 0\n"
|
|
chunk = "def place(x):\n return x > 0"
|
|
assert digest(source, 1, 2) == hashlib.sha256(chunk.encode("utf-8")).hexdigest()
|
|
|
|
assert _owner([_Fn("outer", 1, 10), _Fn("inner", 8, 9)], 8).name == "inner"
|
|
|
|
path = tmp_path / "src" / "demo.py"
|
|
path.parent.mkdir(parents=True)
|
|
hidden = "def _hide():\n return 1\n"
|
|
path.write_text(hidden, encoding="utf-8")
|
|
assert list(form_spans(hidden, path, tmp_path).values())[0][3] is True
|
|
|
|
assert _mutation_field("not-json", 0) is None
|
|
assert _mutation_field('"hello"', 0) is None
|
|
assert _mutation_field("[1]", 0) is None
|
|
assert _mutation_field('["only"]', 1) is None
|
|
assert mutation_file('["src/a.py","demo","defn/place",1,2,">",">="]') == "src/a.py"
|
|
|
|
|
|
def test_grammar_guards_and_non_head_clojure_symbols(tmp_path):
|
|
assert _grammar("typescript", "src/app.tsx") == "tsx"
|
|
assert _grammar("typescript", "src/app.ts") == "typescript"
|
|
assert _grammar("python", "src/app.py") == "python"
|
|
|
|
sites, pairs = _pairs(tmp_path, "src/demo/core.clj", "(ns demo.core)\n(defn place []\n [inc])\n")
|
|
assert "inc" not in {site.original for site in sites}
|
|
sites, _pairs_ignored = _pairs(tmp_path, "src/demo/add.clj", "(ns demo.core)\n(defn place []\n (+ + 1))\n")
|
|
assert sum(site.original == "+" and site.mutant == "-" for site in sites) == 1
|
|
sites, _pairs_ignored = _pairs(tmp_path, "src/demo/two.clj", "(ns demo.core)\n(defn place []\n 2)\n")
|
|
assert sites == []
|
|
|
|
_sites, pairs = _pairs(tmp_path, "src/demo/app.py", "def place(a, b):\n return a - b\n")
|
|
assert ("-", "+") in pairs
|
|
assert ("-", "") not in pairs
|
|
_sites, pairs = _pairs(tmp_path, "src/demo/name.py", "def place(true):\n return true\n")
|
|
assert ("true", "false") not in pairs
|
|
_sites, pairs = _pairs(tmp_path, "src/demo/two.py", "def place():\n return 2\n")
|
|
assert pairs == set()
|
|
sites, _pairs_ignored = _pairs(tmp_path, "src/lib.rs", "fn place() -> bool { true }\n")
|
|
assert sum(site.original == "true" and site.mutant == "false" for site in sites) == 1
|
|
|
|
|
|
def test_coverage_indexes_use_package_names_form_counts_and_exact_paths(tmp_path):
|
|
assert _candidates(lambda value: value, "") == []
|
|
assert _candidates(lambda _value: "same", "src/app.py") == ["same"]
|
|
assert _suffix_hit("demo/Board.java", ["Other.java"], lambda left, right: left.endswith(right)) is False
|
|
|
|
report = tmp_path / "target" / "site" / "jacoco" / "jacoco.xml"
|
|
report.parent.mkdir(parents=True)
|
|
report.write_text(
|
|
"""<?xml version="1.0"?>
|
|
<report name="demo">
|
|
<package name="demo">
|
|
<sourcefile name="Board.java">
|
|
<line nr="6" mi="0" ci="1" mb="0" cb="0"/>
|
|
</sourcefile>
|
|
</package>
|
|
</report>
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
source = tmp_path / "src" / "demo" / "Board.java"
|
|
source.parent.mkdir(parents=True)
|
|
source.write_text("package demo;\nclass Board {}\n", encoding="utf-8")
|
|
assert "demo/Board.java" in _jacoco_index(tmp_path)
|
|
assert covered_lines(tmp_path, source, "java") == {6}
|
|
|
|
html = tmp_path / "target" / "coverage" / "src" / "demo" / "core.clj.html"
|
|
html.parent.mkdir(parents=True)
|
|
html.write_text(
|
|
'<span title="1 out of 1 forms covered">4 </span>\n',
|
|
encoding="utf-8",
|
|
)
|
|
lcov = tmp_path / "target" / "coverage" / "clojure" / "lcov.info"
|
|
lcov.parent.mkdir(parents=True)
|
|
lcov.write_text("SF:src/demo/core.clj\nDA:9,1\nend_of_record\n", encoding="utf-8")
|
|
clojure = tmp_path / "src" / "demo" / "core.clj"
|
|
clojure.write_text("(ns demo.core)\n(defn place [] 1)\n", encoding="utf-8")
|
|
assert covered_lines(tmp_path, clojure, "clojure") == {4}
|
|
|
|
|
|
def test_commands_follow_the_nearest_project_and_a_killed_process(tmp_path):
|
|
assert CommandRunner().verbose is False
|
|
both = tmp_path / "both"
|
|
both.mkdir()
|
|
(both / "bb.edn").write_text("{:tasks {test (clojure)}}\n", encoding="utf-8")
|
|
(both / "deps.edn").write_text("{:deps {}}\n", encoding="utf-8")
|
|
assert _clojure_command(both) == "clj -M:test"
|
|
|
|
root = tmp_path / "proj"
|
|
sub = root / "svc"
|
|
sub.mkdir(parents=True)
|
|
(sub / "bb.edn").write_text("{:tasks {test (clojure)}}\n", encoding="utf-8")
|
|
clojure = sub / "src" / "a.clj"
|
|
clojure.parent.mkdir(parents=True)
|
|
clojure.write_text("(defn place [] 1)\n", encoding="utf-8")
|
|
command, directory = command_for(root, clojure, "clojure", None)
|
|
assert directory == sub
|
|
assert command == "bb test"
|
|
|
|
(sub / "pom.xml").write_text("<project/>\n", encoding="utf-8")
|
|
java = sub / "src" / "A.java"
|
|
java.write_text("class A { int place(){ return 1; } }\n", encoding="utf-8")
|
|
command, directory = command_for(root, java, "java", None)
|
|
assert directory == sub
|
|
assert command == "mvn -q test -DexcludeTags=no-mutate"
|
|
|
|
(sub / "package.json").write_text("{}\n", encoding="utf-8")
|
|
typescript = sub / "src" / "a.ts"
|
|
typescript.write_text("export function place(){ return 1; }\n", encoding="utf-8")
|
|
command, directory = command_for(root, typescript, "typescript", None)
|
|
assert directory == sub
|
|
assert command == "npm test"
|
|
|
|
(sub / "Cargo.toml").write_text("[package]\nname='demo'\n", encoding="utf-8")
|
|
rust = sub / "src" / "lib.rs"
|
|
rust.write_text("fn place() -> i32 { 1 }\n", encoding="utf-8")
|
|
command, directory = command_for(root, rust, "rust", None)
|
|
assert directory == sub
|
|
assert command == "cargo test"
|
|
|
|
(sub / "go.mod").write_text("module example.com/demo\n", encoding="utf-8")
|
|
go = sub / "pkg" / "widget.go"
|
|
go.parent.mkdir()
|
|
go.write_text("package pkg\nfunc Run() int { return 1 }\n", encoding="utf-8")
|
|
command, directory = command_for(root, go, "go", None)
|
|
assert directory == sub
|
|
assert command == "go test -count=1 ./pkg"
|
|
|
|
(sub / "pyproject.toml").write_text("[project]\ndependencies=['pytest']\n", encoding="utf-8")
|
|
python = sub / "app.py"
|
|
python.write_text("def place():\n return 1\n", encoding="utf-8")
|
|
command, directory = command_for(root, python, "python", None)
|
|
assert directory == sub
|
|
assert command.endswith("-m pytest")
|
|
|
|
result = CommandRunner().run(f"{sys.executable} -c 'import time; time.sleep(30)'", tmp_path, 0.4)
|
|
assert result.timed_out is True
|
|
assert result.code == 124
|
|
|
|
|
|
def test_a_process_without_a_return_code_counts_as_failure(monkeypatch, tmp_path):
|
|
class Proc:
|
|
returncode = None
|
|
pid = 1
|
|
|
|
def communicate(self, timeout=None):
|
|
return (None, None)
|
|
|
|
monkeypatch.setattr("mutator.runner.subprocess.Popen", lambda *args, **kwargs: Proc())
|
|
result = CommandRunner().run("true", tmp_path, 5)
|
|
assert result.code == 1
|
|
assert result.output == ""
|
|
assert result.timed_out is False
|
|
|
|
|
|
def test_options_reject_bad_values_and_keep_flag_defaults(monkeypatch):
|
|
assert _take(["--root", "/tmp", "extra"], 0, "--root") == "/tmp"
|
|
assert _take(["--root", "value", ""], 0, "--root") == "value"
|
|
with pytest.raises(ValueError):
|
|
_take(["--root"], 0, "--root")
|
|
with pytest.raises(ValueError):
|
|
_take(["--root", ""], 0, "--root")
|
|
with pytest.raises(ValueError):
|
|
_take(["--lines", "-1"], 0, "--lines")
|
|
|
|
assert parse_args(["--lines", "1"]).lines == {1}
|
|
assert parse_args(["--lines", "0"]).exit_code == 1
|
|
assert parse_args(["--timeout-factor", "1"]).timeout_factor == 1
|
|
assert parse_args(["--timeout-factor", "0"]).exit_code == 1
|
|
assert parse_args(["--timeout-factor", "-1"]).exit_code == 1
|
|
assert parse_args(["--use-existing-coverage"]).use_existing_coverage is True
|
|
assert parse_args(["--reuse-coverage"]).use_existing_coverage is True
|
|
assert parse_args(["--verbose"]).verbose is True
|
|
assert parse_args(["--changed"]).changed is True
|
|
assert parse_args(["--max-workers", "2"]).max_workers == 2
|
|
assert parse_args(["--max-workers", "0"]).exit_code == 1
|
|
assert parse_args(["--max-workers", "nope"]).exit_code == 1
|
|
assert parse_args(["--scan", "--max-workers", "2"]).exit_code == 1
|
|
assert parse_args(["--not-a-flag"]).exit_code == 1
|
|
monkeypatch.setattr(sys, "argv", ["prog", "--verbose"])
|
|
options = parse_args(None)
|
|
assert options.verbose is True
|
|
assert options.positionals == []
|
|
|
|
|
|
def test_help_and_conflicts_use_different_streams(capsys):
|
|
assert run(["--help"]) == 0
|
|
captured = capsys.readouterr()
|
|
assert "uml-viewer" in captured.out
|
|
assert captured.err == ""
|
|
assert run(["--mutate-all", "--since-last-run"]) == 1
|
|
captured = capsys.readouterr()
|
|
assert "cannot be combined" in captured.err
|
|
assert captured.out == ""
|
|
|
|
|
|
def test_git_status_keeps_short_names_renames_and_the_failure_text(monkeypatch, tmp_path, capsys):
|
|
class Result:
|
|
def __init__(self, code, out="", err=""):
|
|
self.returncode = code
|
|
self.stdout = out
|
|
self.stderr = err
|
|
|
|
def fail(*args, **kwargs):
|
|
return Result(1, err="boom\n")
|
|
|
|
monkeypatch.setattr("mutator.cli.subprocess.run", fail)
|
|
assert _changed_files(tmp_path) == []
|
|
assert capsys.readouterr().err.strip() == "boom"
|
|
|
|
def status(*args, **kwargs):
|
|
return Result(0, out="?? a\nR src/old.py -> src/new.py\n")
|
|
|
|
monkeypatch.setattr("mutator.cli.subprocess.run", status)
|
|
found = _changed_files(tmp_path)
|
|
assert (tmp_path / "a").resolve() in found
|
|
assert (tmp_path / "src" / "new.py").resolve() in found
|
|
assert (tmp_path / "src" / "old.py").resolve() not in found
|
|
|
|
|
|
def test_selection_joins_the_root_and_skips_unknown_files(tmp_path, capsys):
|
|
path = tmp_path / "src" / "demo.py"
|
|
path.parent.mkdir()
|
|
path.write_text("def place(x):\n return x > 0\n", encoding="utf-8")
|
|
note = tmp_path / "note.txt"
|
|
note.write_text("hello\n", encoding="utf-8")
|
|
crapper = ensure_crapper()
|
|
assert _is_source(note, crapper) is False
|
|
options = parse_args(["--root", str(tmp_path), "src/demo.py"])
|
|
assert select_files(options) == [path.resolve()]
|
|
|
|
cwd = tmp_path / "cwd"
|
|
project = tmp_path / "proj"
|
|
decoy = cwd / "src" / "demo.py"
|
|
real = project / "src" / "demo.py"
|
|
decoy.parent.mkdir(parents=True)
|
|
real.parent.mkdir(parents=True)
|
|
decoy.write_text("def other(x):\n return x > 1\n", encoding="utf-8")
|
|
real.write_text("def place(x):\n return x > 0\n", encoding="utf-8")
|
|
monkeypatch = pytest.MonkeyPatch()
|
|
monkeypatch.chdir(cwd)
|
|
try:
|
|
chosen = select_files(parse_args(["--root", str(project), "src/demo.py"]))
|
|
finally:
|
|
monkeypatch.undo()
|
|
assert chosen == [real.resolve()]
|
|
|
|
code = run(["--scan", "--no-coverage", "--root", str(tmp_path), "--source-root", "src"])
|
|
captured = capsys.readouterr()
|
|
assert code == 0
|
|
assert "demo.py" in captured.out
|
|
|
|
|
|
def test_existing_coverage_is_not_regenerated(monkeypatch, tmp_path):
|
|
crapper = ensure_crapper()
|
|
calls = []
|
|
monkeypatch.setattr(crapper.runners, "run_coverage", lambda *args, **kwargs: calls.append(args))
|
|
root = tmp_path
|
|
_prepare_coverage(parse_args(["--no-coverage", "--root", str(root)]), root, [])
|
|
_prepare_coverage(parse_args(["--use-existing-coverage", "--root", str(root)]), root, [])
|
|
assert calls == []
|
|
_prepare_coverage(parse_args(["--root", str(root)]), root, [])
|
|
assert len(calls) == 1
|
|
|
|
|
|
def test_cli_exit_codes_follow_kills_baseline_failure_and_an_empty_tree(tmp_path, capsys):
|
|
empty = run(["--no-coverage", "--root", str(tmp_path)])
|
|
assert empty == 0
|
|
assert "No source files" in capsys.readouterr().out
|
|
|
|
path = tmp_path / "src" / "demo.py"
|
|
path.parent.mkdir()
|
|
path.write_text("def add(a, b):\n return a + b\n", encoding="utf-8")
|
|
command = (
|
|
f"{sys.executable} -c \"import sys; sys.path.insert(0, 'src'); "
|
|
"from demo import add; raise SystemExit(0 if add(2, 3) == 5 else 1)\""
|
|
)
|
|
code = run(
|
|
[
|
|
"--no-coverage",
|
|
"--mutate-all",
|
|
"--root",
|
|
str(tmp_path),
|
|
"--test-command",
|
|
command,
|
|
"src/demo.py",
|
|
]
|
|
)
|
|
assert code == 0
|
|
assert "KILLED" in capsys.readouterr().out
|
|
snapshot = snapshot_path(tmp_path, "demo")
|
|
recorded = snapshot.read_text(encoding="utf-8")
|
|
|
|
path.write_text("def place(x):\n return x > 0\n", encoding="utf-8")
|
|
failing = f"{sys.executable} -c 'raise SystemExit(1)'"
|
|
code = run(
|
|
[
|
|
"--no-coverage",
|
|
"--mutate-all",
|
|
"--root",
|
|
str(tmp_path),
|
|
"--test-command",
|
|
failing,
|
|
"src/demo.py",
|
|
]
|
|
)
|
|
assert code == 2
|
|
assert snapshot.read_text(encoding="utf-8") == recorded
|
|
|
|
|
|
def test_a_scan_does_not_restore_a_backup_and_a_run_does(tmp_path, capsys):
|
|
source = tmp_path / "src" / "demo.py"
|
|
source.parent.mkdir()
|
|
dirty = "def place(x):\n return x > 0\n"
|
|
clean = "def place(x):\n return x > 1\n"
|
|
source.write_text(dirty, encoding="utf-8")
|
|
backup = tmp_path / "target" / "mutator-backup" / "src" / "demo.py"
|
|
backup.parent.mkdir(parents=True)
|
|
backup.write_text(clean, encoding="utf-8")
|
|
code = run(["--scan", "--no-coverage", "--root", str(tmp_path), "src/demo.py"])
|
|
assert code == 0
|
|
assert source.read_text(encoding="utf-8") == dirty
|
|
scanned = capsys.readouterr().out
|
|
assert "0 -> 1" in scanned
|
|
assert "1 -> 0" not in scanned
|
|
|
|
failing = f"{sys.executable} -c 'raise SystemExit(1)'"
|
|
code = run(
|
|
[
|
|
"--no-coverage",
|
|
"--mutate-all",
|
|
"--root",
|
|
str(tmp_path),
|
|
"--test-command",
|
|
failing,
|
|
"src/demo.py",
|
|
]
|
|
)
|
|
assert code == 2
|
|
assert source.read_text(encoding="utf-8") == clean
|
|
|
|
|
|
def test_backups_bytecode_and_a_dirty_file_are_put_back(tmp_path):
|
|
backup = tmp_path / "target" / "mutator-backup" / "a" / "b" / "demo.py"
|
|
backup.parent.mkdir(parents=True)
|
|
backup.write_bytes(b"original\n")
|
|
restored = restore_backups(tmp_path)
|
|
assert (tmp_path / "a" / "b" / "demo.py").read_bytes() == b"original\n"
|
|
assert restored == [tmp_path / "a" / "b" / "demo.py"]
|
|
|
|
path = tmp_path / "src" / "demo.py"
|
|
path.parent.mkdir()
|
|
path.write_bytes(b"one")
|
|
saved = _backup(tmp_path, path, b"one")
|
|
assert saved.read_bytes() == b"one"
|
|
assert _backup(tmp_path, path, b"two").read_bytes() == b"one"
|
|
|
|
cache = path.parent / "__pycache__"
|
|
cache.mkdir()
|
|
compiled = cache / f"{path.stem}.cpython-314.pyc"
|
|
compiled.write_bytes(b"pyc")
|
|
_drop_bytecode(path)
|
|
assert not compiled.exists()
|
|
|
|
path.write_bytes(b"dirty")
|
|
_restore_if_dirty(path, b"clean")
|
|
assert path.read_bytes() == b"clean"
|
|
|
|
|
|
def test_selection_coverage_and_timeouts_keep_their_boundaries(tmp_path, capsys, monkeypatch):
|
|
path = tmp_path / "src" / "demo.py"
|
|
path.parent.mkdir()
|
|
path.write_text("def place(x):\n return x > 0\n", encoding="utf-8")
|
|
site = _site()
|
|
assert _covered(site, None, False) is False
|
|
assert _covered(site, {2}, False) is True
|
|
|
|
spans = {
|
|
("demo.Zebra", "defn/long"): (1, 10, "long", False),
|
|
("demo", "defn/short"): (3, 4, "short", False),
|
|
}
|
|
monkeypatch.setattr("mutator.engine.form_spans", lambda *args: spans)
|
|
monkeypatch.setattr("mutator.engine.form_digests", lambda *args: {key: "d" for key in spans})
|
|
ordered = _forms("x", path, tmp_path, "src/demo.py", [], {}, {}, None)
|
|
assert [form.name for form in ordered] == ["long", "short"]
|
|
|
|
owned = _site(namespace="demo", form_id="defn/place", mutation_id="m", line=2)
|
|
place_span = {("demo", "defn/place"): (1, 3, "place", False)}
|
|
monkeypatch.setattr("mutator.engine.form_spans", lambda *args: place_span)
|
|
monkeypatch.setattr("mutator.engine.form_digests", lambda *args: {("demo", "defn/place"): "d"})
|
|
counted = _forms("x", path, tmp_path, "src/demo.py", [owned], {"m": True}, {"m": "survived"}, {2})
|
|
assert counted[0].survived == 1
|
|
assert counted[0].killed == 0
|
|
missing = _forms("x", path, tmp_path, "src/demo.py", [owned], {"m": True}, {}, None)
|
|
assert missing[0].survived == 1
|
|
|
|
history = History(
|
|
forms={("demo", "defn/place"): PriorForm("demo", "defn/place", "old", "src/demo.py")},
|
|
outcomes={"missing": "killed", "m": "killed"},
|
|
)
|
|
carried = _carry_forward([owned], {"m": True}, history, {("demo", "defn/place"): "new"})
|
|
assert carried == {}
|
|
uncovered = _carry_forward([owned], {"m": False}, history, {("demo", "defn/place"): "old"})
|
|
assert uncovered == {}
|
|
|
|
warned = mutate_file(
|
|
path,
|
|
tmp_path,
|
|
runner=_Ok(),
|
|
covered_lines=None,
|
|
ignore_coverage=False,
|
|
mutate_all=True,
|
|
lines=None,
|
|
test_command="fake",
|
|
timeout_factor=10,
|
|
mutation_warning=50,
|
|
baselines={},
|
|
)
|
|
assert warned.forms[0].uncovered == 2
|
|
assert "No coverage data" in capsys.readouterr().err
|
|
|
|
quiet = mutate_file(
|
|
path,
|
|
tmp_path,
|
|
runner=_Ok(),
|
|
covered_lines=None,
|
|
ignore_coverage=True,
|
|
mutate_all=True,
|
|
lines=None,
|
|
test_command="fake",
|
|
timeout_factor=10,
|
|
mutation_warning=2,
|
|
baselines={},
|
|
)
|
|
assert quiet.forms[0].killed == 2
|
|
quiet_err = capsys.readouterr().err
|
|
assert "WARNING" not in quiet_err
|
|
assert "No coverage data" not in quiet_err
|
|
|
|
clock = _Ok(seconds=30)
|
|
mutate_file(
|
|
path,
|
|
tmp_path,
|
|
runner=clock,
|
|
covered_lines=None,
|
|
ignore_coverage=True,
|
|
mutate_all=True,
|
|
lines=None,
|
|
test_command="fake",
|
|
timeout_factor=10,
|
|
mutation_warning=50,
|
|
baselines={},
|
|
)
|
|
assert clock.timeouts[0] is None
|
|
assert clock.timeouts[1] == 300
|
|
|
|
class Red:
|
|
verbose = False
|
|
|
|
def run(self, command, cwd, timeout):
|
|
lines = ["START"] + [f"M{i}" for i in range(1, 24)] + ["END"]
|
|
return CommandResult(code=1, timed_out=False, seconds=0.01, output="\n".join(lines))
|
|
|
|
failed = mutate_file(
|
|
path,
|
|
tmp_path,
|
|
runner=Red(),
|
|
covered_lines=None,
|
|
ignore_coverage=True,
|
|
mutate_all=True,
|
|
lines=None,
|
|
test_command="fake",
|
|
timeout_factor=10,
|
|
mutation_warning=50,
|
|
baselines={},
|
|
)
|
|
assert "M5" in failed.baseline_message
|
|
assert "START" not in failed.baseline_message
|
|
|
|
scanned = scan_file(path, tmp_path, covered_lines={1}, ignore_coverage=False, lines=None)
|
|
assert "uncovered" in scanned
|
|
ignored = scan_file(path, tmp_path, covered_lines={1}, ignore_coverage=True, lines=None)
|
|
assert "uncovered" not in ignored
|
|
|
|
|
|
def test_mutate_all_reruns_a_killed_mutant_and_clears_its_backup(tmp_path):
|
|
path = tmp_path / "src" / "demo.py"
|
|
path.parent.mkdir()
|
|
path.write_text("def add(a, b):\n if a > 0:\n return a + b\n return a + b\n", encoding="utf-8")
|
|
|
|
class Exec:
|
|
def __init__(self):
|
|
self.verbose = False
|
|
self.calls = 0
|
|
self._lock = threading.Lock()
|
|
|
|
def run(self, command, cwd, timeout):
|
|
with self._lock:
|
|
self.calls += 1
|
|
text = (cwd / "src" / "demo.py").read_text(encoding="utf-8")
|
|
namespace: dict = {}
|
|
exec(text, namespace)
|
|
ok = namespace["add"](1, 2) == 3
|
|
return CommandResult(code=0 if ok else 1, timed_out=False, seconds=0.01, output="")
|
|
|
|
runner = Exec()
|
|
first = mutate_file(
|
|
path,
|
|
tmp_path,
|
|
runner=runner,
|
|
covered_lines=None,
|
|
ignore_coverage=True,
|
|
mutate_all=True,
|
|
lines=None,
|
|
test_command="fake",
|
|
timeout_factor=10,
|
|
mutation_warning=50,
|
|
baselines={},
|
|
)
|
|
killed_once = first.forms[0].killed
|
|
first_calls = runner.calls
|
|
second = mutate_file(
|
|
path,
|
|
tmp_path,
|
|
runner=runner,
|
|
covered_lines=None,
|
|
ignore_coverage=True,
|
|
mutate_all=True,
|
|
lines=None,
|
|
test_command="fake",
|
|
timeout_factor=10,
|
|
mutation_warning=50,
|
|
baselines={},
|
|
)
|
|
assert second.forms[0].killed == killed_once
|
|
assert runner.calls - first_calls == first_calls
|
|
assert [item for item in (tmp_path / "target").rglob("*") if item.is_file()] == []
|
|
|
|
|
|
def test_a_missing_backup_is_not_unlinked_again(tmp_path):
|
|
path = tmp_path / "src" / "demo.py"
|
|
path.parent.mkdir()
|
|
path.write_text("def place(x):\n return x > 0\n", encoding="utf-8")
|
|
|
|
class Drop:
|
|
verbose = False
|
|
|
|
def run(self, command, cwd, timeout):
|
|
if timeout is not None:
|
|
for backup in (cwd / "target" / "mutator-backup").rglob("*"):
|
|
if backup.is_file():
|
|
backup.unlink()
|
|
return CommandResult(code=0 if timeout is None else 1, timed_out=False, seconds=0.01, output="")
|
|
|
|
mutate_file(
|
|
path,
|
|
tmp_path,
|
|
runner=Drop(),
|
|
covered_lines=None,
|
|
ignore_coverage=True,
|
|
mutate_all=True,
|
|
lines=None,
|
|
test_command="fake",
|
|
timeout_factor=10,
|
|
mutation_warning=50,
|
|
baselines={},
|
|
)
|
|
assert path.read_text(encoding="utf-8").startswith("def place")
|
|
|
|
|
|
def test_an_unrelated_snapshot_is_left_in_place(tmp_path):
|
|
from mutator.edn import dumps
|
|
|
|
legacy = tmp_path / ".metrics" / "mutate" / "legacy.edn"
|
|
legacy.parent.mkdir(parents=True)
|
|
legacy.write_text(
|
|
dumps({"namespace": "untouched", "source": "c.go", "forms": [], "outcomes": {}}) + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
write_results(tmp_path, "a.go", [_form("demo", "Run", "a.go", "h")], {})
|
|
assert legacy.is_file()
|
|
|
|
|
|
def test_snapshots_keep_other_files_empty_directories_and_odd_records(tmp_path):
|
|
assert _outcomes_of({"outcomes": {"id": ":killed"}}) == {"id": "killed"}
|
|
assert _form_file({"file": ""}, {"source": "a.go"}) == "a.go"
|
|
assert _mentions_file({"forms": [{"file": "a.go"}]}, "a.go") is True
|
|
assert _mentions_file({"forms": [{"file": "b.go"}]}, "a.go") is False
|
|
assert _form_file_only({"source": "a.go", "forms": []}, "a.go") is True
|
|
assert _form_file_only({"source": "b.go", "forms": []}, "a.go") is False
|
|
assert _form_file_only({"forms": [{"file": "a.go"}]}, "a.go") is True
|
|
assert _form_file_only({"forms": [{"file": "b.go"}]}, "a.go") is False
|
|
|
|
assert _write_namespace(tmp_path, "ghost", [], {}, [], {}, "a.go") is None
|
|
missing = tmp_path / "missing.edn"
|
|
_drop_stale([(missing, {"namespace": "demo"})], {"demo"}, set(), "a.go")
|
|
assert not missing.exists()
|
|
|
|
write_results(tmp_path, "b.go", [_form("demo", "Stop", "b.go", "hash-b")], {})
|
|
write_results(tmp_path, "a.go", [], {})
|
|
kept = loads(snapshot_path(tmp_path, "demo").read_text(encoding="utf-8"))
|
|
assert kept["source"] == "b.go"
|
|
assert kept["forms"][0]["id"] == "defn/Stop"
|
|
|
|
from mutator.edn import dumps, keyword
|
|
from mutator.functions import mutation_id
|
|
|
|
other = tmp_path / ".metrics" / "mutate" / "other.edn"
|
|
other.write_text(
|
|
dumps({"namespace": "other", "source": "c.go", "forms": [{"id": "defn/Stay", "file": "c.go", "line": 1, "hash": "h"}], "outcomes": {}})
|
|
+ "\n",
|
|
encoding="utf-8",
|
|
)
|
|
foreign = mutation_id("b.go", "demo", "defn/Stop", 1, 2, "+", "-")
|
|
snapshot_path(tmp_path, "demo").write_text(
|
|
dumps(
|
|
{
|
|
"namespace": "demo",
|
|
"source": "a.go",
|
|
"forms": [
|
|
{"id": "defn/m", "file": "b.go", "hash": "m"},
|
|
{"id": "defn/a", "file": "b.go", "line": 1, "hash": "a"},
|
|
],
|
|
"outcomes": {foreign: keyword("killed")},
|
|
}
|
|
)
|
|
+ "\n",
|
|
encoding="utf-8",
|
|
)
|
|
write_results(tmp_path, "a.go", [], {})
|
|
rewritten = loads(snapshot_path(tmp_path, "demo").read_text(encoding="utf-8"))
|
|
assert [form["id"] for form in rewritten["forms"]] == ["defn/m", "defn/a"]
|
|
assert foreign in rewritten["outcomes"]
|
|
assert other.exists()
|
|
|
|
odd = tmp_path / ".metrics" / "mutate" / "odd.edn"
|
|
odd.write_text(
|
|
dumps({"namespace": "odd", "forms": [{"id": 1, "hash": "digest", "file": "a.go"}], "outcomes": {}}) + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
assert load_history(tmp_path, "a.go").forms == {}
|
|
|
|
base = tmp_path / "empty-metrics"
|
|
nested = base / "empty" / "nested"
|
|
nested.mkdir(parents=True)
|
|
full = base / "full"
|
|
full.mkdir()
|
|
(full / "keep.edn").write_text("x", encoding="utf-8")
|
|
_remove_empty_dirs(base)
|
|
assert not nested.exists()
|
|
assert not (base / "empty").exists()
|
|
assert (full / "keep.edn").is_file()
|
|
_remove_empty_dirs(tmp_path / "missing-metrics")
|