Repository navigation
test: make the environment-sensitive tests attribute their own failures - #25
Conversation
Investigation only. Three groups, three different causes: - the CLI trio fails at the harness deadline, and the harness hides that by synthesizing exitCode -1, so a timeout is indistinguishable from a behavioural break - the write-permissions test asserts 0o644 while the tool sets no mode, so it is asserting this machine's umask; it passes in CI by coincidence - snapshot-tool-race passes in isolation, so it needs a synchronisation point Refs #10 Claude-Session: https://claude.ai/code/session_017iG7xA9wRvNyFYZSXm8inU
|
Session stamp (claim):
Second P1 of the triage pass, picked up after #9 merged. No open PR overlaps: the three open PRs are dependabot bumps under One crew at a time, so this is the only implementation stream in flight. |
…umask #10's stated cost is that "every PR author has to re-derive whether their failure is real". That is a symptom of attribution, not of timing, so the attribution is fixed first and the deadlines second. The write-permissions test was not flaky at all - it asserted the umask. umask 0022 (CI) -> Bun.write yields 0644 -> passed umask 0002 (dev box) -> Bun.write yields 0664 -> failed Measured both ways on the same commit. `src/tool/write.ts` sets no mode, so the test's own name - "sets file permissions when writing sensitive data" - claimed an intent the code does not implement, and it passed in CI by coincidence. Forcing a fixed mode would be the wrong fix, and this is the part worth recording: it would LOOSEN permissions for anyone with a stricter umask, where 0o077 should yield 0o600. Restricting a file is the umask's job. What is worth pinning is that the tool leaves that decision alone, so the expectation is now derived (`0o666 & ~process.umask()`) and the test fails if a chmod or mode option is ever introduced. Verified under umask 0002, 0022 and 0077, and by mutation: adding a chmod 0o600 to the tool fails it. The CLI harness hid its own timeout. cli-process.ts catches AppProcessError - which covers a timeout AND a spawn failure - and synthesized exitCode -1, so a run killed at its deadline failed as `expected 0, received -1`. Indistinguishable from a behavioural break unless someone read stderr, which is exactly the re-derivation #10 describes. RunResult now carries `timedOut`, set using the same predicate core already uses for this distinction in packages/core/src/tool/bash.ts, and expectExit reports a deadline kill as what it is. The three tests that failed in CI now assert through expectExit rather than a bare `toBe(0)` so they get it. Verified by forcing a kill: the failure now reads "the harness killed this run at its 1047ms deadline ... This is the timeout backstop, NOT a behavioural failure", with the knob to confirm it. Deadlines are scaled from one place (test/lib/deadline.ts) rather than being fixed values tuned on one machine: x3 under CI, overridable with OPENCODE_TEST_TIMEOUT_SCALE. Deliberately keyed on CI rather than GITHUB_ACTIONS, because the unit job sets GITHUB_ACTIONS=false explicitly. Also fixed the last instance of the pattern this file's own anomalyco#27371 comment warns about: the SIGINT test bounded `durationMs` by the same 30_000 as its own test budget, so the assertion could only fail by racing the thing that would have killed the test anyway. It now bounds at a third of the budget, which still catches a hang. snapshot-tool-race: budget raised and its exhaustion made self-describing, but the cause is NOT established and the commit should not imply otherwise. Measured in isolation three times, the diff is present on the FIRST poll, so the old 5s budget was never the binding constraint there. #10 records it failing only in a full run. A full-suite reproduction is running; until it says otherwise, this change makes the next occurrence attributable rather than fixing a known mechanism. Refs #10 Claude-Session: https://claude.ai/code/session_017iG7xA9wRvNyFYZSXm8inU
Progress: two of three groups fixed with established causes, one honestly unresolvedB.
|
The e2e spec that failed three attempts in one CI run, then passed three runs,
passes `sessionStatus` TWICE in the same mock config:
sessionStatus: { [sourceID]: { type: "busy" } }, <- discarded
...
sessionStatus: () => sessionStatus, <- wins
JavaScript keeps the last key, so the first never had any effect and the
intended "source session starts busy" was silently lost. The mock instead read
a getter over a map that started EMPTY, which left the test depending entirely
on a pushed SSE event arriving with no initial-state fallback.
Confirmed by the repository's own linter, and it is the only such warning in
the whole e2e directory:
session-todo-dock-navigation.spec.ts:60:5
eslint(no-dupe-keys): Duplicate key 'sessionStatus'
Both intents are preserved: the map is seeded busy at declaration and still
read through the getter, so the test can mutate it later. The now-redundant
re-assignment is removed and the status event kept, since the page still has to
be told.
What this does NOT claim: that it is the whole cause. The spec also asserts
animation state by SAMPLING opacity during fixed windows and requiring a
sample to land mid-transition, which is a race between the sampler and the
animation. That is a candidate too, and it is not yet measured. The fix here is
a definite defect found by a linter nothing runs, with a plausible mechanism
for a failure that reproduces within a run and not across runs.
Refs #10, #26
Claude-Session: https://claude.ai/code/session_017iG7xA9wRvNyFYZSXm8inU
Evidence from two full-suite runs, and the e2e cause foundFollowing the note above: I said I would report what a full-suite reproduction showed rather than close group C on a guess. Here it is. The attribution fix works on organic failures, not just a forced oneTwo full runs of That is what The suite is less stable than #10 says — 12 failures, then 4Same tree, two consecutive runs:
So the population is wider than the five #10 names, and two of run 2's four are in files #10 does not mention. Group C:
|
The duplicate key fixed in the previous commit was detectable by the repository's own linter for as long as it existed. Two reasons nothing caught it, and both are fixed here rather than filed. First, no workflow runs lint at all - verified by grepping .github/workflows. Second, and worse: `bun run lint` would have exited 0 anyway, because .oxlintrc.json sets almost everything to "warn", including no-dupe-keys. So even wiring up the existing script would not have caught this bug. `--deny-warnings` is not an option - there are hundreds of pre-existing style warnings and a gate that fails on all of them gets switched off in a week. .oxlintrc.gate.json instead promotes thirteen rules to errors, chosen so that a hit is almost certainly a defect rather than a preference: duplicate keys, class members, else-if branches and switch cases; self-assignment and self-comparison; unsafe negation; isnan misuse; constant binary expressions; a getter or setter that does not return; assigning to an import; unreachable code. It runs with typeAware off, which matters: the repository's type-aware config needs more than 6 GiB on this monorepo and was OOM-killed at that cap during this work. With it off the gate is ~2 seconds and 0.25 GiB. Every rule in it is syntactic, so nothing is lost. Invoked through a `lint:correctness` script so CI uses the pinned oxlint devDependency (1.60.0) rather than whatever `bunx` would fetch that day. One pre-existing error had to be fixed for the gate to be green on landing, in packages/web/src/types/lang-map.d.ts: `export interface MapReturn` alongside `export = map` is TS2309. The interface is the return type of `map()` and is referenced nowhere else, so dropping its `export` resolves it and consumers still get it structurally through that signature. A gate that lands red is not a gate. Mutation-verified through the exact CI command: reintroducing the duplicate key makes `bun run lint:correctness` exit 1, and the clean tree exits 0. The digest for test.yml::unit is updated because this adds a step to an allowlisted job - the pin from #22 demanding that acknowledgement on an unrelated PR is it working as intended. Refs #10, #26 Claude-Session: https://claude.ai/code/session_017iG7xA9wRvNyFYZSXm8inU
Prettier flagged it after the previous commit; the pre-commit hook runs typecheck but not format, so it landed unformatted. Refs #10 Claude-Session: https://claude.ai/code/session_017iG7xA9wRvNyFYZSXm8inU
The lint gate is in, and a correction on my own e2e verificationFixed rather than filedPer the instruction to close out rather than grow the backlog, three things that would have been issues are fixed in this PR instead:
Cost mattered here: the repository's type-aware oxlint config needs more than 6 GiB on this monorepo and was OOM-killed at that cap during this work. The gate runs with Mutation-verified through the exact CI command: reintroducing the duplicate key makes Incidentally, adding a step to an allowlisted job made #22's own digest pin demand an acknowledgement on this unrelated PR. That is it working as intended, and it is the first time it has fired outside its own PR. Correction: my e2e verification measured nothingI ran the fixed spec with The repository pins Recording it because "5 of 5 failed" is exactly the shape of result I would have been tempted to report, and it would have been wrong in both directions: it neither proves the duplicate-key fix works nor that it broke anything. Installing the right build and re-running. This is the third time in this session that a measurement rather than a code change was the thing at fault — the others being a mutation whose |
Measured: the scaling prevents the CLI timeouts, and my e2e hypothesis was wrongGroup A proven, not just attributedThree full runs of
So the ×3 scaling removes the CLI subprocess timeouts rather than merely explaining them, and the attribution message is there for the case where it is not enough. The one remaining failure under the CI profile is My e2e diagnosis was wrong, and the control is what showed itI said the duplicate With chromium 1217 actually installed, I ran the spec five times on each version:
The first local reproduction of this flake. But the base failure is at line 84, and it is not the SSE path I hypothesised: That is the sampling assertion — it requires a sample to land inside a narrow opacity band while a spring animation runs. A race between the sampler and the animation, with no bound on either. And my 5-of-5 pass proves nothing: at the measured 1-in-5 base rate, five clean runs happen by chance about a third of the time. I am not counting that as evidence. The actual fixThe opacity is not a CSS transition — it is a reactive style, The assertions now check the ramp instead of an instant, over present-only samples:
Present-only matters: the sampler reports opacity 0 for an absent dock, and counting those is what forced the arbitrary Running it 10× to compare against the measured 1-in-5 base rate. Still standingThe duplicate-key fix stays. It is a real defect confirmed by the repository's own linter, it silently discarded the intended initial state, and the lint gate now prevents its class. It is simply not what made this spec fail. |
My first e2e fix made it worse — 4 failures in 10, against a base of 1 in 5Measuring it caught this, so recording it before the corrected version. I added Direction is what is actually checkable, so the assertion is now: it started faint (min < 0.95) and rose (last > first). Same intent, nothing asserted that cannot be observed. The 10× run also exposed a second, pre-existing raceSeparate line, not mine: The Raised to 2 500 ms via a named constant. Only present samples are read, so a longer window cannot weaken the assertion — it just stops the window closing before the thing it exists to observe. What this says about the original diagnosisWorth being explicit, since I got this wrong twice: this spec had three independent flake sources, and the duplicate
The duplicate key is still a genuine defect worth the fix and the lint gate — it silently discarded the intended initial state — but I should not have offered it as a candidate cause without measuring first. The control run is what corrected me, and it only existed because I ran the unmodified spec alongside the fixed one. Re-running 10× on the corrected version. |
…ndow
The duplicate-key fix in the earlier commit was a real defect but NOT the cause
of this spec's flakiness. The control run is what showed that: running the
unmodified spec alongside the fixed one reproduced the failure locally for the
first time, at
expect((await opening).some((s) => s.opacity > 0.05 && s.opacity < 0.95))
which requires a sample to land inside a narrow opacity band while a spring
animation runs. There were three independent flake sources here, and measuring
each one took a separate run:
1. that narrow-band assertion, which is the failure CI hit three attempts in
a row
2. a 700ms return window that sometimes closed before the dock reappeared, so
the assertion failed on an empty array rather than on the dock
3. a settle threshold I added myself in a first attempt - max opacity > 0.98 -
which the spring does not reach inside the window. Measured at 0.878927. It
doubled the failure rate.
The opacity is not a CSS transition, so there is no transitionrun or
animationstart to observe: it is a reactive style, `opacity: 1 - shut()`,
driven by useSpring through a dockProgress prop. The Web Animations API does
not apply.
So sampling stays, but every window becomes condition-driven with time as a
bounded backstop - the same doctrine as the unit harness change in this PR.
`sampleDock(page, "appears" | "opaque" | "absent")` samples until the thing
being waited for has happened, then a few frames past it so the caller sees
the settled state too. Assertions check the RAMP - it started faint and rose -
rather than any particular instant.
The absence check keeps a bounded window on purpose, because absence is a
claim about an interval rather than an event to wait for. The "appears"
condition still applies to it, so a dock that wrongly returns ends sampling
immediately and fails, instead of costing the full budget.
Measured, 10 repeats each, single worker, chromium 1217:
base (unmodified) 2 failed / 8 passed
attempt 1 (max > 0.98) 4 failed / 6 passed
attempt 2 (longer window) 2 failed / 8 passed
condition-driven 10 passed
Refs #10, #26
Claude-Session: https://claude.ai/code/session_017iG7xA9wRvNyFYZSXm8inU
Found by the confirmation run rather than by reading: repeating the todo-dock
spec ten times passed 10/10 on an idle box, then 9/10 on a loaded one. The one
failure was not the dock at all:
expect(locator).toBeVisible() failed
Locator: getByRole('heading', { name: 'Todo dock animation' })
Timeout: 30000ms
at utils/waits.ts:6
That is APP_READY_TIMEOUT - the app shell never rendered inside 30s - which
asserts nothing about what the spec is testing. The same run took 9.6 minutes
against 4.2 for the identical work, so the box was roughly 2.3x slower. A
fourth distinct flake source in one spec, and the same class as the other
three: a fixed wall-clock bound tuned on a fast, idle machine.
APP_READY_TIMEOUT is used across 34 e2e files, so scaling it reaches the whole
suite rather than the spec that happened to expose it. The config's
`expect.timeout` is scaled too, because that 10s bound is exactly what #10
records the other two named e2e specs failing on - Playwright `toBeVisible`
timeouts, which are a statement about machine speed rather than about the UI.
One knob for both suites, OPENCODE_TEST_TIMEOUT_SCALE, keyed on CI rather than
GITHUB_ACTIONS since the unit job sets that to false explicitly. Verified
wired: scale 3 gives 90s app-ready and 30s expect, unscaled stays 30s and 10s.
Honest measurement of the dock spec across 20 repeats, and it is NOT zero:
base (unmodified) 2 failures in 10
condition-driven 0 in 10, then 1 in 10 under load
The remaining one is this readiness timeout, not a dock assertion - no failure
at any of the three dock lines across all 20 runs.
Refs #10, #26
Claude-Session: https://claude.ai/code/session_017iG7xA9wRvNyFYZSXm8inU
Condition-driven sampling works — and the confirmation run found a fourth causeMeasured, 20 repeats, and not zero
I am not reporting the 10/10 alone. A confirmation run on a loaded box gave 9/10, and the honest figure is 1 failure in 20 against a base of 2 in 10 — roughly a 4× improvement, not elimination. No failure at any of the three dock assertions across all 20 runs. The remaining one is somewhere else entirely: That is A fourth distinct flake source in one spec, same class as the other three. Fixed at the shared level, not in this spec
One knob across both suites — What the sampler does nowThe opacity is a reactive style driven by The absence check deliberately keeps a bounded window, because absence is a claim about an interval rather than an event to wait for. The Tally for this one specFour independent flake sources, and the duplicate
Each one needed its own measurement to find. Three of the four were only visible because I kept running the unmodified spec as a control alongside the fixed one. |
Refs #10
Draft, claiming the work. Root causes established before writing any fix — and only one of the three groups is a timeout story.
A. The CLI trio — the harness hides its own timeout
packages/opencode/test/lib/cli-process.tscatchesAppProcessError, which covers both a timeout and a spawn failure, and synthesizes:So a run killed at its deadline fails as
expected 0, received -1— indistinguishable from a behavioural break unless someone reads stderr. That is precisely the cost #10 names: "every PR author has to re-derive whether their failure is real".The three that failed in CI, all within ~400 ms of the 30-second deadline:
unknown stream finish preserves partial output and continues--format json records an unknown stream finish and continuation--format json preserves reasoning, tool, and continuation orderingThe same file already fixed one instance of this, and its comment is the template:
B.
write.test.tsfile permissions — umask, not flakeBun.writeyields00020022The test asserts exactly
0o644, andpackages/opencode/src/tool/write.tssets no mode at all. So it passes in CI by coincidence, and the test's own name — "sets file permissions when writing sensitive data" — claims an intent the code does not implement.Forcing
0644in the tool would be wrong, and this is the part worth flagging: it would loosen permissions for a user with a strict umask, where0077should yield0600. The tool's behaviour is correct; the assertion is environment-dependent.C.
snapshot-tool-race— passes in isolationNeeds a real synchronisation point or isolation, not a longer deadline.
Deliberately out of scope, to be filed separately
The two
packages/appe2e Playwright specs: different subsystem, different tooling, and e2e has now passed three consecutive runs after a single failure that took three attempts within one run to reproduce. That is a different shape from the unit timeouts and should not be lumped in with them.🤖 Generated with Claude Code
https://claude.ai/code/session_017iG7xA9wRvNyFYZSXm8inU