Skip to content

Run the e2e suite in CI, and make it say what it actually did - #123

Merged
blooop merged 4 commits into
mainfrom
wayfinder/devlaunch-114
Aug 8, 2026
Merged

blooop merged 4 commits into
mainfrom
wayfinder/devlaunch-114

Conversation

@blooop

@blooop blooop commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Closes #114.

One e2e job in .github/workflows/ci.yml, plain unnested pytest -m e2e on
the runner's own Docker. It runs wherever the rest of this workflow runs: pushes
to main, and pull requests targeting main. A PR onto any other base — a
stacked one — triggers the whole workflow not at all, which is a repo-wide gap
rather than an e2e one, and is now stated rather than papered over with "every
pull request". No dind, no devcontainer, no devpod install step —
ubuntu-latest is an ephemeral VM with Docker preinstalled, and devpod is a pixi
dependency already restored by setup-pixi. Outside the py310–py313 matrix,
because what the suite exercises is devpod and Docker rather than a Python
version.

Per-PR, not nightly

Per pull request, and the measurement settles it more comfortably than the
estimate did: the e2e job finishes before the matrix does — 37s against
42–52s on the final run, with the suite itself 28.67s and the 1.25 GB fixture
image pulled once for all four workspaces. It runs beside the matrix, so it adds
nothing to the wait. Against that, a nightly failure arrives detached from the
change that caused it, on a repo where nobody is rostered to triage one.

The choice is one job with one comment above it: to make it nightly, add
schedule: to on: and one if: to the job. Nothing else moves. There is also
a workflow_dispatch trigger now, so the suite can be run on demand without
either.

timeout-minutes: 25 is not boilerplate. The registry outage described below is
a job that hangs for six hours without it.

The part that makes the tick mean something

A job that fires and skips its way to green is worse than one that never fires,
and this suite is unusually good at it. skipped names two unrelated outcomes
here: thirteen test_interactive_session.py tests declining an opt-in they were
never given, and a test that could not reach a registry. #114's comment records a
run that reported 7 passed, 14 skipped having created zero containers,
because mcr.microsoft.com was serving the fixture image at 640 B/s. That
summary line is byte-identical to a healthy run's. Thirteen legitimate skips is
exactly the noise floor that makes the fourteenth invisible.

So the deliberate case gets a type of its own, and everything else that skips
is treated as the absence it is:

  • opt_out(reason) raises a DeclaredOptOut, a subclass of what
    pytest.skip raises. The three sites that are genuinely opt-outs use it: no
    DEVLAUNCH_E2E_WORKSPACE, no claude in the workspace, no real ~/.devpod
    state to be isolated from.
  • Any other skip under test/e2e/ is rewritten into a failure. The hook
    judges call.excinfo — the live exception — rather than the printed report,
    so nothing can spell its way into an opt-out, an xfail (which also reports
    skipped, but raises the assertion that failed) passes through untouched, and
    a pytest.skip(allow_module_level=True) raised at collection is caught too.
    Rewritten into the report, not checked at the end — the complaint is that
    the summary line lies, so the correction has to land in the summary line, as
    an F against the test's own name.
  • The two skips that were really failures now say so. A named
    DEVLAUNCH_E2E_WORKSPACE that devpod cannot describe, and a missing devpod —
    the latter moved out of five per-test guards into one session precondition, so
    a machine with no devpod on it stops reporting an e2e suite that passed.
  • A floor on what the run promised. Tests that build a workspace say so with
    @pytest.mark.creates_workspace; the one place that creates workspaces records
    each one, and only when the real subprocess runner did it; every run prints the
    tally; and a session that ran a workspace-building test and finished with none
    built does not exit zero. That is the exact quantity that was zero in the
    outage run, and it is the one thing no report of a passing test can answer —
    inferring a container from a green test is the mistake being fixed.
--------------------------------- e2e session ---------------------------------
22 e2e tests attempted, 4 workspaces created: e2e-test-create, e2e-test-lifecycle, e2e-test-git, e2e-test-purge

A shortfall is filed as a failure report rather than only printed, so the last
line of a run that built nothing reads 1 failed, 4 passed, 18 skipped in red
instead of leaving the green line the ticket complained about as the final word.

Two runs are not judged, because neither promised anything: one with no e2e
tests in it (pixi run test collects the directory and deselects it), and one
with no workspace-building tests in it — pytest -m e2e test/e2e/test_interactive_session.py with DEVLAUNCH_E2E_WORKSPACE set
attaches to a workspace somebody else built and correctly creates none.

Verified on the runner

run what it proves
31256546714 final state, green: 8 passed, 14 skipped, 4 workspaces, 28.67s suite in a 37s job
31256036009 the crux. Every workspace-creating test made to declare an opt-out. pytest reports 4 passed, 18 skipped and not one failure — the run that used to be green. The job is red, and says why
31256477982 a deliberately broken assertion turns the job red; and an undeclared pytest.skip is reported as FAILED, not as a skip
31255686455 the first run, red on a real defect — see below

After review, on the current head:

run what it proves
31257818770 green, 39s. 22 e2e tests attempted, 4 workspaces created: … — the number the README now prints. -ra names the test that opted out (test/e2e/test_interactive_session.py:114), where it used to name the helper
31257907158 the crux, re-run against the new mechanism. Every workspace-building test opted out, one undeclared pytest.skip, one xfail. Last line: 2 failed, 3 passed, 18 skipped, 999 deselected, 1 xfailed — the shortfall is in it (FAILED e2e session floor), the undeclared skip is an F against its own test, and the xfail is still an xfail rather than the hard failure the old text-matching check would have made of it

