Skip to content

test: stop depending on another test file's leaked indexedDB global - #1201

Open
njbrake wants to merge 3 commits into
thunderbird:mainfrom
njbrake:pr2-test-indexeddb-isolation
Open

test: stop depending on another test file's leaked indexedDB global#1201
njbrake wants to merge 3 commits into
thunderbird:mainfrom
njbrake:pr2-test-indexeddb-isolation

Conversation

@njbrake

@njbrake njbrake commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Note: this PR description was drafted by Claude via back-and-forth with @njbrake. The reasoning and decisions are his; the prose is Claude's.

Removes a cross-file dependency that makes sidebar-footer.test.tsx fail depending on test order.

Its "sync retry flow" block enables sync, which puts the encryption config on the render path. That reads the bare indexedDB global, which happy-dom does not implement, so the tests only passed when some earlier test file had already stubbed it. With --randomize, an ordering that runs this file first fails with ReferenceError: indexedDB is not defined.

use-app-initialization.test.tsx was the accidental supplier, and its restore was itself wrong: it captured globalThis.indexedDB at module load (undefined, since happy-dom has none) and wrote that back in afterAll, leaving the property defined-but-undefined rather than absent.

Adds stubIndexedDb/restoreIndexedDb, which delete the global when it did not exist before, and has both files opt in explicitly. The absence is load-bearing elsewhere, since the boot pipeline's storage pre-flight is supposed to report STORAGE_UNAVAILABLE without it, so this stays opt-in rather than going into the global preload.

I saw this fail on a randomized run of main. I could not pin a seed that reproduces it on main itself, but it reproduces deterministically once any single extra test file changes the shuffle.

_Note: this PR description was drafted by Claude via back-and-forth with @njbrake. The reasoning and decisions are his; the prose is Claude's._

Removes a cross-file dependency that makes `sidebar-footer.test.tsx` fail depending on test order.

Its "sync retry flow" block enables sync, which puts the encryption config on the render path. That reads the bare `indexedDB` global, which happy-dom does not implement, so the tests only passed when some earlier test *file* had already stubbed it. With `--randomize`, an ordering that runs this file first fails with `ReferenceError: indexedDB is not defined`.

`use-app-initialization.test.tsx` was the accidental supplier, and its restore was itself wrong: it captured `globalThis.indexedDB` at module load (undefined, since happy-dom has none) and wrote that back in `afterAll`, leaving the property defined-but-undefined rather than absent.

Adds `stubIndexedDb`/`restoreIndexedDb`, which delete the global when it did not exist before, and has both files opt in explicitly. The absence is load-bearing elsewhere, since the boot pipeline's storage pre-flight is supposed to report STORAGE_UNAVAILABLE without it, so this stays opt-in rather than going into the global preload.

I saw this fail on a randomized run of `main`. I could not pin a seed that reproduces it on `main` itself, but it reproduces deterministically once any single extra test file changes the shuffle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@njbrake
njbrake force-pushed the pr2-test-indexeddb-isolation branch from 8d9d8ff to 7b2dc27 Compare August 13, 2026 15:57
@njbrake
njbrake deployed to fork-preview-approval August 13, 2026 15:58 — with GitHub Actions Active
@github-actions

Copy link
Copy Markdown

Preview environment deployed 🚀

Service URL
Marketing / blog / docs https://thunderbolt-pr-1201.preview.thunderbolt.io
App https://app-pr-1201.preview.thunderbolt.io
API https://api-pr-1201.preview.thunderbolt.io
Keycloak https://auth-pr-1201.preview.thunderbolt.io
PowerSync https://powersync-pr-1201.preview.thunderbolt.io

Stack: preview-pr-1201 · Commit: 7b2dc2720e418eed28ae4a6607b0b7bab5a42b84

Auto-destroys on PR close/merge. Login via the bundled Keycloak realm — demo@thunderbolt.io / demo by default.

@ital0 ital0 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can see one concrete benefit here: use-app-initialization.test.tsx now restores a missing indexedDB global correctly. The old cleanup left the property defined with an undefined value.

I could not reproduce the reported failure. I ran the affected tests alone and together, then ran the full randomized suite five times on the parent commit and five times on this PR. Every run completed with 4,312 passing tests and no failures. That does not prove the flake cannot happen, but this PR has no regression test that fails before the change and passes after it.

My main concern is the sidebar test. It reaches key storage only when the E2EE state sends it there, but the test does not control that state. The new IndexedDB stub reports that no key exists, which makes the hook disable sync after render. The test can therefore assert the retry UI before the async migration check finishes.

The shared helper also presents itself as an IDBFactory that supports reads and writes, but several key-storage operations do not work with it. It can also leave the stub installed when calls are nested.

