jitcatch 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- jitcatch/__init__.py +2 -0
- jitcatch/__main__.py +5 -0
- jitcatch/adapters/__init__.py +36 -0
- jitcatch/adapters/base.py +137 -0
- jitcatch/adapters/golang.py +88 -0
- jitcatch/adapters/java.py +136 -0
- jitcatch/adapters/javascript.py +165 -0
- jitcatch/adapters/python.py +187 -0
- jitcatch/assessors/__init__.py +94 -0
- jitcatch/assessors/failure_parse.py +126 -0
- jitcatch/assessors/llm_judge.py +75 -0
- jitcatch/assessors/rules.py +343 -0
- jitcatch/cli.py +338 -0
- jitcatch/config.py +259 -0
- jitcatch/engine.py +521 -0
- jitcatch/github.py +90 -0
- jitcatch/gitutil.py +231 -0
- jitcatch/hook.py +98 -0
- jitcatch/jsonutil.py +87 -0
- jitcatch/llm/__init__.py +21 -0
- jitcatch/llm/base.py +100 -0
- jitcatch/llm/providers.py +127 -0
- jitcatch/models.py +150 -0
- jitcatch/prompts.py +191 -0
- jitcatch/report.py +163 -0
- jitcatch/targeting.py +44 -0
- jitcatch/templates.py +76 -0
- jitcatch/userconfig.py +188 -0
- jitcatch-0.2.0.dist-info/METADATA +164 -0
- jitcatch-0.2.0.dist-info/RECORD +34 -0
- jitcatch-0.2.0.dist-info/WHEEL +5 -0
- jitcatch-0.2.0.dist-info/entry_points.txt +2 -0
- jitcatch-0.2.0.dist-info/licenses/LICENSE +21 -0
- jitcatch-0.2.0.dist-info/top_level.txt +1 -0
jitcatch/__init__.py
ADDED
jitcatch/__main__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .base import Adapter, TestPlacement
|
|
7
|
+
from .golang import GoAdapter
|
|
8
|
+
from .java import JavaAdapter
|
|
9
|
+
from .javascript import TypeScriptAdapter
|
|
10
|
+
from .python import PythonAdapter
|
|
11
|
+
|
|
12
|
+
ADAPTER_CLASSES: dict[str, type[Adapter]] = {
|
|
13
|
+
"python": PythonAdapter,
|
|
14
|
+
"typescript": TypeScriptAdapter,
|
|
15
|
+
"java": JavaAdapter,
|
|
16
|
+
"go": GoAdapter,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def build_adapters(settings: dict[str, Any], repo: Path) -> list[Adapter]:
|
|
21
|
+
out = []
|
|
22
|
+
for name, cls in ADAPTER_CLASSES.items():
|
|
23
|
+
s = settings.get(name, {})
|
|
24
|
+
if s.get("enabled", True):
|
|
25
|
+
out.append(cls(s, repo))
|
|
26
|
+
return out
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def adapter_for(adapters: list[Adapter], rel_path: str) -> Adapter | None:
|
|
30
|
+
for a in adapters:
|
|
31
|
+
if a.handles(rel_path):
|
|
32
|
+
return a
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
__all__ = ["Adapter", "TestPlacement", "build_adapters", "adapter_for", "ADAPTER_CLASSES"]
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Language adapter interface: where generated tests go, how they run, and how outcomes are classified."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from ..models import Outcome, TestRun
|
|
13
|
+
|
|
14
|
+
SKIP_DIRS = {".git", ".jitcatch", "node_modules", ".venv", "venv", "target", "build", "dist", "__pycache__", "vendor"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class TestPlacement:
|
|
19
|
+
__test__ = False
|
|
20
|
+
rel_path: str # repo-relative path of the generated test file
|
|
21
|
+
instructions: str # language/framework-specific constraints for the LLM
|
|
22
|
+
test_name_prefix: str = "" # e.g. Go test function prefix used to select the test
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Adapter:
|
|
26
|
+
name: str = "base"
|
|
27
|
+
fence_langs: tuple[str, ...] = ()
|
|
28
|
+
source_exts: tuple[str, ...] = ()
|
|
29
|
+
|
|
30
|
+
def __init__(self, settings: dict[str, Any], repo: Path):
|
|
31
|
+
self.settings = settings
|
|
32
|
+
self.repo = repo
|
|
33
|
+
|
|
34
|
+
# -- classification ---------------------------------------------------------------
|
|
35
|
+
def handles(self, rel_path: str) -> bool:
|
|
36
|
+
return rel_path.endswith(self.source_exts) and not self.is_test_file(rel_path)
|
|
37
|
+
|
|
38
|
+
def is_test_file(self, rel_path: str) -> bool:
|
|
39
|
+
raise NotImplementedError
|
|
40
|
+
|
|
41
|
+
# -- placement & prompting --------------------------------------------------------
|
|
42
|
+
def placement(self, source_rel: str, test_id: str, source_text: str) -> TestPlacement:
|
|
43
|
+
raise NotImplementedError
|
|
44
|
+
|
|
45
|
+
def related_test(self, source_rel: str) -> str | None:
|
|
46
|
+
"""Repo-relative path of an existing test that most likely tests `source_rel`."""
|
|
47
|
+
stem = Path(source_rel).stem.lower()
|
|
48
|
+
best: tuple[int, str] | None = None
|
|
49
|
+
src_dir = Path(source_rel).parent
|
|
50
|
+
for dirpath, dirnames, filenames in os.walk(self.repo):
|
|
51
|
+
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
|
|
52
|
+
for fn in filenames:
|
|
53
|
+
rel = str(Path(dirpath, fn).relative_to(self.repo))
|
|
54
|
+
if not self.is_test_file(rel) or "jitcatch" in fn.lower():
|
|
55
|
+
continue
|
|
56
|
+
name = fn.lower()
|
|
57
|
+
if stem and stem in name:
|
|
58
|
+
# prefer tests closest to the source file
|
|
59
|
+
distance = len(set(Path(rel).parent.parts) ^ set(src_dir.parts))
|
|
60
|
+
score = distance
|
|
61
|
+
if best is None or score < best[0]:
|
|
62
|
+
best = (score, rel)
|
|
63
|
+
return best[1] if best else None
|
|
64
|
+
|
|
65
|
+
def any_test_example(self) -> str | None:
|
|
66
|
+
for dirpath, dirnames, filenames in os.walk(self.repo):
|
|
67
|
+
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
|
|
68
|
+
for fn in sorted(filenames):
|
|
69
|
+
rel = str(Path(dirpath, fn).relative_to(self.repo))
|
|
70
|
+
if self.is_test_file(rel) and "jitcatch" not in fn.lower():
|
|
71
|
+
return rel
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
# -- execution --------------------------------------------------------------------
|
|
75
|
+
def run_test(self, worktree: Path, test_rel: str, placement: TestPlacement, timeout: float) -> TestRun:
|
|
76
|
+
raise NotImplementedError
|
|
77
|
+
|
|
78
|
+
def run_existing_tests(self, worktree: Path, source_rel: str, timeout: float) -> TestRun | None:
|
|
79
|
+
"""Run the project's existing tests relevant to `source_rel`. None = unsupported."""
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
def available(self) -> tuple[bool, str]:
|
|
83
|
+
"""Is the toolchain usable? Returns (ok, reason)."""
|
|
84
|
+
return True, ""
|
|
85
|
+
|
|
86
|
+
def can_test(self, rel_path: str) -> tuple[bool, str]:
|
|
87
|
+
"""Can this particular file be tested (e.g. its package has a supported runner)? Returns (ok, reason)."""
|
|
88
|
+
return True, ""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# -- helpers ------------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
def run_cmd(cmd: list[str], cwd: Path, timeout: float, env_extra: dict[str, str] | None = None) -> tuple[int, str, float, bool]:
|
|
94
|
+
"""Run a command; returns (exit_code, combined_output, seconds, timed_out)."""
|
|
95
|
+
env = dict(os.environ)
|
|
96
|
+
env.update({"CI": "1", "NO_COLOR": "1", "FORCE_COLOR": "0", "PYTHONDONTWRITEBYTECODE": "1"})
|
|
97
|
+
if env_extra:
|
|
98
|
+
env.update(env_extra)
|
|
99
|
+
t0 = time.monotonic()
|
|
100
|
+
try:
|
|
101
|
+
proc = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, encoding="utf-8",
|
|
102
|
+
errors="replace", timeout=timeout, env=env)
|
|
103
|
+
out = (proc.stdout or "") + ("\n" + proc.stderr if proc.stderr else "")
|
|
104
|
+
return proc.returncode, strip_ansi(out), time.monotonic() - t0, False
|
|
105
|
+
except subprocess.TimeoutExpired as e:
|
|
106
|
+
out = (e.stdout or b"")
|
|
107
|
+
out = out.decode("utf-8", "replace") if isinstance(out, bytes) else out
|
|
108
|
+
return -1, strip_ansi(out) + f"\n[jitcatch] TIMEOUT after {timeout}s", time.monotonic() - t0, True
|
|
109
|
+
except FileNotFoundError as e:
|
|
110
|
+
return -2, f"[jitcatch] command not found: {e}", time.monotonic() - t0, False
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
_ANSI = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def strip_ansi(s: str) -> str:
|
|
117
|
+
return _ANSI.sub("", s)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def make_run(outcome: Outcome, code: int, out: str, secs: float, cmd: list[str]) -> TestRun:
|
|
121
|
+
return TestRun(outcome=outcome, exit_code=code, output=truncate_output(out), duration_s=round(secs, 2), command=cmd)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def truncate_output(out: str, head: int = 3000, tail: int = 12000) -> str:
|
|
125
|
+
if len(out) <= head + tail:
|
|
126
|
+
return out
|
|
127
|
+
return out[:head] + f"\n... [{len(out) - head - tail} chars truncated] ...\n" + out[-tail:]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def nearest_dir_with(start: Path, stop: Path, names: tuple[str, ...]) -> Path | None:
|
|
131
|
+
d = start
|
|
132
|
+
while True:
|
|
133
|
+
if any((d / n).exists() for n in names):
|
|
134
|
+
return d
|
|
135
|
+
if d == stop or d.parent == d:
|
|
136
|
+
return None
|
|
137
|
+
d = d.parent
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Go adapter (go test)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path, PurePosixPath
|
|
7
|
+
|
|
8
|
+
from ..models import Outcome, TestRun
|
|
9
|
+
from .base import Adapter, TestPlacement, make_run, nearest_dir_with, run_cmd
|
|
10
|
+
|
|
11
|
+
_PKG_RE = re.compile(r"^\s*package\s+(\w+)", re.M)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class GoAdapter(Adapter):
|
|
15
|
+
name = "go"
|
|
16
|
+
fence_langs = ("go", "golang")
|
|
17
|
+
source_exts = (".go",)
|
|
18
|
+
|
|
19
|
+
def is_test_file(self, rel_path: str) -> bool:
|
|
20
|
+
return rel_path.endswith("_test.go")
|
|
21
|
+
|
|
22
|
+
def handles(self, rel_path: str) -> bool:
|
|
23
|
+
return super().handles(rel_path) and "/vendor/" not in f"/{rel_path}" and "/testdata/" not in f"/{rel_path}"
|
|
24
|
+
|
|
25
|
+
def related_test(self, source_rel: str) -> str | None:
|
|
26
|
+
p = PurePosixPath(source_rel)
|
|
27
|
+
cand = p.with_name(p.stem + "_test.go")
|
|
28
|
+
if (self.repo / cand).exists():
|
|
29
|
+
return str(cand)
|
|
30
|
+
for f in sorted((self.repo / p.parent).glob("*_test.go")):
|
|
31
|
+
if "jitcatch" not in f.name:
|
|
32
|
+
return str(f.relative_to(self.repo))
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
def available(self) -> tuple[bool, str]:
|
|
36
|
+
code, _, _, _ = run_cmd(["go", "version"], self.repo, 60)
|
|
37
|
+
return (code == 0, "" if code == 0 else "go not available")
|
|
38
|
+
|
|
39
|
+
def placement(self, source_rel: str, test_id: str, source_text: str) -> TestPlacement:
|
|
40
|
+
p = PurePosixPath(source_rel)
|
|
41
|
+
m = _PKG_RE.search(source_text)
|
|
42
|
+
pkg = m.group(1) if m else "main"
|
|
43
|
+
rel = str(p.parent / f"{p.stem}_jitcatch_{test_id}_test.go")
|
|
44
|
+
prefix = f"TestJitcatch{test_id.upper()}"
|
|
45
|
+
instr = (
|
|
46
|
+
f"Framework: Go's standard `testing` package. The file must start with `package {pkg}` (same package, "
|
|
47
|
+
f"so unexported identifiers are accessible) and every test function name must start with `{prefix}` "
|
|
48
|
+
f"(e.g. `func {prefix}Something(t *testing.T)`). Only import the standard library and modules already "
|
|
49
|
+
"in go.mod. Do not use network, sleeps, randomness or time.Now; avoid reflect."
|
|
50
|
+
)
|
|
51
|
+
return TestPlacement(rel_path=rel, instructions=instr, test_name_prefix=prefix)
|
|
52
|
+
|
|
53
|
+
def _module(self, base: Path, rel: str) -> Path:
|
|
54
|
+
return nearest_dir_with((base / rel).parent, base, ("go.mod",)) or base
|
|
55
|
+
|
|
56
|
+
def run_test(self, worktree: Path, test_rel: str, placement: TestPlacement, timeout: float) -> TestRun:
|
|
57
|
+
mod = self._module(worktree, test_rel)
|
|
58
|
+
pkg_dir = os.path.relpath(worktree / PurePosixPath(test_rel).parent, mod)
|
|
59
|
+
target = "./" + pkg_dir.replace(os.sep, "/") if pkg_dir != "." else "."
|
|
60
|
+
cmd = ["go", "test", target, "-run", f"^{placement.test_name_prefix}", "-count=1", "-v",
|
|
61
|
+
*self.settings.get("extra_args", [])]
|
|
62
|
+
code, out, secs, timed_out = run_cmd(cmd, mod, timeout)
|
|
63
|
+
return make_run(self._classify(code, out, timed_out), code, out, secs, cmd)
|
|
64
|
+
|
|
65
|
+
def run_existing_tests(self, worktree: Path, source_rel: str, timeout: float) -> TestRun | None:
|
|
66
|
+
mod = self._module(worktree, source_rel)
|
|
67
|
+
pkg_dir = os.path.relpath(worktree / PurePosixPath(source_rel).parent, mod)
|
|
68
|
+
target = "./" + pkg_dir.replace(os.sep, "/") if pkg_dir != "." else "."
|
|
69
|
+
cmd = ["go", "test", target, "-count=1"]
|
|
70
|
+
code, out, secs, timed_out = run_cmd(cmd, mod, timeout)
|
|
71
|
+
outcome = Outcome.PASS if code == 0 else self._classify(code, out, timed_out)
|
|
72
|
+
return make_run(outcome, code, out, secs, cmd)
|
|
73
|
+
|
|
74
|
+
@staticmethod
|
|
75
|
+
def _classify(code: int, out: str, timed_out: bool) -> Outcome:
|
|
76
|
+
if timed_out or code == -2:
|
|
77
|
+
return Outcome.INFRA
|
|
78
|
+
if re.search(r"\[(build|setup) failed\]|^# \S+|cannot find package|no required module provides", out, re.M):
|
|
79
|
+
return Outcome.ERROR
|
|
80
|
+
if code == 0:
|
|
81
|
+
if "no tests to run" in out or "[no test files]" in out:
|
|
82
|
+
return Outcome.NO_TESTS
|
|
83
|
+
return Outcome.PASS
|
|
84
|
+
if re.search(r"^--- FAIL|^panic:|^FAIL\s", out, re.M):
|
|
85
|
+
return Outcome.FAIL
|
|
86
|
+
if re.search(r"go: (downloading|error loading)|dial tcp|proxy\.golang\.org", out):
|
|
87
|
+
return Outcome.INFRA
|
|
88
|
+
return Outcome.ERROR
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Java adapter for JUnit 4/5 via Maven or Gradle."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path, PurePosixPath
|
|
7
|
+
|
|
8
|
+
from ..models import Outcome, TestRun
|
|
9
|
+
from .base import Adapter, TestPlacement, make_run, nearest_dir_with, run_cmd
|
|
10
|
+
|
|
11
|
+
_PKG_RE = re.compile(r"^\s*package\s+([\w.]+)\s*;", re.M)
|
|
12
|
+
_BUILD_FILES = ("pom.xml", "build.gradle", "build.gradle.kts")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class JavaAdapter(Adapter):
|
|
16
|
+
name = "java"
|
|
17
|
+
fence_langs = ("java",)
|
|
18
|
+
source_exts = (".java",)
|
|
19
|
+
|
|
20
|
+
def is_test_file(self, rel_path: str) -> bool:
|
|
21
|
+
p = PurePosixPath(rel_path)
|
|
22
|
+
return "test" in p.parts or bool(re.search(r"(Test|Tests|IT)\.java$", p.name))
|
|
23
|
+
|
|
24
|
+
def handles(self, rel_path: str) -> bool:
|
|
25
|
+
return super().handles(rel_path) and "/src/main/java/" in f"/{rel_path}"
|
|
26
|
+
|
|
27
|
+
def module_dir(self, base: Path, rel: str) -> Path:
|
|
28
|
+
return nearest_dir_with((base / rel).parent, base, _BUILD_FILES) or base
|
|
29
|
+
|
|
30
|
+
def build_tool(self, module: Path) -> str:
|
|
31
|
+
forced = self.settings.get("build", "auto")
|
|
32
|
+
if forced in ("maven", "gradle"):
|
|
33
|
+
return forced
|
|
34
|
+
if (module / "pom.xml").exists():
|
|
35
|
+
return "maven"
|
|
36
|
+
if (module / "build.gradle").exists() or (module / "build.gradle.kts").exists():
|
|
37
|
+
return "gradle"
|
|
38
|
+
return "maven"
|
|
39
|
+
|
|
40
|
+
def junit_version(self, module: Path) -> int:
|
|
41
|
+
text = ""
|
|
42
|
+
for n in _BUILD_FILES:
|
|
43
|
+
p = module / n
|
|
44
|
+
if p.exists():
|
|
45
|
+
text += p.read_text(encoding="utf-8", errors="replace")
|
|
46
|
+
if module != self.repo:
|
|
47
|
+
for n in _BUILD_FILES:
|
|
48
|
+
p = self.repo / n
|
|
49
|
+
if p.exists():
|
|
50
|
+
text += p.read_text(encoding="utf-8", errors="replace")
|
|
51
|
+
if re.search(r"junit-jupiter|org\.junit\.jupiter|useJUnitPlatform", text):
|
|
52
|
+
return 5
|
|
53
|
+
if re.search(r"junit:junit|<artifactId>junit</artifactId>", text):
|
|
54
|
+
return 4
|
|
55
|
+
return 5
|
|
56
|
+
|
|
57
|
+
def placement(self, source_rel: str, test_id: str, source_text: str) -> TestPlacement:
|
|
58
|
+
src = PurePosixPath(source_rel)
|
|
59
|
+
m = _PKG_RE.search(source_text)
|
|
60
|
+
pkg = m.group(1) if m else ""
|
|
61
|
+
module_rel = PurePosixPath(os.path.relpath(self.module_dir(self.repo, source_rel), self.repo))
|
|
62
|
+
if str(module_rel) == ".":
|
|
63
|
+
module_rel = PurePosixPath("")
|
|
64
|
+
cls = f"{src.stem}JitcatchTest{test_id.upper()}"
|
|
65
|
+
rel = str(module_rel / "src/test/java" / PurePosixPath(*pkg.split(".")) / f"{cls}.java") if pkg else \
|
|
66
|
+
str(module_rel / "src/test/java" / f"{cls}.java")
|
|
67
|
+
junit = self.junit_version(self.module_dir(self.repo, source_rel))
|
|
68
|
+
fw = ("JUnit 5 (org.junit.jupiter.api.Test, org.junit.jupiter.api.Assertions.*)" if junit == 5
|
|
69
|
+
else "JUnit 4 (org.junit.Test, org.junit.Assert.*)")
|
|
70
|
+
instr = (
|
|
71
|
+
f"Framework: {fw}. The file must declare `package {pkg};` and a single public class named exactly "
|
|
72
|
+
f"`{cls}`. Only use libraries already on the test classpath (see the example test). Do not use "
|
|
73
|
+
"reflection, network, sleeps, randomness or the current time; test through the public API only."
|
|
74
|
+
)
|
|
75
|
+
return TestPlacement(rel_path=rel, instructions=instr, test_name_prefix=(f"{pkg}.{cls}" if pkg else cls))
|
|
76
|
+
|
|
77
|
+
def _wrapper(self, module: Path, tool: str) -> list[str]:
|
|
78
|
+
if tool == "maven":
|
|
79
|
+
for d in (module, self.repo):
|
|
80
|
+
if (d / "mvnw").exists():
|
|
81
|
+
return [str(d / "mvnw")]
|
|
82
|
+
return ["mvn"]
|
|
83
|
+
for d in (module, self.repo):
|
|
84
|
+
if (d / "gradlew").exists():
|
|
85
|
+
return [str(d / "gradlew")]
|
|
86
|
+
return ["gradle"]
|
|
87
|
+
|
|
88
|
+
def available(self) -> tuple[bool, str]:
|
|
89
|
+
code, _, _, _ = run_cmd(["java", "-version"], self.repo, 60)
|
|
90
|
+
return (code == 0, "" if code == 0 else "java not available")
|
|
91
|
+
|
|
92
|
+
def run_test(self, worktree: Path, test_rel: str, placement: TestPlacement, timeout: float) -> TestRun:
|
|
93
|
+
module = self.module_dir(worktree, test_rel)
|
|
94
|
+
tool = self.build_tool(module)
|
|
95
|
+
exe = self._wrapper(module, tool)
|
|
96
|
+
fqcn = placement.test_name_prefix
|
|
97
|
+
if tool == "maven":
|
|
98
|
+
cmd = [*exe, "-B", "--no-transfer-progress", "test", f"-Dtest={fqcn}",
|
|
99
|
+
"-Dsurefire.failIfNoSpecifiedTests=false", "-DfailIfNoTests=false", "-Djacoco.skip=true",
|
|
100
|
+
*self.settings.get("extra_args", [])]
|
|
101
|
+
else:
|
|
102
|
+
cmd = [*exe, "test", "--tests", fqcn, "--console=plain", "--no-daemon", *self.settings.get("extra_args", [])]
|
|
103
|
+
code, out, secs, timed_out = run_cmd(cmd, module, timeout)
|
|
104
|
+
return make_run(self._classify(code, out, timed_out), code, out, secs, cmd)
|
|
105
|
+
|
|
106
|
+
def run_existing_tests(self, worktree: Path, source_rel: str, timeout: float) -> TestRun | None:
|
|
107
|
+
module = self.module_dir(worktree, source_rel)
|
|
108
|
+
tool = self.build_tool(module)
|
|
109
|
+
exe = self._wrapper(module, tool)
|
|
110
|
+
if tool == "maven":
|
|
111
|
+
cmd = [*exe, "-B", "--no-transfer-progress", "test", "-Djacoco.skip=true"]
|
|
112
|
+
else:
|
|
113
|
+
cmd = [*exe, "test", "--console=plain", "--no-daemon"]
|
|
114
|
+
code, out, secs, timed_out = run_cmd(cmd, module, timeout)
|
|
115
|
+
outcome = Outcome.PASS if code == 0 else self._classify(code, out, timed_out)
|
|
116
|
+
return make_run(outcome, code, out, secs, cmd)
|
|
117
|
+
|
|
118
|
+
@staticmethod
|
|
119
|
+
def _classify(code: int, out: str, timed_out: bool) -> Outcome:
|
|
120
|
+
if timed_out or code == -2:
|
|
121
|
+
return Outcome.INFRA
|
|
122
|
+
if re.search(r"Could not resolve dependencies|Could not transfer artifact|Could not GET|"
|
|
123
|
+
r"Could not resolve all (files|dependencies)|Unable to access jarfile", out):
|
|
124
|
+
return Outcome.INFRA
|
|
125
|
+
if re.search(r"No tests found for given includes|No tests were executed|Tests run: 0,", out) and \
|
|
126
|
+
not re.search(r"Tests run: [1-9]", out):
|
|
127
|
+
return Outcome.NO_TESTS if code == 0 or "No tests" in out else Outcome.ERROR
|
|
128
|
+
if code == 0:
|
|
129
|
+
return Outcome.PASS
|
|
130
|
+
if re.search(r"Tests run: \d+, Failures: [1-9]|Tests run: \d+, Failures: \d+, Errors: [1-9]|"
|
|
131
|
+
r"\d+ tests? completed, \d+ failed|FAILED\s*$", out, re.M) and \
|
|
132
|
+
not re.search(r"COMPILATION ERROR|Compilation failed|compile\w*Java FAILED", out):
|
|
133
|
+
return Outcome.FAIL
|
|
134
|
+
if re.search(r"COMPILATION ERROR|Compilation failed|compile\w*Java FAILED|error: cannot find symbol", out):
|
|
135
|
+
return Outcome.ERROR
|
|
136
|
+
return Outcome.FAIL
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""TypeScript / JavaScript adapter for Jest and Vitest."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path, PurePosixPath
|
|
8
|
+
|
|
9
|
+
from ..models import Outcome, TestRun
|
|
10
|
+
from .base import SKIP_DIRS, Adapter, TestPlacement, make_run, nearest_dir_with, run_cmd
|
|
11
|
+
|
|
12
|
+
_TEST_RE = re.compile(r"\.(test|spec)\.[cm]?[jt]sx?$")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TypeScriptAdapter(Adapter):
|
|
16
|
+
name = "typescript"
|
|
17
|
+
fence_langs = ("typescript", "ts", "tsx", "javascript", "js", "jsx")
|
|
18
|
+
source_exts = (".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs")
|
|
19
|
+
|
|
20
|
+
def is_test_file(self, rel_path: str) -> bool:
|
|
21
|
+
p = PurePosixPath(rel_path)
|
|
22
|
+
return bool(_TEST_RE.search(p.name)) or "__tests__" in p.parts
|
|
23
|
+
|
|
24
|
+
def handles(self, rel_path: str) -> bool:
|
|
25
|
+
if not super().handles(rel_path) or rel_path.endswith(".d.ts"):
|
|
26
|
+
return False
|
|
27
|
+
n = PurePosixPath(rel_path).name
|
|
28
|
+
return not re.match(r"^(jest|vitest|vite|webpack|rollup|babel|eslint|prettier|tsup)\.config\.", n)
|
|
29
|
+
|
|
30
|
+
# -- package / runner discovery ----------------------------------------------------
|
|
31
|
+
def package_dir(self, base: Path, source_rel: str) -> Path:
|
|
32
|
+
d = nearest_dir_with((base / source_rel).parent, base, ("package.json",))
|
|
33
|
+
return d or base
|
|
34
|
+
|
|
35
|
+
def runner(self, pkg_dir: Path) -> str:
|
|
36
|
+
return self.detect_runner(pkg_dir) or "jest"
|
|
37
|
+
|
|
38
|
+
def detect_runner(self, pkg_dir: Path) -> str | None:
|
|
39
|
+
"""Jest or Vitest for this package (falling back to the repo root), or None if neither is set up."""
|
|
40
|
+
forced = self.settings.get("runner", "auto")
|
|
41
|
+
if forced in ("jest", "vitest"):
|
|
42
|
+
return forced
|
|
43
|
+
deps: dict[str, str] = {}
|
|
44
|
+
pj = pkg_dir / "package.json"
|
|
45
|
+
if pj.exists():
|
|
46
|
+
try:
|
|
47
|
+
data = json.loads(pj.read_text(encoding="utf-8"))
|
|
48
|
+
for k in ("dependencies", "devDependencies", "peerDependencies"):
|
|
49
|
+
deps.update(data.get(k) or {})
|
|
50
|
+
except (json.JSONDecodeError, OSError):
|
|
51
|
+
pass
|
|
52
|
+
names = {p.name for p in pkg_dir.iterdir()} if pkg_dir.is_dir() else set()
|
|
53
|
+
if "vitest" in deps or any(n.startswith("vitest.config.") for n in names):
|
|
54
|
+
return "vitest"
|
|
55
|
+
if "jest" in deps or any(n.startswith("jest.config.") for n in names):
|
|
56
|
+
return "jest"
|
|
57
|
+
# monorepo: look at the repository root as a fallback
|
|
58
|
+
if pkg_dir != self.repo:
|
|
59
|
+
return self.detect_runner(self.repo)
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
def available(self) -> tuple[bool, str]:
|
|
63
|
+
code, out, _, _ = run_cmd(["npx", "--version"], self.repo, 60)
|
|
64
|
+
if code != 0:
|
|
65
|
+
return False, "npx not available"
|
|
66
|
+
if self.settings.get("runner", "auto") in ("jest", "vitest"):
|
|
67
|
+
return True, ""
|
|
68
|
+
pkgs = self._package_dirs()
|
|
69
|
+
if not any(self.detect_runner(d) for d in pkgs):
|
|
70
|
+
return False, ("no Jest or Vitest found in any package.json (other runners, e.g. Playwright, "
|
|
71
|
+
"are not supported; set [adapters.typescript] runner to force one)")
|
|
72
|
+
return True, ""
|
|
73
|
+
|
|
74
|
+
def can_test(self, rel_path: str) -> tuple[bool, str]:
|
|
75
|
+
pkg = self.package_dir(self.repo, rel_path)
|
|
76
|
+
if self.detect_runner(pkg):
|
|
77
|
+
return True, ""
|
|
78
|
+
where = os.path.relpath(pkg, self.repo).replace(os.sep, "/")
|
|
79
|
+
return False, f"no Jest or Vitest configured for package '{where}'"
|
|
80
|
+
|
|
81
|
+
def _package_dirs(self) -> list[Path]:
|
|
82
|
+
out: list[Path] = []
|
|
83
|
+
for dirpath, dirnames, filenames in os.walk(self.repo):
|
|
84
|
+
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS and not d.startswith(".")]
|
|
85
|
+
if "package.json" in filenames:
|
|
86
|
+
out.append(Path(dirpath))
|
|
87
|
+
return out
|
|
88
|
+
|
|
89
|
+
# -- placement --------------------------------------------------------------------
|
|
90
|
+
def placement(self, source_rel: str, test_id: str, source_text: str) -> TestPlacement:
|
|
91
|
+
src = PurePosixPath(source_rel)
|
|
92
|
+
related = self.related_test(source_rel)
|
|
93
|
+
test_dir = PurePosixPath(related).parent if related else src.parent
|
|
94
|
+
ext = {".tsx": ".tsx", ".ts": ".ts", ".mts": ".mts", ".cts": ".cts", ".jsx": ".jsx",
|
|
95
|
+
".mjs": ".mjs", ".cjs": ".cjs"}.get(src.suffix, ".js")
|
|
96
|
+
rel = str(test_dir / f"{src.stem}.jitcatch-{test_id}.test{ext}")
|
|
97
|
+
runner = self.runner(self.package_dir(self.repo, source_rel))
|
|
98
|
+
rel_import = os.path.relpath(str(src.with_suffix("")), str(test_dir)).replace(os.sep, "/")
|
|
99
|
+
if not rel_import.startswith("."):
|
|
100
|
+
rel_import = "./" + rel_import
|
|
101
|
+
if runner == "vitest":
|
|
102
|
+
fw = ("Framework: Vitest. Import test helpers explicitly: "
|
|
103
|
+
"`import { describe, it, expect, vi } from 'vitest'`.")
|
|
104
|
+
else:
|
|
105
|
+
fw = ("Framework: Jest. Use the Jest globals (describe/it/expect/jest) — mirror the example test's "
|
|
106
|
+
"imports (e.g. '@jest/globals') if it has any.")
|
|
107
|
+
instr = (
|
|
108
|
+
f"{fw} The test file is located at `{rel}`; import the module under test with the relative path "
|
|
109
|
+
f"'{rel_import}' (keep the import style and extension conventions of the example test if shown). "
|
|
110
|
+
"Do not use network, timers, randomness or the current date. Do not access private/#private members."
|
|
111
|
+
)
|
|
112
|
+
return TestPlacement(rel_path=rel, instructions=instr)
|
|
113
|
+
|
|
114
|
+
# -- execution --------------------------------------------------------------------
|
|
115
|
+
def _cmd(self, runner: str, rel_to_pkg: str | None) -> list[str]:
|
|
116
|
+
extra = list(self.settings.get("extra_args", []))
|
|
117
|
+
if runner == "vitest":
|
|
118
|
+
cmd = ["npx", "--no-install", "vitest", "run"]
|
|
119
|
+
if rel_to_pkg:
|
|
120
|
+
cmd += [rel_to_pkg]
|
|
121
|
+
return cmd + extra
|
|
122
|
+
cmd = ["npx", "--no-install", "jest", "--ci", "--colors=false"]
|
|
123
|
+
if rel_to_pkg:
|
|
124
|
+
cmd += ["--runTestsByPath", rel_to_pkg]
|
|
125
|
+
else:
|
|
126
|
+
cmd += ["--testPathIgnorePatterns", r"\.jitcatch-[0-9a-f]+\.test\."]
|
|
127
|
+
return cmd + extra
|
|
128
|
+
|
|
129
|
+
def run_test(self, worktree: Path, test_rel: str, placement: TestPlacement, timeout: float) -> TestRun:
|
|
130
|
+
pkg = self.package_dir(worktree, test_rel)
|
|
131
|
+
runner = self.runner(pkg)
|
|
132
|
+
rel_to_pkg = os.path.relpath(worktree / test_rel, pkg).replace(os.sep, "/")
|
|
133
|
+
cmd = self._cmd(runner, rel_to_pkg)
|
|
134
|
+
code, out, secs, timed_out = run_cmd(cmd, pkg, timeout)
|
|
135
|
+
return make_run(self._classify(code, out, timed_out), code, out, secs, cmd)
|
|
136
|
+
|
|
137
|
+
def run_existing_tests(self, worktree: Path, source_rel: str, timeout: float) -> TestRun | None:
|
|
138
|
+
pkg = self.package_dir(worktree, source_rel)
|
|
139
|
+
runner = self.runner(pkg)
|
|
140
|
+
cmd = self._cmd(runner, None)
|
|
141
|
+
if runner == "vitest":
|
|
142
|
+
cmd += ["--exclude", "**/*.jitcatch-*.test.*", "--passWithNoTests"]
|
|
143
|
+
else:
|
|
144
|
+
cmd += ["--passWithNoTests"]
|
|
145
|
+
code, out, secs, timed_out = run_cmd(cmd, pkg, timeout)
|
|
146
|
+
return make_run(self._classify(code, out, timed_out), code, out, secs, cmd)
|
|
147
|
+
|
|
148
|
+
@staticmethod
|
|
149
|
+
def _classify(code: int, out: str, timed_out: bool) -> Outcome:
|
|
150
|
+
if timed_out or code == -2:
|
|
151
|
+
return Outcome.INFRA
|
|
152
|
+
if re.search(r"could not determine executable to run|npm ERR! could not|command not found|"
|
|
153
|
+
r"Cannot find module '(jest|vitest)", out):
|
|
154
|
+
return Outcome.INFRA
|
|
155
|
+
if re.search(r"No tests found|No test files found|no tests? (were )?found", out, re.I):
|
|
156
|
+
return Outcome.NO_TESTS
|
|
157
|
+
if code == 0:
|
|
158
|
+
return Outcome.PASS
|
|
159
|
+
# suite-level failures: compile / import / transform errors before any test executed
|
|
160
|
+
if re.search(r"Test suite failed to run|Failed to load url|Transform failed|"
|
|
161
|
+
r"error TS\d+|SyntaxError:|Failed to resolve import|Cannot find module|"
|
|
162
|
+
r"FAIL\s+\S+\s+\[\s*\S+\s*\]", out):
|
|
163
|
+
if not re.search(r"(✕|×|●\s.+›|AssertionError|expect\()", out):
|
|
164
|
+
return Outcome.ERROR
|
|
165
|
+
return Outcome.FAIL
|