Skip to content

Commit c67cb69

Browse files
committed
test(e2e): address PR review — clippy gate, real assertions, CI hardening
Resolves the review findings on PR #69: Blocking: - Wire clippy for the e2e harness + step files: the `test = false` e2e target is skipped by `clippy --all-targets`, leaving ~1.6k lines unlinted. Add a CI step `cargo clippy -p e2e-cucumber --test e2e -- -D warnings`, and fix the 9 lints it surfaced (map/unwrap_or_else→let-else, collapsible-if, Duration::from_mins, operator-precedence, doc-paragraph, items-after-statements). - Two vacuous scenarios no longer pass while testing nothing: chat-privacy-notice-accurate now fails (TUI-only, unverifiable black-box) and is tracked xfail (EAI-7222); serve-vllm-default-on-instinct asserts rc == 0. Should-fix: - Unify the report.json pass predicate: the CI gate (scenario_results_by_id) and the grid (id_pass_map/tally) disagreed on "skipped" status; both now route through scenario_passed (all-steps-passed), so the same artifact can't fail the job yet render green. - Enforce serial GPU serves: cap cucumber concurrency to 1 when a GPU is present (shared port 11435 / one card); mock keeps default parallelism. - Mock e2e job runs at job level (not skipped on non-heavy PRs, which stalled the merge queue for a required check); heavy/build-and-test gating moved to a step. - Remove the unanchored `pkill -f 'vulkan/llama-server'` (killed manual serves on the shared runner); the anchored /tmp/rocm-e2e line already covers it. - scenario_status treats a failed before/after hook as failed (was scored passed). - Default-engine serve uses a model-aware readiness wait + asserts the reply model id, so it can't pass against a leaked prior serve on the shared port. - Update the stale README (abandoned tag model / 3-tier CI → @requires-* + expectations.toml + one-job-per-platform) and fix the expectations.toml path ref. Nits: bar_widths skip remainder, e2e-report crate doc (serde_json), duplicate-@id assertion, stale 15-min comment. Signed-off-by: fredespi <fredrik.espinoza@gmail.com>
1 parent 4f39d2b commit c67cb69

9 files changed

Lines changed: 250 additions & 66 deletions

File tree

.github/workflows/ci.yml

Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,14 @@ jobs:
358358
if: needs.changes.outputs.rust == 'true'
359359
run: cargo clippy --locked --workspace --all-targets -- -D warnings
360360

361+
# The e2e-cucumber `e2e` target sets `test = false` (so nextest/`cargo test
362+
# --all-targets` skip the custom harness), which also excludes it from
363+
# `clippy --all-targets` above — leaving the ~1.6k lines of harness + step
364+
# code unlinted. Clippy honours an explicit `--test`, so lint it directly.
365+
- name: Clippy (e2e harness + steps)
366+
if: needs.changes.outputs.rust == 'true'
367+
run: cargo clippy --locked -p e2e-cucumber --test e2e -- -D warnings
368+
361369
- name: MANIFEST.md dependency table is current
362370
if: needs.changes.outputs.rust == 'true'
363371
run: cargo xtask manifest --check
@@ -585,38 +593,56 @@ jobs:
585593
timeout-minutes: 15
586594
runs-on: ubuntu-latest
587595
needs: [changes, build-and-test]
588-
# On push/PR/merge_group: run when build-and-test succeeded and the change is
589-
# heavy. On workflow_dispatch: build-and-test is skipped, so tolerate that and
590-
# gate on the platform input instead.
596+
# This is a REQUIRED check, so — like every sibling required job — the job
597+
# itself must always run (except on a manual dispatch that didn't select the
598+
# mock platform), and the actual work is gated at the STEP level. Gating
599+
# `heavy` at the job level would SKIP the job on a non-heavy PR, and a required
600+
# check that is never produced stalls the merge queue. `merge_group` forces
601+
# heavy=true, so the mock gate always executes there as a backstop.
591602
if: >-
592603
always()
593604
&& needs.changes.result == 'success'
594-
&& (
595-
(github.event_name != 'workflow_dispatch'
596-
&& needs.build-and-test.result == 'success'
597-
&& needs.changes.outputs.heavy == 'true')
598-
|| (github.event_name == 'workflow_dispatch'
599-
&& (inputs.platform == 'all' || inputs.platform == 'mock'))
600-
)
605+
&& (github.event_name != 'workflow_dispatch'
606+
|| inputs.platform == 'all' || inputs.platform == 'mock')
601607
steps:
602608
- uses: actions/checkout@v6
603609

