Files
cleveragents-core/robot/_tdd_fixture_runner.py
hurui200320 51aab18411
CI / benchmark-publish (pull_request) Has been skipped
CI / helm (pull_request) Successful in 26s
CI / lint (pull_request) Successful in 29s
CI / push-validation (pull_request) Successful in 28s
CI / quality (pull_request) Successful in 31s
CI / build (pull_request) Successful in 32s
CI / typecheck (pull_request) Successful in 59s
CI / security (pull_request) Successful in 59s
CI / e2e_tests (pull_request) Successful in 3m0s
CI / integration_tests (pull_request) Successful in 4m3s
CI / unit_tests (pull_request) Successful in 5m21s
CI / docker (pull_request) Successful in 1m19s
CI / coverage (pull_request) Successful in 10m25s
CI / status-check (pull_request) Successful in 1s
CI / push-validation (push) Successful in 17s
CI / build (push) Successful in 27s
CI / helm (push) Successful in 29s
CI / quality (push) Successful in 32s
CI / lint (push) Successful in 35s
CI / security (push) Successful in 51s
CI / typecheck (push) Successful in 51s
CI / benchmark-regression (push) Has been skipped
CI / e2e_tests (push) Successful in 3m7s
CI / integration_tests (push) Successful in 4m0s
CI / unit_tests (push) Successful in 4m54s
CI / docker (push) Successful in 21s
CI / coverage (push) Successful in 10m24s
CI / status-check (push) Successful in 1s
CI / benchmark-regression (pull_request) Successful in 59m2s
CI / benchmark-publish (push) Successful in 1h13m43s
fix(testing): add guard logic to Robot tdd_expected_fail listener to prevent flaky CI
Add three guard conditions to the Robot Framework tdd_expected_fail_listener
end_test() function, paralleling the Behave apply_tdd_inversion() guards in
features/environment.py. These guards prevent the listener from blindly
inverting ALL test failures to passes, which was masking infrastructure errors
and causing flaky CI behavior.

Guards added:
1. Setup/teardown error guard (_has_setup_teardown_failure): checks
   result.setup/teardown status and message prefix to detect infrastructure
   failures where the test body never executed.
2. Non-assertion failure guard (_is_infrastructure_error): checks
   result.message against known infrastructure error patterns (keyword
   resolution errors, Python exception types, network errors) to avoid
   inverting failures that are not the captured bug.
3. Dry-run guard: detects Robot Framework dry-run mode by checking if all
   body keywords have status NOT RUN, preventing meaningless inversions
   when no test actually executed.

Also fixes collateral issues exposed by the guards:
- Fixed Variable Should Exist syntax error in e2e test files (single space
  was being parsed as part of keyword name instead of separator)
- Removed tdd_expected_fail from 4 context assembly e2e tests where the
  bugs appear to be fixed (previously masked by syntax error + blind
  inversion)

ISSUES CLOSED: #5436
2026-04-10 05:21:24 +00:00

256 lines
8.1 KiB
Python

