Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/skills/test-authoring/behave/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,22 @@ never matches and passes falsely.
`@flatpak_cli`. Tag CLI-only, image-agnostic software scenarios with `@flatpak_cli`
so they still run on gnomeos and other non-Bluefin images.

## `@requires_cached_image` gates scenarios on a pre-pulled OCI image

A scenario must never pull the container image it needs — a cold
`distrobox create` pulls inside the scenario and eats the CI timeout (#501).
Tag it `@requires_cached_image`; `skip_when_image_not_cached()` in
`tests/shared/image_cache.py`, called from the suite's `before_scenario` right
after `skip_quarantine`, reads the image refs out of the scenario's own step
text, probes each with `podman image exists` on the DUT, and skips while any is
absent. The scenario then activates on its own once the image is cached.

It is a **runtime capability gate** like `@requires_bctl`, not a non-runnable
tag: keep it out of `_SKIP_TAGS` / `NON_RUNNABLE_TAGS` / `BEHAVE_TAG_ARGS`, and
never pair it with `@pending` or `@future` — `skip_quarantine` returns first and
the gate goes inert. See
[the cached-image gate reference](references/cached-image-gate.md).

## Feature scaffolding with @future


Expand Down Expand Up @@ -326,6 +342,8 @@ Each suite loads only its own `steps/*.py` files plus `qecore.common_steps`. A s

Lesson surfaced 2026-05-30: `No journal entries match "{pattern}"` was added to `software/steps.py` but `ptyxis.feature` (developer suite) also used it — causing `UndefinedStep` at runtime.

Isolation cuts the other way too: an `environment.py` hook that imports a module containing `@step` decorators registers those phrases into the suite it runs in. `tests/shared/ssh_steps.py` collides with the DX suite's own `SSH command return code is "{code}"`, so hooks that need to run a command on the DUT resolve connection details from `tests/shared/ssh_config.py` and call `subprocess` directly rather than importing the step library (#501).

## behave rerun output can contain non-path noise


Expand Down Expand Up @@ -473,6 +491,7 @@ non-dependent scenarios to a separate feature.
- [Mocking interactive CLI tools (gum, fzf, gh) in ujust coverage.](references/mocking-interactive-cli.md)
- [Driving bluefinctl devmode non-interactively, and the assertion traps around it.](references/bctl-devmode.md)
- [Which ujust recipes can be driven non-interactively, and why the rest stay @pending.](references/ujust-noninteractive.md)
- [Gating scenarios on a pre-pulled OCI image with @requires_cached_image.](references/cached-image-gate.md)

## Sources

Expand Down
104 changes: 104 additions & 0 deletions docs/skills/test-authoring/behave/references/cached-image-gate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
---
name: cached-image-gate
description: "Gating scenarios on a pre-pulled OCI image with @requires_cached_image, and the masking trap that makes the tag inert."
metadata:
type: reference
audience: agents
maturity: stable
---
# Cached Image Gate

## Why the tag exists

A scenario that needs a container image on the device under test must never pull
that image itself. `distrobox create --image registry.fedoraproject.org/fedora-toolbox:latest`
against a cold podman store pulls hundreds of megabytes inside the scenario and
eats the CI timeout, so the run reports "distrobox is broken" when the real
finding is "the runner had no image". That is the blocker recorded on
`projectbluefin/testsuite#501` and tracked lab-side as `projectbluefin/lab#621`.

`@requires_cached_image` turns that blocker into a runtime condition instead of a
hand-maintained `@pending` marker.

## How it works

`tests/shared/image_cache.py` exposes `skip_when_image_not_cached(context, scenario)`.
A suite calls it from `before_scenario`, immediately after `skip_quarantine`:

```python
from tests.shared.image_cache import skip_when_image_not_cached

if skip_when_image_not_cached(context, scenario):
return
```

For a tagged scenario it:

1. reads the image references out of the **scenario's own step text** — the
feature file stays the single source of truth for what is under test, so the
probe can never drift from the image the steps actually use;
2. runs `podman image exists <ref>` on the DUT for each distinct reference;
3. skips with the missing references named when any is absent.

`podman image exists` is local-only and never contacts a registry. That is
deliberate and load-bearing: a probe that could pull would trigger the very
timeout the gate exists to prevent. `tests/unit/test_image_cache.py` asserts the
probe command shape for exactly this reason.

An unreachable DUT counts as "not cached" — a failed SSH probe is not evidence
that an image is present, and skipping is the safe reading.

## Why the probe does not use `run_ssh`

`tests/shared/ssh_steps.py` looks like the obvious way to run the probe, but
importing it registers its `@step` phrases into behave's global registry. Two of
them — `SSH command return code is "{code}"` and `Last command output contains
"{text}"` — are also defined by `tests/dx/features/steps/steps.py`. A DX
`before_scenario` that imported the module would raise `AmbiguousStep` and take
the entire suite down, and the DX suite is the first consumer of this gate.

So the probe resolves connection details from `tests/shared/ssh_config.py` — the
helper explicitly documented for "suite `environment.py` hooks that probe the VM
directly", carrying no step definitions — and shells out itself.
`test_gate_does_not_import_the_shared_ssh_step_library` pins this.

The probe also leaves `context.command_stdout` / `context.ssh_rc` alone, unlike
a step. It runs before `before_scenario` resets per-scenario state, so writing
to those would leak a probe result into the scenario's first assertion.

## Three rules that are easy to get wrong

**It is not a non-runnable tag.** Do not add it to `_SKIP_TAGS` in
`tests/shared/quarantine.py`, to `NON_RUNNABLE_TAGS` in
`tests/shared/behave_retry.py`, or to `BEHAVE_TAG_ARGS` in `e2e.yml`. It is a
runtime capability gate in the family of `@requires_bctl` and
`@requires_toggle_action`: the scenario must begin running the moment the image
is cached, with no feature-file edit and no follow-up PR.

**Never pair it with `@pending`, `@future`, `@quarantine`, or `@hardware_blocked`.**
`skip_quarantine` runs first and returns early, so the gate never executes and the
tag is inert — the masking trap documented in
`docs/skills/test-authoring/suite-map/SKILL.md`. This is not hypothetical: the
three distrobox scenarios carried `@pending @requires_cached_image` from the day
they landed, and the tag did nothing at all until #501 was finished.
`test_tagged_scenarios_are_not_masked_by_a_non_runnable_tag` fails the build if it
recurs.

**Name the image with a registry and a tag.** `registry.fedoraproject.org/fedora-toolbox:latest`
is recognised; bare `fedora:latest` is not, because what it resolves to depends on
the DUT's `registries.conf` search list — the probe could then disagree with the
pull the scenario would perform. A tagged scenario whose steps name no qualified
image is an authoring error, and
`test_every_tagged_scenario_names_an_image` fails on it rather than letting it
skip forever while looking like an infra gap.

## Verifying a gated scenario

`behave --dry-run` cannot exercise the gate: it never calls `before_scenario`.
Check the gate with the unit tests, and check the scenario body against a DUT
that has the image pre-pulled:

```bash
python3 -m pytest tests/unit/test_image_cache.py -q
podman pull registry.fedoraproject.org/fedora-toolbox:latest # on the DUT
```
7 changes: 4 additions & 3 deletions docs/skills/test-authoring/suite-map/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ Set `chunked_enabled: true` once `ghcr.io/projectbluefin/bluefin:latest` ships z
| `@regression` | Anchors a known incident regression guard; must remain active indefinitely |
| `@kde_smoke` | KDE Plasma smoke-suite identifier; used by `e2e.yml` suite registration (#645) |
| `@informational` | Bake-period tier; scenario runs and reports results but does not gate promotion until promoted to `@critical` |
| `@requires_cached_image` | Scenario needs the OCI image named in its own steps to be pre-pulled on the DUT; `tests/shared/image_cache.py` probes `podman image exists` from `before_scenario` and skips while it is absent. A **runtime capability gate** like `@requires_bctl`, not a non-runnable tag — never pair it with `@pending`/`@future`, which mask it (#501) |

## Coverage snapshot

Expand All @@ -159,14 +160,14 @@ Set `chunked_enabled: true` once `ghcr.io/projectbluefin/bluefin:latest` ships z

<!-- coverage-snapshot:start -->

526 scenarios across 72 feature files: 412 active, 0 quarantined, 114 `@future`/`@pending`/`@hardware_blocked`
526 scenarios across 72 feature files: 415 active, 0 quarantined, 111 `@future`/`@pending`/`@hardware_blocked`

| Suite | Scenarios | Active | Quarantined | Pending/Future | Notes |
|---|---|---|---|---|---|
| bazzite | 20 | 20 | 0 | 0 | Extension presence + shell behaviour |
| common | 121 | 101 | 0 | 20 | Signing assertions `@future` pending the ublue-os→projectbluefin policy migration; flatpak model/state, dconf defaults, immutability and portal socket checks `@pending` on CI infra; Flatpak model + state; XDG portal health + integration; container runtime (podman); polkit rules; shell env + sourcing; system scripts; ujust recipes; devmode via bctl (non-interactive contract + idempotent state-check gated `@requires_bctl`, group mutation `@pending` on CI polkit); GSettings/dconf defaults; immutable OS integrity; desktop entries; signing assertions; Dakota `ujust --choose` regression guard active (`@dakota_only`); `ujust report` is `@pending` on #706 until a Dakota lab run validates the mocked submit flow |
| developer | 23 | 7 | 0 | 16 | 6 brew + 6 ptyxis + 4 bctl now `@pending`: `brew-setup.service` masked in CI (#487) and the ptyxis AT-SPI restart issue (#368) |
| dx | 18 | 10 | 0 | 8 | distrobox enter/create/install/export, JupyterLab, brew, mise — infra gaps, all `@pending` |
| dx | 18 | 13 | 0 | 5 | distrobox create/install/export are active behind the `@requires_cached_image` runtime gate — they skip until `fedora-toolbox:latest` is pre-pulled on the VM (#501 / projectbluefin/lab#621) and activate without a feature-file edit; distrobox enter, JupyterLab, brew, mise remain `@pending` on infra gaps |
| flatcar | 13 | 12 | 0 | 1 | boot (7 active) + lifecycle (5 active); 1 `@future` (boot from installed target disk — needs KubeVirt boot-order support in `projectbluefin/lab`) |
| hardware | 13 | 13 | 0 | 0 | udev rules syntax validation (ZSA, Apple SuperDrive, Framework 16, AMD s2idle, Wooting, VIIA); emulated peripherals driven by shared SSH steps |
| installer | 3 | 3 | 0 | 0 | post-boot assertions for installer-driven installs (UEFI, Flatpak exclusion, LUKS cmdline) |
Expand Down Expand Up @@ -255,7 +256,7 @@ skipped-coverage table above.
| ptyxis: `@brew` (×1) | developer | `@pending` | brew must be initialized first (#487) |
| ptyxis: `@input`, `@podman`, `@regression`, `@new_tab`, `@close` (×5) | developer | `@pending` | AT-SPI restart issue in CI (#368) — ptyxis reopens between scenarios but the new process isn't reliably accessible |
| distrobox enter (×1) | dx | `@pending` | pulls `fedora:latest`; no pre-pull in CI, times out |
| distrobox create/install/export (×3) | dx | `@pending @requires_cached_image` | no cached `fedora-toolbox:latest` on the VM; lab-side OCI image pre-pull required (#501, tracked in projectbluefin/lab#621) |
| distrobox create/install/export (×3) | dx | `@requires_cached_image` | Active, gated at runtime, **not** `@pending`. `tests/shared/image_cache.py` probes `podman image exists` for the image each scenario names and skips while `fedora-toolbox:latest` is absent from the VM's podman store. Self-activating once the lab-side OCI image pre-pull lands (#501, tracked in projectbluefin/lab#621) — no feature-file edit needed |
| JupyterLab (×1) | dx | `@pending` | not preinstalled in DX image |
| brew + mise (×3) | dx | `@pending` | `brew-setup.service` masked (#487) — mise uses brew-installed shims |
| ujust report confirm validation (×1) | smoke | `@pending` | `just` template change not in the booted image; awaiting rebuild |
Expand Down
2 changes: 1 addition & 1 deletion scripts/update_coverage_snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
"hardware": "udev rules syntax validation (ZSA, Apple SuperDrive, Framework 16, AMD s2idle, Wooting, VIIA); emulated peripherals driven by shared SSH steps",
"security": "cosign verify: projectbluefin (bluefin, lts, dakota) + ublue-os (latest, LTS, DX, nvidia, GTS, DX-nvidia, negative)",
"bazzite": "Extension presence + shell behaviour",
"dx": "distrobox enter/create/install/export, JupyterLab, brew, mise — infra gaps, all `@pending`",
"dx": "distrobox create/install/export are active behind the `@requires_cached_image` runtime gate — they skip until `fedora-toolbox:latest` is pre-pulled on the VM (#501 / projectbluefin/lab#621) and activate without a feature-file edit; distrobox enter, JupyterLab, brew, mise remain `@pending` on infra gaps",
"nvidia": "`@future` / `@hardware_blocked` until GPU passthrough exists in the lab",
"flatcar": "boot (7 active) + lifecycle (5 active); 1 `@future` (boot from installed target disk — needs KubeVirt boot-order support in `projectbluefin/lab`)",
"kde-smoke": "Plasma session, D-Bus services, AT-SPI tree, KWin output, one KCM, Dolphin, Konsole, Kickoff; all `@informational`",
Expand Down
17 changes: 11 additions & 6 deletions tests/dx/features/dx_tools.feature
Original file line number Diff line number Diff line change
Expand Up @@ -41,19 +41,24 @@ Feature: Bluefin DX variant smoke tests
# create a container, install an app inside it, export it to the host.
# All carry @requires_cached_image: they need a pre-pulled
# registry.fedoraproject.org/fedora-toolbox:latest on the VM, which no
# caching infrastructure provides yet (see projectbluefin/testsuite#501).
# They are @pending until projectbluefin/lab provisions that image cache;
# tests/shared/quarantine.py skips @pending at runtime.
@pending @dx @distrobox @plain_ssh @requires_cached_image
# caching infrastructure provides yet (see projectbluefin/testsuite#501,
# blocked on projectbluefin/lab#621).
#
# @requires_cached_image is a runtime capability gate, not a pending marker:
# environment.py probes `podman image exists` for the image each scenario
# names and skips with an explicit reason while it is absent, exactly as
# @requires_bctl does for bluefinctl. Once lab#621 pre-pulls the image these
# scenarios activate on their own — no edit to this file.
@dx @distrobox @plain_ssh @requires_cached_image
Scenario: distrobox container can be created from fedora-toolbox
* DX distrobox "test-box" can be created from "registry.fedoraproject.org/fedora-toolbox:latest"

@pending @dx @distrobox @plain_ssh @requires_cached_image
@dx @distrobox @plain_ssh @requires_cached_image
Scenario: package can be installed inside a distrobox container
* DX distrobox "test-box" can be created from "registry.fedoraproject.org/fedora-toolbox:latest"
* DX distrobox "test-box" installs package "htop"

@pending @dx @distrobox @plain_ssh @requires_cached_image
@dx @distrobox @plain_ssh @requires_cached_image
Scenario: app inside a distrobox container can be exported to the host
* DX distrobox "test-box" can be created from "registry.fedoraproject.org/fedora-toolbox:latest"
* DX distrobox "test-box" installs package "htop"
Expand Down
6 changes: 6 additions & 0 deletions tests/dx/features/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,16 @@ def before_all(context):


def before_scenario(context, scenario):
from tests.shared.image_cache import skip_when_image_not_cached
from tests.shared.quarantine import skip_quarantine

if skip_quarantine(scenario):
return
# @requires_cached_image scenarios assume their image is already in the
# VM's podman store; running one without it would pull from the registry
# mid-scenario and hit the CI timeout instead of reporting a result.
if skip_when_image_not_cached(context, scenario):
return
context.scenario = scenario
context.command_stdout = ""
context.last_command_output = ""
Expand Down
Loading
Loading