610+
# Whether the real work runs this time. On push/PR/merge_group: only when
611+
# build-and-test succeeded and the change is heavy. On workflow_dispatch:
612+
# build-and-test is skipped, so tolerate that and rely on the job-level
613+
# platform gate above.
614+
- name: Decide whether to run
615+
id: gate
616+
run: |
617+
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
618+
echo "run=true" >> "$GITHUB_OUTPUT"
619+
elif [ "${{ needs.build-and-test.result }}" = "success" ] \
620+
&& [ "${{ needs.changes.outputs.heavy }}" = "true" ]; then
621+
echo "run=true" >> "$GITHUB_OUTPUT"
622+
else
623+
echo "run=false" >> "$GITHUB_OUTPUT"
624+
fi
625+
604626
- name: Install native build deps
627+
if: steps.gate.outputs.run == 'true'
605628
run: sudo apt-get update && sudo apt-get install -y pkg-config libcap-dev
606629

607630
- uses: actions-rust-lang/setup-rust-toolchain@v1
631+
if: steps.gate.outputs.run == 'true'
608632

609633
# The workspace test job selects only affected crates and runs the cucumber
610634
# harness, not the library's own unit tests — run the capability/expectation
611635
# /report unit tests (resolver logic, grid reconciliation) here.
612636
- name: Unit tests (e2e-cucumber lib)
637+
if: steps.gate.outputs.run == 'true'
613638
run: cargo test -p e2e-cucumber --lib
614639

615640
- name: Run E2E tests
641+
if: steps.gate.outputs.run == 'true'
616642
run: cargo xtask e2e
617643

618644
- name: Upload E2E report
619-
if: always()
645+
if: always() && steps.gate.outputs.run == 'true'
620646
uses: actions/upload-artifact@v4
621647
with:
622648
name: e2e-report
@@ -647,10 +673,11 @@ jobs:
647673
)
648674
continue-on-error: true
649675
env:
650-
# Bound serve readiness below the 15-min job cap so a serve that never comes
676+
# Bound serve readiness below the 35-min job cap so a serve that never comes
651677
# ready fails the scenario with a real error instead of hanging until the
652678
# job is cancelled. 300s is ample for a real MI300X vLLM cold-start;
653-
# per-scenario known-bug overrides in expectations.toml shorten it further.
679+
# per-scenario overrides in expectations.toml / a `@serve-timeout` tag adjust
680+
# it (shorter for known bugs, longer for large models).
654681
E2E_SERVE_TIMEOUT_SECS: "300"
655682
steps:
656683
- uses: actions/checkout@v6
@@ -665,7 +692,11 @@ jobs:
665692
pkill -f '/tmp/rocm-e2e.*llama-server' 2>/dev/null || true
666693
pkill -f '/tmp/rocm-e2e.*vllm serve' 2>/dev/null || true
667694
pkill -f 'e2e-shared.*llama-server' 2>/dev/null || true
668-
pkill -f 'vulkan/llama-server' 2>/dev/null || true
695+
# NOTE: no unanchored `pkill -f 'vulkan/llama-server'` — the lemonade
696+
# Vulkan assistant an e2e scenario spawns lives under /tmp/rocm-e2e-*
697+
# and is already caught by the first line; an unanchored pattern would
698+
# also kill a legitimate /workload manual-testing serve on this shared
699+
# self-hosted runner.
669700
pkill -f '__engine-serve-http.*rocm-e2e' 2>/dev/null || true
670701
pkill -f 'e2e-target/release/rocm daemon' 2>/dev/null || true
671702
rm -rf /tmp/rocm-e2e-* 2>/dev/null || true

