test: gate live-cluster tests behind --live and announce resolved credentials - #198
Merged
Merged
Conversation
…dentials `tests/conftest.py` calls `load_dotenv()` unconditionally, so the same pytest command means different things in the main checkout (which has a .env carrying HYPHA_TOKEN and BIOIMAGE_IO_TOKEN) and in a git worktree (which has none). The documented --ignore list does not exclude the affected tests: all 25 of tests/apps/model-runner/ deploy to and call bioimage-io on hypha.aicell.io, and they are inside the standard scope. Live tests are now deselected unless --live is passed, and any resolved credential is announced with its server and workspace before the first test runs, so the exposure is visible even when the flag is absent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…es it missed Follow-up to the --live gate. Six defects, each verified: The banner never fired on the default invocation. `addopts` carries `--numprocesses=1`, so collection runs inside an xdist worker whose terminalreporter output is discarded and whose deselection stats never reach the controller: measured 0 banner lines and no deselected count. Every sub-run in the new tests passed `-o addopts=`, which is exactly why none of them caught it. A worker's stderr *is* inherited by the controller, so the banner goes there instead, and two tests now run under pytest.ini as shipped. tests/apps/cellpose/test_ui_e2e.py drives a browser at the deployed app and injects HYPHA_TOKEN into localStorage, but uses only the `page` fixture, so nothing gated it. tests/test_artifact_version.py calls upload_app and deletes artifacts on bioimage-io/bioengine-worker; the fixture-name rule caught it only because it happens to name its own fixture `hypha_client`. Both now carry an explicit module marker. The live set in the standard scope is 29, not 25; tree-wide it is 80. A token whose payload decodes to a JSON scalar raised AttributeError out of a collection hook, which pytest turns into INTERNALERROR: the scope lookup sat outside the try guarding the decode. The count is now restated next to the wall clock at the end of the run, with or without --live. The flag only stops the accidental case; once someone has opted in deliberately, the count and the runtime in the log are the only things that make an after-the-fact audit possible. `pytest tests/end_to_end/ -v` in the maintainer skill and the command in the model-runner conftest docstring both became silent no-ops; both now carry --live. The README claimed a git worktree has no .env. find_dotenv() walks up, so a worktree under <repo>/.claude/worktrees/ reaches the repo root's and is not protected; one outside the repo tree is. Neither protects against a credential exported into the shell, which is the only clause that holds in every case, so that is the one the README leads with. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Attaching the marker in pytest_collection_modifyitems is what makes `-m live` agree with the gate: most live tests are detected from their fixture closure, so without it they are gated but unnamed and `-m live --live` returns only the modules that declare the marker themselves -- 4 of 29 on a mixed scope. That line had no test; deleting it left the whole gate suite green. The assertion compares the count `-m live --live` selects against the count the baseline deselects, rather than a literal, so it stays correct as live tests are added. Verified it kills both the missing attachment and a trylast reordering. Dropping tryfirst alone survives, because a conftest hookimpl already runs before pytest's mark plugin under pluggy's reverse-registration order -- so the comment now calls it a guarantee against a future earlier-registered plugin rather than the fix, which is what the mutation actually shows. Also pins that -m live without the flag selects nothing, so naming the marker cannot become a way past the gate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Running the test suite with a Hypha credential in scope silently deploys to and calls the live
bioimage-ioworkspace on hypha.aicell.io, with nothing in the output saying so. This makes that impossible by accident and visible when it is deliberate.tests/conftest.pycallsload_dotenv()unconditionally, andfind_dotenv()searches upward fromtests/, so a credential arrives without anyone asking for it. The documented exclusion list (--ignore=tests/end_to_end …) does not exclude what that enables: 29 tests in the standard scope act on production — 25 intests/apps/model-runner/, which call the live model-runner service, and 4 intests/test_artifact_version.py, which callupload_appand delete artifacts onbioimage-io/bioengine-worker. Tree-wide the live set is 80. The only visible symptom is that the suite takes ten times longer, so a flake hunt that loops it forty times becomes forty rounds of calling production.What changes
Live tests are deselected unless
--liveis passed. A test counts as live if it requestshypha_client,hypha_tokenormodel_runner, or carries the new@pytest.mark.live. Detection is by fixture closure, so no existing test needed editing and the rule holds for tests added later against the same fixtures.Every resolved credential is announced before the first test, with the server, the workspace each token is scoped to, and the number of live-reaching tests it enables:
The count is printed with or without
--live, and restated next to the wall clock when the run ends. The flag only prevents the accidental case; once someone has opted in deliberately, the count and the runtime in the log are the only things that make an after-the-fact audit possible. Runtime is the corroborating signal — the same scope takes ~30s offline and several minutes against a cluster.The workspace comes from the
wid:scope in the Hypha JWT, decoded locally without verification; the token itself is never printed.load_dotenv()is untouched — other things depend on it, and it is no longer what decides which tests run.Five things the first pass got wrong
The banner never fired on the default invocation.
pytest.ini'saddoptscarries--numprocesses=1, so collection runs inside an xdist worker whose terminalreporter output is discarded and whosepytest_deselectedstats never reach the controller. Measured on the standard scope with a credential resolved: zero banner lines, no deselected count anywhere. Every sub-run in the first set of tests passed-o addopts=, which is precisely why none of them caught it. Probing the channels showed a worker'sprintandterminalreporterare swallowed but its stderr is inherited by the controller, so the banner goes there; two tests now run underpytest.inias shipped, skipped only where pytest-xdist is absent. Independently swept at-n0, 1, 2, 4 and auto: exactly one banner copy at every width. Deselection itself always worked under xdist, so this was visibility, not safety — but visibility was the entire point of the announcement.Two live files were ungated.
tests/apps/cellpose/test_ui_e2e.pydrives a browser at the deployed app and injectsHYPHA_TOKENinto its localStorage, but uses only thepagefixture. It fails at import where playwright is absent, which is why the standard command already ignores it and why nobody noticed; on a machine with playwright it ran live.tests/test_artifact_version.pyis the most destructive file in the suite and its fixtureasserts on the token rather than skipping, so it errors rather than skips without one — which is why a skip-reason census missed it. The fixture-name rule does catch it, but only because the file happens to name its own fixturehypha_client; rename that and the gate silently stops applying. Both now carry an explicit module-level marker, and the test asserts on the marker rather than on the deselection, so removing it fails.A malformed token aborted the session. The
claims.get("scope")lookup sat outside thetryguarding the base64/JSON decode, so a payload decoding to a JSON scalar raisedAttributeErrorout of a collection hook — which pytest turns into INTERNALERROR. It failed closed and leaked nothing, but it now returns an unreadable-token marker instead.-m liveresolved a wrong subset. Registering alivemarker inpytest.iniimplies-m liveworks, but most live tests are detected from their fixture closure rather than from a marker anyone wrote, so the hook has to attach it to them. Until it did,-m live --livereturned 4 of 29 on a mixed scope — a wrong answer that looks like a complete one. The fix is one line,item.add_marker(...), and it initially shipped with no test: deleting it left the entire gate suite green. There is now a regression test, and it compares the count-m live --liveselects against the count the baseline deselects rather than asserting a literal, so it survives live tests being added. A second test pins that-m livewithout the flag selects nothing, so naming the marker cannot become a way past the gate.Two documented commands became silent no-ops.
pytest tests/end_to_end/ -vin the maintainer skill andpytest tests/apps/model-runner/ -v -o "addopts="in that directory's conftest docstring both exit 5 with nothing collected. Both now carry--live, and the skill says why. They are in this PR rather than pushed separately on purpose: landing them apart would put a window onmainwhere the documentation tells agents to pass a flag that does not parse yet, or the reverse.On "run it from a worktree"
The interim guidance in circulation — and the first version of this PR's README text — said a git worktree has no
.envand is therefore safe. That is false for a worktree under<repo>/.claude/worktrees/:find_dotenv()walks up and reaches the repo root's.env. Verified directly; it resolves to/data/nmechtel/bioengine/.env. A worktree outside the repo tree does stop the walk.tests/test_artifact_version.pyloads the repo-root.envby explicit path regardless of where you are. Andsource .envor an exported token defeats all of it.So the README now states all three routes and leads with the only clause true in every case: no directory choice protects you from a credential you export yourself. Running inside the worker image is safe for a different reason — only the repo is bind-mounted, so the upward walk cannot escape it.
Verification
Standard suite at
a2196b4before: 525 passed, 25 skipped, 29.8s. After: 541 passed, 2 skipped, 25 deselected, 49.2s. Additive — every previously passing test still passes, the 18 new ones are the gate's own, and the 2 skips are the xdist-dependent tests under a runner that installs a narrower package set thanrequirements-test.txt. The 25 deselected is that runner's scope, which excludestests/test_artifact_version.py; in the full standard scope it is 29, and tree-wide 80.Sixteen mutations, each caught by a named test:
--livetest_live_tests_are_selected_with_the_flagtest_nothing_is_announced_when_no_credential_resolvesBIOIMAGE_IO_TOKENnot watchedtest_the_banner_survives_the_default_addoptstest_the_wall_clock_summary_states_the_exposure_even_with_the_flagtrytest_a_malformed_token_does_not_abort_the_sessiontest_the_modules_a_marker_alone_can_gate_carry_one[…test_ui_e2e.py]test_the_modules_a_marker_alone_can_gate_carry_one[…test_artifact_version.py]test_m_live_selects_exactly_what_the_gate_deselectstryfirst→trylasttest_m_live_selects_exactly_what_the_gate_deselects-m livemade a way past the gatetest_m_live_without_the_flag_selects_nothingTwo mutations survived and both are recorded rather than papered over. Removing
tryfirstentirely changes nothing on this machine — a conftest hookimpl already runs before pytest'smarkplugin under pluggy's reverse-registration order — so the decorator is a guarantee against a future plugin registering an earliermodifyitems, not the fix;trylastdoes break the invariant, which is why the ordering is worth stating. Removing thegw0de-duplication in the xdist emitter also survives; it is cosmetic, and the-nsweep above covers it.Two earlier mutations survived during development and were what prompted the tests above: the artifact-version marker (caught by the fixture-name rule anyway, so the test was asserting the outcome rather than its cause) and the marker attachment.
Sub-runs use synthetic JWTs and either deselect everything or pass
--collect-only, so nothing here contacts a cluster.🤖 Generated with Claude Code