Extract test scenarios into scenarios.yaml - #3
Conversation
The list of integration test scenarios that run_tests.py exercises was previously hardcoded as a TEST_CASES Python list inside run_tests.py. Moving it into a separate scenarios.yaml has two benefits: 1. Scenarios become data, not code. Adding or modifying a scenario no longer requires editing the runner, and consuming SDK repositories can drop in their own scenarios file via the new --scenarios CLI flag without forking run_tests.py. This realises the per-SDK scenarios.yaml / scenarios_full.yaml split that the README has documented as the intended PR-vs-nightly model. 2. The schema grows a 'steps' list on each test case, where every step is one traversal execution (the old TestCase fields: sdks, behavior, protocols, edges, streaming, build_subtests). All 21 existing scenarios become single-step tests; the legacy flat form is still accepted and is auto-normalised into a single step, so existing /run callers do not break. Other changes in this PR: * itk_service.py: TestCase split into TestCase (named, multi-step, optional expected=pass|fail) + TestStep (the per-traversal shape). Added _run_test_case() helper used by both run_tests.py and the /run endpoint. * run_tests.py: reduced to a thin CLI that loads YAML, validates against TestCase, and delegates to _run_test_case. Adds --scenarios flag (defaults to scenarios.yaml next to the script). * scenarios.yaml: all 21 previously hardcoded scenarios, with the two known-failing ones (failing-go-v03-http-json, failing-go-v10-grpc) marked expected: fail so the runner can keep exercising them and alert when they start passing again. * pyproject.toml: added pyyaml dependency. * README.md: documents the new scenarios.yaml format and the --scenarios CLI flag.
There was a problem hiding this comment.
Code Review
This pull request refactors the ITK integration test orchestrator to dynamically load test scenarios from a YAML file (scenarios.yaml) instead of using hardcoded definitions. It introduces a multi-step test execution schema (TestStep) with backwards compatibility for the legacy flat form, supported by Pydantic models in itk_service.py. Feedback on the changes highlights a logic bug in how expected-failing test cases invert individual step results, a potential FileNotFoundError caused by using forward slashes in step labels, and several robustness issues in the YAML loading and validation logic in run_tests.py.
| if case.expected == 'fail': | ||
| for entry in merged.values(): | ||
| entry['passed'] = not entry['passed'] | ||
| entry['expected'] = 'fail' |
There was a problem hiding this comment.
Inverting the passed flag of every individual step/subtest in an expected-failing test case (expected: fail) introduces a logic bug for multi-step or subtest scenarios.
If a test case has multiple steps (e.g., Step 1 passes, Step 2 fails), the overall test case has successfully failed (meeting the expectation). However, inverting each step individually results in Step 1 becoming passed: False and Step 2 becoming passed: True. Because the runner checks if all entries are successful, the presence of passed: False on Step 1 will cause the entire test run to fail.
To fix this, we should determine if the test case actually failed (i.e., at least one step/subtest failed). If it did, we report all steps as passed: True (expectation met). If all steps passed, we report all steps as passed: False (expectation not met).
| if case.expected == 'fail': | |
| for entry in merged.values(): | |
| entry['passed'] = not entry['passed'] | |
| entry['expected'] = 'fail' | |
| if case.expected == 'fail': | |
| actually_failed = any(not entry['passed'] for entry in merged.values()) | |
| for entry in merged.values(): | |
| entry['passed'] = actually_failed | |
| entry['expected'] = 'fail' |
There was a problem hiding this comment.
Good catch — accepted in 682c27b. The latent bug only matters for multi-step cases or single steps with build_subtests: true, neither of which are exercised by the 21 scenarios in this PR, so behaviour is unchanged for them. But it would have produced incoherent results for any future test in either of those shapes, so worth fixing now.
Verified with mocked execute_itk_test across 7 scenarios:
- single-step
expected: failthat actually fails →passed: true✓ - single-step
expected: failthat actually passes →passed: false✓ - multi-step
expected: failwhere one of two steps fails → both entries getpassed: true✓ - multi-step
expected: failwhere all steps pass → all entries getpassed: false✓ build_subtests+expected: failwith mixed pass/fail subtests → all entries getpassed: true✓
| label = ( | ||
| f'{case.name}/step-{idx}' if multi_step else case.name | ||
| ) |
There was a problem hiding this comment.
Using a forward slash / in the step label (e.g., f'{case.name}/step-{idx}') causes a FileNotFoundError when running with ITK_LOG_LEVEL=DEBUG.
In testlib.py, start_notification_server attempts to write logs to os.path.join(cwd, 'logs', f'{test_name}_notifications.log'). If test_name contains a slash, Python interprets it as a subdirectory path (e.g., logs/my-scenario/step-1_notifications.log). Since logs/my-scenario/ is not created, open() fails.
Replacing the slash with a hyphen or underscore avoids this issue while keeping the step label clear and distinguishable.
| label = ( | |
| f'{case.name}/step-{idx}' if multi_step else case.name | |
| ) | |
| label = ( | |
| f'{case.name}-step-{idx}' if multi_step else case.name | |
| ) |
There was a problem hiding this comment.
Accepted in 682c27b. Switched to -step- instead of /step-. Doesn't collide with the existing -sub- suffix that execute_itk_test uses for build_subtests, so process_results.py's name.split('-sub-')[0] parent-lookup still works unchanged.
| def load_scenarios(path: pathlib.Path) -> list[TestCase]: | ||
| """Load and validate a scenarios file. | ||
|
|
||
| The file is expected to have a top-level ``tests:`` list, each entry | ||
| of which conforms to :class:`TestCase` (either the multi-step or | ||
| legacy flat form). | ||
| """ | ||
| if not path.exists(): | ||
| logger.error('Scenarios file not found: %s', path) | ||
| sys.exit(1) | ||
| with path.open() as f: | ||
| data = yaml.safe_load(f) or {} | ||
| raw_tests = data.get('tests') | ||
| if not isinstance(raw_tests, list) or not raw_tests: | ||
| logger.error( | ||
| "Scenarios file %s must contain a non-empty 'tests' list.", path | ||
| ) | ||
| sys.exit(1) | ||
| try: | ||
| return [TestCase.model_validate(entry) for entry in raw_tests] | ||
| except Exception: | ||
| logger.exception('Failed to parse scenarios from %s', path) | ||
| sys.exit(1) |
There was a problem hiding this comment.
The load_scenarios function is prone to crashes and unhandled exceptions:
yaml.safe_load(f)is called outside thetry...exceptblock, meaning any YAML syntax/parsing errors will crash the runner with a traceback instead of a clean error message.- If the YAML file is not a dictionary (e.g., a list or a string),
data.get('tests')will raise anAttributeError. - There is no validation to ensure test case names are unique, which can lead to duplicate test cases silently overwriting each other in the results map.
Wrapping the entire loading and validation logic in the try...except block, validating the data type, and checking for duplicate test names makes the runner much more robust.
def load_scenarios(path: pathlib.Path) -> list[TestCase]:
"""Load and validate a scenarios file.
The file is expected to have a top-level ``tests:`` list, each entry
of which conforms to :class:`TestCase` (either the multi-step or
legacy flat form).
"""
if not path.exists():
logger.error('Scenarios file not found: %s', path)
sys.exit(1)
try:
with path.open() as f:
data = yaml.safe_load(f)
if data is None:
data = {}
elif not isinstance(data, dict):
logger.error("Scenarios file %s must be a YAML dictionary.", path)
sys.exit(1)
raw_tests = data.get('tests')
if not isinstance(raw_tests, list) or not raw_tests:
logger.error(
"Scenarios file %s must contain a non-empty 'tests' list.", path
)
sys.exit(1)
tests = [TestCase.model_validate(entry) for entry in raw_tests]
names = [t.name for t in tests]
duplicates = {name for name in names if names.count(name) > 1}
if duplicates:
logger.error("Duplicate test case names found in %s: %s", path, ", ".join(duplicates))
sys.exit(1)
return tests
except Exception:
logger.exception('Failed to parse scenarios from %s', path)
sys.exit(1)There was a problem hiding this comment.
All three sub-issues accepted in 682c27b:
yaml.safe_loadis now inside thetryblock — YAML syntax errors produce a clean logged error andsys.exit(1)instead of an unhandledyaml.YAMLErrortraceback.- Added an explicit
isinstance(data, dict)check before.get('tests'). List-rooted or scalar-rooted YAML now reports the actual type in the error message instead of raisingAttributeError. - Added a duplicate-name check using
collections.Counter(O(n) rather than O(n²) — the suggestion'snames.count(name)inside a comprehension is O(n²)).
Verified with 9 negative scenarios — missing file, list-rooted YAML, scalar-rooted YAML, empty file, malformed YAML, missing tests key, empty tests list, pydantic validation failure, and duplicate names — all produce a single logged error line and exit 1 with no unhandled exceptions. The real scenarios.yaml (21 cases) still loads.
One small structural difference from the suggested patch: I kept the existing path.exists() check outside the try block (it has its own targeted error message) and added an inner except SystemExit: raise so the explicit sys.exit(1) calls inside the try aren't accidentally swallowed by the broad except Exception.
The previous commit added the 'expected' key to result dicts inside _run_test_case() but did not declare it on TestResultDetails. FastAPI's response_model serialization therefore stripped the field from HTTP responses, so the CLI saw the inverted-pass behaviour but HTTP callers (including run_itk.sh in consuming SDK repos) did not. Verified against the live upstream scenario files: - a2aproject/a2a-python:itk/scenarios.json (8 cases) parses cleanly - a2aproject/a2a-python:itk/scenarios_full.json (12 cases) parses cleanly - a2aproject/a2a-go:itk/scenarios.json (8 cases) parses cleanly - a2aproject/a2a-go:itk/scenarios_full.json (12 cases) parses cleanly All existing fields preserved; 'expected' is omitted when the test had the default expected: pass, so legacy consumers see no schema change.
Three fixes from the PR-bot review on a2aproject#3: 1. (high) Aggregate the 'expected: fail' inversion at the case level instead of per-entry. The old code inverted each result entry's 'passed' flag independently, which breaks for any case where merged contains more than one entry (multi-step tests, or single steps with build_subtests=true). In that scenario a case that legitimately failed-as-expected could still end up with at least one passed=False entry, causing the downstream 'any entry false -> overall fail' aggregator to mark the run as failing. Now compute a single actually_failed = any(not passed) over all entries and write that aggregated value back to every entry. Behaviour is identical for the two existing single-step no-subtests scenarios; only the latent multi-step / subtest cases change. 2. (medium) Label multi-step results '<name>-step-N' instead of '<name>/step-N'. testlib.start_notification_server writes debug logs to logs/<name>_notifications.log; a forward slash in the name made Python treat it as a missing subdirectory and crash with FileNotFoundError when ITK_LOG_LEVEL=DEBUG. Hyphen avoids the issue and doesn't collide with the existing '-sub-' suffix that build_subtests uses. 3. (medium) Harden load_scenarios: - Wrap yaml.safe_load + the rest of the parsing in try/except so YAML syntax errors produce a clean logged error instead of an unhandled traceback (still exits 1). - Validate that the YAML root is a mapping before .get('tests'); a top-level list or scalar now logs a typed error message instead of raising AttributeError. - Reject duplicate test-case names (collections.Counter, O(n)) so two entries with the same name can't silently overwrite each other in the results dict. Verified with mocked execute_itk_test: - 7 scenarios covering single-step pass/fail, multi-step mixed, build_subtests + expected:fail, label format, and bare name for single-step tests. - 9 negative scenarios for load_scenarios covering missing file, list-root, scalar-root, empty file, malformed YAML, missing tests key, empty tests list, pydantic validation failure, and duplicate names \u2014 each produces a clean sys.exit(1). - The real scenarios.yaml (21 cases) still loads.
Summary
Move the hardcoded
TEST_CASESlist fromrun_tests.pyinto a separatescenarios.yamldata file, and grow the schema with asteps:list per test case (in preparation for ACTS-style multi-step tests where one test needs setup → action → assertion across multiple traversals).Motivation
--scenariosCLI flag, which is the per-SDK PR-vs-nightly model the README has documented but not previously enabled in code.steps:list makes each test case a sequence of traversals run sequentially against the same cluster. All 21 existing scenarios become single-step tests; nothing about their behaviour changes. This is groundwork for being able to express tests like "create a task, wait for it to enter state X, then run the actual assertion" — useful both for ITK's own future tests and as a stepping stone toward acting as an ACTS conformance runner (cf. feat: Add new spec for A2A protocol conformance tests A2A#1882).sdks/behavior/ etc. at the top level of a test entry, nosteps:block) is still accepted and auto-normalised into a single step, so existing/runHTTP callers and any pre-existingscenarios.jsonfiles keep working.Changes
scenarios.yaml(new)failing-go-v03-http-json,failing-go-v10-grpc) markedexpected: failso the runner keeps exercising them and alerts when they start passing again.itk_service.pyTestCasesplit intoTestCase(named, multi-step, optionalexpected: pass|fail) +TestStep(the per-traversal shape). New_run_test_case()helper shared byrun_tests.pyand the/runendpoint. Backwards-compat shim auto-normalises the legacy flat form.run_tests.pyTestCase, and delegates to_run_test_case. New--scenarios <path>flag (defaults toscenarios.yamlnext to the script).pyproject.tomlpyyaml>=6.0dependency.README.md--scenariosflag.Validation
Sanity-checked locally:
sdks/behavior/protocols/edges/streaming/build_subtestsvalues in the right order.expected: fail.steps:block) auto-normalises to a single-step test.uv run run_tests.py --helpworks.I did not run the actual integration tests (
uv run run_tests.py) end-to-end as that requires the full multi-language container environment; this PR is intentionally pure refactor and the runtime call intoexecute_itk_testis unchanged.Out of scope (future PRs)
scenarios.yamlmodel into the consuming SDK repos (a2a-python,a2a-go, ...) — none of them currently have anitk/directory or any ITK-specific files on disk yet, contrary to what the README implies. That's a separate coordination effort.tck-cancel→ poll-for-state →cancel_tasktest) belongs in a follow-up.a2aproject/A2A). This PR is a prerequisite step toward that, not the thing itself.