Files
pad/web/vitest.config.ts
xarmian b759898d5b test(web): fake-indexeddb harness for the localIndex persistence layer (PLAN-2636 unit 1) (#1156)
The localIndex IndexedDB persistence layer ran as a no-op under vitest —
there is no IndexedDB in the node test environment, so `isSupported()`
returned false and every persist/hydrate call short-circuited. The entire
layer, and BUG-2609's seq-guard fix, shipped on live-browser evidence runs
only (the #1148 finding). This adds the harness that makes it testable, the
prerequisite for unit 2's order-and-merge regression matrix.

- fake-indexeddb dev dependency (exact-pinned).
- A dedicated `idb` vitest project (glob `*.idb.test.ts`, node env) whose
  setup installs fake-indexeddb's globals and a fresh IDBFactory per test.
  It self-disables when the dep can't be resolved — mirroring the jsdom
  project — so a symlinked worktree without it keeps `npm run test` green,
  and CI activates it once installed. The idb glob is excluded from the node
  project so the persistence layer can't no-op there and pass vacuously.
- Harness helpers unit 2 builds on: a second cross-tab connection to the
  same database (2635), a v1-database seed + higher-format-version reopen +
  downgrade VersionError (the v1→v2 migration exercise), a fresh-module
  loader that clears the connection cache, and raw ground-truth reads.
  `harnessDbName` mirrors the module's `dbName` exactly, pinned by a test so
  a drift can't make assertions read an empty sibling database.
- BUG-2609's evidence run is ported as a deterministic sequential regression:
  a newer delta commits its atomic rows+cursor transaction, then a stale
  older-seq snapshot lands last and is refused (IDB serializes overlapping
  transactions, so no interleaving control is needed). Plus a raw
  serialization characterization pinning that platform guarantee.

No production code changes.

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V
2026-08-18 15:20:13 -04:00

155 lines
6.2 KiB
TypeScript

import { defineConfig } from 'vitest/config';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
import { realpathSync } from 'node:fs';
// Two-project vitest setup (TASK-2081 / PLAN-1984):
//
// - `node` — the existing pure-TS unit suite. Plain node environment, no
// Svelte plugin (fast; matches the pre-TASK-2081 behavior).
// - `jsdom` — `.svelte` component + `.svelte.ts` rune-module tests. Runs in a
// browser-like DOM with the Svelte plugin so runes/components
// compile, and aliases `$app/environment` to a browser=true mock.
//
// Split by filename: `*.svelte.test.ts` routes to jsdom, everything else
// (`*.test.ts`) stays on node. Keeping the node suite out of jsdom avoids
// slowing/altering the pure-logic tests.
//
// The jsdom project's deps (`jsdom`, `@testing-library/svelte`,
// `@testing-library/jest-dom`) are declared in package.json but may be absent
// until `npm install` runs (worktrees share a read-only node_modules). When
// they're missing we register ONLY the node project, so `npm run test` keeps
// the existing suite green; once installed, the jsdom project activates
// automatically and `npm run test` runs BOTH.
const require = createRequire(import.meta.url);
function canResolve(id: string): boolean {
try {
require.resolve(id);
return true;
} catch {
return false;
}
}
const projectRoot = fileURLToPath(new URL('.', import.meta.url));
const $lib = fileURLToPath(new URL('./src/lib', import.meta.url));
const appEnvironmentMock = fileURLToPath(new URL('./src/test/mocks/app-environment.ts', import.meta.url));
const appStateMock = fileURLToPath(new URL('./src/test/mocks/app-state.ts', import.meta.url));
const appNavigationMock = fileURLToPath(new URL('./src/test/mocks/app-navigation.ts', import.meta.url));
// Agent worktrees symlink `web/node_modules` to the main checkout's
// node_modules rather than `npm ci`-ing a copy (running npm ci THROUGH the
// symlink deletes the shared tree — see CLAUDE.md's "Working in a git
// worktree" section, which also covers the svelte-kit sync prerequisite).
// Vite's dev-server fs-access guard checks a
// requested file's REALPATH against `server.fs.allow`, which defaults to the
// project root and its ancestors — a node_modules symlink that resolves
// outside that root (the worktree lives under a sibling directory tree) gets
// denied, breaking every jsdom-project test that imports a real package
// (e.g. `@testing-library/svelte/vitest`) with a confusing "does the file
// exist?" error even though it does. Explicitly allowing the resolved
// realpath fixes worktrees without changing behavior for a normal checkout,
// where the realpath is just `<project>/node_modules` — already inside the
// default allow-list. `projectRoot` MUST stay in the allow list alongside
// it — setting `server.fs.allow` REPLACES Vite's default (project root +
// ancestors), so omitting the root here would deny access to ordinary
// project source files (Codex review).
const nodeModulesRealPath = (() => {
try {
return realpathSync(fileURLToPath(new URL('./node_modules', import.meta.url)));
} catch {
return undefined;
}
})();
const browserTestDepsInstalled =
canResolve('jsdom') &&
canResolve('@testing-library/svelte') &&
canResolve('@testing-library/jest-dom') &&
canResolve('@sveltejs/vite-plugin-svelte');
// The `idb` project needs fake-indexeddb (PLAN-2636 unit 1). Like the jsdom
// deps it's declared in package.json but absent in a symlinked worktree until
// npm install runs; when it can't be resolved we register only the other
// projects so `npm run test` stays green, and the idb suite activates
// automatically once the dep is present (mirrors the jsdom self-disable).
const idbTestDepInstalled = canResolve('fake-indexeddb');
const BROWSER_TEST_GLOB = 'src/**/*.svelte.test.ts';
// IDB-backed persistence tests. The `.idb.test.ts` suffix routes them to the
// dedicated `idb` project; they must be excluded from the node project (which
// has no indexedDB — the persistence layer would silently no-op there and the
// test would pass vacuously) the same way the svelte glob is.
const IDB_TEST_GLOB = 'src/**/*.idb.test.ts';
const nodeProject = {
resolve: { alias: { $lib } },
test: {
name: 'node',
environment: 'node',
include: ['src/**/*.test.ts'],
// The jsdom / idb projects own these; they'd blow up or no-op in the
// plain node env.
exclude: [BROWSER_TEST_GLOB, IDB_TEST_GLOB],
},
};
const idbProject = {
resolve: { alias: { $lib } },
server: nodeModulesRealPath
? { fs: { allow: [projectRoot, nodeModulesRealPath] } }
: undefined,
test: {
name: 'idb',
// fake-indexeddb runs in plain node — no DOM needed. Its setup installs
// a fresh in-memory IndexedDB per test.
environment: 'node',
include: [IDB_TEST_GLOB],
setupFiles: ['./src/test/setup-idb.ts'],
},
};
export default defineConfig(async () => {
const projects: Record<string, unknown>[] = [nodeProject];
if (idbTestDepInstalled) {
projects.push(idbProject);
}
if (browserTestDepsInstalled) {
// Dynamic import so a missing plugin can never crash config loading.
const { svelte } = await import('@sveltejs/vite-plugin-svelte');
const { svelteTesting } = await import('@testing-library/svelte/vite');
projects.push({
plugins: [svelte(), svelteTesting()],
resolve: {
alias: {
$lib,
// No SvelteKit plugin in this project, so provide `$app/environment`
// and `$app/state` — without a provider these don't just come back
// undefined, they fail to RESOLVE, which is a load-time error for
// any component that imports them (and one `vi.mock` can't rescue,
// since resolution happens first).
'$app/environment': appEnvironmentMock,
'$app/state': appStateMock,
// `$app/navigation` for the same reason (TASK-2430) — without it
// Sidebar / TopBar / PaneHost can't even be IMPORTED under jsdom.
'$app/navigation': appNavigationMock,
},
},
server: nodeModulesRealPath
? { fs: { allow: [projectRoot, nodeModulesRealPath] } }
: undefined,
test: {
name: 'jsdom',
environment: 'jsdom',
include: [BROWSER_TEST_GLOB],
setupFiles: ['./src/test/setup-jsdom.ts'],
},
});
}
return { test: { projects } };
});