crates/e2e-report/src/lib.rs

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
//! HTML/markdown reporting for the cucumber E2E suite.
66
//!
7-
//! Lives in its own lean crate (only `maud` + `serde`) so both the
7+
//! Lives in its own lean crate (only `maud` + `serde`/`serde_json`) so both the
88
//! `e2e-cucumber` test harness and `xtask` can depend on it without pulling the
99
//! harness's heavy tree (cucumber/axum/reqwest/tokio) into `xtask`.
1010
@@ -30,6 +30,21 @@ struct Element {
3030
tags: Vec<Tag>,
3131
#[serde(default)]
3232
steps: Vec<Step>,
33+
/// Before-scenario hooks (cucumber JSON `before`). A failing Before hook
34+
/// leaves `steps` empty, so it must be inspected too or the scenario scores
35+
/// as passed despite never running.
36+
#[serde(default)]
37+
before: Vec<Hook>,
38+
/// After-scenario hooks (cucumber JSON `after`).
39+
#[serde(default)]
40+
after: Vec<Hook>,
41+
}
42+
43+
/// A cucumber before/after hook entry — we only need its result status.
44+
#[derive(Deserialize)]
45+
struct Hook {
46+
#[serde(default)]
47+
result: StepResult,
3348
}
3449

3550
#[derive(Deserialize)]
@@ -102,7 +117,10 @@ impl Stats {
102117
}
103118
let pw = self.passed * 100 / self.total;
104119
let fw = self.failed * 100 / self.total;
105-
let sw = 100 - pw - fw;
120+
// Derive the skip width from the actual skipped count, not `100 - pw - fw`
121+
// — the latter dumped the integer-division remainder into the skip
122+
// segment, rendering a grey sliver even when there are zero skips.
123+
let sw = self.skipped * 100 / self.total;
106124
Some((pw, fw, sw))
107125
}
108126

@@ -130,6 +148,14 @@ fn stats_bar(stats: &Stats) -> Markup {
130148
}
131149

132150
fn scenario_status(el: &Element) -> &'static str {
151+
// A failing before/after hook fails the scenario even when `steps` is empty
152+
// (a Before-hook failure prevents steps from running), so it must be checked
153+
// — otherwise a hook-failed scenario falls through to "passed".
154+
for h in el.before.iter().chain(el.after.iter()) {
155+
if !matches!(h.result.status.as_str(), "" | "passed" | "skipped") {
156+
return "failed";
157+
}
158+
}
133159
// Any non-pass, non-skip step status (failed, undefined, ambiguous, pending)
134160
// fails the scenario — an undefined step must not report as passed.
135161
for s in &el.steps {
@@ -145,6 +171,18 @@ fn scenario_status(el: &Element) -> &'static str {
145171
"passed"
146172
}
147173

174+
/// The single source of truth for "did this scenario pass" across BOTH the CI
175+
/// gate (`scenario_results_by_id`) and the report grid (`id_pass_map`/tally).
176+
///
177+
/// A scenario counts as passed ONLY when every step passed — a `skipped` status
178+
/// (steps skipped after an early bail, or an undefined step) is NOT a pass. The
179+
/// gate and the grid previously disagreed on this (gate: `== "passed"`, grid:
180+
/// `!= "failed"`), so the same `report.json` could fail the job yet render green
181+
/// in the consolidated grid. Route both through here so they can never diverge.
182+
fn scenario_passed(el: &Element) -> bool {
183+
scenario_status(el) == "passed"
184+
}
185+
148186
fn scenario_duration(el: &Element) -> u64 {
149187
el.steps.iter().map(|s| s.result.duration).sum()
150188
}
@@ -247,7 +285,7 @@ pub fn scenario_results_by_id(json_path: &Path) -> std::io::Result<Vec<(String,
247285
for f in &features {
248286
for el in &f.elements {
249287
if let Some(id) = scenario_id(el) {
250-
out.push((id, scenario_status(el) == "passed"));
288+
out.push((id, scenario_passed(el)));
251289
}
252290
}
253291
}
@@ -699,7 +737,7 @@ fn id_pass_map(json_path: &Path) -> std::collections::HashMap<String, bool> {
699737
features
700738
.iter()
701739
.flat_map(|f| &f.elements)
702-
.filter_map(|el| scenario_id(el).map(|id| (id, scenario_status(el) != "failed")))
740+
.filter_map(|el| scenario_id(el).map(|id| (id, scenario_passed(el))))
703741
.collect()
704742
}
705743

@@ -1801,6 +1839,35 @@ mod tests {
18011839
);
18021840
}
18031841