As written, the PR fixes one cleanup bug, but it does not demonstrate the reported failure or add coverage. It also introduces a shared fake that can leave global state behind and can make key-storage calls hang or throw.

Comment thread src/components/sidebar-footer.test.tsx Outdated
// put this file first failed with `ReferenceError: indexedDB is not defined`.
// Stubbing it here makes the block self-contained.
beforeEach(() => {
stubIndexedDb()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests do not control e2eeEnabled. In this path, the real needsSyncSetupWizard() only calls getCK() when encryption is enabled. This stub returns no key, so the hook disables sync after render. The assertions can therefore observe the retry UI before the async migration check finishes, instead of the stable state described by the test.

afterAll(async () => {
await teardownTestDatabase()
Object.defineProperty(globalThis, 'indexedDB', { value: realIndexedDb, configurable: true, writable: true })
restoreIndexedDb()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This cleanup fixes a real leak. The old code could replace a missing global with an indexedDB property whose value was undefined. However, no test asserts that this restore removes the property, so the same regression could return without any test failing.

Comment thread src/test-utils/indexed-db.ts Outdated
/** Present so `restoreIndexedDb` can tell "was absent" from "was something". */
const absent = Symbol('absent')

let previous: IDBFactory | typeof absent = absent

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

previous is shared by every call. If stubIndexedDb() is called twice before restoring, the second call overwrites the first snapshot, and two restores still leave a stub installed. The helper can therefore create the same global leak it is meant to prevent.

Comment thread src/test-utils/indexed-db.ts Outdated
onblocked: null as (() => void) | null,
result: {
close: () => {},
// Enough of a database for key-storage's read/write round trip to be

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This says the fake supports a read/write round trip, but it does not store values, implement delete(), or fire transaction.oncomplete. I verified that storeCK() and getKeyPair() never settle, while clearCK() throws. The type assertion presents this incomplete object as a full IDBFactory, so future tests can hang or fail when they use the advertised operations.

- keep the fake to what it can honestly do: open() succeeds, but the
  database has no transaction, so key-storage round trips fail loudly
  instead of hanging on an event the fake never fires
- make nested stub/restore pairs safe, so a restore can never leave a
  stub behind in place of the real global
- pin the config store off in sidebar-footer's sync retry block: the
  encryption path was only reachable because another test file can leave
  that persisted module global switched on
- cover the helper itself, including the delete-vs-undefined distinction
  the boot storage pre-flight depends on

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YPSDjepyzL3q9bZgDmtPm2
@njbrake
njbrake deployed to fork-preview-approval August 20, 2026 15:29 — with GitHub Actions Active

@ital0 ital0 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey! Not sure if you're done with the fixes... But I took another look and the follow-up fixes several issues from the first review.

The main order dependency is still reproducible. Running pending-device-modal.test.tsx before sidebar-footer.test.tsx leaves a global @/db/encryption module mock in place. Resetting the Zustand config does not remove that mock. In that order, two sidebar retry tests fail with ReferenceError: indexedDB is not defined.

The full randomized suite passed with 4,316 tests, and the three directly affected files passed with 39 tests. The specific two-file order above still failed with 25 passing tests and 2 failures. This means the general run can miss the exact ordering problem the PR is meant to remove.

The remaining test-isolation and documentation problems are noted inline.

// on, so pin it off here. That keeps sync enabled for the whole block and keeps
// the encryption path (and IndexedDB with it) off the render path entirely.
beforeEach(() => {
useConfigStore.setState({ config: {} })

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This reset does not isolate the test from leaked module mocks. pending-device-modal.test.tsx registers a global mock.module('@/db/encryption'). When that file runs first, this reset does not remove the mock, and the two sync retry tests fail with ReferenceError: indexedDB is not defined. The test still depends on file order.

Comment thread src/test-utils/indexed-db.test.ts Outdated
describe('stubIndexedDb / restoreIndexedDb', () => {
// happy-dom ships no IndexedDB, so "absent" is the real baseline — assert
// against it explicitly rather than trusting whatever ran before this file.
beforeEach(() => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This deletes the global before each test, but cleanup still happens inside the individual test bodies. If an assertion fails before restoreIndexedDb(), both the global and the helper's private snapshot stay dirty and can affect later randomized tests.

Reflect.deleteProperty(globalThis, 'indexedDB')
})

it('installs an openable factory and removes it again', async () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file never exercises a key-storage read or write. The old fake that hung on transaction events would still pass all four tests here, so the new claim that unsupported operations fail instead of hanging is not covered.

Comment thread src/test-utils/indexed-db.test.ts Outdated
expect(opened).toBe(true)
restoreIndexedDb()
// Deleted, not set to undefined: code reading the bare global must still
// see a ReferenceError, which is what the boot pre-flight relies on.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment is not accurate. The boot preflight reads globalThis.indexedDB and returns false both when the property is absent and when its value is undefined. It does not rely on a ReferenceError. That error comes from key storage reading the bare global.

Comment thread src/test-utils/indexed-db.test.ts Outdated
})

it('restores a pre-existing global rather than deleting it', () => {
const original = { marker: 'real' } as unknown as IDBFactory

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This double assertion bypasses the type system and conflicts with the repository rule against as unknown as. It also treats a marker object as a complete IDBFactory even though it does not implement that contract.

Comment thread src/test-utils/indexed-db.ts Outdated
* fail loudly rather than hanging on an event this fake never fires. Extend it
* here, with a test, if a case genuinely needs a backing store.
*
* Nested calls keep the first snapshot, so a stub can never restore over a stub.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These calls are not safe as nested pairs. After an outer and inner stubIndexedDb(), the inner restoreIndexedDb() removes the outer caller's stub. The test named nested also calls restore only once, so it verifies repeated setup rather than nested ownership.

@njbrake

njbrake commented Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

@ital0 thanks for the review! I'll take another look, thanks for your patience 🙏 !

- pending-device-modal.test.tsx and use-pending-device-notification.test.tsx
  register a worker-global mock.module('@/db/encryption'). Bun also rewrites the
  binding needsSyncSetupWizard reads inside ./config, so resetting the config
  store could not switch encryption back off. Re-provide both modules with the
  store-backed implementation, the same defense db/encryption/config.test.ts uses.
- cover the helper's contract: the fake exposes no transaction, so key-storage
  calls throw instead of hanging on an event it never fires.
- reset the global and the helper's snapshot in afterEach, so a failed assertion
  cannot leave either dirty for whatever the shuffle runs next.
- correct the delete-vs-undefined comment: the ReferenceError comes from key
  storage reading the bare global, not from the boot storage pre-flight.

Verified: seeds 3200613218 and 4217200907 fail on ca4ff05 and pass here;
sidebar-footer paired with all 25 mock-registering test files went from 5
failing runs to 0 across 100 randomized runs; three full `bun run test` clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YPSDjepyzL3q9bZgDmtPm2
@njbrake
njbrake deployed to fork-preview-approval August 24, 2026 18:18 — with GitHub Actions Active

@ital0 ital0 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the follow-up. I took another pass at the latest commit and reran the failing file pairs. The IndexedDB helper is in much better shape now: cleanup runs in afterEach, repeated stubbing restores the original global, unsupported key-storage use fails instead of hanging, and the comments now match the implementation.

The two known pending-device orders now pass. I found one more leaked encryption export, though. If use-sync-enabled-toggle.test.ts runs first, its mocked needsSyncSetupWizard survives the new sidebar guards because the spread comes from an already mocked module and only isEncryptionEnabled is replaced. In that order, the sidebar first renders the retry state, then the migration effect disables sync. The existing assertions run before that effect settles, so the tests remain green while observing a transient state.

I reproduced this against the current main integration and left the details inline. I also noted two smaller repository-convention issues in the new code.

Comment on lines +234 to +238
const realEncryption = await import('@/db/encryption')
mock.module('@/db/encryption', () => ({
...realEncryption,
isEncryptionEnabled: () => useConfigStore.getState().config.e2eeEnabled === true,
}))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I may be missing a Bun detail here, but realEncryption can already be the namespace registered by an earlier mock.module(). use-sync-enabled-toggle.test.ts replaces needsSyncSetupWizard, and this spread carries that replacement forward because this guard only overwrites isEncryptionEnabled. I reproduced that order with E2EE off: needsSyncSetupWizard() still returned true, the hook rendered with sync enabled, and the migration effect then disabled it. The sidebar assertions pass before that effect settles, so this still leaves an order-dependent false positive.

These two registrations are also worker-global and permanent. That is the same behavior called out in docs/development/testing.md under "Avoid mock.module() for Shared Modules."

// the real store-backed implementation, the same defense `db/encryption/config.test.ts`
// uses. Without it, a shuffle that runs either of those files first sends the sync
// retry tests into key storage and fails them on the missing `indexedDB` global.
const realEncryptionConfig = await import('@/db/encryption/config')

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small convention note: these await import(...) calls are not tied to a documented circular dependency. AGENTS.md asks us to prefer top-level imports in that case. Here the dynamic ordering seems to exist only to support the module-mock workaround.

Reflect.deleteProperty(globalThis, 'indexedDB')
}

const openStub = async () =>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small style note: openStub is a new test utility without JSDoc. AGENTS.md asks new utility functions to carry a short JSDoc comment.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants