testless 0.1.5__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.
- testless/__init__.py +3 -0
- testless/analyze/__init__.py +0 -0
- testless/analyze/dead_tests.py +44 -0
- testless/analyze/duplicate_tests.py +102 -0
- testless/analyze/missing_tests.py +98 -0
- testless/analyze/refactor_candidates.py +87 -0
- testless/analyze/service_risk.py +41 -0
- testless/cli.py +306 -0
- testless/collect/__init__.py +0 -0
- testless/collect/coverage_loader.py +45 -0
- testless/collect/endpoint_inventory.py +106 -0
- testless/collect/fixture_index.py +72 -0
- testless/collect/pytest_runner.py +76 -0
- testless/config.py +56 -0
- testless/models/__init__.py +0 -0
- testless/models/coverage_map.py +58 -0
- testless/models/findings.py +69 -0
- testless/models/planfile.py +70 -0
- testless/reporters/__init__.py +0 -0
- testless/reporters/console.py +60 -0
- testless/reporters/json.py +19 -0
- testless/reporters/markdown.py +58 -0
- testless/suggest/__init__.py +0 -0
- testless/suggest/e2e.py +42 -0
- testless/suggest/service_tests.py +35 -0
- testless/suggest/smoke.py +42 -0
- testless/suggest/testql.py +43 -0
- testless/tickets/__init__.py +0 -0
- testless/tickets/builder.py +186 -0
- testless/tickets/prompts.py +42 -0
- testless/tickets/serializer.py +33 -0
- testless-0.1.5.dist-info/METADATA +185 -0
- testless-0.1.5.dist-info/RECORD +37 -0
- testless-0.1.5.dist-info/WHEEL +5 -0
- testless-0.1.5.dist-info/entry_points.txt +2 -0
- testless-0.1.5.dist-info/licenses/LICENSE +201 -0
- testless-0.1.5.dist-info/top_level.txt +1 -0
testless/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Detect tests that contribute no unique coverage."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from testless.models.coverage_map import CoverageMap
|
|
6
|
+
from testless.models.findings import DeadTestFinding, TestMeta
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def find_dead_tests(
|
|
10
|
+
tests: list[TestMeta],
|
|
11
|
+
cov_map: CoverageMap,
|
|
12
|
+
min_unique_lines: int = 1,
|
|
13
|
+
) -> list[DeadTestFinding]:
|
|
14
|
+
"""
|
|
15
|
+
Return tests whose unique coverage falls below *min_unique_lines*.
|
|
16
|
+
|
|
17
|
+
A test is considered "dead" if every line it touches is already covered
|
|
18
|
+
by at least one other test.
|
|
19
|
+
"""
|
|
20
|
+
all_node_ids = [t.node_id for t in tests]
|
|
21
|
+
findings: list[DeadTestFinding] = []
|
|
22
|
+
|
|
23
|
+
for test in tests:
|
|
24
|
+
total_lines = 0
|
|
25
|
+
unique_lines = 0
|
|
26
|
+
|
|
27
|
+
for fc in cov_map.files.values():
|
|
28
|
+
covered = fc.lines_covered_by(test.node_id)
|
|
29
|
+
unique = fc.unique_lines_for(test.node_id, all_node_ids)
|
|
30
|
+
total_lines += len(covered)
|
|
31
|
+
unique_lines += len(unique)
|
|
32
|
+
|
|
33
|
+
if total_lines > 0 and unique_lines < min_unique_lines:
|
|
34
|
+
findings.append(
|
|
35
|
+
DeadTestFinding(
|
|
36
|
+
node_id=test.node_id,
|
|
37
|
+
file=test.file,
|
|
38
|
+
unique_lines=unique_lines,
|
|
39
|
+
total_lines=total_lines,
|
|
40
|
+
reason="no unique coverage",
|
|
41
|
+
)
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
return findings
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Detect duplicate tests using coverage overlap, AST similarity, fixtures and names."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import difflib
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from testless.models.coverage_map import CoverageMap
|
|
10
|
+
from testless.models.findings import DuplicateFinding, TestMeta
|
|
11
|
+
from testless.collect.fixture_index import FixtureIndex
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _ast_similarity(file_a: str, name_a: str, file_b: str, name_b: str) -> float:
|
|
15
|
+
"""Compute normalized edit-distance similarity between two test function bodies."""
|
|
16
|
+
|
|
17
|
+
def _get_source(file: str, name: str) -> str:
|
|
18
|
+
path = Path(file)
|
|
19
|
+
if not path.exists():
|
|
20
|
+
return ""
|
|
21
|
+
try:
|
|
22
|
+
source = path.read_text(encoding="utf-8")
|
|
23
|
+
tree = ast.parse(source)
|
|
24
|
+
except (SyntaxError, UnicodeDecodeError):
|
|
25
|
+
return ""
|
|
26
|
+
for node in ast.walk(tree):
|
|
27
|
+
if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef) and node.name == name:
|
|
28
|
+
return ast.unparse(node)
|
|
29
|
+
return ""
|
|
30
|
+
|
|
31
|
+
src_a = _get_source(file_a, name_a)
|
|
32
|
+
src_b = _get_source(file_b, name_b)
|
|
33
|
+
if not src_a or not src_b:
|
|
34
|
+
return 0.0
|
|
35
|
+
return difflib.SequenceMatcher(None, src_a, src_b).ratio()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _name_similarity(name_a: str, name_b: str) -> float:
|
|
39
|
+
"""Normalized edit-distance similarity between two test names."""
|
|
40
|
+
return difflib.SequenceMatcher(None, name_a, name_b).ratio()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _duplicate_score(
|
|
44
|
+
cov_overlap: float,
|
|
45
|
+
ast_sim: float,
|
|
46
|
+
fix_overlap: float,
|
|
47
|
+
name_sim: float,
|
|
48
|
+
) -> float:
|
|
49
|
+
return (
|
|
50
|
+
0.45 * cov_overlap
|
|
51
|
+
+ 0.25 * ast_sim
|
|
52
|
+
+ 0.15 * fix_overlap
|
|
53
|
+
+ 0.15 * name_sim
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def find_duplicates(
|
|
58
|
+
tests: list[TestMeta],
|
|
59
|
+
cov_map: CoverageMap,
|
|
60
|
+
fixture_index: FixtureIndex,
|
|
61
|
+
min_score: float = 0.85,
|
|
62
|
+
) -> list[DuplicateFinding]:
|
|
63
|
+
"""Return pairs of tests that are likely duplicates."""
|
|
64
|
+
findings: list[DuplicateFinding] = []
|
|
65
|
+
seen: set[frozenset[str]] = set()
|
|
66
|
+
|
|
67
|
+
for i, test_a in enumerate(tests):
|
|
68
|
+
for test_b in tests[i + 1 :]:
|
|
69
|
+
pair = frozenset({test_a.node_id, test_b.node_id})
|
|
70
|
+
if pair in seen:
|
|
71
|
+
continue
|
|
72
|
+
seen.add(pair)
|
|
73
|
+
|
|
74
|
+
cov_overlap = cov_map.overlap(test_a.node_id, test_b.node_id)
|
|
75
|
+
fix_overlap = fixture_index.fixture_overlap(test_a.node_id, test_b.node_id)
|
|
76
|
+
|
|
77
|
+
# Derive file and function name from node_id
|
|
78
|
+
parts_a = test_a.node_id.split("::")
|
|
79
|
+
parts_b = test_b.node_id.split("::")
|
|
80
|
+
file_a = parts_a[0] if parts_a else test_a.file
|
|
81
|
+
file_b = parts_b[0] if parts_b else test_b.file
|
|
82
|
+
name_a = parts_a[-1] if parts_a else test_a.name
|
|
83
|
+
name_b = parts_b[-1] if parts_b else test_b.name
|
|
84
|
+
|
|
85
|
+
ast_sim = _ast_similarity(file_a, name_a, file_b, name_b)
|
|
86
|
+
name_sim = _name_similarity(name_a, name_b)
|
|
87
|
+
|
|
88
|
+
score = _duplicate_score(cov_overlap, ast_sim, fix_overlap, name_sim)
|
|
89
|
+
if score >= min_score:
|
|
90
|
+
findings.append(
|
|
91
|
+
DuplicateFinding(
|
|
92
|
+
test_a=test_a.node_id,
|
|
93
|
+
test_b=test_b.node_id,
|
|
94
|
+
score=round(score, 3),
|
|
95
|
+
coverage_overlap=round(cov_overlap, 3),
|
|
96
|
+
ast_similarity=round(ast_sim, 3),
|
|
97
|
+
fixture_overlap=round(fix_overlap, 3),
|
|
98
|
+
name_similarity=round(name_sim, 3),
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
return sorted(findings, key=lambda f: f.score, reverse=True)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Suggest missing tests based on endpoints, services, and modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from testless.collect.endpoint_inventory import EndpointInfo, ServiceInfo
|
|
6
|
+
from testless.models.findings import MissingTestFinding, TestMeta
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
# Endpoint paths that should always have smoke test coverage
|
|
10
|
+
_CRITICAL_PATHS = {"/health", "/login", "/logout", "/search", "/checkout", "/billing", "/register"}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _has_smoke_test(endpoint: EndpointInfo, tests: list[TestMeta]) -> bool:
|
|
14
|
+
"""Heuristic: check if any test name references the endpoint path."""
|
|
15
|
+
slug = endpoint.path.strip("/").replace("/", "_").replace("-", "_")
|
|
16
|
+
for test in tests:
|
|
17
|
+
if slug.lower() in test.name.lower() or slug.lower() in test.file.lower():
|
|
18
|
+
return True
|
|
19
|
+
return False
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _has_service_test(service: ServiceInfo, tests: list[TestMeta]) -> bool:
|
|
23
|
+
"""Heuristic: check if any test imports or references the service module."""
|
|
24
|
+
module_slug = service.module.split(".")[-1]
|
|
25
|
+
for test in tests:
|
|
26
|
+
if module_slug.lower() in test.name.lower() or module_slug.lower() in test.file.lower():
|
|
27
|
+
return True
|
|
28
|
+
return False
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def find_missing_tests(
|
|
32
|
+
tests: list[TestMeta],
|
|
33
|
+
endpoints: list[EndpointInfo],
|
|
34
|
+
services: list[ServiceInfo],
|
|
35
|
+
) -> list[MissingTestFinding]:
|
|
36
|
+
"""Return suggested missing tests based on inventory."""
|
|
37
|
+
findings: list[MissingTestFinding] = []
|
|
38
|
+
|
|
39
|
+
for endpoint in endpoints:
|
|
40
|
+
if not _has_smoke_test(endpoint, tests):
|
|
41
|
+
priority = "high" if endpoint.path in _CRITICAL_PATHS else "medium"
|
|
42
|
+
findings.append(
|
|
43
|
+
MissingTestFinding(
|
|
44
|
+
target=f"{endpoint.method} {endpoint.path}",
|
|
45
|
+
test_type="smoke",
|
|
46
|
+
description=(
|
|
47
|
+
f"No smoke test found for {endpoint.method} {endpoint.path} "
|
|
48
|
+
f"(handler: {endpoint.handler}). "
|
|
49
|
+
"Add tests for 2xx, 4xx, and 5xx responses."
|
|
50
|
+
),
|
|
51
|
+
priority=priority,
|
|
52
|
+
suggested_file=f"tests/smoke/test_{endpoint.handler}.py",
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
for service in services:
|
|
57
|
+
if not _has_service_test(service, tests):
|
|
58
|
+
if service.has_sql:
|
|
59
|
+
findings.append(
|
|
60
|
+
MissingTestFinding(
|
|
61
|
+
target=service.module,
|
|
62
|
+
test_type="testql",
|
|
63
|
+
description=(
|
|
64
|
+
f"Service {service.module} uses SQL but has no detected tests. "
|
|
65
|
+
"Consider adding TestQL contract tests for critical queries."
|
|
66
|
+
),
|
|
67
|
+
priority="high",
|
|
68
|
+
suggested_file=f"tests/service/test_{service.module.split('.')[-1]}.py",
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
if service.has_http_client:
|
|
72
|
+
findings.append(
|
|
73
|
+
MissingTestFinding(
|
|
74
|
+
target=service.module,
|
|
75
|
+
test_type="contract",
|
|
76
|
+
description=(
|
|
77
|
+
f"Service {service.module} makes HTTP calls but has no contract tests. "
|
|
78
|
+
"Add service contract tests for timeout and dependency failure scenarios."
|
|
79
|
+
),
|
|
80
|
+
priority="medium",
|
|
81
|
+
suggested_file=f"tests/service/test_{service.module.split('.')[-1]}_contract.py",
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
if service.has_retry:
|
|
85
|
+
findings.append(
|
|
86
|
+
MissingTestFinding(
|
|
87
|
+
target=service.module,
|
|
88
|
+
test_type="resilience",
|
|
89
|
+
description=(
|
|
90
|
+
f"Service {service.module} uses retry/backoff but has no resilience tests. "
|
|
91
|
+
"Add tests for circuit-breaker and retry-exhaustion scenarios."
|
|
92
|
+
),
|
|
93
|
+
priority="medium",
|
|
94
|
+
suggested_file=f"tests/service/test_{service.module.split('.')[-1]}_resilience.py",
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
return findings
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Identify tests that are good candidates for refactoring."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from testless.models.findings import RefactorFinding, TestMeta
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
_MAX_ASSERTIONS = 10
|
|
12
|
+
_MAX_DURATION_S = 5.0
|
|
13
|
+
_MAX_LINES = 80
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _count_assertions(file: str, func_name: str) -> int:
|
|
17
|
+
path = Path(file)
|
|
18
|
+
if not path.exists():
|
|
19
|
+
return 0
|
|
20
|
+
try:
|
|
21
|
+
source = path.read_text(encoding="utf-8")
|
|
22
|
+
tree = ast.parse(source)
|
|
23
|
+
except (SyntaxError, UnicodeDecodeError):
|
|
24
|
+
return 0
|
|
25
|
+
for node in ast.walk(tree):
|
|
26
|
+
if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef) and node.name == func_name:
|
|
27
|
+
return sum(
|
|
28
|
+
1
|
|
29
|
+
for child in ast.walk(node)
|
|
30
|
+
if isinstance(child, ast.Assert)
|
|
31
|
+
or (
|
|
32
|
+
isinstance(child, ast.Call)
|
|
33
|
+
and isinstance(child.func, ast.Attribute)
|
|
34
|
+
and child.func.attr.startswith("assert")
|
|
35
|
+
)
|
|
36
|
+
)
|
|
37
|
+
return 0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _count_lines(file: str, func_name: str) -> int:
|
|
41
|
+
path = Path(file)
|
|
42
|
+
if not path.exists():
|
|
43
|
+
return 0
|
|
44
|
+
try:
|
|
45
|
+
source = path.read_text(encoding="utf-8")
|
|
46
|
+
tree = ast.parse(source)
|
|
47
|
+
except (SyntaxError, UnicodeDecodeError):
|
|
48
|
+
return 0
|
|
49
|
+
for node in ast.walk(tree):
|
|
50
|
+
if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef) and node.name == func_name:
|
|
51
|
+
end = getattr(node, "end_lineno", node.lineno)
|
|
52
|
+
return end - node.lineno + 1
|
|
53
|
+
return 0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def find_refactor_candidates(tests: list[TestMeta]) -> list[RefactorFinding]:
|
|
57
|
+
"""Return tests that warrant refactoring."""
|
|
58
|
+
findings: list[RefactorFinding] = []
|
|
59
|
+
|
|
60
|
+
for test in tests:
|
|
61
|
+
parts = test.node_id.split("::")
|
|
62
|
+
file = parts[0] if parts else test.file
|
|
63
|
+
func_name = parts[-1] if len(parts) > 1 else test.name
|
|
64
|
+
|
|
65
|
+
reasons: list[str] = []
|
|
66
|
+
|
|
67
|
+
if test.duration > _MAX_DURATION_S:
|
|
68
|
+
reasons.append(f"slow test ({test.duration:.1f}s > {_MAX_DURATION_S}s threshold)")
|
|
69
|
+
|
|
70
|
+
assertions = _count_assertions(file, func_name)
|
|
71
|
+
if assertions > _MAX_ASSERTIONS:
|
|
72
|
+
reasons.append(f"too many assertions ({assertions} > {_MAX_ASSERTIONS})")
|
|
73
|
+
|
|
74
|
+
lines = _count_lines(file, func_name)
|
|
75
|
+
if lines > _MAX_LINES:
|
|
76
|
+
reasons.append(f"test function too long ({lines} lines > {_MAX_LINES})")
|
|
77
|
+
|
|
78
|
+
if reasons:
|
|
79
|
+
findings.append(
|
|
80
|
+
RefactorFinding(
|
|
81
|
+
node_id=test.node_id,
|
|
82
|
+
file=file,
|
|
83
|
+
reason="; ".join(reasons),
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
return findings
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Assess service risk based on missing tests and critical characteristics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from testless.collect.endpoint_inventory import ServiceInfo
|
|
6
|
+
from testless.models.findings import MissingTestFinding
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def score_service_risk(
|
|
10
|
+
service: ServiceInfo,
|
|
11
|
+
missing: list[MissingTestFinding],
|
|
12
|
+
) -> dict[str, object]:
|
|
13
|
+
"""
|
|
14
|
+
Return a risk assessment dict for a service.
|
|
15
|
+
|
|
16
|
+
Higher score = higher risk of undetected failures.
|
|
17
|
+
"""
|
|
18
|
+
risk_score = 0.0
|
|
19
|
+
reasons: list[str] = []
|
|
20
|
+
|
|
21
|
+
# Missing tests for this service
|
|
22
|
+
service_missing = [m for m in missing if m.target == service.module]
|
|
23
|
+
if service_missing:
|
|
24
|
+
risk_score += 0.4 * len(service_missing)
|
|
25
|
+
reasons.append(f"{len(service_missing)} missing test type(s)")
|
|
26
|
+
|
|
27
|
+
if service.has_sql:
|
|
28
|
+
risk_score += 0.3
|
|
29
|
+
reasons.append("uses SQL without verified query tests")
|
|
30
|
+
if service.has_http_client:
|
|
31
|
+
risk_score += 0.2
|
|
32
|
+
reasons.append("makes external HTTP calls")
|
|
33
|
+
if service.has_retry:
|
|
34
|
+
risk_score += 0.1
|
|
35
|
+
reasons.append("uses retry/backoff logic")
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
"module": service.module,
|
|
39
|
+
"risk_score": round(min(risk_score, 1.0), 2),
|
|
40
|
+
"reasons": reasons,
|
|
41
|
+
}
|
testless/cli.py
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""CLI entry-point for testless."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from testless.config import load_config
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.group()
|
|
14
|
+
@click.option("--config", "-c", default=None, help="Path to .testless.yml config file.")
|
|
15
|
+
@click.pass_context
|
|
16
|
+
def main(ctx: click.Context, config: str | None) -> None:
|
|
17
|
+
"""testless — analyze test value, coverage, duplication, and generate LLM planfiles."""
|
|
18
|
+
ctx.ensure_object(dict)
|
|
19
|
+
ctx.obj["config"] = load_config(config)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# pretest scan
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
@main.command()
|
|
27
|
+
@click.option("--out", default=None, help="Override coverage output directory.")
|
|
28
|
+
@click.pass_context
|
|
29
|
+
def scan(ctx: click.Context, out: str | None) -> None:
|
|
30
|
+
"""Run pytest with coverage contexts and collect test metadata."""
|
|
31
|
+
from testless.collect.pytest_runner import run_pytest
|
|
32
|
+
|
|
33
|
+
cfg = ctx.obj["config"]
|
|
34
|
+
coverage_dir = out or cfg.coverage_dir
|
|
35
|
+
|
|
36
|
+
click.echo(f"Running pytest with coverage contexts → {coverage_dir}")
|
|
37
|
+
tests, cov_json = run_pytest(
|
|
38
|
+
packages=cfg.packages,
|
|
39
|
+
test_dirs=cfg.test_dirs,
|
|
40
|
+
coverage_dir=coverage_dir,
|
|
41
|
+
extra_args=cfg.pytest_args,
|
|
42
|
+
)
|
|
43
|
+
click.echo(f"Collected {len(tests)} tests. Coverage JSON: {cov_json}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
# pretest duplicates
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
@main.command()
|
|
51
|
+
@click.option("--min-overlap", default=None, type=float, help="Minimum duplicate score (0–1).")
|
|
52
|
+
@click.option("--coverage-json", default=None, help="Path to coverage.json (default: auto-detect).")
|
|
53
|
+
@click.pass_context
|
|
54
|
+
def duplicates(ctx: click.Context, min_overlap: float | None, coverage_json: str | None) -> None:
|
|
55
|
+
"""Detect duplicate tests based on coverage overlap, AST similarity, and fixture use."""
|
|
56
|
+
from testless.collect.coverage_loader import load_coverage_json
|
|
57
|
+
from testless.collect.fixture_index import FixtureIndex
|
|
58
|
+
from testless.analyze.duplicate_tests import find_duplicates
|
|
59
|
+
from testless.reporters.console import print_report
|
|
60
|
+
from testless.models.findings import AnalysisReport, TestMeta
|
|
61
|
+
|
|
62
|
+
cfg = ctx.obj["config"]
|
|
63
|
+
min_score = min_overlap if min_overlap is not None else cfg.min_duplicate_score
|
|
64
|
+
|
|
65
|
+
# Load coverage
|
|
66
|
+
cov_path = coverage_json or str(Path(cfg.coverage_dir) / "coverage.json")
|
|
67
|
+
cov_map = load_coverage_json(cov_path)
|
|
68
|
+
|
|
69
|
+
# Build minimal test list from coverage map contexts
|
|
70
|
+
all_node_ids: set[str] = set()
|
|
71
|
+
for fc in cov_map.files.values():
|
|
72
|
+
for tests_list in fc.line_to_tests.values():
|
|
73
|
+
all_node_ids.update(tests_list)
|
|
74
|
+
|
|
75
|
+
tests = [
|
|
76
|
+
TestMeta(
|
|
77
|
+
node_id=nid,
|
|
78
|
+
file=nid.split("::")[0],
|
|
79
|
+
name=nid.split("::")[-1],
|
|
80
|
+
)
|
|
81
|
+
for nid in sorted(all_node_ids)
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
fixture_index = FixtureIndex()
|
|
85
|
+
for td in cfg.test_dirs:
|
|
86
|
+
if Path(td).exists():
|
|
87
|
+
fixture_index.scan_directory(td)
|
|
88
|
+
|
|
89
|
+
dups = find_duplicates(tests, cov_map, fixture_index, min_score=min_score)
|
|
90
|
+
|
|
91
|
+
report = AnalysisReport(duplicates=dups, tests=tests)
|
|
92
|
+
print_report(report)
|
|
93
|
+
|
|
94
|
+
if dups:
|
|
95
|
+
sys.exit(1)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
# pretest missing
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
@main.command()
|
|
103
|
+
@click.option(
|
|
104
|
+
"--services",
|
|
105
|
+
"-s",
|
|
106
|
+
multiple=True,
|
|
107
|
+
help="Source directories to scan for endpoints/services.",
|
|
108
|
+
)
|
|
109
|
+
@click.pass_context
|
|
110
|
+
def missing(ctx: click.Context, services: tuple[str, ...]) -> None:
|
|
111
|
+
"""Suggest missing smoke, e2e, contract, and TestQL tests."""
|
|
112
|
+
from testless.collect.endpoint_inventory import EndpointInventory
|
|
113
|
+
from testless.analyze.missing_tests import find_missing_tests
|
|
114
|
+
from testless.reporters.console import print_report
|
|
115
|
+
from testless.models.findings import AnalysisReport
|
|
116
|
+
|
|
117
|
+
cfg = ctx.obj["config"]
|
|
118
|
+
dirs = list(services) if services else cfg.packages or ["."]
|
|
119
|
+
|
|
120
|
+
inventory = EndpointInventory()
|
|
121
|
+
for d in dirs:
|
|
122
|
+
if Path(d).exists():
|
|
123
|
+
inventory.scan_directory(d)
|
|
124
|
+
else:
|
|
125
|
+
click.echo(f"Warning: directory not found: {d}", err=True)
|
|
126
|
+
|
|
127
|
+
missing_findings = find_missing_tests([], inventory.endpoints, inventory.services)
|
|
128
|
+
report = AnalysisReport(missing_tests=missing_findings)
|
|
129
|
+
print_report(report)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
# pretest planfiles
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
@main.command()
|
|
137
|
+
@click.option("--out", default=None, help="Output directory for planfile YAML files.")
|
|
138
|
+
@click.option("--coverage-json", default=None, help="Path to coverage.json.")
|
|
139
|
+
@click.option(
|
|
140
|
+
"--services",
|
|
141
|
+
"-s",
|
|
142
|
+
multiple=True,
|
|
143
|
+
help="Source directories to scan for endpoints/services.",
|
|
144
|
+
)
|
|
145
|
+
@click.option("--with-prompts", is_flag=True, default=False, help="Embed LLM system prompts.")
|
|
146
|
+
@click.pass_context
|
|
147
|
+
def planfiles(
|
|
148
|
+
ctx: click.Context,
|
|
149
|
+
out: str | None,
|
|
150
|
+
coverage_json: str | None,
|
|
151
|
+
services: tuple[str, ...],
|
|
152
|
+
with_prompts: bool,
|
|
153
|
+
) -> None:
|
|
154
|
+
"""Generate planfile YAML tickets for LLM from all findings."""
|
|
155
|
+
from testless.collect.coverage_loader import load_coverage_json
|
|
156
|
+
from testless.collect.fixture_index import FixtureIndex
|
|
157
|
+
from testless.collect.endpoint_inventory import EndpointInventory
|
|
158
|
+
from testless.analyze.duplicate_tests import find_duplicates
|
|
159
|
+
from testless.analyze.dead_tests import find_dead_tests
|
|
160
|
+
from testless.analyze.missing_tests import find_missing_tests
|
|
161
|
+
from testless.analyze.refactor_candidates import find_refactor_candidates
|
|
162
|
+
from testless.tickets.builder import build_planfiles
|
|
163
|
+
from testless.tickets.serializer import write_planfiles, write_summary_json
|
|
164
|
+
from testless.tickets.prompts import attach_prompt
|
|
165
|
+
from testless.models.findings import AnalysisReport, TestMeta
|
|
166
|
+
|
|
167
|
+
cfg = ctx.obj["config"]
|
|
168
|
+
output_dir = out or cfg.planfiles_dir
|
|
169
|
+
|
|
170
|
+
# Load coverage
|
|
171
|
+
cov_path = coverage_json or str(Path(cfg.coverage_dir) / "coverage.json")
|
|
172
|
+
cov_map = load_coverage_json(cov_path)
|
|
173
|
+
|
|
174
|
+
all_node_ids: set[str] = set()
|
|
175
|
+
for fc in cov_map.files.values():
|
|
176
|
+
for tests_list in fc.line_to_tests.values():
|
|
177
|
+
all_node_ids.update(tests_list)
|
|
178
|
+
|
|
179
|
+
tests = [
|
|
180
|
+
TestMeta(
|
|
181
|
+
node_id=nid,
|
|
182
|
+
file=nid.split("::")[0],
|
|
183
|
+
name=nid.split("::")[-1],
|
|
184
|
+
)
|
|
185
|
+
for nid in sorted(all_node_ids)
|
|
186
|
+
]
|
|
187
|
+
|
|
188
|
+
fixture_index = FixtureIndex()
|
|
189
|
+
for td in cfg.test_dirs:
|
|
190
|
+
if Path(td).exists():
|
|
191
|
+
fixture_index.scan_directory(td)
|
|
192
|
+
|
|
193
|
+
dirs = list(services) if services else cfg.packages or ["."]
|
|
194
|
+
inventory = EndpointInventory()
|
|
195
|
+
for d in dirs:
|
|
196
|
+
if Path(d).exists():
|
|
197
|
+
inventory.scan_directory(d)
|
|
198
|
+
|
|
199
|
+
report = AnalysisReport(
|
|
200
|
+
duplicates=find_duplicates(tests, cov_map, fixture_index, min_score=cfg.min_duplicate_score),
|
|
201
|
+
dead_tests=find_dead_tests(tests, cov_map),
|
|
202
|
+
missing_tests=find_missing_tests(tests, inventory.endpoints, inventory.services),
|
|
203
|
+
refactor_candidates=find_refactor_candidates(tests),
|
|
204
|
+
tests=tests,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
tickets = build_planfiles(report)
|
|
208
|
+
if with_prompts:
|
|
209
|
+
tickets = [attach_prompt(t) for t in tickets]
|
|
210
|
+
|
|
211
|
+
written = write_planfiles(tickets, output_dir)
|
|
212
|
+
write_summary_json(tickets, output_dir)
|
|
213
|
+
|
|
214
|
+
click.echo(f"Generated {len(written)} planfile(s) in {output_dir}/")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# ---------------------------------------------------------------------------
|
|
218
|
+
# pretest doctor
|
|
219
|
+
# ---------------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
@main.command()
|
|
222
|
+
@click.argument("question", required=False)
|
|
223
|
+
@click.option("--coverage-json", default=None, help="Path to coverage.json.")
|
|
224
|
+
@click.option(
|
|
225
|
+
"--services",
|
|
226
|
+
"-s",
|
|
227
|
+
multiple=True,
|
|
228
|
+
help="Source directories to scan for endpoints/services.",
|
|
229
|
+
)
|
|
230
|
+
@click.pass_context
|
|
231
|
+
def doctor(
|
|
232
|
+
ctx: click.Context,
|
|
233
|
+
question: str | None,
|
|
234
|
+
coverage_json: str | None,
|
|
235
|
+
services: tuple[str, ...],
|
|
236
|
+
) -> None:
|
|
237
|
+
"""Answer questions about test health (what to remove, add, or fix)."""
|
|
238
|
+
from testless.collect.coverage_loader import load_coverage_json
|
|
239
|
+
from testless.collect.fixture_index import FixtureIndex
|
|
240
|
+
from testless.collect.endpoint_inventory import EndpointInventory
|
|
241
|
+
from testless.analyze.duplicate_tests import find_duplicates
|
|
242
|
+
from testless.analyze.dead_tests import find_dead_tests
|
|
243
|
+
from testless.analyze.missing_tests import find_missing_tests
|
|
244
|
+
from testless.analyze.refactor_candidates import find_refactor_candidates
|
|
245
|
+
from testless.reporters.console import print_report
|
|
246
|
+
from testless.models.findings import AnalysisReport, TestMeta
|
|
247
|
+
|
|
248
|
+
cfg = ctx.obj["config"]
|
|
249
|
+
|
|
250
|
+
cov_path = coverage_json or str(Path(cfg.coverage_dir) / "coverage.json")
|
|
251
|
+
cov_map = load_coverage_json(cov_path)
|
|
252
|
+
|
|
253
|
+
all_node_ids: set[str] = set()
|
|
254
|
+
for fc in cov_map.files.values():
|
|
255
|
+
for tests_list in fc.line_to_tests.values():
|
|
256
|
+
all_node_ids.update(tests_list)
|
|
257
|
+
|
|
258
|
+
tests = [
|
|
259
|
+
TestMeta(
|
|
260
|
+
node_id=nid,
|
|
261
|
+
file=nid.split("::")[0],
|
|
262
|
+
name=nid.split("::")[-1],
|
|
263
|
+
)
|
|
264
|
+
for nid in sorted(all_node_ids)
|
|
265
|
+
]
|
|
266
|
+
|
|
267
|
+
fixture_index = FixtureIndex()
|
|
268
|
+
for td in cfg.test_dirs:
|
|
269
|
+
if Path(td).exists():
|
|
270
|
+
fixture_index.scan_directory(td)
|
|
271
|
+
|
|
272
|
+
dirs = list(services) if services else cfg.packages or ["."]
|
|
273
|
+
inventory = EndpointInventory()
|
|
274
|
+
for d in dirs:
|
|
275
|
+
if Path(d).exists():
|
|
276
|
+
inventory.scan_directory(d)
|
|
277
|
+
|
|
278
|
+
report = AnalysisReport(
|
|
279
|
+
duplicates=find_duplicates(tests, cov_map, fixture_index, min_score=cfg.min_duplicate_score),
|
|
280
|
+
dead_tests=find_dead_tests(tests, cov_map),
|
|
281
|
+
missing_tests=find_missing_tests(tests, inventory.endpoints, inventory.services),
|
|
282
|
+
refactor_candidates=find_refactor_candidates(tests),
|
|
283
|
+
tests=tests,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
if question:
|
|
287
|
+
q = question.lower()
|
|
288
|
+
click.echo(click.style(f"\nQuestion: {question}\n", bold=True))
|
|
289
|
+
if any(kw in q for kw in ("usuń", "remove", "delete", "usunąć")):
|
|
290
|
+
click.echo("Tests to consider removing:")
|
|
291
|
+
for d in report.dead_tests:
|
|
292
|
+
click.echo(f" • {d.node_id} — {d.reason}")
|
|
293
|
+
for dup in report.duplicates:
|
|
294
|
+
click.echo(f" • {dup.test_b} — duplicate of {dup.test_a} (score={dup.score:.2f})")
|
|
295
|
+
if not report.dead_tests and not report.duplicates:
|
|
296
|
+
click.echo(" Nothing to remove — suite looks clean.")
|
|
297
|
+
elif any(kw in q for kw in ("dodaj", "add", "missing", "brakuje")):
|
|
298
|
+
click.echo("Tests to consider adding:")
|
|
299
|
+
for m in report.missing_tests:
|
|
300
|
+
click.echo(f" • [{m.test_type}] {m.target}: {m.description}")
|
|
301
|
+
if not report.missing_tests:
|
|
302
|
+
click.echo(" No obvious missing tests detected.")
|
|
303
|
+
else:
|
|
304
|
+
print_report(report)
|
|
305
|
+
else:
|
|
306
|
+
print_report(report)
|
|
File without changes
|