1842+
#[test]
1843+
fn scenario_passed_is_strict_and_shared() {
1844+
// The unified predicate: only an all-steps-passed scenario counts as
1845+
// passed. A skipped scenario is NOT a pass — both the CI gate and the
1846+
// grid go through scenario_passed, so they can't diverge on this.
1847+
assert!(scenario_passed(&scenario_from(&["passed", "passed"])));
1848+
assert!(!scenario_passed(&scenario_from(&["passed", "skipped"])));
1849+
assert!(!scenario_passed(&scenario_from(&["failed"])));
1850+
}
1851+
1852+
#[test]
1853+
fn before_hook_failure_scores_scenario_failed() {
1854+
// A failing Before hook leaves steps empty; without checking hooks the
1855+
// scenario would fall through to "passed". It must score failed.
1856+
let el: Element = serde_json::from_str(
1857+
r#"{"name":"s","steps":[],"before":[{"result":{"status":"failed"}}]}"#,
1858+
)
1859+
.expect("valid element json");
1860+
assert_eq!(scenario_status(&el), "failed");
1861+
assert!(!scenario_passed(&el));
1862+
1863+
// A passed Before hook + passed steps is still a pass.
1864+
let ok: Element = serde_json::from_str(
1865+
r#"{"name":"s","steps":[{"keyword":"Given ","name":"x","result":{"status":"passed"}}],"before":[{"result":{"status":"passed"}}]}"#,
1866+
)
1867+
.expect("valid element json");
1868+
assert!(scenario_passed(&ok));
1869+
}
1870+
18041871
fn write_report(features_json: &str) -> tempfile::NamedTempFile {
18051872
use std::io::Write as _;
18061873
let mut f = tempfile::NamedTempFile::new().expect("temp file");

tests/e2e-cucumber/README.md

Lines changed: 40 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -27,19 +27,26 @@ Tests exercise the `rocm` binary as a black box — no imports from the rocm-cli
2727
tests/e2e-cucumber/
2828
├── Cargo.toml # crate definition, dependencies
2929
├── README.md
30+
├── expectations.toml # per-scenario known-bug (xfail) matrix, keyed by @id
3031
3132
├── features/ # .feature files — one per feature area
3233
│ ├── chat.feature
33-
│ └── model_serving.feature
34+
│ ├── examine.feature
35+
│ ├── model_serving.feature
36+
│ └── runtime_setup.feature
3437
3538
├── tests/ # test binary + step modules
36-
│ ├── e2e.rs # World struct, runner, Drop cleanup
39+
│ ├── e2e.rs # World struct, runner, expectation reconciliation, Drop cleanup
3740
│ └── e2e/ # step functions — one file per feature area
3841
│ ├── chat_steps.rs
42+
│ ├── examine_steps.rs
43+
│ ├── runtime_steps.rs
3944
│ └── serving_steps.rs
4045
4146
└── src/ # shared test infrastructure
4247
├── lib.rs
48+
├── capability.rs # host capability probe (OS / GPU / effective engine)
49+
├── expectation.rs # tag parsing + pass/xfail/skip resolution
4350
└── mock_server.rs # axum mock OpenAI server
4451
```
4552

@@ -55,9 +62,6 @@ cargo xtask e2e
5562
# Filter by scenario name:
5663
cargo xtask e2e -- -n "model name"
5764

58-
# Skip known-bug scenarios:
59-
cargo xtask e2e -- -t "not @expected-failure-EAI-*"
60-
6165
# Stop on first failure:
6266
cargo xtask e2e -- --fail-fast
6367

@@ -74,24 +78,42 @@ ROCM_CLI_BINARY=./target/release/rocm cargo xtask e2e
7478
| `ROCM_CLI_DATA_DIR` | (temp dir) | Isolated data directory per scenario |
7579
| `ROCM_CLI_CACHE_DIR` | (temp dir) | Isolated cache directory per scenario |
7680

77-
## Tags
81+
## Tags and per-scenario expectations
82+
83+
There is no tag-filter tiering. Each CI job runs the **whole** suite
84+
(`cargo xtask e2e`, no `-t` filter); the harness resolves every scenario to
85+
**pass / xfail / skip** at runtime from its capability tags plus the known-bug
86+
matrix, then reconciles the actual result against that expectation.
7887

79-
Cucumber tag filters match exact tag names (no globbing), so scenarios carry a
80-
bare category tag for filtering plus an optional specific tag for traceability.
88+
Scenarios carry stable-id and capability tags:
8189

8290
| Tag | Meaning |
8391
|---|---|
84-
| `@gpu` | Requires real AMD GPU / ROCm hardware. Runs only in the non-blocking `e2e-gpu` CI job; excluded from the mock-tier job via `-t "not @gpu"`. |
85-
| `@expected-failure` | Known bug — excluded from the blocking mock job (`-t "not @expected-failure"`) and run in the non-blocking known-bugs job. |
86-
| `@expected-failure-EAI-NNNN` | Traceability to the specific tracked bug. Always paired with the bare `@expected-failure`. Remove both when the bug is fixed. |
87-
88-
CI runs three selections:
89-
90-
| Job | Filter | Blocking |
92+
| `@id:<slug>` | Stable scenario id. Keys the expectation matrix and the report grid; every scenario has one. |
93+
| `@requires-gpu` | Needs a real AMD GPU. Resolves to **skip** (n/a) on a host with none (e.g. the mock job). |
94+
| `@requires-engine:<vllm\|lemonade>` | Pins the serve engine. Resolves to skip where that engine can't start (e.g. vLLM on a lemonade-only Strix host). |
95+
| `@requires-os:<linux\|windows>` | Premise is OS-specific; skip on other OSes. |
96+
| `@serve-timeout:<secs>` | Lengthen the serve-readiness wait for a genuinely slow serve (e.g. a large model). |
97+
98+
Known bugs are **not** tagged in the `.feature` files — they live in
99+
`expectations.toml`, keyed by `@id`, each with a `when = { ... }` condition (e.g.
100+
`effective_engine = "vllm"`), a `bug` reference, and a `reason`. A scenario that
101+
matches a condition is expected to fail (xfail); if it then passes, that is an
102+
**XPASS** (stale entry — remove it). See `src/expectation.rs` for the resolver
103+
and `expectations.toml`'s header for the condition grammar.
104+
105+
CI runs one job per platform, each executing the full suite:
106+
107+
| Job | Platform | Blocking |
91108
|---|---|---|
92-
| `e2e` | `not @gpu and not @expected-failure` | yes |
93-
| `e2e-known-bugs` | `@expected-failure and not @gpu` | no |
94-
| `e2e-gpu` | `@gpu` | no |
109+
| `e2e` | Mock (no GPU, GitHub-hosted) | yes |
110+
| `e2e-gpu` | MI300X (self-hosted) | no |
111+
| `e2e-gpu-strix-ubuntu` | Strix Halo / Ubuntu (self-hosted) | no |
112+
| `e2e-gpu-strix-windows` | Strix Halo / Windows (self-hosted) | no |
113+
114+
The blocking mock job passes when every applicable scenario is pass-or-xfail with
115+
no XPASS or unexpected failure; the GPU jobs are non-blocking. The `e2e-report`
116+
job consolidates all platforms' results into one cross-platform report.
95117

96118
## From scenarios to tests
97119

tests/e2e-cucumber/expectations.toml

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
# does `serve` default to? does this platform prefer vLLM? — is DERIVED from the
77
# host capability probe (see src/capability.rs) and is NOT encoded here.
88
#
9-
# Resolution at runtime (see tests/e2e-cucumber/tests/e2e/expectation.rs):
9+
# Resolution at runtime (see tests/e2e-cucumber/src/expectation.rs):
1010
# 1. A scenario whose @requires-gpu / @requires-engine can't be satisfied on
1111
# this host is SKIPPED (not-applicable) — never listed here.
1212
# 2. Otherwise, the FIRST matching condition below marks it expected-fail
@@ -23,6 +23,14 @@
2323
#
2424
# An empty `when = {}` means "always xfail" (platform-independent bug).
2525

26+
# --- EAI-7222: privacy notice is only shown in the interactive dash/TUI, so a
27+
# black-box CLI test can't verify it. Tracked as xfail (any platform) rather than
28+
# a silent green no-op — the step panics, which is the expected outcome here. ---
29+
[["chat-privacy-notice-accurate"]]
30+
when = {}
31+
bug = "EAI-7222"
32+
reason = "Privacy notice is TUI-only; cannot be verified by a black-box CLI test."
33+
2634
# --- EAI-7219: short-name expansion not surfaced in serve output (any engine) ---
2735
[["serve-short-name-expansion"]]
2836
when = {}

0 commit comments

Comments
 (0)