"""Subprocess runner for TDD Robot Framework fixture files.
Runs ``.robot`` fixture files with the ``tdd_expected_fail_listener``
active and inspects the resulting ``output.xml`` to determine the final
test status after listener processing.
Extracted from ``helper_tdd_tag_validation.py`` to keep file lengths
under the 500-line project limit.
"""
from __future__ import annotations
__all__: list[str] = []
import subprocess
import sys
import tempfile
# Robot Framework generates the output XML; we parse it to inspect test
# results. The XML is always self-generated (never from untrusted input),
# so the standard library parser is sufficient — no XXE risk.
import xml.etree.ElementTree as ET
from pathlib import Path
_ROBOT_DIR = Path(__file__).resolve().parent
_LISTENER = str(_ROBOT_DIR / "tdd_expected_fail_listener.py")
_FIXTURES = _ROBOT_DIR / "fixtures"
_SUBPROCESS_TIMEOUT = 120 # seconds — matches project convention
def _extract_message(
test_el: ET.Element,
status_el: ET.Element,
) -> str:
"""Extract the test message from an RF output XML status element.
RF may place the message as element text, a ``message`` attribute
(RF 7.3+), or in a child ``<msg>`` element. Check all three.
"""
message = status_el.text or ""
if not message:
message = status_el.get("message", "")
if not message:
msg_el = test_el.find(".//msg")
if msg_el is not None and msg_el.text:
message = msg_el.text
return message
def _run_fixture_impl(
fixture_name: str,
*,
extra_args: tuple[str, ...] = (),
) -> tuple[str, str]:
"""Execute a fixture and return its ``(status, message)`` tuple.
Args:
fixture_name: Name of the fixture file without the ``.robot`` suffix.
extra_args: Additional command-line arguments passed to ``robot``.
Returns:
A tuple containing the Robot test status and message for the first
test case in the output XML.
"""
_validate_fixture_name(fixture_name)
fixture_path = _FIXTURES / f"{fixture_name}.robot"
if not fixture_path.exists():
print(f"ERROR: fixture not found: {fixture_path}", file=sys.stderr)
sys.exit(1)
with tempfile.TemporaryDirectory() as tmpdir:
out_xml = str(Path(tmpdir) / "output.xml")
cmd = [
sys.executable,
"-m",
"robot",
"--listener",
_LISTENER,
*extra_args,
"--outputdir",
tmpdir,
"--loglevel",
"INFO",
"--report",
"NONE",
"--log",
"NONE",
str(fixture_path),
]
try:
proc = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=_SUBPROCESS_TIMEOUT,
)
except subprocess.TimeoutExpired:
print(
f"ERROR: fixture {fixture_name!r} timed out after "
f"{_SUBPROCESS_TIMEOUT}s",
file=sys.stderr,
)
sys.exit(1)
if not Path(out_xml).exists():
print(
f"ERROR: Robot did not produce output.xml (rc={proc.returncode})",
file=sys.stderr,
)
print(f"stderr: {proc.stderr}", file=sys.stderr)
sys.exit(1)
try:
tree = ET.parse(out_xml)
except ET.ParseError as exc:
print(
f"ERROR: Failed to parse output.xml for {fixture_name!r}: {exc}",
file=sys.stderr,
)
print(f"stderr: {proc.stderr}", file=sys.stderr)
return "ERROR", f"Malformed Robot output: {exc}"
root = tree.getroot()
test_el = root.find(".//test")
if test_el is None:
return "ERROR", "No test found in output XML"
status_el = test_el.find("status")
if status_el is None:
return "ERROR", "No status element found"
status = status_el.get("status", "UNKNOWN")
message = _extract_message(test_el, status_el)
return status, message
def _validate_fixture_name(fixture_name: str) -> None:
"""Ensure the fixture name does not contain directory traversal tokens."""
if (
not fixture_name
or any(token in fixture_name for token in ("/", "\\", ".."))
or fixture_name.startswith(":")
):
raise ValueError(f"Invalid fixture name: {fixture_name!r}")
def run_fixture(fixture_name: str) -> tuple[str, str]:
"""Run a fixture ``.robot`` file and return ``(status, message)``.
Returns the status and message of the *first* test case found in
the Robot output XML.
"""
return _run_fixture_impl(fixture_name)
def run_fixture_dryrun(fixture_name: str) -> tuple[str, str]:
"""Run a fixture ``.robot`` file in dry-run mode and return ``(status, message)``.
Dry-run mode (``--dryrun``) causes Robot Framework to validate
keyword calls without executing them. Tests get status ``NOT RUN``
(or ``FAIL`` if keyword resolution fails). This function is used
to verify the listener's dry-run guard.
Returns the status and message of the *first* test case found in
the Robot output XML.
"""
return _run_fixture_impl(fixture_name, extra_args=("--dryrun",))
def run_multi_fixture(
*fixture_names: str,
) -> dict[str, tuple[str, str]]:
"""Run multiple fixture ``.robot`` files in a single Robot invocation.
Returns a dict mapping test name to ``(status, message)``. Running
multiple fixtures together proves the listener is loaded and
selectively applies (normal tests are unaffected *in the same run*
as ``tdd_expected_fail`` tests).
"""
fixture_paths = []
for name in fixture_names:
_validate_fixture_name(name)
fp = _FIXTURES / f"{name}.robot"
if not fp.exists():
print(f"ERROR: fixture not found: {fp}", file=sys.stderr)
sys.exit(1)
fixture_paths.append(str(fp))
with tempfile.TemporaryDirectory() as tmpdir:
out_xml = str(Path(tmpdir) / "output.xml")
cmd = [
sys.executable,
"-m",
"robot",
"--listener",
_LISTENER,
"--outputdir",
tmpdir,
"--loglevel",
"INFO",
"--report",
"NONE",
"--log",
"NONE",
*fixture_paths,
]
try:
proc = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=_SUBPROCESS_TIMEOUT,
)
except subprocess.TimeoutExpired:
print(
f"ERROR: multi-fixture run timed out after {_SUBPROCESS_TIMEOUT}s",
file=sys.stderr,
)
sys.exit(1)
if not Path(out_xml).exists():
print(
f"ERROR: Robot did not produce output.xml (rc={proc.returncode})",
file=sys.stderr,
)
print(f"stderr: {proc.stderr}", file=sys.stderr)
sys.exit(1)
try:
tree = ET.parse(out_xml)
except ET.ParseError as exc:
print(
f"ERROR: Failed to parse output.xml for multi-fixture run: {exc}",
file=sys.stderr,
)
print(f"stderr: {proc.stderr}", file=sys.stderr)
return {
"__multi_fixture_parse_error__": (
"ERROR",
f"Malformed Robot output: {exc}",
)
}
root = tree.getroot()
results: dict[str, tuple[str, str]] = {}
for test_el in root.findall(".//test"):
test_name = test_el.get("name", "UNKNOWN")
status_el = test_el.find("status")
if status_el is None:
results[test_name] = ("ERROR", "No status element found")
continue
status = status_el.get("status", "UNKNOWN")
message = _extract_message(test_el, status_el)
results[test_name] = (status, message)
return results