The sabotage commits were pushed to this branch and then taken back out; the run
logs outlive them.

The first thing the job caught

test_git_status_via_ssh failed, and it is this PR's own subject in miniature.

It handed devpod the fixture's bare repo — the stand-in remote its siblings
use as a clone source. devpod opens a local path as the workspace folder rather
than cloning it, so the container came up sitting inside objects/, refs/ and
a core.bare=true config, where git status exits 128 saying it must be run in
a work tree. Not a runner quirk; it could never have been anything else, on any
machine.

It had never been observed because it had never run. Both assertions sat
behind an if result.returncode == 0 that was never true — devpod up without
--ide none exits 1 on any headless machine, and the test read that as "could
not create" and moved on. #118 fixed the creation step and thereby made these
assertions reachable; the first session ever to reach them was the first CI run.
Fixed here by pointing it at the working copy, which is what a test about git in
a container needed all along, and the rc assertion now carries the output because
devpod buries git's message in its own logging.

Worth someone deciding separately: devpod up <local path> mounts, it does not
clone.
Every workspace-creating test in the suite passes a filesystem path, so
none of them is exercising a clone. The other three only assert the workspace
exists, so they are not wrong — but the fixture's remote_url is not being used
as a remote by anything.

Proposed CHANGELOG entry

Not applied here — four PRs are in flight and four version bumps would collide.

Nothing about how you install or run dl changes; this release is entirely
about what the project's own test suite does and what a green tick from it
means.

Added

  • The e2e suite runs in CI, in a job of its own, on pushes to main and on pull
    requests targeting main. It is
    plain pytest -m e2e against the runner's own Docker rather than a nested
    daemon: a development machine needs one because it is shared and long-lived,
    and a runner is an ephemeral VM with Docker already on it. The job sits outside
    the py310–py313 matrix, because what it exercises is devpod and Docker and not
    a Python version, and it needs no devpod install step, devpod being a pixi
    dependency already in the lockfile. It finishes before the matrix does.
    pixi run test-e2e is the same run on your own machine — where it builds real
    containers on your Docker, so read the README first.

Fixed

  • An e2e run that could not do anything no longer reports that it passed. Two
    unrelated outcomes were both spelled skipped: tests declining an opt-in they
    were never given, and tests that could not reach what they needed. A run
    against a registry serving the suite's 1.25 GB fixture image at 640 B/s
    reported 7 passed, 14 skipped having created no containers at all, which is
    the summary line a healthy run prints — and with thirteen legitimate skips in
    the baseline, one more was invisible. Deliberate skips now say so in a word of
    their own — a distinct exception type, so the check is what a test raised and
    not how it worded it; any other skip under the e2e directory is reported as a
    failure against the test's own name; a missing devpod fails the session once
    instead of skipping five tests quietly; and every run prints the workspaces it
    actually built and refuses to exit zero if the tests that promised one built
    none.
  • test_git_status_via_ssh tests git in a container again. It had been pointed
    at the fixture's bare repository, which has no work tree, so git status
    inside the container exited 128 on any machine; it now gets the working copy.
    Nobody had seen it, because until the creation
    step was made unskippable its assertions sat behind a condition that was never
    true on a headless machine. The first session to reach them was the first CI
    run of this suite.

🤖 Generated with Claude Code

One `e2e` job, per pull request, outside the python matrix: plain unnested
`pytest -m e2e` on the runner's own Docker. A runner is an ephemeral VM with
Docker preinstalled, so there is no host to protect and no nested daemon to
build; devpod is already in the lockfile setup-pixi restores.

The job is only worth having if its result means something, and today it would
not. `skipped` names two unrelated outcomes in this suite -- thirteen tests
declining an opt-in, and a test that could not reach a registry -- so a run that
built nothing at all printed the same summary line as a healthy one. This gives
the deliberate case a word of its own, treats every other skip as the failure it
is, and has the session refuse to exit zero having created no workspaces.

Closes #114

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @blooop, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@sourcery-ai

sourcery-ai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Reviewer's Guide

Adds a dedicated per-PR e2e CI job that runs the real devpod/Docker-based suite, and introduces an e2e guard mechanism so runs accurately report what they did by treating undeclared skips as failures and enforcing a minimum level of actual workspace creation.

File-Level Changes

Change Details Files
Introduce an e2e guard that distinguishes deliberate opt-outs from broken runs, tracks workspace creation, and enforces that e2e sessions which did nothing cannot pass.
  • Add fixtures.e2e_guard module implementing opt-out semantics, an E2ELedger for tracking tests and workspaces, and helper functions for reporting session shortfalls and summaries.
  • Wire the guard into test/e2e/conftest.py via pytest hooks to count attempted tests, rewrite undeclared skips as failures, and fail sessions that created no workspaces.
  • Record workspace creation in create_e2e_workspace so the guard’s ledger reflects actual containers built.
  • Add unit tests in test/unit/test_e2e_guard.py to validate opt-out detection, session floor behavior, and summary output.
