bug(test): flaky browser-mode test — admin edit-user unsaved-changes guard #538
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
src/routes/admin/users/[id]/page.svelte.spec.ts— the "unsaved-changes guard" suite intermittently fails in browser mode (Chromium/vitest-browser):The
gotomock records zero calls, which means the discard button click either didn't fire or the navigation side-effect didn't complete before the assertion ran.Observed on
Branch
feat/issue-535-birpc-teardown-race(and presumablymain) — surfaced while reviewing PR #536. The failures appear consistently across multiple runs in this test session, suggesting the issue is reproducible but timing-sensitive.Root cause hypothesis
The test clicks a button and then immediately asserts
gotowas called. In browser-mode vitest (real Chromium), click events and the resulting Svelte reactive updates may be async. The assertion likely needs anawaiton the click or awaitFor/expect.pollwrapper rather than a synchronous check.Acceptance criteria
page.svelte.spec.tspasses consistently across 10 local runsThread.sleepor arbitrary timeouts — useexpect.pollor the vitest-browser auto-wait pattern👨💻 Felix Brandt — Senior Fullstack Developer
Observations
Root cause confirmed. Line 256 (
await page.getByRole('button', { name: /verwerfen/i }).click()) awaits the browser's delivery of the click event — not the Svelte effect triggered by it.goto()is called inside a Svelte reactive handler that runs asynchronously after the click returns. Line 258 then asserts synchronously before that effect has settled, which is why the mock call count is zero.The fix is a one-liner. The file already uses
vi.waitForfor exactly this pattern in the delete-confirmation suite (lines 190, 192, 203, 205). The discard-button test is just missing the same wrap:vi.waitForpolls until the assertion passes or the default timeout (1 000 ms) is reached — zero arbitrary delay, noThread.sleep.No other tests in this file share the problem. All other async outcomes are either checked via
expect.element(...)(which auto-waits through vitest-browser) or viavi.waitFor. Only the discard-button test uses a bare sync assertion after an awaited browser click.The
beforeEach(() => vi.clearAllMocks())on line 214 is correct — it ensuresgotostarts at zero calls for each test in the suite.Recommendations
vi.waitForwrap to line 258 as shown above. Nothing else needs changing.vitest --reporter=verboseto confirm AC1 before closing.vi.waitForcall consistent with the rest of the file.🧪 Sara Holt — QA Engineer & Test Automation Specialist
Observations
This is the classic "await click, sync assert" anti-pattern in browser-mode testing. The problem is structural:
await click()resolves when the browser dispatches the event, not when all downstream reactive effects have settled. Asserting on a mock call immediately after is a race condition by design.The file is otherwise well-structured. The three other async outcomes in the delete-confirmation suite are all correctly guarded with
vi.waitFor. The unsaved-changes guard suite correctly usesexpect.element(...)for DOM assertions (which have built-in retries). The discard-button test is the sole exception — it falls into the sync-assert trap.The acceptance criteria are achievable and well-scoped. "10 local runs consistently green" is a pragmatic bar. I'd additionally recommend a CI run on the fixed branch before closing — flaky tests sometimes only surface under the slower resource contention of a shared runner.
No other tests in the file need the same fix. I reviewed every assertion after a browser interaction:
vi.waitForalready present ✅expect.element(...)auto-waits ✅rerender-based test (line 261): re-renders are synchronous in vitest-browser, assertion is safe ✅Recommendations
await vi.waitFor(() => expect(vi.mocked(goto)).toHaveBeenCalledWith('http://localhost/admin/users/u2')). One line, no test logic changes.vitest run --reporter=verboseover 10 runs locally, then verify CI green before closing the issue.vi.waitFor— this is the most common misunderstanding in vitest-browser migrations.🛠️ Tobias Wendt — DevOps & Platform Engineer
Observations
Flaky tests are CI debt. A test that passes 80–90% of the time trains the team to re-run pipelines rather than investigate failures — real regressions start hiding behind noise. P2-medium is the right priority for this: not urgent, but not deferrable either.
The fix is entirely in source code — no CI config changes, no runner changes, no workflow changes. The scope is minimal.
The failure mode described (zero-calls on
gotomock) is consistent and timing-sensitive rather than environment-specific, so the fix should hold on the Gitea act_runner too, not just locally.Recommendations
vi.waitForfix and run a CI pass to confirm the test is stable under runner resource contention (which is typically slower than local Chromium).🏗️ Markus Keller — Application Architect
Observations
No architectural concerns. This issue is entirely within the test layer — a single test assertion is in the wrong execution phase relative to Svelte's async reactive model. The application code, module boundaries, and service layer are unaffected.
The fix does not touch production code, does not cross module boundaries, and does not require any API, schema, or documentation changes.
Recommendations
Apply the
vi.waitForfix as described by Felix and Sara — it's the right call, consistent with patterns already in the file.Preventive measure worth considering: run a codebase-wide grep for the same anti-pattern in other
*.spec.tsfiles — specifically,await.*\.click()on one line followed immediately by a syncexpect(vi.mocked(...))on the next. This pattern can exist silently in other tests that happen to be fast enough to not surface the race on current hardware. Surfacing them before they become CI failures is cheap:Then check each match: if the preceding line is an awaited browser interaction (not a
vi.waitFor), it needs the same fix.No open decisions from my side — the approach is clear.
🔐 Nora "NullX" Steiner — Application Security Engineer
No security concerns from this angle.
The affected test exercises the unsaved-changes guard — a pure UX flow that calls
goto()with a URL already present in the component'sbeforeNavigatecallback context. No user input is reflected into the mock call, no auth state is involved, and no security control is bypassed. The fix (wrapping a mock assertion invi.waitFor) has no security surface.I checked that the
gotomock is properly scoped viavi.mock('$app/navigation')and cleared viavi.clearAllMocks()inbeforeEach— no risk of cross-test mock state leaking authorization assumptions.🎨 Leonie Voss — UX Designer & Accessibility Strategist
No UX or accessibility concerns from this angle.
This is a pure test infrastructure fix. The "discard changes" flow itself — showing an unsaved-changes warning banner and offering a "Verwerfen" button — is a sound UX pattern (Nielsen's heuristic #3: user control and freedom). The test correctly verifies that the discard button navigates the user to their intended destination. The issue is only that the assertion runs too early in browser mode, not that the interaction design or accessibility of the feature is incorrect.