Files
cleveragents-core/robot/_tdd_fixture_runner.py
hurui200320 a5de448856 feat(testing): implement @tdd_expected_fail tag handling in Robot Framework
Implements the three-tag TDD bug-capture system in Robot Framework via a
Listener v3 module, paralleling the Behave implementation. Tests tagged
tdd_expected_fail that fail have their result inverted to PASS (bug still
exists); tests that unexpectedly pass are inverted to FAIL with guidance.

Addresses all 15 findings from code review (PR !673, reviewer hamza.khyari):

P2 fixes:
- Added idempotency guard (_processed_tests set) to prevent double-inversion
  when the listener is loaded twice in the same process.
- Rewrote normal-test-unaffected check to run alongside a tdd_expected_fail
  fixture in a single Robot invocation, proving the listener is loaded and
  selectively applies rather than being a tautological pass.

P3 fixes:
- Added output.xml existence guard with clear diagnostics in _run_fixture.
- Documented intentional use of data.tags (static definition) vs result.tags
  (runtime-modifiable) in end_test docstring.
- Added SKIP status test fixture and integration test case.
- Added message content assertion in cmd_expected_fail_inverted.
- Tightened substring assertions to match specific error text.
- Added tdd_expected_fail-alone fixture (both companions missing).
- Added close() hook to clear _validation_errors and _processed_tests.
- Simplified _run_fixture return type to tuple[str, str].
- Changed listener path resolution from CWD-relative to __file__-relative
  in noxfile.py (integration_tests, slow_integration_tests, e2e_tests).

P4 fixes:
- Added __all__ declaration to helper module.
- Changed module docstring from "mirroring" to "paralleling".
- Added comment documenting accepted XML parsing risk (self-generated XML).

Additional fixes:
- Increased M4 E2E plan-tree test timeout from 30s to 120s (pre-existing
  timeout failure unrelated to this feature).

Quality gates (post-rebase onto latest master):
- nox -s lint: PASS
- nox -s typecheck: PASS (0 errors)
- nox -s unit_tests: PASS (10,700 scenarios)
- nox -s integration_tests: PASS (1,505 tests)
- nox -s coverage_report: PASS (97.9% >= 97% threshold)
- nox -s benchmark: PASS
- nox -s docs: PASS
- nox -s build: PASS
- nox -s security_scan: PASS
- nox -s dead_code: PASS

ISSUES CLOSED: #628
2026-03-16 22:45:55 +00:00

187 lines
5.8 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(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.
"""
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,
"--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)
tree = ET.parse(out_xml)
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 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:
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)
tree = ET.parse(out_xml)
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