diff --git a/vitest.config.ts b/vitest.config.ts index 8c67976..10f2530 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -14,5 +14,8 @@ export default defineConfig({ // jsdom. The ops suite spins real mock-mint HTTP servers on loopback. environment: 'node', include: ['src/**/*.test.ts'], + // Node 22 (CI) has no navigator.locks; install a passthrough + // LockManager where it is absent so the suite is hermetic + setupFiles: ['./vitest.setup.ts'], }, }); diff --git a/vitest.setup.ts b/vitest.setup.ts new file mode 100644 index 0000000..784ba23 --- /dev/null +++ b/vitest.setup.ts @@ -0,0 +1,61 @@ +// vitest setup (registered via setupFiles in vitest.config.ts). +// +// CI runs the suite on Node 22, where navigator.locks does not exist, so +// the fail-closed fund-mutation guard (withRequiredStorageLock) rejects +// every mutation; locally on Node 24 the native Web Locks are present. +// Install a minimal in-memory LockManager where locks are absent so the +// suite is hermetic across Node 20/22/24. It serializes same-name +// requests like the real API (storageLock.test.ts asserts that ordering) +// but gives no cross-tab guarantee - the same trade-off as +// withStorageLock's unlocked fallback. Tests that exercise the no-locks +// refusal stub navigator themselves (vi.stubGlobal('navigator', {})), +// which overrides this install. FORCE_STUB_LOCKS=1 replaces the native +// LockManager too, proving the suite never depends on real lock semantics. + +const createPassthroughLockManager = (): { + request: (name: string, callback: () => unknown) => Promise; +} => { + const tail = new Map>(); + return { + request: (name, callback) => { + const previous = tail.get(name) ?? Promise.resolve(); + const result = previous.then(callback); + tail.set( + name, + result.then( + () => undefined, + () => undefined, + ), + ); + return result; + }, + }; +}; + +const installPassthroughLocks = (): void => { + const locks = createPassthroughLockManager(); + const navigator = globalThis.navigator; + if (typeof navigator === 'undefined') { + Object.defineProperty(globalThis, 'navigator', { + value: { locks }, + configurable: true, + enumerable: false, + writable: true, + }); + return; + } + Object.defineProperty(navigator, 'locks', { + value: locks, + configurable: true, + writable: true, + }); +}; + +const locksAbsent = + typeof globalThis.navigator === 'undefined' || + !('locks' in globalThis.navigator) || + !globalThis.navigator.locks; + +if (process.env.FORCE_STUB_LOCKS === '1' || locksAbsent) { + installPassthroughLocks(); +}