The Dashboard rail broke npm test -w apps/admin without failing a test โ
- Status: FIXED, 2026-08-27.
npm test -w apps/adminexits 0. - Source: the CI blocker on
feat/admin-and-merchant-portals, the last thing between the day's work and a mergeable PR. - Introduced by:
cd57a7b6, the Dashboard reading-page build recorded indashboard-redesign.md. - Fixed by:
11b244d3, one file,apps/admin/src/admin/__tests__/AdminShell.test.jsx.
What did the failure look like? โ
Everything passed and the run still failed. โ
Test Files 77 passed (77)
Tests 490 passed (490)
Errors 9 errors
EXIT: 1- Nine unhandled rejections landed outside the assertions. Vitest fails a run on those regardless of how the tests themselves went, which is the correct behavior: a promise nobody caught is a real defect the assertions cannot see.
- All nine came from one file,
src/admin/__tests__/AdminShell.test.jsx, in three identical groups of three.
The three groups map to the three tests that render /admin. โ
renders the admin sidebar with major nav sections,renders sidebar and main content structure, andrenders the LanternChat floating assistant for adminsare the only cases that mount the index route.- The index route is
DashboardHome, so each one boots the status rail. The other seven tests render mocked page stubs and never touch it.
What was actually wrong? โ
The mock was incomplete. The component was right. โ
useDashboardRailcallsgetSystemHealth,listMerchantApplicationsandlistOffersForReview, all three real exports ofapps/admin/src/firebase.js(lines 1165, 889, 1677). It reachesgetAppCheckToken(line 121) indirectly, becausegetRealtimeActivitygoes throughapiClient.authRequest, which imports it from the same module.- The test's
vi.mock('../../firebase', ...)supplied exactly two things:authandgetAdminProfile. It was written before the index route had a dashboard on it and was never revisited when one arrived. - Nothing about the rail is asking for something it should not. Every call is one the portal already makes from the screen the row links to, which is the design the rail was built to.
Per symbol, since the two need opposite fixes: โ
| Symbol | Verdict | Why |
|---|---|---|
getSystemHealth | Mock wrong | Real export, legitimately called by the rail |
getAppCheckToken | Mock wrong | Real export, legitimately read by apiClient on every authed request |
getAuthToken | Neither | Not a mock gap at all, see below |
getAuthToken was never a missing export. โ
- It is module-private to
apiClient.jsand is not exported byfirebase.jsat all. It threwUser not authenticatedbecause the mock'sauth: {}has nocurrentUser, which is exactly what it is supposed to do against a bare auth stub. - It surfaced as unhandled only as collateral of the
getAppCheckTokenthrow, described next. With the export present it settles insidePromise.alland disappears. No change was made for it.
Why did a missing mock export escape as an unhandled rejection? โ
Reading a missing export off a mocked module throws SYNCHRONOUSLY, and it throws mid-argument-list. โ
- Vitest's mock proxy raises on property access, not on call. So
getSystemHealth()throws whilePromise.allSettled's argument array is still being built:
js
await Promise.allSettled([
getRealtimeActivity(), // already started, returns a pending promise
getSystemHealth(), // throws here, so the array literal never completes
listMerchantApplications({ status: 'pending' }),
listOffersForReview({ status: 'pending_review' }),
])allSettledis therefore never called. The promise fromgetRealtimeActivity()has already been created and has no handler attached to it, so it surfaces as an unhandled rejection on its own.- The same thing happens one level down inside
authRequest, wherePromise.all([getAuthToken(), getAppCheckToken()])startsgetAuthToken()and then throws on thegetAppCheckTokenaccess, orphaning it.
That is the three per render: โ
getAuthToken'sUser not authenticated, orphaned insideauthRequest'sPromise.all.getAppCheckToken's missing-export error, whichgetRealtimeActivityrejects with and which nothing catches once the outer array throws.getSystemHealth's missing-export error, which rejectsload()itself.useEffectcallsload()fire-and-forget, so there is nothing to catch it.
- Three per render, three renders, nine errors.
The failures arrive after the test that caused them has already passed. โ
- This is why the count stayed at 490. React flushes the mount effect, the test's assertions finish, the test is recorded green, and only then does the microtask queue surface the rejection. Vitest attributes it to the file rather than the case.
What was hidden behind the short-circuit? โ
Two of the four gaps never reported themselves. โ
- Array evaluation stopped at
getSystemHealth, index 1.listMerchantApplicationsandlistOffersForReview, at indexes 2 and 3, were never reached, so they never appeared in the log. - Fixing only the two symbols the log named would have moved the same error one slot to the right. All four are mocked.
What was changed? โ
The firebase mock now carries every export the rail path touches, and analyticsApi is stubbed alongside it. โ
- Four exports added to the existing
vi.mock('../../firebase', ...), with resolved values that let the rail settle into a real rendered state rather than a thrown one. ../../shared/lib/analyticsApiis stubbed too, so the shell's routing assertions never depend on a network client. That matches how every other module this file reaches is treated, and the rail's own behavior already has its own coverage inuseDashboardRail.test.js.- Without it, the test relies on
auth.currentUserbeing absent to keepfetchfrom firing, which works by accident rather than by intent.
One synchronous test now awaits the rail settling, which removed an act warning that predates this fix. โ
renders sidebar and main content structurewas the file's only/admincase with noawait. Once the rail stopped throwing it started resolving, and itssetStatelanded after the test had returned, which React reports as an update not wrapped in act.- It now awaits the rail's settled note (
Read at ...). That is an added assertion, not a relaxed one, and it takes the file from one act warning to zero. Suite-wide the count went from 27 to 26.
What was deliberately NOT done? โ
No catch was added to useDashboardRail. โ
- It is the obvious-looking fix and it is the wrong one. A
.catchonload()would have swallowed all nine errors while the mock stayed broken, leaving the rail stuck onloading: trueand rendering a permanent "Reading current counts." skeleton, with the suite green. - The unhandled rejection is the only thing that surfaced the gap.
Promise.allSettledalready covers the failure the rail is designed to survive, which is one service answering badly. A throw above it is a programming error and should stay loud.
Nothing was skipped, deleted or weakened, and no ordering flag was used. โ
- 490 tests before, 490 after. No
--no-threads, nopool: 'forks', noisolate, nosequence.shuffle, no blanketunhandledRejectionhandler in the setup file.
Did the "passes in isolation, fails in parallel" lead hold? โ
No. It was wrong, and it would have sent the fix in the wrong direction. โ
- A prior session reported the file passing alone and erroring only under the parallel run, which points at leaked module state or cross-file mock bleed.
- Run alone, the file reproduces the failure exactly:
Test Files 1 passed, Tests 10 passed, Errors 9, exit 1. Identical nine errors, identical grouping. - There is no ordering dependency and no shared-state involvement. The mock was self-contained and wrong on its own.
- Worth noting for whoever repeats this:
npx vitest run <file> --root apps/admindoes NOT reproduce it either, but for an unrelated reason. It fails at import withDenied ID .../docs/business/launches/README.md?raw, because the release feed's raw doc imports fall outside vite'sfs.allowwhen the root is set that way. Usenpm test -w apps/admin -- run <file>instead. A previous isolation attempt hitting that error would look like "it does not fail alone".
What else in this suite passes while being wrong? โ
26 act warnings remain, across nine files, none of them from this change. โ
- Concentrated in
VenuesPage.test.jsx(4),Offers.test.jsx(3),BigQueryWorkspace.test.jsx(9 across three cases),QueryConsolePage.test.jsx(3) andConsolePanel.test.jsx(3). - Each one is a state update landing after its test returned. They do not fail the run today. They are the same family as this bug: work still in flight when the assertions were counted.
23 of the 24 test files that mock firebase do not stub getAppCheckToken. โ
- Latent rather than firing. They only matter for a file whose render path reaches
apiClient, which today is just this one. Any future test that mounts a screen doing an authed fetch will hit the identical shape. - The general lesson: a
vi.mockfactory is a hardcoded snapshot of a module's surface at the moment it was written, and nothing re-checks it when the module or its callers grow. The failure it produces does not point at the test that caused it.
How was it verified? โ
npm test -w apps/admin -- run src/admin/__tests__/AdminShell.test.jsx: exit 0, 10 tests, 0 errors, 0 act warnings.npm test -w apps/admin: exit 0,Test Files 77 passed (77),Tests 490 passed (490), no Errors line.npm run validate -- --scope lint --workspace apps/admin: 13 passed, 0 failed, Em Dash Check included.- Full
npm run validatewas NOT run, per this session's instruction.