Skip to content

The Dashboard rail broke npm test -w apps/admin without failing a test โ€‹

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, and renders the LanternChat floating assistant for admins are 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. โ€‹

  • useDashboardRail calls getSystemHealth, listMerchantApplications and listOffersForReview, all three real exports of apps/admin/src/firebase.js (lines 1165, 889, 1677). It reaches getAppCheckToken (line 121) indirectly, because getRealtimeActivity goes through apiClient.authRequest, which imports it from the same module.
  • The test's vi.mock('../../firebase', ...) supplied exactly two things: auth and getAdminProfile. 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: โ€‹

SymbolVerdictWhy
getSystemHealthMock wrongReal export, legitimately called by the rail
getAppCheckTokenMock wrongReal export, legitimately read by apiClient on every authed request
getAuthTokenNeitherNot a mock gap at all, see below

getAuthToken was never a missing export. โ€‹

  • It is module-private to apiClient.js and is not exported by firebase.js at all. It threw User not authenticated because the mock's auth: {} has no currentUser, which is exactly what it is supposed to do against a bare auth stub.
  • It surfaced as unhandled only as collateral of the getAppCheckToken throw, described next. With the export present it settles inside Promise.all and 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 while Promise.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' }),
])
  • allSettled is therefore never called. The promise from getRealtimeActivity() 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, where Promise.all([getAuthToken(), getAppCheckToken()]) starts getAuthToken() and then throws on the getAppCheckToken access, orphaning it.

That is the three per render: โ€‹

  1. getAuthToken's User not authenticated, orphaned inside authRequest's Promise.all.
  2. getAppCheckToken's missing-export error, which getRealtimeActivity rejects with and which nothing catches once the outer array throws.
  3. getSystemHealth's missing-export error, which rejects load() itself. useEffect calls load() 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. listMerchantApplications and listOffersForReview, 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/analyticsApi is 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 in useDashboardRail.test.js.
  • Without it, the test relies on auth.currentUser being absent to keep fetch from 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 structure was the file's only /admin case with no await. Once the rail stopped throwing it started resolving, and its setState landed 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 .catch on load() would have swallowed all nine errors while the mock stayed broken, leaving the rail stuck on loading: true and rendering a permanent "Reading current counts." skeleton, with the suite green.
  • The unhandled rejection is the only thing that surfaced the gap. Promise.allSettled already 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, no pool: 'forks', no isolate, no sequence.shuffle, no blanket unhandledRejection handler 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/admin does NOT reproduce it either, but for an unrelated reason. It fails at import with Denied ID .../docs/business/launches/README.md?raw, because the release feed's raw doc imports fall outside vite's fs.allow when the root is set that way. Use npm 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) and ConsolePanel.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.mock factory 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 validate was NOT run, per this session's instruction.

Built with VitePress