test/fixtures/e2e_guard.py
test/e2e/conftest.py
test/fixtures/e2e_helpers.py
test/unit/test_e2e_guard.py
Refactor e2e tests to use explicit opt-out semantics instead of silent skipping when preconditions are not met, and to fail on misconfiguration rather than skip.
  • Replace ad-hoc devpod availability checks and pytest.skip calls with opt_out in interactive-session and full-workflow tests for genuine opt-outs (missing DEVLAUNCH_E2E_WORKSPACE, no claude, no ~/.devpod workspaces).
  • Change interactive-session workspace lookup to fail when DEVLAUNCH_E2E_WORKSPACE names an unusable workspace rather than skipping.
  • Centralize devpod existence checks via require_devpod in the e2e conftest, removing per-test devpod_available guards in workflow tests.
  • Clarify docstrings and comments in e2e tests to explain opt-in/opt-out behavior and failure conditions.
test/e2e/test_interactive_session.py
test/e2e/test_full_workflow.py
test/fixtures/e2e_helpers.py
Add a dedicated e2e CI job that runs the real devpod/Docker e2e suite on every pull request, and expose a matching pixi command for local runs.
  • Extend .github/workflows/ci.yml with a new e2e job that runs on ubuntu-latest, restores the pixi environment, and executes pixi run test-e2e with a bounded timeout.
  • Add workflow_dispatch trigger to the CI workflow to allow manual runs.
  • Define a test-e2e pixi task in pyproject.toml that runs pytest -m e2e -ra, overriding default addopts that exclude e2e.
  • Document e2e suite behavior, prerequisites, CI behavior, and output in README.md, including the e2e session summary line and DEVLAUNCH_E2E_WORKSPACE opt-in mechanism.
.github/workflows/ci.yml
pyproject.toml
README.md

Assessment against linked issues

Issue Objective Addressed Explanation
#114 Add a CI job that runs the e2e test suite on GitHub runners, unnested, using the runner's own Docker, outside the Python version matrix, and choose/document per-PR vs nightly execution.
#114 Ensure the e2e CI job cannot silently succeed without actually running meaningful e2e tests (i.e., convert unintended skips into failures and enforce a floor on what the session does so a broken run turns red).
#114 Expose a clear way to run the e2e suite via project tooling and document its behavior and usage (e.g., pixi command and README updates).

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@codecov

codecov Bot commented Aug 8, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 92.76%. Comparing base (e31b982) to head (b96734e).

Additional details and impacted files

Impacted file tree graph

@@           Coverage Diff           @@
##             main     #123   +/-   ##
=======================================
  Coverage   92.76%   92.76%           
=======================================
  Files          18       18           
  Lines        2005     2005           
=======================================
  Hits         1860     1860           
  Misses        145      145           

Impacted file tree graph

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

`test_git_status_via_ssh` handed devpod the fixture's *bare* repo -- the
stand-in for a remote its siblings use as a clone source. devpod opens a
local path as the workspace folder rather than cloning it, so the container
came up sitting in `objects/`, `refs/` and a `core.bare=true` config, where
`git status` exits 128 saying it must be run in a work tree. That is not
environment-specific; it could never have been anything else.

It had simply never been observed. Until the creation step was made
unskippable, `devpod up` without `--ide none` exited 1 on any headless
machine and both assertions sat behind an `if result.returncode == 0` that
was never true -- a test that passed by not running. The first session to
reach them was the first run of this suite in CI.

The source is now the working copy, which is what a test about git in a
container needed all along, and the rc assertion carries the output: devpod
reports a failed remote command as its own exit 1 and buries git's message
in its logging.
@blooop
blooop force-pushed the wayfinder/devlaunch-114 branch 3 times, most recently from c5aeb38 to 586abe1 Compare August 8, 2026 12:07
@blooop blooop mentioned this pull request Aug 8, 2026

@blooop blooop left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was generated by AI during review.

Fixed point 85e27b1...586abe1. All checks green, CI / e2e present on both pushes. I re-read the four cited runs from the logs rather than reproducing anything locally.

The sabotage evidence holds, exactly as claimed. Run 31256036009 log line 331: 4 passed, 18 skipped, 992 deselected in 2.77s, zero failures — and line 332 ##[error]Process completed with exit code 1, with the shortfall printed at line 311. Run 31256477982 line 355: a bare pytest.skip reported as FAILED test/e2e/test_full_workflow.py::...::test_dl_version_command, against the test's own name. Run 31255686455 line 1779 shows Process exited with status 128 from git status — consistent with a bare repo and nothing else. Final run 37s (12:08:09→12:08:46) against py310–py313 at 42/42/51/52s. Every number in the body checks out.

I also confirmed the vacuous case the ledger's tests_attempted == 0 carve-out leaves open is closed by pytest itself: with everything deselected pytest exits 5, so a job that selects no e2e tests is red regardless. That carve-out is safe.

Standards

S1 — the ledger's central invariant is false (blocking). create_e2e_workspace is documented as "the only door", but it takes an injectable run=, and test/unit/test_e2e_workspace_helper.py:57,70,102 calls it with a stub — each stubbed success appends to the module-global LEDGER. Nothing in pixi run test / test-e2e / ci reaches it (addopts and -m e2e keep the two sets disjoint), so no shipped path is broken today. But "no report of a passing test can answer whether a container was built" is precisely the claim, and a pytest -m "" run makes three phantom workspaces clear the floor. Record only when run is subprocess.run, or reset the ledger in pytest_sessionstart.

