spec2test 0.1.1__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.
- spec2test/__init__.py +0 -0
- spec2test/adapters/__init__.py +0 -0
- spec2test/adapters/api/__init__.py +0 -0
- spec2test/adapters/api/codegen.py +197 -0
- spec2test/adapters/api/runner.py +249 -0
- spec2test/adapters/common/__init__.py +0 -0
- spec2test/adapters/common/classify.py +69 -0
- spec2test/adapters/common/process.py +209 -0
- spec2test/adapters/common/validate.py +266 -0
- spec2test/adapters/ui/__init__.py +0 -0
- spec2test/adapters/ui/codegen.py +345 -0
- spec2test/adapters/ui/runner.py +403 -0
- spec2test/cli.py +1187 -0
- spec2test/core/__init__.py +0 -0
- spec2test/core/config.py +131 -0
- spec2test/core/exceptions.py +98 -0
- spec2test/core/extract.py +497 -0
- spec2test/core/extraction_store.py +150 -0
- spec2test/core/gate.py +23 -0
- spec2test/core/generator.py +311 -0
- spec2test/core/ids.py +44 -0
- spec2test/core/llm.py +286 -0
- spec2test/core/logging_config.py +96 -0
- spec2test/core/metrics.py +132 -0
- spec2test/core/mutation_signal.py +52 -0
- spec2test/core/mutations_registry.py +35 -0
- spec2test/core/registry.py +53 -0
- spec2test/core/report.py +479 -0
- spec2test/core/requirements.py +130 -0
- spec2test/core/result_cache.py +135 -0
- spec2test/core/review.py +168 -0
- spec2test/core/schema.py +154 -0
- spec2test/core/sources/__init__.py +4 -0
- spec2test/core/sources/base.py +20 -0
- spec2test/core/sources/clickup.py +84 -0
- spec2test/core/sources/file.py +28 -0
- spec2test/core/sources/jira.py +133 -0
- spec2test/core/sources/registry.py +19 -0
- spec2test/core/ui_contract.py +122 -0
- spec2test/dashboard/__init__.py +0 -0
- spec2test/dashboard/main.py +598 -0
- spec2test/dashboard/static/htmx.min.js +1 -0
- spec2test/dashboard/templates/_case_row.html +19 -0
- spec2test/dashboard/templates/_draft_row.html +25 -0
- spec2test/dashboard/templates/_extraction_panel.html +39 -0
- spec2test/dashboard/templates/_requirement_box.html +32 -0
- spec2test/dashboard/templates/_review_panel.html +20 -0
- spec2test/dashboard/templates/_run_panel.html +49 -0
- spec2test/dashboard/templates/_traceability.html +21 -0
- spec2test/dashboard/templates/base.html +364 -0
- spec2test/dashboard/templates/index.html +18 -0
- spec2test-0.1.1.dist-info/METADATA +397 -0
- spec2test-0.1.1.dist-info/RECORD +57 -0
- spec2test-0.1.1.dist-info/WHEEL +5 -0
- spec2test-0.1.1.dist-info/entry_points.txt +2 -0
- spec2test-0.1.1.dist-info/licenses/LICENSE +201 -0
- spec2test-0.1.1.dist-info/top_level.txt +1 -0
spec2test/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""adapters/api/codegen.py — LLM writes pytest+httpx source for one APPROVED
|
|
2
|
+
TestCase -> ast.parse + assertion-presence + import-whitelist validation ->
|
|
3
|
+
retry <= MAX_CODEGEN_ATTEMPTS -> quarantine. See
|
|
4
|
+
implementation/01-architecture.md 'Codegen strategy' and 'Codegen:
|
|
5
|
+
code-execution trust boundary'.
|
|
6
|
+
|
|
7
|
+
case_id / requirement_id / target_type are injected as module-level
|
|
8
|
+
constants AFTER the model's output validates - never asked of the model,
|
|
9
|
+
same pattern as core/generator.py never asking the model for
|
|
10
|
+
id/requirement_id (02-components.md). runner.py recovers them by static
|
|
11
|
+
ast parsing, never by importing/exec'ing generated code outside pytest's
|
|
12
|
+
own subprocess.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from pydantic import BaseModel
|
|
20
|
+
|
|
21
|
+
from spec2test.adapters.common.validate import categorize_error, validate_common
|
|
22
|
+
from spec2test.core.config import settings
|
|
23
|
+
from spec2test.core.exceptions import CodegenValidationError
|
|
24
|
+
from spec2test.core.gate import require_approved
|
|
25
|
+
from spec2test.core.llm import LLMHandle
|
|
26
|
+
from spec2test.core.logging_config import get_logger
|
|
27
|
+
from spec2test.core.metrics import record_codegen_attempt
|
|
28
|
+
from spec2test.core.schema import TestCase
|
|
29
|
+
|
|
30
|
+
log = get_logger("codegen.api")
|
|
31
|
+
|
|
32
|
+
# Total attempts including the first draft - mirrors core/generator.py's
|
|
33
|
+
# MAX_REPAIRS=2 (1 initial + 2 repairs = 3 total attempts), and matches the
|
|
34
|
+
# M4 exit criterion "retry <=3, quarantine" read as a 3-attempt budget.
|
|
35
|
+
MAX_CODEGEN_ATTEMPTS = 3
|
|
36
|
+
|
|
37
|
+
# Hardening against the code-execution trust boundary (01-architecture.md
|
|
38
|
+
# 'Codegen: code-execution trust boundary'): anything outside this set -
|
|
39
|
+
# os, subprocess, socket, etc. - fails validation and is quarantined,
|
|
40
|
+
# never executed.
|
|
41
|
+
IMPORT_WHITELIST = {"pytest", "httpx"}
|
|
42
|
+
|
|
43
|
+
GENERATED_DIR = Path("artifacts/generated")
|
|
44
|
+
QUARANTINE_DIR = Path("artifacts/quarantine")
|
|
45
|
+
|
|
46
|
+
SYSTEM_PROMPT = """You write a single self-contained pytest test function \
|
|
47
|
+
that verifies one approved API test case, for a FastAPI service reachable \
|
|
48
|
+
at {base_url}.
|
|
49
|
+
|
|
50
|
+
Rules, all mandatory:
|
|
51
|
+
- Start the file with `import httpx` on its own line. Add `import pytest` \
|
|
52
|
+
too if you use it (e.g. pytest.raises). These are the ONLY imports allowed \
|
|
53
|
+
- never `os`, `json`, `re`, or anything else. httpx.Response.json() gives \
|
|
54
|
+
you parsed JSON without importing json. Every name you reference below \
|
|
55
|
+
(httpx.Client, etc.) must come from one of these two import lines - do not \
|
|
56
|
+
call httpx.anything without `import httpx` actually present in the file.
|
|
57
|
+
- Define exactly one function, named `test_<snake_case_of_the_case_id>`, \
|
|
58
|
+
taking no fixture arguments - create the client inside the function body: \
|
|
59
|
+
`with httpx.Client(base_url="{base_url}") as client:`.
|
|
60
|
+
- The function body must issue the HTTP calls described in the steps, in \
|
|
61
|
+
order (is_setup steps first), matching the exact method and path in each \
|
|
62
|
+
step's action, with data as the JSON request body, then assert the \
|
|
63
|
+
expected result. Use real `assert` statements checking status codes and \
|
|
64
|
+
response body fields - never a test with no assertions, never a trivially \
|
|
65
|
+
always-true assertion like `assert True`.
|
|
66
|
+
- Any identifier value you invent for a NEW entity this test creates (e.g. \
|
|
67
|
+
a book's isbn, a member's email) must be derived at runtime from the \
|
|
68
|
+
module-level constant `TESTFORGE_CASE_ID` (already defined above your \
|
|
69
|
+
function in this file - reference it directly, do not redefine it), never \
|
|
70
|
+
a fixed literal like "1234567890123" or "m@example.com". Two different \
|
|
71
|
+
generated tests must never be able to collide on the same entity. Example \
|
|
72
|
+
patterns: `isbn = str(abs(hash(TESTFORGE_CASE_ID)))[:13].ljust(13, "0")` \
|
|
73
|
+
for a 13-digit isbn, `email = f"member+{{TESTFORGE_CASE_ID}}@example.com"` \
|
|
74
|
+
for an email. Values the requirement itself pins down exactly (e.g. an \
|
|
75
|
+
expected due_date offset, a loan limit) are NOT invented identifiers - \
|
|
76
|
+
leave those as specified. If the test creates MORE THAN ONE new entity of \
|
|
77
|
+
the SAME kind (e.g. two distinct new books), each one's derived value must \
|
|
78
|
+
be distinguishable from the others' - append the step order or a short \
|
|
79
|
+
suffix before hashing (e.g. `hash(TESTFORGE_CASE_ID + "-2")` for the \
|
|
80
|
+
second one), never derive two entities of the same kind from the exact \
|
|
81
|
+
same expression.
|
|
82
|
+
- Output ONLY the Python source code. No markdown fences, no explanation, \
|
|
83
|
+
no comments describing what you're doing.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class GeneratedScript(BaseModel):
|
|
88
|
+
source: str
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _build_prompt(tc: TestCase) -> list[tuple[str, str]]:
|
|
92
|
+
steps_desc = "\n".join(
|
|
93
|
+
f" {s.order}. [{'setup' if s.is_setup else 'verify'}] {s.action}" + (f" data={s.data}" if s.data else "")
|
|
94
|
+
for s in tc.steps
|
|
95
|
+
)
|
|
96
|
+
user = (
|
|
97
|
+
f"case_id (for the function name, and available at runtime as the "
|
|
98
|
+
f"TESTFORGE_CASE_ID constant for deriving unique entity values - "
|
|
99
|
+
f"do not print it literally): {tc.id}\n"
|
|
100
|
+
f"title: {tc.title}\n"
|
|
101
|
+
f"requirement_excerpt: {tc.requirement_excerpt}\n"
|
|
102
|
+
f"preconditions: {tc.preconditions}\n"
|
|
103
|
+
f"steps:\n{steps_desc}\n"
|
|
104
|
+
f"expected_result: {tc.expected_result}\n"
|
|
105
|
+
)
|
|
106
|
+
return [("system", SYSTEM_PROMPT.format(base_url=settings.target_base_url)), ("user", user)]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _validate(source: str) -> list[str]:
|
|
110
|
+
"""Every check here now lives in adapters/common/validate.py, shared
|
|
111
|
+
with the UI adapter. Extracted in M5 Phase 3 BEFORE the UI adapter was
|
|
112
|
+
written, specifically so it couldn't start from a hand-copied
|
|
113
|
+
pre-fix version of these checks - three of them are fixes for real
|
|
114
|
+
bugs that had already shipped once (module-scoped assertion check,
|
|
115
|
+
missing-import check, dynamic-import bypass). See that module's
|
|
116
|
+
docstring for the full history."""
|
|
117
|
+
_tree, errors = validate_common(source, IMPORT_WHITELIST)
|
|
118
|
+
return errors
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _inject_metadata(source: str, tc: TestCase) -> str:
|
|
122
|
+
header = (
|
|
123
|
+
"# Generated by spec2test - DO NOT EDIT BY HAND\n"
|
|
124
|
+
f"TESTFORGE_CASE_ID = {tc.id!r}\n"
|
|
125
|
+
f"TESTFORGE_REQUIREMENT_ID = {tc.requirement_id!r}\n"
|
|
126
|
+
f"TESTFORGE_TARGET_TYPE = {tc.target_type.value!r}\n\n"
|
|
127
|
+
)
|
|
128
|
+
return header + source
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def generate_script(tc: TestCase, llm_handle: LLMHandle) -> Path:
|
|
132
|
+
"""Hybrid loop: draft -> validate -> retry <= MAX_CODEGEN_ATTEMPTS ->
|
|
133
|
+
quarantine. Raises GateViolationError (via require_approved) if tc isn't
|
|
134
|
+
APPROVED (NFR-2) - this is the first line, before any LLM call. Raises
|
|
135
|
+
CodegenValidationError, and writes the last attempt + its errors to
|
|
136
|
+
artifacts/quarantine/, if nothing validates within the attempt budget -
|
|
137
|
+
the quarantined file is never executed by anything downstream."""
|
|
138
|
+
require_approved(tc)
|
|
139
|
+
|
|
140
|
+
base_messages = _build_prompt(tc)
|
|
141
|
+
last_errors: list[str] = []
|
|
142
|
+
last_source = ""
|
|
143
|
+
|
|
144
|
+
for attempt in range(1, MAX_CODEGEN_ATTEMPTS + 1):
|
|
145
|
+
messages = list(base_messages)
|
|
146
|
+
if last_errors:
|
|
147
|
+
errors_text = "\n".join(f"- {e}" for e in last_errors)
|
|
148
|
+
messages.append(
|
|
149
|
+
("user", f"Your previous attempt had these problems - fix them and resend the FULL source:\n{errors_text}")
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
result = llm_handle.invoke_structured(GeneratedScript, messages)
|
|
153
|
+
# Inject before validating, not after: the prompt tells the model
|
|
154
|
+
# TESTFORGE_CASE_ID is "already defined above your function" (true
|
|
155
|
+
# only once this header is prepended), so validating the raw
|
|
156
|
+
# pre-injection source flags a correct script as referencing an
|
|
157
|
+
# unbound name. _inject_metadata is a pure, deterministic prepend
|
|
158
|
+
# of three fixed literals - injecting first can only add names the
|
|
159
|
+
# validator resolves, never hide a real unresolved-reference bug.
|
|
160
|
+
last_source = _inject_metadata(result.source, tc)
|
|
161
|
+
last_errors = _validate(last_source)
|
|
162
|
+
|
|
163
|
+
log.info(
|
|
164
|
+
"codegen_attempt",
|
|
165
|
+
extra={"event": "codegen_attempt", "case_id": tc.id, "attempt": attempt, "valid": not last_errors},
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
# Distinct event per implementation/06-observability.md's event
|
|
169
|
+
# taxonomy ("import_whitelist_violation ... always WARNING, case
|
|
170
|
+
# quarantined") - separate from the generic codegen_attempt/
|
|
171
|
+
# quarantined events so import-safety incidents are greppable/
|
|
172
|
+
# alertable on their own, not buried in ordinary validation noise.
|
|
173
|
+
import_errors = [
|
|
174
|
+
e for e in last_errors if e.startswith("disallowed import:") or e.startswith("disallowed dynamic import")
|
|
175
|
+
]
|
|
176
|
+
if import_errors:
|
|
177
|
+
log.warning(
|
|
178
|
+
"import_whitelist_violation",
|
|
179
|
+
extra={"event": "import_whitelist_violation", "case_id": tc.id, "attempt": attempt, "violations": import_errors},
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
if not last_errors:
|
|
183
|
+
GENERATED_DIR.mkdir(parents=True, exist_ok=True)
|
|
184
|
+
path = GENERATED_DIR / f"{tc.id}.py"
|
|
185
|
+
path.write_text(last_source, encoding="utf-8")
|
|
186
|
+
log.info("codegen_success", extra={"event": "codegen_success", "case_id": tc.id, "path": str(path)})
|
|
187
|
+
record_codegen_attempt(succeeded=True)
|
|
188
|
+
return path
|
|
189
|
+
|
|
190
|
+
QUARANTINE_DIR.mkdir(parents=True, exist_ok=True)
|
|
191
|
+
(QUARANTINE_DIR / f"{tc.id}.py").write_text(last_source, encoding="utf-8")
|
|
192
|
+
(QUARANTINE_DIR / f"{tc.id}.errors.txt").write_text("\n".join(last_errors), encoding="utf-8")
|
|
193
|
+
log.error("codegen_quarantined", extra={"event": "quarantined", "case_id": tc.id, "errors": last_errors})
|
|
194
|
+
record_codegen_attempt(succeeded=False, quarantine_reason=categorize_error(last_errors[0]) if last_errors else "other")
|
|
195
|
+
raise CodegenValidationError(
|
|
196
|
+
f"TestCase {tc.id} quarantined after {MAX_CODEGEN_ATTEMPTS} attempts, never executed: {last_errors}"
|
|
197
|
+
)
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""adapters/api/runner.py — executes generated pytest+httpx scripts and maps
|
|
2
|
+
results to TestResult. Recovers case_id/requirement_id/target_type by
|
|
3
|
+
STATIC ast parsing of each script's injected metadata constants (see
|
|
4
|
+
codegen.py's _inject_metadata) - never by importing/exec'ing the file
|
|
5
|
+
outside of pytest's own subprocess. See implementation/02-components.md
|
|
6
|
+
'adapters/api/codegen.py + runner.py'.
|
|
7
|
+
|
|
8
|
+
Found during M5 planning (B1/B3 in the deep bug sweep) - two gaps against
|
|
9
|
+
implementation/05-test-data-and-state-strategy.md's LOCKED design, neither
|
|
10
|
+
caught by the existing suite because the corpus was too small to trigger
|
|
11
|
+
them:
|
|
12
|
+
- The doc requires "POST /_test/reset ... called once before each test
|
|
13
|
+
file" (file-level isolation). Nothing here ever called it - the only
|
|
14
|
+
reset in the whole codebase was cli.py's single pre-batch call. Two
|
|
15
|
+
book-creating scripts in one run collided on 409 DUPLICATE_ISBN, a
|
|
16
|
+
false failure attributed to the wrong requirement.
|
|
17
|
+
- settings.test_exec_timeout_s was defined and never passed to
|
|
18
|
+
subprocess.run() - no execution timeout existed at all. Harmless for
|
|
19
|
+
httpx (rarely hangs); a real risk once adapters/ui/runner.py reuses
|
|
20
|
+
this pattern for Playwright, where a hung selector wait can block
|
|
21
|
+
forever.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import ast
|
|
27
|
+
import json
|
|
28
|
+
import subprocess
|
|
29
|
+
import sys
|
|
30
|
+
import tempfile
|
|
31
|
+
import time
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
import httpx
|
|
35
|
+
|
|
36
|
+
from spec2test.adapters.common.classify import classify_pytest_outcome
|
|
37
|
+
from spec2test.adapters.common.process import run_with_tree_kill_timeout
|
|
38
|
+
from spec2test.core.config import settings
|
|
39
|
+
from spec2test.core.exceptions import TestForgeError
|
|
40
|
+
from spec2test.core.logging_config import get_logger
|
|
41
|
+
from spec2test.core.metrics import record_test_result
|
|
42
|
+
from spec2test.core.schema import TargetType, TestResult
|
|
43
|
+
|
|
44
|
+
log = get_logger("runner.api")
|
|
45
|
+
|
|
46
|
+
_METADATA_NAMES = {"TESTFORGE_CASE_ID", "TESTFORGE_REQUIREMENT_ID", "TESTFORGE_TARGET_TYPE"}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _read_metadata(path: Path) -> dict[str, str]:
|
|
50
|
+
tree = ast.parse(path.read_text(encoding="utf-8"))
|
|
51
|
+
meta: dict[str, str] = {}
|
|
52
|
+
for node in tree.body:
|
|
53
|
+
if not isinstance(node, ast.Assign) or len(node.targets) != 1:
|
|
54
|
+
continue
|
|
55
|
+
target = node.targets[0]
|
|
56
|
+
if isinstance(target, ast.Name) and target.id in _METADATA_NAMES and isinstance(node.value, ast.Constant):
|
|
57
|
+
meta[target.id] = node.value.value
|
|
58
|
+
missing = _METADATA_NAMES - meta.keys()
|
|
59
|
+
if missing:
|
|
60
|
+
raise ValueError(f"{path} is missing spec2test metadata constants: {sorted(missing)}")
|
|
61
|
+
return meta
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _reset_sandbox() -> None:
|
|
65
|
+
"""B1 fix: file-level reset before each script, per the LOCKED design in
|
|
66
|
+
implementation/05-test-data-and-state-strategy.md ("called once before
|
|
67
|
+
each test file"). Previously never called here - cli.py's single
|
|
68
|
+
pre-batch reset was the only one in the codebase, so entity ids/state
|
|
69
|
+
from an earlier script leaked into every later one in the same run.
|
|
70
|
+
|
|
71
|
+
httpx errors are caught and re-raised as TestForgeError, matching the
|
|
72
|
+
precedent in cli.py's _wait_healthy - found during a deeper review pass
|
|
73
|
+
that an unwrapped httpx.ConnectError/HTTPStatusError here would escape
|
|
74
|
+
cli.py's TestForgeError-only exception handler as a raw traceback
|
|
75
|
+
instead of the one-line message the exception taxonomy promises. B1
|
|
76
|
+
calling this once PER SCRIPT (not once per batch) multiplies the
|
|
77
|
+
exposure to a transient failure here relative to before."""
|
|
78
|
+
try:
|
|
79
|
+
httpx.post(f"{settings.target_base_url}{settings.test_reset_path}", timeout=10).raise_for_status()
|
|
80
|
+
except httpx.HTTPError as exc:
|
|
81
|
+
raise TestForgeError(f"could not reset sandbox at {settings.target_base_url}: {exc}") from exc
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _execute_once(path: Path, run_id: str, meta: dict[str, str]) -> tuple[TestResult, bool]:
|
|
85
|
+
"""Runs the script exactly once via pytest. Returns (result, retryable) -
|
|
86
|
+
retryable is True only for a genuine reported test failure (pytest ran
|
|
87
|
+
the test and it failed), never for a timeout or a collection-error/crash
|
|
88
|
+
(report_path never written) - retrying either of those doesn't test
|
|
89
|
+
"is this flaky", it just wastes the timeout budget again on something
|
|
90
|
+
that isn't going to change. Split out of _run_one so the flaky-retry
|
|
91
|
+
wrapper (gap list item 6) can call this twice without duplicating the
|
|
92
|
+
execution/parsing logic."""
|
|
93
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
94
|
+
report_path = Path(tmp) / "report.json"
|
|
95
|
+
start = time.monotonic()
|
|
96
|
+
try:
|
|
97
|
+
proc = run_with_tree_kill_timeout(
|
|
98
|
+
[sys.executable, "-m", "pytest", str(path), "-q", "--json-report", f"--json-report-file={report_path}"],
|
|
99
|
+
timeout_s=settings.test_exec_timeout_s,
|
|
100
|
+
)
|
|
101
|
+
except subprocess.TimeoutExpired:
|
|
102
|
+
# B3 fix: settings.test_exec_timeout_s was defined but never
|
|
103
|
+
# passed to subprocess.run() - no execution timeout existed at
|
|
104
|
+
# all. Harmless for httpx; a real risk for Playwright (a hung
|
|
105
|
+
# selector wait blocks forever) - see
|
|
106
|
+
# adapters/common/process.py's run_with_tree_kill_timeout,
|
|
107
|
+
# which is what actually matters for that case: bare
|
|
108
|
+
# subprocess.run(timeout=...) only kills the immediate pytest
|
|
109
|
+
# child, not a grandchild browser process it launched, and that
|
|
110
|
+
# gap produced real orphaned Chromium processes in M5. Reported
|
|
111
|
+
# as a normal FAILED result, not a crash that would abort the
|
|
112
|
+
# rest of the batch - same "one bad case doesn't zero out the
|
|
113
|
+
# run" discipline as cli.py's per-case quarantine handling.
|
|
114
|
+
duration_ms = int(settings.test_exec_timeout_s * 1000)
|
|
115
|
+
result = TestResult(
|
|
116
|
+
test_case_id=meta["TESTFORGE_CASE_ID"],
|
|
117
|
+
requirement_id=meta["TESTFORGE_REQUIREMENT_ID"],
|
|
118
|
+
target_type=TargetType(meta["TESTFORGE_TARGET_TYPE"]),
|
|
119
|
+
status="FAILED",
|
|
120
|
+
duration_ms=duration_ms,
|
|
121
|
+
failure_message=f"test exceeded {settings.test_exec_timeout_s}s execution timeout",
|
|
122
|
+
script_path=str(path),
|
|
123
|
+
run_id=run_id,
|
|
124
|
+
)
|
|
125
|
+
log.warning(
|
|
126
|
+
"test_execution_timeout",
|
|
127
|
+
extra={
|
|
128
|
+
"event": "test_execution_timeout",
|
|
129
|
+
"case_id": result.test_case_id,
|
|
130
|
+
"requirement_id": result.requirement_id,
|
|
131
|
+
"timeout_s": settings.test_exec_timeout_s,
|
|
132
|
+
},
|
|
133
|
+
)
|
|
134
|
+
return result, False
|
|
135
|
+
duration_ms = int((time.monotonic() - start) * 1000)
|
|
136
|
+
|
|
137
|
+
failure_message: str | None = None
|
|
138
|
+
if report_path.exists():
|
|
139
|
+
report = json.loads(report_path.read_text(encoding="utf-8"))
|
|
140
|
+
tests = report.get("tests", [])
|
|
141
|
+
outcome = tests[0]["outcome"] if tests else "error"
|
|
142
|
+
longrepr = (tests[0].get("call") or {}).get("longrepr") if tests else None
|
|
143
|
+
status, is_assertion_failure, recognized = classify_pytest_outcome(outcome, longrepr)
|
|
144
|
+
if not recognized:
|
|
145
|
+
log.warning(
|
|
146
|
+
"failure_classification_unrecognized_format",
|
|
147
|
+
extra={"event": "failure_classification_unrecognized_format", "longrepr_tail": (longrepr or "")[-500:]},
|
|
148
|
+
)
|
|
149
|
+
if status != "PASSED":
|
|
150
|
+
failure_message = longrepr
|
|
151
|
+
# ERROR is never retryable - a TypeError/NameError/etc. is
|
|
152
|
+
# deterministic based on the code, not runtime flakiness;
|
|
153
|
+
# retrying a broken script wastes a full timeout budget for no
|
|
154
|
+
# chance of a different outcome. See R-23.
|
|
155
|
+
retryable = bool(tests) and is_assertion_failure
|
|
156
|
+
else:
|
|
157
|
+
# pytest itself didn't run (collection error, crash before the
|
|
158
|
+
# report plugin could write) - surface raw output rather than
|
|
159
|
+
# silently reporting a pass. ERROR, not FAILED: pytest never
|
|
160
|
+
# even ran the test body, so this can never be a genuine
|
|
161
|
+
# assertion outcome. Not retryable, same reasoning as above.
|
|
162
|
+
status = "ERROR"
|
|
163
|
+
failure_message = (proc.stdout + proc.stderr)[-4000:]
|
|
164
|
+
retryable = False
|
|
165
|
+
|
|
166
|
+
result = TestResult(
|
|
167
|
+
test_case_id=meta["TESTFORGE_CASE_ID"],
|
|
168
|
+
requirement_id=meta["TESTFORGE_REQUIREMENT_ID"],
|
|
169
|
+
target_type=TargetType(meta["TESTFORGE_TARGET_TYPE"]),
|
|
170
|
+
status=status,
|
|
171
|
+
duration_ms=duration_ms,
|
|
172
|
+
failure_message=failure_message,
|
|
173
|
+
script_path=str(path),
|
|
174
|
+
run_id=run_id,
|
|
175
|
+
)
|
|
176
|
+
# retryable is already False whenever status != "FAILED" (classify_pytest_outcome's
|
|
177
|
+
# own invariant: is_assertion_failure is only ever True alongside "FAILED").
|
|
178
|
+
return result, retryable
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _run_one(path: Path, run_id: str) -> TestResult:
|
|
182
|
+
meta = _read_metadata(path)
|
|
183
|
+
_reset_sandbox()
|
|
184
|
+
|
|
185
|
+
result, retryable = _execute_once(path, run_id, meta)
|
|
186
|
+
|
|
187
|
+
# Gap list item 6: TestResult.status has always included "FLAKY" as a
|
|
188
|
+
# schema option, but nothing ever implemented the retry-and-reclassify
|
|
189
|
+
# step the original design intended - only a genuinely reported test
|
|
190
|
+
# failure (never a timeout, never a collection crash - see _execute_once)
|
|
191
|
+
# gets one retry. If the retry PASSES, the result is reclassified FLAKY,
|
|
192
|
+
# not PASSED - a test that fails once and passes once is not the same
|
|
193
|
+
# as a test that reliably passes, and silently reporting PASSED would
|
|
194
|
+
# hide real instability. If the retry ALSO fails, nothing changes: a
|
|
195
|
+
# mutation-caught failure is deterministic and will fail again on
|
|
196
|
+
# retry, staying correctly attributed as FAILED, not misclassified as
|
|
197
|
+
# flaky just because a retry happened.
|
|
198
|
+
if retryable:
|
|
199
|
+
first_failure_message = result.failure_message
|
|
200
|
+
log.info(
|
|
201
|
+
"test_retry_after_failure",
|
|
202
|
+
extra={"event": "test_retry_after_failure", "case_id": result.test_case_id, "requirement_id": result.requirement_id},
|
|
203
|
+
)
|
|
204
|
+
# Reset again before the retry - API scripts create their own
|
|
205
|
+
# entities (see module docstring's DUPLICATE_ISBN collision), so
|
|
206
|
+
# without this the retry runs against state the failed first
|
|
207
|
+
# attempt already mutated. That's not a re-run under the same
|
|
208
|
+
# conditions; a collision there would be misattributed as FLAKY or
|
|
209
|
+
# a second, differently-caused FAILED rather than reflecting the
|
|
210
|
+
# actual determinism of the test.
|
|
211
|
+
_reset_sandbox()
|
|
212
|
+
retry_result, _ = _execute_once(path, run_id, meta)
|
|
213
|
+
if retry_result.status == "PASSED":
|
|
214
|
+
result = retry_result.model_copy(
|
|
215
|
+
update={
|
|
216
|
+
"status": "FLAKY",
|
|
217
|
+
"failure_message": f"passed on retry; first attempt failed: {first_failure_message}",
|
|
218
|
+
}
|
|
219
|
+
)
|
|
220
|
+
log.warning(
|
|
221
|
+
"test_flaky",
|
|
222
|
+
extra={"event": "test_flaky", "case_id": result.test_case_id, "requirement_id": result.requirement_id},
|
|
223
|
+
)
|
|
224
|
+
else:
|
|
225
|
+
result = retry_result # consistently failing, not flaky - report the retry's own detail verbatim
|
|
226
|
+
|
|
227
|
+
log.info(
|
|
228
|
+
"test_executed",
|
|
229
|
+
extra={
|
|
230
|
+
"event": "test_executed",
|
|
231
|
+
"case_id": result.test_case_id,
|
|
232
|
+
"requirement_id": result.requirement_id,
|
|
233
|
+
"status": result.status,
|
|
234
|
+
"duration_ms": result.duration_ms,
|
|
235
|
+
},
|
|
236
|
+
)
|
|
237
|
+
record_test_result(result.status)
|
|
238
|
+
return result
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def run(paths: list[Path], run_id: str) -> list[TestResult]:
|
|
242
|
+
"""Runs each generated script in its own pytest subprocess - isolation,
|
|
243
|
+
so one script's collection error or crash can't take down the batch or
|
|
244
|
+
poison another script's process state. run_id is the caller's real CLI
|
|
245
|
+
session id (cli.py's own per-invocation run_id, threaded down through
|
|
246
|
+
core/registry.py) - not minted here anymore, so every case run within
|
|
247
|
+
one `report`/`mutate` invocation shares one true session id instead of
|
|
248
|
+
each adapter batch getting its own disconnected uuid."""
|
|
249
|
+
return [_run_one(path, run_id) for path in paths]
|
|
File without changes
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Shared FAILED-vs-ERROR classification for both adapters' runners — see
|
|
2
|
+
implementation/04-failure-analysis.md R-23.
|
|
3
|
+
|
|
4
|
+
A pytest result that isn't "passed" can mean two very different things: the
|
|
5
|
+
test's own assertion caught a real problem (AssertionError - a genuine
|
|
6
|
+
signal), or the generated script itself is broken (TypeError, NameError, a
|
|
7
|
+
bad Playwright locator call, etc. - not a real signal, would fail
|
|
8
|
+
identically against a perfectly correct app). Collapsing both into the same
|
|
9
|
+
"FAILED" string is exactly the class of bug this project has already found
|
|
10
|
+
and fixed twice before in adapters/common/validate.py's docstring (a
|
|
11
|
+
missing import, a bare __import__ call) - both times because a script that
|
|
12
|
+
errors out LOOKS like a script that correctly caught a mutation.
|
|
13
|
+
|
|
14
|
+
Extracted here, not duplicated per-adapter, for the same reason
|
|
15
|
+
validate.py's checks are shared: a hand-copied version in one adapter would
|
|
16
|
+
drift from a fix made in the other.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import re
|
|
22
|
+
|
|
23
|
+
_FOOTER_PATTERN = re.compile(r":\s*([A-Za-z_][\w.]*)\s*$")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def classify_pytest_outcome(outcome: str, longrepr: str | None) -> tuple[str, bool, bool]:
|
|
27
|
+
"""Returns (status, is_assertion_failure, recognized_format).
|
|
28
|
+
|
|
29
|
+
status is "PASSED", "FAILED", or "ERROR" - never anything else.
|
|
30
|
+
is_assertion_failure is True only for a genuine AssertionError, which is
|
|
31
|
+
what callers should use to decide retry eligibility (retrying a broken
|
|
32
|
+
script is pointless - it's deterministic based on the code, not runtime
|
|
33
|
+
flakiness). recognized_format is False only in the fail-safe path below
|
|
34
|
+
- callers should log a warning when it's False, since it means pytest's
|
|
35
|
+
output didn't look like what this function expects.
|
|
36
|
+
|
|
37
|
+
Reads the last non-blank line of pytest's default --tb=long longrepr,
|
|
38
|
+
which always has the stable footer "{path}:{lineno}: {ExceptionClassName}"
|
|
39
|
+
for a call-phase failure (verified against real pytest output for a
|
|
40
|
+
bare assert, a message-carrying assert, AttributeError, and a custom
|
|
41
|
+
exception class). Deliberately does NOT parse the JSON report's
|
|
42
|
+
structured `call.crash.message` field - it omits the "AssertionError:"
|
|
43
|
+
prefix entirely for a bare `assert False` with no custom message, which
|
|
44
|
+
would misclassify the single most common generated-test shape as ERROR.
|
|
45
|
+
|
|
46
|
+
Fails safe to FAILED+retryable (today's pre-fix behavior) if the footer
|
|
47
|
+
doesn't match the expected shape - never invents a new false-ERROR for
|
|
48
|
+
an unanticipated pytest output format."""
|
|
49
|
+
if outcome == "passed":
|
|
50
|
+
return "PASSED", False, True
|
|
51
|
+
|
|
52
|
+
if not longrepr:
|
|
53
|
+
return "ERROR", False, True
|
|
54
|
+
|
|
55
|
+
last_line = ""
|
|
56
|
+
for line in reversed(longrepr.splitlines()):
|
|
57
|
+
if line.strip():
|
|
58
|
+
last_line = line.strip()
|
|
59
|
+
break
|
|
60
|
+
|
|
61
|
+
match = _FOOTER_PATTERN.search(last_line)
|
|
62
|
+
if not match:
|
|
63
|
+
return "FAILED", True, False # unrecognized format - fail safe to today's behavior
|
|
64
|
+
|
|
65
|
+
exception_name = match.group(1).rsplit(".", 1)[-1]
|
|
66
|
+
if exception_name == "AssertionError":
|
|
67
|
+
return "FAILED", True, True
|
|
68
|
+
|
|
69
|
+
return "ERROR", False, True
|