Clear one fixture tree at the start of every run - #232
Conversation
A test that fails reverts where it fails, and a mismatched `vm.expectRevert` fires at the guarded call, upstream of every `vm.removeDir` in the contract, so the run that leaves a fixture behind is a run that already failed. The next run then read that residue as if a test had put it there: a leftover `<tag>/` makes `freeze` refuse `SnapshotAlreadyFrozen` before it reaches the ordering guard `testFreezeChecksTheRecordItIsAppendingTo` exists to observe, so one failure turned a repeatable test into a permanently red one naming a different cause. `setUp` clears the contract's fixture roots before any of its tests run, which is the only point where clearing is safe: the roots are split one per test because forge runs the tests in a contract concurrently, and forge runs `setUp` once per contract. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V8ViHcKLVk2YoS2joH4HdN
|
Warning Review limit reachedNext included review available in 23 minutes. View limit detailsLimit details: You’ve used the included review currently available. You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Review configuration: ⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Advanced Run ID: 📒 Files selected for processing (2)
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Advanced Run ID: 📒 Files selected for processing (2)
Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review. WalkthroughSnapshot tests now use isolated ChangesSnapshot fixture isolation
Priority: ➖ Normal Estimated code review effort: 2 (Simple) | ~15 minutes Change: Bug fix · Severity of issue fixed: Medium Suggested reviewers: Merge Risk: ⚪ Minimal · up to The fixture cleanup and migration changes have no identified merge-blocking risk. 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
The first pass listed every fixture root by hand in `setUp`, which is the defect again one level up: a root added to the contract and not added to the list is a root that still decides the next run. Every record fixture root is now a subdirectory of `test/generated-snapshot/`, so there is one name to clear and a new test takes a subdirectory rather than a list entry. `fixture-lib` is cleared as well, and is already one root with a subdirectory per test. It cannot move under the fixture tree: a generated lib left under a compiled root fails the next BUILD, which is upstream of anything `setUp` could do about it. `FIXTURE_ROOT` now names that one tree; the record-walk fixture that held the name is `WALK_FIXTURE_ROOT`. `test/fixture-record/` is committed and stays outside the tree, so nothing can clear it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V8ViHcKLVk2YoS2joH4HdN
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V8ViHcKLVk2YoS2joH4HdN
The sweep's message says these blocks are what this branch added. Most of them are not: the doc blocks on `MISSING_FIXTURE_ROOT`, `NESTED_FIXTURE_ROOT`, `SELECTED_FIXTURE_ROOT`, `FREEZE_FIXTURE_ROOT`, `NEWEST_FIXTURE_ROOT`, `AGGREGATE_DEFAULTS_FIXTURE_DIR`, the `FIXTURE_LIB_ROOT` paragraph and the "Read while the fixture is still there" comment are all on main, and cutting them also left the comments that cross-reference them — "See `NEWEST_FIXTURE_ROOT`.", "for the reason `RELEASED_FIXTURE_ROOT` is not `WALK_FIXTURE_ROOT`" — pointing at nothing. What each of them carries is a constraint no line of code states: forge runs the tests in a contract concurrently, so a root shared between two of them is a test reading another test's fixtures, and a fixture release under `src/generated` is one the inherited record check fails on from the contracts running in parallel. The `setUp` block is this PR's whole thesis: why the START of the run, why a mismatched `vm.expectRevert` is the case that read-before-remove cannot cover, and why two trees rather than one. `clearFixtureTree`'s block stays cut: it restates the name and the signature. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V8ViHcKLVk2YoS2joH4HdN
|
Closes #190.
LibRainDeploySnapshotTesthad nosetUp, so every run started on whatever therun before it left in the tree. Each test reads before it removes and asserts
after — the discipline the file documents — but a mismatched
vm.expectRevertfires at the guarded call, which is upstream of every
vm.removeDirin thecontract, so the one run that leaves a fixture behind is a run that already
failed.
For
testFreezeChecksTheRecordItIsAppendingTothat residue is self-poisoning:the unrefused freeze writes a real
<tag>/cut, andfreezechecksvm.exists(frozenDir)and revertsSnapshotAlreadyFrozenBEFORE it reachescheckReleaseFollowsRecord. One failing run therefore turns a repeatable testinto a permanently red one reporting a cause that has nothing to do with the
property under test, recoverable only by deleting the directory by hand. In a
mutation pass the first mutant to break it silently kills every mutant after it.
What changed
Every fixture root this contract writes under
test/moves into ONE tree,test/generated-snapshot/, named once byFIXTURE_ROOT.setUpremoves thattree, so a root added later is covered by being under it — nothing enumerates
the roots, so nothing can be left out of the clear.
setUpis the only safepoint to do it: forge runs a contract's tests concurrently, and runs
setUponce, before any of them.
Two
clearFixtureTreecalls rather than one, becauseFIXTURE_LIB_ROOT(
fixture-lib/) cannot join the tree. A generated lib imports../generated/,../abstract/and./Lib<Contract>Released.sol, which resolve fromsrc/liband nowhere else, so a copy under any COMPILED root fails the build for every
suite — and the build runs before
setUp, which puts it upstream of anythingsetUpcould do about it. That is whyfixture-lib/is outsidesrc/andtest/to begin with.Deliberately NOT cleared:
src/generated/<dir>fixtures. That root is the writer under test,and those directory names are deliberately not tag shaped, so what a failure
leaves there is passed over by every record walk and overwritten by the test
that wrote it.
FROZEN_FIXTURE_ROOT(test/fixture-record/). Being outside the tree is whatkeeps it: it is committed and read only, so a clear that reached it would
delete it from the repo.
testClearingAFixtureTreeRemovesAStaleCutdrives the clear over exactly theresidue a failed freeze-guard run leaves.
No source change:
freezewriting that cut isfreezedoing its job once theordering guard has passed. The defect is that the run inherits it.
What the diff actually is
+170/-58, of which the mechanism is 106 added lines: theFIXTURE_ROOTconstant,
setUp,clearFixtureTreeand the new test — comments included, andthe comments are most of it.
The remaining
+64/-58has no behaviour in it. 24 existing fixture-rootconstants are re-rooted under the tree, one line each (
+24/-24,test/generated-freeze->test/generated-snapshot/freeze); the walk test'sFIXTURE_ROOTbecomesWALK_FIXTURE_ROOTat all 21 of its sites, now that thebare name belongs to the tree; and
forge fmtre-wraps the comments andassertions the longer name no longer fits on one line.
foundry.tomlis+2/-2, the comment naming the root.The hand-maintained surface is what the extra lines buy. The first version of
this branch cleared the same residue from a written-out
fixtureRoots()arrayof 33 entries — every fixture directory named twice, once at its constant and
once in the list, and a root silently uncleared if the list was not updated with
it. That was
+135/-2: a smaller diff carrying a standing invariant no compilerchecks. One tree costs more lines once and nothing after.
QA
testFreezeChecksTheRecordItIsAppendingTo— pre-existing, and itdiscriminates from the defect's own precondition, which is the whole of the
defect. With
<root>/0_1_10/MockDeployable.solplanted before the run: onbase (
baa1a9c) it is[FAIL: Error != expected error: SnapshotAlreadyFrozen("0_1_10", "test/generated-freeze-guard/0_1_10") != NonMonotonicRelease("0_1_10", "9_9_9")],still red on a second run, and leaves
?? test/generated-freeze-guard/behind; on this branch, from the identical plant at
test/generated-snapshot/freeze-guard/0_1_10/, it passes andgit statusisclean afterwards. Both run.
testClearingAFixtureTreeRemovesAStaleCut— new, and cannot run on base:clearFixtureTreeand the guarantee it pins do not exist there. It isdiscriminated by mutation instead, below.
FIXTURE_ROOT:"test/generated-snapshot"->"test/generated-snapshot/walk"— the clear still runs, but over one test'ssubtree instead of over the tree — with
test/generated-snapshot/freeze-guard/0_1_10/MockDeployable.solplanted ->killed by
testFreezeChecksTheRecordItIsAppendingTo, asSnapshotAlreadyFrozen("0_1_10", "test/generated-snapshot/freeze-guard/0_1_10") != NonMonotonicRelease("0_1_10", "9_9_9").Unmutated, from the same plant, it passes and leaves nothing. This is the
mutation that pins the one-tree design itself: the umbrella has to be the
umbrella and not one of the things under it.
clearFixtureTree:if (vm.exists(root))->if (false && vm.exists(root))(the clear becomes a no-op) -> killed bytestClearingAFixtureTreeRemovesAStaleCut, asSnapshotAlreadyFrozen("0_1_10", "test/generated-snapshot/freeze-stale/0_1_10") != NonMonotonicRelease("0_1_10", "9_9_9")— the exact shadowing A failing freeze-guard test leaves its fixture cut behind and then fails permanently with a different error #190 describes.
clearFixtureTree:vm.removeDir(root, true)->vm.removeDir(root, false)(non-recursive) -> killed bytestClearingAFixtureTreeRemovesAStaleCut, asvm.removeDir: failed to remove dir ".../test/generated-snapshot/freeze-stale": Directory not empty (os error 39).NonMonotonicRelease(tag, "9_9_9"), theordering guard's own refusal in
checkReleaseFollowsRecord, reached only whenthe
SnapshotAlreadyFrozenguard above it does not fire — read fromsrc/lib/LibRainDeploySnapshot.sol, not from anything the test writes. Theintent — that a run does not inherit the fixtures of the run before it — is
the file's own documented read-before-remove, assert-after discipline, stated
at three of its cleanup sites.
behind and (b) the next run not failing permanently with a different error.
Both are covered by the same change and verified together: from the planted
0_1_10/cut the run passes ANDgit statusis clean when it ends, so theresidue a failure leaves is gone by the start of the next run rather than
deciding it. The issue also names the shape as class-wide — every test in the
file writing under
test/generated-*or the fixture lib root — which is whythe clear is a
setUpover a whole tree rather than one root, and why theroots moved into a tree rather than into a list.
Verification
git clean -fdq test src && rm -rf fixture-lib cache/fuzzbefore every run,.env.examplewith the hyperliquid, arbitrum and bsc endpoints swapped forreachable ones.
baa1a9c512 passed / 0 failed; thisbranch 513 passed / 0 failed.
git statusclean after both.forge fmt --checkclean.forge lintreports oneboolean-cstwarning atLibRainDeploySnapshot.t.sol:1596, which is the same code on base and is nottouched here.
The pre-existing race, measured
Unrelated, pre-existing, and NOT touched here:
freezeConsensusFixture()isshared by
testWriteSnapshotRecordsTheZoltuAddressandtestWriteSnapshotHashesTheCodeItRecords, which run concurrently over the onesrc/generated/writeConsensusNotATagdirectory.Measured rather than assumed: 40 runs of
forge test --match-contract LibRainDeploySnapshotTestper side, alternatingbase and branch so both sides see the same machine load. Base
baa1a9c24/40red; this branch 26/40 red. Every one of those 50 red runs failed on
src/generated/writeConsensusNotATagand on nothing else, in one of those twotests, as an ENOENT out of
vm.writeFile,vm.readFile,vm.removeFileorvm.removeDir— the one shared-directory race in four of its spellings. Therate swings hard with load (4/10, 4/10 and 16/20 on base against 5/10, 7/10 and
14/20 on the branch, in that order) and the two sides swing together, which is
the observation worth having: the race is there on base, this change neither
introduces it nor removes it, and 40 runs a side do not separate the two rates.
It is a different defect — two tests sharing one directory — from the one #190
names, so it is left for its own issue.
🤖 Generated with Claude Code
https://claude.ai/code/session_01V8ViHcKLVk2YoS2joH4HdN
Summary by CodeRabbit