S2 — the opt-out protocol is a magic substring, and it did not have to be. is_declared_opt_out matches "declared e2e opt-out: " inside str(report.longrepr). e2e_guard.py:24-27 justifies this with "the report a hook sees at the far end of the run is text". That is not correct: pytest_runtest_makereport(item, call) receives the live call.excinfo, and the hook here explicitly discards it (# noqa: ARG001). A class DeclaredOptOut(Skipped) checked via call.excinfo.errisinstance(...) is unforgeable, and fixes two live consequences of the string form:

  • report.skipped is True for xfail. Any @pytest.mark.xfail added under test/e2e/ is silently rewritten into a hard failure. No xfail exists there today; the trap is loaded.
  • -ra attributes the skip to the helper, not the test: run 31256546714 line 313 reads SKIPPED [1] test/fixtures/e2e_guard.py:39. In a PR about output that names things accurately, the opt-out line does not name who opted out.

S3 — the floor's correction does not land in the summary line, contrary to the design principle the module states twice ("the correction has to land in the summary line"). The F-rewrite half does. The floor half does not: the last line of the sabotage run is still 4 passed, 18 skipped in green, with the shortfall 20 rows above it. That is the byte-identical line #114 complained about. Mitigated by the exit code, but the stated principle is only half delivered — worth either honest wording or a terminalreporter.stats entry so the count moves.

S4 — speculative generality (minor). shortfalls() -> List[str] is only ever length 0 or 1, and pytest_sessionfinish ignores its contents. devpod_available() now has exactly one caller — require_devpod(), in the same file.

S5 — stale dependency floor (minor). pytest>=7.4 in [project.optional-dependencies], but @pytest.hookimpl(wrapper=True) is pytest-8/pluggy-1.2 syntax. The lock (pytest 9.0.2) and frozen: true protect CI; the declared range is unsound.

S6 — the README teaches a number no run prints (blocking, one line). README:439 shows 9 e2e tests attempted, 4 workspaces created. Every real run prints 22. This is in the paragraph telling the reader that this is the line to read when a green tick is in doubt.

Spec

Against #114 and its comment.

  • "one e2e job in .github/workflows/ci.yml, outside the py310–py313 matrix"met, verified: e2e is a sibling job, one run per push, one image pull.
  • "say which you chose and why, and make the choice easy to reverse"met. The reversal instruction is slightly wrong, though: with push: branches: [main] still in on:, adding schedule: plus if: github.event_name != 'pull_request' yields everything except PRs, not nightly. One extra clause, or amend the comment.
  • "Verify the job triggers on a pull request and that a deliberately broken e2e test turns it red — do not accept a green tick from a job that skipped everything"met and exceeded; the second sabotage covers the case a broken test cannot.
  • Comment: "assert a floor on what actually ran… Distinguishing 'opted out by design' from 'could not run' is the real requirement; today they are the same word"met. 4 passed, 18 skipped now exits 1.
  • Gap 1 (blocking, prose). README:426 and the CHANGELOG both say the suite runs "on every pull request". on.pull_request.branches: [main] means a PR onto any non-main base — a stacked PR, which this repo's tooling produces — runs no CI at all, e2e included. Either widen the trigger or stop claiming "every".
  • Gap 2 (follow-up, outside the diff). The main ruleset requires exactly one check: ci (py313). e2e is advisory — a red e2e job does not block a merge. #114 asks that "a job that silently does not run is worse than one that fails"; a job whose failure nothing enforces is the same family. Not fixable in a diff, and #114 did not ask for it, so it is not a change request — but it should be a ticket, or this PR's tick still does not gate anything.

The test_git_status_via_ssh fix — correct. git_fixtures.py:165 returns remote_url as the bare remote_dir; a bare repo has no work tree and git status exits 128 there, which is exactly what run 31255686455 line 1779 shows. Pointing at work_dir (which carries .devcontainer/devcontainer.json, added at git_fixtures.py:182) is right, and the test now genuinely exercises git-in-a-container. The added rc message is a real improvement over assert 1 == 0.

Ruling on the mounts-not-clones residual — deferring is correct. Three tests pass remote_url to devpod up; devpod mounts it, so none exercises a clone and remote_url is a remote to nothing. But those three assert only that a workspace exists, and they do that truthfully — so the suite this PR wires into CI is narrower than its names suggest, not hollow. Widening it is a different ticket with a different shape (it needs a real remote, or an accepted local-clone path), and folding it in here would have delayed a guard whose absence is the actual live hazard. One thing worth adding to that ticket, which I could not settle from the logs: since the bare directory contains no .devcontainer/ on disk, those three workspaces are probably built from devpod's fallback image rather than the fixture's declared one — meaning git_fixtures.py's devcontainer.json may be exercised by exactly one test in the suite. Unverified.

Does opt_out make the distinction unrepresentable? No — it makes it loud, which is nearly as good, with one hole. A new test writing bare pytest.skip is caught and reported as FAILED (proven on the runner), so the wrong thing is not silent. But it remains writable, and one route escapes entirely: pytest.skip(..., allow_module_level=True) raises during collection, produces a CollectReport, and never reaches pytest_runtest_makereport. A module-level skip of test_full_workflow.py would still be caught by the floor (no workspaces); a module-level skip of test_interactive_session.py would vanish 13 tests green. The two mechanisms cover each other partially, not completely. See S2 for the stronger encoding.

CHANGELOG entry. Voice and structure match 0.0.14–0.0.16 well — explanatory prose in the bullets, mechanism before consequence. Two corrections:

  1. The Fixed bullet reverses its own meaning. "test_git_status_via_ssh tests git in a container again, having been pointed at the fixture's bare repository — which has no work tree" reads as though pointing it at the bare repo is the fix. It was the bug. Needs "having previously been pointed at" or a recast.
  2. No lead paragraph. 0.0.14, 0.0.15 and 0.0.16 each open with a framing sentence naming user impact ("Nothing about how you install or run dl changes"). This entry is entirely developer-facing and should say so before the sections.

Accuracy otherwise checks out: devpod is in [tool.pixi.dependencies] as claimed, "five per-test guards" is exactly the five devpod_available() checks removed, and "it finishes before the matrix does" is true on the one measurement taken.

Verdict

Request changes — small ones. Nothing about the mechanism is wrong, and the evidence for it is the strongest I have reviewed on this map; every claim in the body reproduced from the logs.

Blocking (all cheap):

  1. S1create_e2e_workspace's injected run= lets stubbed unit calls credit the global LEDGER with workspaces that do not exist, falsifying the guard's central invariant.
  2. S6 — README:439 prints 9 e2e tests attempted; every run prints 22.
  3. Spec Gap 1 — README:426 and the CHANGELOG claim "every pull request"; the trigger is branches: [main] only.
  4. CHANGELOG #1 — the dangling modifier that reverses the git-status bullet.

Follow-up tickets, not blocking: S2 (exception-typed opt-out; xfail trap; skip attributed to the helper), S3 (floor's correction never reaches the summary line), Spec Gap 2 (e2e is not a required check on main), the mounts-not-clones widening, and the module-level-skip escape. S4/S5 are notes.

timeout-minutes: 25 is the right call: ~40x the observed 37s, and the failure it guards (640 B/s on 1.25 GB ≈ 22 days) blows any bound, so the number only has to be small enough to return a red tick the same day. Nothing in the workflow can make the job silently not run on a PR to main — no if:, no continue-on-error, and an all-deselected run exits 5.

@blooop blooop left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was generated by AI during review.

Fixed point 85e27b1...586abe1. All checks green, CI / e2e present on both pushes. I re-read the four cited runs from the logs rather than reproducing anything locally.

The sabotage evidence holds, exactly as claimed. Run 31256036009 log line 331: 4 passed, 18 skipped, 992 deselected in 2.77s, zero failures — and line 332 ##[error]Process completed with exit code 1, with the shortfall printed at line 311. Run 31256477982 line 355: a bare pytest.skip reported as FAILED test/e2e/test_full_workflow.py::...::test_dl_version_command, against the test's own name. Run 31255686455 line 1779 shows Process exited with status 128 from git status — consistent with a bare repo and nothing else. Final run 37s (12:08:09→12:08:46) against py310–py313 at 42/42/51/52s. Every number in the body checks out.

I also confirmed the vacuous case the ledger's tests_attempted == 0 carve-out leaves open is closed by pytest itself: with everything deselected pytest exits 5, so a job that selects no e2e tests is red regardless. That carve-out is safe.

Standards

S1 — the ledger's central invariant is false (blocking). create_e2e_workspace is documented as "the only door", but it takes an injectable run=, and test/unit/test_e2e_workspace_helper.py:57,70,102 calls it with a stub — each stubbed success appends to the module-global LEDGER. Nothing in pixi run test / test-e2e / ci reaches it (addopts and -m e2e keep the two sets disjoint), so no shipped path is broken today. But "no report of a passing test can answer whether a container was built" is precisely the claim, and a pytest -m "" run makes three phantom workspaces clear the floor. Record only when run is subprocess.run, or reset the ledger in pytest_sessionstart.

S2 — the opt-out protocol is a magic substring, and it did not have to be. is_declared_opt_out matches "declared e2e opt-out: " inside str(report.longrepr). e2e_guard.py:24-27 justifies this with "the report a hook sees at the far end of the run is text". That is not correct: pytest_runtest_makereport(item, call) receives the live call.excinfo, and the hook here explicitly discards it (# noqa: ARG001). A class DeclaredOptOut(Skipped) checked via call.excinfo.errisinstance(...) is unforgeable, and fixes two live consequences of the string form:

  • report.skipped is True for xfail. Any @pytest.mark.xfail added under test/e2e/ is silently rewritten into a hard failure. No xfail exists there today; the trap is loaded.
  • -ra attributes the skip to the helper, not the test: run 31256546714 line 313 reads SKIPPED [1] test/fixtures/e2e_guard.py:39. In a PR about output that names things accurately, the opt-out line does not name who opted out.

S3 — the floor's correction does not land in the summary line, contrary to the design principle the module states twice ("the correction has to land in the summary line"). The F-rewrite half does. The floor half does not: the last line of the sabotage run is still 4 passed, 18 skipped in green, with the shortfall 20 rows above it. That is the byte-identical line #114 complained about. Mitigated by the exit code, but the stated principle is only half delivered — worth either honest wording or a terminalreporter.stats entry so the count moves.

S4 — speculative generality (minor). shortfalls() -> List[str] is only ever length 0 or 1, and pytest_sessionfinish ignores its contents. devpod_available() now has exactly one caller — require_devpod(), in the same file.

S5 — stale dependency floor (minor). pytest>=7.4 in [project.optional-dependencies], but @pytest.hookimpl(wrapper=True) is pytest-8/pluggy-1.2 syntax. The lock (pytest 9.0.2) and frozen: true protect CI; the declared range is unsound.

S6 — the README teaches a number no run prints (blocking, one line). README:439 shows 9 e2e tests attempted, 4 workspaces created. Every real run prints 22. This is in the paragraph telling the reader that this is the line to read when a green tick is in doubt.

Spec

Against #114 and its comment.

  • "one e2e job in .github/workflows/ci.yml, outside the py310–py313 matrix"met, verified: e2e is a sibling job, one run per push, one image pull.
  • "say which you chose and why, and make the choice easy to reverse"met. The reversal instruction is slightly wrong, though: with push: branches: [main] still in on:, adding schedule: plus if: github.event_name != 'pull_request' yields everything except PRs, not nightly. One extra clause, or amend the comment.
  • "Verify the job triggers on a pull request and that a deliberately broken e2e test turns it red — do not accept a green tick from a job that skipped everything"met and exceeded; the second sabotage covers the case a broken test cannot.
  • Comment: "assert a floor on what actually ran… Distinguishing 'opted out by design' from 'could not run' is the real requirement; today they are the same word"met. 4 passed, 18 skipped now exits 1.
  • Gap 1 (blocking, prose). README:426 and the CHANGELOG both say the suite runs "on every pull request". on.pull_request.branches: [main] means a PR onto any non-main base — a stacked PR, which this repo's tooling produces — runs no CI at all, e2e included. Either widen the trigger or stop claiming "every".
  • Gap 2 (follow-up, outside the diff). The main ruleset requires exactly one check: ci (py313). e2e is advisory — a red e2e job does not block a merge. #114 asks that "a job that silently does not run is worse than one that fails"; a job whose failure nothing enforces is the same family. Not fixable in a diff, and #114 did not ask for it, so it is not a change request — but it should be a ticket, or this PR's tick still does not gate anything.

The test_git_status_via_ssh fix — correct. git_fixtures.py:165 returns remote_url as the bare remote_dir; a bare repo has no work tree and git status exits 128 there, which is exactly what run 31255686455 line 1779 shows. Pointing at work_dir (which carries .devcontainer/devcontainer.json, added at git_fixtures.py:182) is right, and the test now genuinely exercises git-in-a-container. The added rc message is a real improvement over assert 1 == 0.

Ruling on the mounts-not-clones residual — deferring is correct. Three tests pass remote_url to devpod up; devpod mounts it, so none exercises a clone and remote_url is a remote to nothing. But those three assert only that a workspace exists, and they do that truthfully — so the suite this PR wires into CI is narrower than its names suggest, not hollow. Widening it is a different ticket with a different shape (it needs a real remote, or an accepted local-clone path), and folding it in here would have delayed a guard whose absence is the actual live hazard. One thing worth adding to that ticket, which I could not settle from the logs: since the bare directory contains no .devcontainer/ on disk, those three workspaces are probably built from devpod's fallback image rather than the fixture's declared one — meaning git_fixtures.py's devcontainer.json may be exercised by exactly one test in the suite. Unverified.

Does opt_out make the distinction unrepresentable? No — it makes it loud, which is nearly as good, with one hole. A new test writing bare pytest.skip is caught and reported as FAILED (proven on the runner), so the wrong thing is not silent. But it remains writable, and one route escapes entirely: pytest.skip(..., allow_module_level=True) raises during collection, produces a CollectReport, and never reaches pytest_runtest_makereport. A module-level skip of test_full_workflow.py would still be caught by the floor (no workspaces); a module-level skip of test_interactive_session.py would vanish 13 tests green. The two mechanisms cover each other partially, not completely. See S2 for the stronger encoding.

CHANGELOG entry. Voice and structure match 0.0.14–0.0.16 well — explanatory prose in the bullets, mechanism before consequence. Two corrections:

  1. The Fixed bullet reverses its own meaning. "test_git_status_via_ssh tests git in a container again, having been pointed at the fixture's bare repository — which has no work tree" reads as though pointing it at the bare repo is the fix. It was the bug. Needs "having previously been pointed at" or a recast.
  2. No lead paragraph. 0.0.14, 0.0.15 and 0.0.16 each open with a framing sentence naming user impact ("Nothing about how you install or run dl changes"). This entry is entirely developer-facing and should say so before the sections.

Accuracy otherwise checks out: devpod is in [tool.pixi.dependencies] as claimed, "five per-test guards" is exactly the five devpod_available() checks removed, and "it finishes before the matrix does" is true on the one measurement taken.

Verdict

Request changes — small ones. Nothing about the mechanism is wrong, and the evidence for it is the strongest I have reviewed on this map; every claim in the body reproduced from the logs.

Blocking (all cheap):

  1. S1create_e2e_workspace's injected run= lets stubbed unit calls credit the global LEDGER with workspaces that do not exist, falsifying the guard's central invariant.
  2. S6 — README:439 prints 9 e2e tests attempted; every run prints 22.
  3. Spec Gap 1 — README:426 and the CHANGELOG claim "every pull request"; the trigger is branches: [main] only.
  4. CHANGELOG #1 — the dangling modifier that reverses the git-status bullet.

Follow-up tickets, not blocking: S2 (exception-typed opt-out; xfail trap; skip attributed to the helper), S3 (floor's correction never reaches the summary line), Spec Gap 2 (e2e is not a required check on main), the mounts-not-clones widening, and the module-level-skip escape. S4/S5 are notes.

timeout-minutes: 25 is the right call: ~40x the observed 37s, and the failure it guards (640 B/s on 1.25 GB ≈ 22 days) blows any bound, so the number only has to be small enough to return a red tick the same day. Nothing in the workflow can make the job silently not run on a PR to main — no if:, no continue-on-error, and an all-deselected run exits 5.

The guard said three things that were not quite so.

**The ledger's only door had a second one.** `create_e2e_workspace` takes an
injectable `run=` so its own logic can be unit-tested without devpod, and each
of those stubbed calls appended a workspace to the session ledger. No shipped
path reached it -- `test` and `test-e2e` select disjoint sets -- but `pytest -m
""` credited three containers that never existed, and three is enough to clear
the floor. The ledger is now credited only when the real runner was the thing
that ran, which a stub cannot be.

**The opt-out was a magic substring, and it did not have to be.** Skips were
told apart by grepping the report text, justified by "the report a hook sees at
the far end of the run is text". `pytest_runtest_makereport` has `call.excinfo`
-- the live exception -- and the hook was throwing it away. Opting out now
raises a `DeclaredOptOut`, and the hook asks the exception what it is. That
closes a trap that was loaded and not yet sprung: `report.skipped` is also true
for xfail, so any `@pytest.mark.xfail` added under `test/e2e/` would have been
rewritten into a hard failure. It also puts the `-ra` line against the test that
opted out instead of against the helper, and it reaches the one skip that used
to escape entirely -- `pytest.skip(allow_module_level=True)` raises at
collection, and a module-level skip of `test_interactive_session.py` would have
vanished thirteen tests green.

**The floor's correction never reached the summary line**, though the module
said twice that this was the whole point. The `F`-rewrite half did; the floor
half left `4 passed, 18 skipped` as the last green line with the shortfall
twenty rows above it. The shortfall is now filed as a failure report, so the
last line reads `1 failed, 4 passed, 18 skipped` in red and names it.

And the floor was asking the wrong question. "Did any e2e test run" reddens an
honest run: `pytest -m e2e test/e2e/test_interactive_session.py` with
`DEVLAUNCH_E2E_WORKSPACE` set attaches to a workspace somebody else built and
correctly builds none. The question is "did the tests that promised a container
produce one", and which tests promise one is now declared by them with
`@pytest.mark.creates_workspace` rather than guessed at afterwards.

Docs: the README printed `9 e2e tests attempted` where every run prints 22, in
the paragraph teaching the reader that this is the line to read; and both the
README and the workflow comment claimed "every pull request" when
`pull_request: branches: [main]` means a PR onto any other base runs no CI at
all. The nightly-reversal instruction was wrong for the same reason -- with
`push: branches: [main]` still there, `!= 'pull_request'` is everything except
PRs, not nightly.

`shortfalls()` returned a list that was only ever empty or one long and whose
contents the caller ignored; it is `shortfall() -> str | None` now.
`devpod_available()` had one caller and is inlined into it.
@blooop
blooop force-pushed the wayfinder/devlaunch-114 branch from 44d0bbe to e3cd0ce Compare August 8, 2026 12:45
@blooop

blooop commented Aug 8, 2026

Copy link
Copy Markdown
Owner Author

This was generated by AI during a retry pass.

All four blocking findings fixed, plus S2, S3, S4 and the module-level-skip
escape. Head is e3cd0ce, all checks green on run
31257954598
, and
the new mechanism was re-sabotaged on the runner rather than argued for.

S1 — the ledger's only door had a second one. Fixed.

The identity of the runner is what the ledger now records against:

_REAL_RUN = subprocess.run
...
def create_e2e_workspace(..., run=_REAL_RUN):
    ...
    if run is _REAL_RUN:
        LEDGER.record_workspace_created(workspace_id)

run stays injectable, because this function's own logic — --ide none, the
register-before-create ordering, fail-don't-skip — is worth unit-testing without
a daemon. What is no longer possible is a stub earning credit for a container:
the ledger's claim is now "a real subprocess ran devpod up and it returned 0",
which is a thing a stub cannot be rather than a thing the test suite currently
happens not to do. Captured at import as _REAL_RUN rather than compared
against subprocess.run at call time, so a monkeypatched subprocess.run fails
the comparison instead of quietly passing it.

Pinned by
test_a_stubbed_creation_does_not_credit_the_session_ledger, which asserts the
global ledger is unchanged across a stubbed create. pytest -m "" now credits
nothing that does not exist.

S6 — 922. Fixed.

README:439 now reads

22 e2e tests attempted, 4 workspaces created: e2e-test-create, e2e-test-lifecycle, e2e-test-git, e2e-test-purge

which is byte-for-byte what run
31257818770
line
310 printed. (The 9 was the count of test_full_workflow.py alone — a local
run of one file, written up as the whole suite.)

Spec Gap 1 — "every pull request". Fixed by stopping the claim, not widening

the trigger.

README, the workflow comment and the CHANGELOG entry now say: pushes to main,
and pull requests targeting main; a PR onto any other base triggers the
workflow not at all. The README adds that this covers every job here rather than
this one — widening on: is a repo-wide decision about stacked PRs that would
be smuggled in under an e2e ticket, and you are ticketing it separately.

CHANGELOG — both corrections applied.

The dangling modifier is gone (it now reads "It had been pointed at the
fixture's bare repository … it now gets the working copy"), and the entry opens
with the framing sentence the 0.0.14–0.0.16 entries all have: "Nothing about
how you install or run dl changes; this release is entirely about what the
project's own test suite does and what a green tick from it means."

S2 — moved onto call.excinfo. You were right that the justification was

false.

opt_out raises a DeclaredOptOut(Skipped), and the hook asks
excinfo.errisinstance instead of grepping text. Both consequences you named
are fixed, and both are visible on the runner:

  • xfail. Sabotage run
    31257907158

    line 336: XFAIL test/e2e/test_full_workflow.py::TestDLCommandsE2E::test_sabotage_xfail_is_not_rewritten.
    Under the old check that line was an F. The trap was loaded; it is now
    unloaded rather than merely unsprung.
  • -ra attribution. Run
    31257818770

    line 313: SKIPPED [1] test/e2e/test_interactive_session.py:114: … where it
    used to read test/fixtures/e2e_guard.py:39. DeclaredOptOut passes
    _use_item_location=True, the same flag @pytest.mark.skip uses.

One note: the class is built on from _pytest.outcomes import Skipped rather
than pytest.skip.Exception. They are the same object, but ty cannot resolve
an MRO through a function attribute (unsupported-base), and a type checker
that cannot see the base class is a poor guard for a mechanism whose whole point
is the base class. It is the only _pytest reference in the tree.

The module-level escape is closed too — you listed it as follow-up, but it
turned out to be six lines, and leaving a documented hole in a guard about
honesty seemed worse than the six lines. pytest_make_collect_report is a
wrapper now; the CollectReport carries the CallInfo that produced it
(rep.call), so the same question gets asked of the same exception. Verified
locally against the real hooks: an undeclared pytest.skip(..., allow_module_level=True) is a collection error and exit 2, where it used to
vanish thirteen tests green. opt_out(reason, module_level=True) is the
declared form, so a module that is genuinely entitled to decline still can.

S3 — the correction reaches the summary line now.

The shortfall is filed as a pytest.TestReport under the failed key rather
than only printed. The sabotage run's last line is

2 failed, 3 passed, 18 skipped, 999 deselected, 1 xfailed in 2.90s

in red, one of those two being FAILED e2e session floor, named in the short
summary alongside the tests. The previous run's last line was 4 passed, 18 skipped in green.

A note for the record, because I tried the cheaper thing first: appending a
plain string under a custom stats key breaks the run. hypothesis's plugin
does report.__dict__.get(...) over every entry in terminalreporter.stats and
dies on a str with an AttributeError during terminal summary. A real report
is not belt-and-braces here, it is the only version that works.

S4 — both simplified.

shortfalls() -> List[str] is shortfall() -> Optional[str]. devpod_available()
is inlined into require_devpod(), its only caller.

The reachable false positive — fixed, and it changed the floor's question.

pytest -m e2e test/e2e/test_interactive_session.py with
DEVLAUNCH_E2E_WORKSPACE set passes, builds nothing by design, and went red.
That is this PR's own complaint inverted, so it had to go. The floor was asking
"did any e2e test run"; it now asks "did the tests that promised a container
produce one", and the promise is declared by the test —
@pytest.mark.creates_workspace, on the four tests that call
create_e2e_workspace — rather than inferred at the end of the run. An inferred
promise is exactly the thing that would have to be wrong for the tick to be
wrong.

The sabotage case is unaffected: those four tests are marked, attempted, and
built nothing, so the floor still fires (run 31257907158). The honest subset now
prints 2 e2e tests attempted, none of which builds a workspace and exits 0 —
three situations, three lines, so a reader is not left working out which one
they are looking at.

The nightly reversal comment — corrected.

It now says if: github.event_name == 'schedule', and says why it cannot be
!= 'pull_request': push: branches: [main] stays in on: for the matrix job,
so excluding only PRs leaves e2e running on every merge as well as nightly.

Left alone, deliberately

  • S5, the pytest>=7.4 floor. Real, and unsound as you say. Changing
    [project.optional-dependencies] changes the pixi manifest, which invalidates
    pixi.lock — and frozen: true in CI is what the correctness of every job
    here currently rests on. Trading a live lockfile for a corrected declared
    bound is a bad trade to make inside an e2e ticket; it belongs with the next
    pixi update.
  • Gap 2 (e2e not a required check) and the stacked-PR CI gap. Yours to
    ticket; untouched here.
  • The mounts-not-clones widening. Still deferred, per your ruling. Your
    unverified addendum — that the three tests pointing at the bare directory get
    devpod's fallback image rather than the fixture's declared one, leaving
    git_fixtures.py's devcontainer.json exercised by one test — is worth
    carrying into that ticket; I have not verified it either.

@blooop
blooop merged commit b09d37d into main Aug 8, 2026
10 checks passed
@blooop
blooop deleted the wayfinder/devlaunch-114 branch August 8, 2026 12:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Run the e2e suite in CI

1 participant