refactor(e2e-report): group the expectation grid by feature and fix scenario indexing - #186
refactor(e2e-report): group the expectation grid by feature and fix scenario indexing#186rominf wants to merge 6 commits into
Conversation
volen-silo
left a comment
There was a problem hiding this comment.
Reviewed the full diff, the rename blast radius, and the new drift guard. No blocking issues — the risky part is provably complete. Findings below are robustness nits in the guard plus one inaccurate claim in the description. Leaving the formal approve to a maintainer.
Verified
- The four
@idrenames are complete. Grepped each old id repo-wide (excluding.git/target, filtering out the new qualified forms): zero live occurrences ofhelp-lists-subcommands-alphabetically,fix-lists-known-recipes,fix-dry-run-changes-nothing,fix-unknown-id-rejected. Nothing inexpectations.toml, feature files, step defs,crates/e2e-report/**,xtask/**,scripts/**,.github/workflows/**, or any doc/fixture. - No dead xfail rows. Parsed the tree directly: 11 unique
expectations.tomlkeys, 66 unique@ids, and every key resolves to a live scenario id. (chat-tool-definitions-acceptedandserve-readiness-contractappearing twice are array-of-tables OR-conditions, not duplicates.) - Merge-order hazard is moot — #182 merged 2026-08-05, and it only appended
flaky = trueto rows on lines disjoint from the renamed key.git merge-tree --write-treeagainst currentmainis clean, and enumerating keys vs ids on the resulting merged tree still yields zero orphans. #174 shares no files. - Ordering is numeric, not lexical.
scenario_indexparses tou32and the sort is(index.is_none(), index, id), soNonesorts last andchat-09precedeschat-10; ≥100 follows fromu32. Malformed names returnNonevia?rather than panicking. - No anchor collisions — grid rows come from a
BTreeMapkeyed by id, so one id → one row → one heading. Uniqueness is triple-enforced (the map, the newduplicate scenario @idassert attests/e2e-cucumber/tests/e2e.rs:844-853, and the guard's suite-wide check). That new assert is a good defensive addition: a copy-pasted scenario with a forgotten id now fails loudly instead of silently overwriting a resolution the grid keys on. scripts/xfail_expectations_hint.pyis rename-agnostic — it derives ids from whatever keys are inexpectations.tomlat runtime and hardcodes nothing.- Locally:
cargo fmt --all --check,cargo test -p e2e-report -p e2e-cucumber(98 passed),cargo test -p e2e-cucumber --test feature_naming(4/4), both of CI's clippy invocations, andcargo xtask manifest --check— all pass. 21/21 CI checks green.
1. The drift guard passes vacuously if a feature file yields zero parseable scenarios
tests/e2e-cucumber/tests/feature_naming.rs:101-153
All three enforcement tests are for … in scenarios_of(file) loops, so an empty Vec means zero loop bodies and a green test. feature_files_and_declared_keys_agree (:80-98) is the only file-level test, and it asserts only that each FEATURE_KEYS entry has a file and vice versa — never that a file contains any scenarios. There's no minimum-count assertion anywhere in the file.
The parser is strict enough to make this reachable: :70 requires the exact literal "Scenario: ", so Scenario:Foo, Scenario: Foo, or a keyword typo silently yields nothing for that file. Concrete scenario: a bad bulk find-replace — exactly the class of change this PR is — mangles Scenario: in one feature file. That file's scenarios vanish from the guard's view, the guard stays green, and the grid then renders that feature's rows unsorted (all index == None, sorted last) with nobody warned. Reproduced in a throwaway copy: renaming all seven Scenario: keywords in chat.feature to Situation: still gives 4 passed; 0 failed.
One line in feature_files_and_declared_keys_agree closes it:
assert!(!scenarios_of(file).is_empty(), "{file}: no scenarios parsed — the naming checks would pass vacuously");2. The guard's tag parser diverges from the harness's — @id: must be first on its line
tests/e2e-cucumber/tests/feature_naming.rs:63-68
if let Some(rest) = line.strip_prefix('@') {
for tag in rest.split_whitespace() {
if let Some(id) = tag.strip_prefix("id:") {Only the line's leading @ is stripped, so on a multi-tag line every token after the first keeps its own @ and "@id:examine-version".strip_prefix("id:") returns None. The production parser does it per-tag and correctly — tests/e2e-cucumber/src/expectation.rs:96-101:
let tag = tag.as_ref().strip_prefix('@').unwrap_or_else(|| tag.as_ref());
if let Some(rest) = tag.strip_prefix(ID_PREFIX) {So a contributor writing @requires-os:linux @id:examine-version — valid Gherkin, resolved correctly by ScenarioDecl::from_tags — gets a CI failure from feature_naming reading scenario "…" has no @id: tag. Latent today because every @id: in the corpus happens to sit first on its line. Fix: tag.strip_prefix('@').unwrap_or(tag).strip_prefix("id:").
3. #[serde(default)] on the producer side is a no-op; the backward-compat claim is half wrong
tests/e2e-cucumber/src/expectation.rs:267, 273, 278
ResolvedScenario derives #[derive(Debug, Clone, serde::Serialize)] — Serialize only. #[serde(default)] is a deserialization attribute, so on :273 and :278 it does nothing, and nothing in the repo deserializes ResolvedScenario anyway.
The description says "Both new fields are #[serde(default)] on both sides, so pre-existing artifacts still render rather than dropping rows." The backward compatibility is real, but it comes entirely from the consumer: ManifestExpectation at crates/e2e-report/src/lib.rs:609-619 derives Deserialize with #[serde(default)] on both fields and no deny_unknown_fields. The producer attribute contributes nothing. Worth noting this PR is copying an existing no-op — flaky at expectation.rs:287 has the same dead attribute. Harmless, but both the attribute and the claim are misleading; drop one or reword the other.
4. Stale comment introduced by this PR
crates/e2e-report/src/lib.rs:1450-1451 — the inline comment still says GitHub anchors #### <id>, but this PR moved those headings to ##### {id} (now :1543) so they nest under the new #### {feature} group headings. The function doc at :1505-1508 was updated correctly; this comment wasn't. Comment rot in the exact code the PR touches.
5. Feature/name merge is write-order-dependent between two named artifacts, and the display name has no override at all
crates/e2e-report/src/lib.rs:832-848
The "authoritative overrides a guess" rule is inferred from exp.feature.is_empty() on the incoming expectation rather than tracked with a flag. Consequences:
- Guess vs. name is correctly order-independent — the named value always wins, and the new test exercises both orders. That's the case the PR set out to fix, and it's fixed.
- Name vs. name is last-write-wins, silently. Two artifacts naming different non-empty
featurevalues for one id resolve by read order. Only reachable when mixing artifact vintages across aFeature:rename, and no test covers it. - A feature can still split across vintages — the fallback at
:790-792yields the lowercase id prefix ("serve") while the authoritative value is the full title ("Model serving"), so in a mixed-vintage run some ids group under each. This looks like an accepted best-effort limit andgrid_falls_back_to_report_feature_then_id_prefixdocuments the shape. entry.1, the display name (:845-847), has no override — first non-empty wins permanently, which is precisely the asymmetryfeaturewas just fixed out of. That name is whatscenario_index(:769) parses the sort index from, so a stale name from an older artifact would pin a stale sort position. Same mixed-vintage precondition. Worth at least a comment on whyfeaturegets an override andscenariodoesn't.
6. The README duplicates the FEATURE_KEYS list, unguarded
tests/e2e-cucumber/README.md:98-99 hardcodes all eight keys inline. The guard forces FEATURE_KEYS to stay in sync with the filesystem, but nothing keeps the README copy in sync with FEATURE_KEYS — adding a ninth feature file fails the guard until FEATURE_KEYS is updated, and the README then silently goes wrong. :111 already points at the real list; consider dropping the enumeration and keeping just the lifecycle-vs-install_lifecycle example, which is the one non-obvious case since the key isn't the filename.
7. Pre-existing, not introduced here
crates/e2e-report/src/lib.rs:1518 — scenario_reference_markdown bails on scenarios.is_empty() || grid.is_empty() while expectation_grid_markdown gates only on grid.is_empty(), so if platform.json sidecars exist but no report.json recorded a scenario, every grid link dangles. Confirmed against ad12ac7 that this guard is unchanged — and this PR fixes the far more common dangling case, since the reference now iterates grid.groups rather than scenarios. Noted only so the "every grid row gets an anchor" claim is understood to hold except in that degenerate corner.
Not verified: a real cargo xtask e2e-report over genuine CI artifacts (none available here — the unit tests cover the same logic on synthetic fixtures and pass), the full e2e suite (needs a built binary and Linux runner conditions; CI's four E2E jobs are green and authoritative), and rendered-output inspection in GitHub's markdown renderer — anchor resolution is inferred from documented heading-slug behaviour plus the unit tests' string assertions, not observed in a browser.
Heads-up: the branch is currently BEHIND main and will want an update-branch before merge.
Every feature file numbered its scenarios from 1, so the index identified nothing: '1', '2', '3' each appeared eight times across the suite. Some files had drifted further - examine numbered 1, 2, 5, 3, 4; model_serving had a '6b' and a '10' sitting in sixth place; install_lifecycle had no indexes at all. Renumber every scenario as <feature-key>-<NN> in declaration order, so an index names exactly one scenario suite-wide. Qualify the four scenario ids that did not carry their feature's key (help-* in examine, fix-* in diagnose) and rename the matching expectations.toml key. Record each scenario's feature and name in platform.json alongside its resolved expectation. A skipped scenario never reaches report.json, so this is the only place that identity survives - the report needs it to group the expectation grid by feature. Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
The grid rendered every scenario in the suite as one undivided ~60-row table, sorted alphabetically by scenario id. Nothing marked where one area of the CLI ended and the next began, and the ordering bore no relation to the order the scenarios are declared in. Render one sub-table per feature instead, under its own heading, with rows in feature-file order (by the <key>-<NN> index now carried in each scenario name). The Scenario reference section follows the same grouping and order, so the links from the grid land in a layout that matches it. Feature and scenario name come from platform.json. For an artifact written before those fields existed, fall back to the feature name in report.json, then to the scenario id's leading segment - an older artifact still groups sensibly rather than collapsing into one bucket. Also give every grid row an anchor in the Scenario reference. A scenario that was n/a on every platform has no report.json entry, so its link previously dangled. Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
The report groups its grid by feature and orders rows by the <key>-<NN> index in each scenario name, so the convention is load-bearing. It had already drifted badly before it was enforced: indexes restarting at 1 in every file, examine numbered 1, 2, 5, 3, 4, a stray '6b' in model_serving, and no indexes at all in install_lifecycle. Add a plain test target that parses the .feature files and asserts indexes are sequential per feature, unique suite-wide, and that every scenario carries a feature-qualified @id. It runs in the ordinary cargo test set, unlike the e2e target, so a mis-numbered scenario is caught without a full suite run. Verified it fails on each of those drift shapes before passing on the fixed files. Also cover the report side: grouping, index ordering (09 before 10, not lexically), the fallback for an artifact with no feature field, and the anchor for a scenario that ran nowhere. Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
The <key>-<NN> scenario index and feature-qualified @id are now enforced by tests/feature_naming.rs, so the README should say what the convention is and where adding a feature file needs a matching entry. Also qualify Expectation in the Resolution struct - it sits above the function that imports the name. The e2e test target sets test = false, so cargo check --tests skips it and this only surfaced on cargo xtask e2e. Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
Grid::build only filled in a scenario's feature when the slot was still empty, but feature_of never returns empty - its last resort is the id's leading segment. So the first input to mention an id fixed its feature permanently, and a later artifact that actually names the feature was ignored. Consolidating a pre-expectation artifact with a current one then split a feature in two: 'serve' from the id prefix and 'Model serving' from the real name, sorted far apart. That is precisely the collapse the fallback chain exists to prevent. Treat an artifact that names the feature as authoritative and let it overwrite. Covered by a test that mixes both vintages in either input order; it fails on the previous logic. Also from review: match Scenario Outline in the drift guard (none today, but an outline would slip past all four checks silently), assert FEATURE_KEYS has no entry for a deleted file, and align the scenario-reference name preference with the grid's. Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
From review of #186. The guard passed vacuously when a feature file yielded no parseable scenarios: every check is a loop over scenarios_of(), so an empty result meant zero loop bodies and a green test. Mangling the Scenario: keyword in chat.feature - the class of change this PR itself makes - hid all seven of its scenarios while the guard still reported 4 passed. Assert each file parses at least one scenario. The guard also read tags differently from the harness: it stripped the leading @ off the line rather than off each tag, so on a multi-tag line every token after the first kept its own @ and the id was invisible. Valid Gherkin the harness resolves fine (@requires-os:linux @id:examine-version) failed the guard. Strip per tag, as ScenarioDecl::from_tags does. Drop the two #[serde(default)] attributes added to ResolvedScenario: it derives Serialize only, so a deserialization attribute is dead there. The backward compatibility is real but comes entirely from ManifestExpectation on the consuming side; say so where the fields are declared. Give the display name the same last-non-empty-wins rule as the feature. Note that the reviewer's stated failure mode for this one does not hold - the previous is_empty() check already meant first NON-EMPTY wins, so a nameless artifact could not pin a stale sort position. The only case the rules differ on is two artifacts naming an id differently, which is what the new test pins. Also fix a comment left stale by this PR (#### -> ##### for the id anchors) and stop duplicating the FEATURE_KEYS list in the README, where nothing kept it in sync. Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
b66e9aa to
7c03df5
Compare
|
Thanks — this was a genuinely useful review. I reproduced findings 1 and 2 before fixing them, and both behaved exactly as you described. All six actionable items are addressed in 7c03df5, and the branch is now rebased onto current 1 — vacuous guard. Confirmed: renaming the seven 2 — tag parser divergence. Confirmed: 3 — dead 4 — stale comment. Fixed, 5 — asymmetric override. Applied the same last-non-empty-wins rule to the display name, but one part of the rationale doesn't hold and I want to flag it rather than quietly bank the finding: the previous 6 — README duplication. Dropped the enumeration; it now points at 7 — pre-existing corner. Agreed, and thanks for checking it against Rebase. Onto |
CI status after the rebase20/21 green. Two jobs went red on the first post-rebase run; I chased both rather than waving them through.
That is a lemonade runtime failure — nothing a scenario-renaming and report-grouping change can reach. Notably these are the same two ids already declared Worth a maintainer's judgement as separate work, not something I want to fold into this PR: we now have evidence EAI-7423 reaches the Windows lane too, so the
This is the runner agent failing to read its own configuration before any workflow step runs — not a test failure and not reachable from this diff. The same job passed on this branch pre-rebase in 7m54s on |
Summary
The consolidated E2E report's expectation grid rendered all 66 scenarios as one
undivided table, sorted alphabetically by scenario id. This splits it into one
sub-table per feature and gives every scenario an index that actually
identifies it.
per
Feature:, with rows in feature-file order. The Scenario referencesection follows the same grouping, so the links from the grid land in a
matching layout.
1, so
1named eight different scenarios. Some files had drifted further:examineran 1, 2, 5, 3, 4;model_servinghad a stray6band a10sitting in sixth place;
install_lifecyclehad no indexes at all. Scenariosare now
<feature-key>-<NN>, sequential in declaration order.@ids didn't carry their feature's key(
help-*inexamine.feature,fix-*indiagnose.feature), so the idalone didn't say where the scenario lived. Renamed, along with the matching
expectations.tomlkey.tests/feature_naming.rsenforces the convention (indexessequential per feature and unique suite-wide, ids feature-qualified) in the
ordinary
cargo testrun.Why: the grid is the main artifact for "where should each test pass", and at 66
undivided rows it was hard to scan a single area of the CLI or find a scenario
you cared about.
Risk: low. The report is a CI artifact — no product code is touched. The
scenario renames are internal to the suite; nothing outside it keys on scenario
names, and the four renamed ids were grepped repo-wide.
Non-obvious decisions
platform.json, not the id prefix. A scenarioskipped on every platform never reaches
report.json, so the harness recordseach scenario's feature and name alongside its resolved expectation. Deriving
the feature from the id prefix would re-encode structure in a string and break
the moment a key is renamed, so it is kept only as the last fallback.
platform.json'sfeature→report.json's feature name → the id's leading segment. Both new fields are#[serde(default)]on the consuming side (ManifestExpectation), sopre-existing artifacts still render rather than dropping rows. (An earlier
revision of this description said "on both sides" — that was wrong: the
producer derives
Serializeonly, where a deserialization attribute is dead.The attributes have been removed there.) An artifact that names the feature is treated as
authoritative and overrides a fallback guess — without that, the first input
to mention an id fixed its feature permanently and one feature could split
into two groups.
that was n/a on every platform previously had a dangling link.
Test plan
cargo test -p e2e-report -p e2e-cucumber— new unit tests cover grouping,numeric-not-lexical index ordering (
09before10), the no-feature-fieldfallback, the authoritative-override case, and the anchor for a scenario that
ran nowhere.
existed before this change (restarted numbering, out-of-order index,
unqualified id) before passing on the fixed files.
cargo fmt --checkand both CI clippy invocations clean.cargo xtask e2e-reportover its output — 66 rows across 8 feature groups inthe right order, and 66 resolving anchors. Re-ran with the new fields stripped
from
platform.json: all 66 rows still render via the fallback.Two
diagnose-*scenarios fail on my local WSL2 box (rocm diagnosereportsitself out of scope on WSL2, which uses
/dev/dxgrather than/dev/kfd);they are unrelated to this change and pass on the Linux runners.
tests/e2e-cucumber/expectations.tomlfor the fixed ticket ID and removed/narrowed any now-stale xfail rows.