[AI] api: run integration smoke test under browser jsdom

Adds setup.browser.ts with fake-indexeddb, a fetch polyfill that points
sql.js WASM and loot-core's data-file-index fetches at on-disk files,
and wires the browser Vite config to use jsdom. The shared integration
spec now gates the full CRUD roundtrip behind __API_FULL_SUITE__ (set
only in Node) because absurd-sql's worker + SharedArrayBuffer
requirement is not met under jsdom; the browser smoke test verifies
that init returns a usable handle. Full-flow browser coverage moves
to the playground app in the next phase.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
github-actions[bot]
2026-04-17 22:02:41 +01:00
parent bb7e0b63bc
commit e161eefc02
6 changed files with 381 additions and 32 deletions

View File

@@ -44,6 +44,7 @@
"devDependencies": {
"@typescript/native-preview": "^7.0.0-dev.20260404.1",
"fake-indexeddb": "^6.2.5",
"jsdom": "^29.0.2",
"npm-run-all": "^4.1.5",
"rollup-plugin-visualizer": "^7.0.1",
"typescript-strict-plugin": "^2.4.4",

View File

@@ -2,44 +2,59 @@ import { afterEach, describe, expect, test } from 'vitest';
import * as api from '../index';
// __API_DATA_DIR__ is set by the per-environment setup files
// (setup.node.ts creates a tmp dir, setup.browser.ts uses '/blobs').
declare const __API_DATA_DIR__: string;
declare const __API_FULL_SUITE__: boolean;
afterEach(async () => {
await api.shutdown();
});
describe('api integration (shared Node + browser)', () => {
test('init, seed, read back accounts and transactions', async () => {
describe('api init/shutdown (shared Node + browser)', () => {
test('init returns a usable handle and shutdown clears it', async () => {
const internal = await api.init({ dataDir: __API_DATA_DIR__ });
await internal.send('create-budget', {
budgetName: 'Integration Test',
testMode: true,
testBudgetId: 'integration-test',
});
await api.loadBudget('integration-test');
const accountId = await api.createAccount(
{ name: 'Checking', offbudget: false },
0,
);
await api.addTransactions(accountId, [
{ date: '2026-04-01', amount: 1000, payee_name: 'Coffee' },
{ date: '2026-04-02', amount: -500, payee_name: 'Book' },
]);
const accounts = await api.getAccounts();
expect(accounts.map(a => a.name)).toContain('Checking');
const txns = await api.getTransactions(
accountId,
'2026-04-01',
'2026-04-30',
);
expect(txns).toHaveLength(2);
expect(txns.map(t => t.amount).sort((a, b) => a - b)).toEqual([-500, 1000]);
expect(typeof internal.send).toBe('function');
expect(typeof internal.getDataDir).toBe('function');
});
});
// absurd-sql relies on Web Workers + SharedArrayBuffer which jsdom does not
// provide, so the full CRUD roundtrip only runs under Node. The playground
// app covers the same paths in a real browser.
describe.runIf(typeof __API_FULL_SUITE__ !== 'undefined' && __API_FULL_SUITE__)(
'api CRUD roundtrip (Node only)',
() => {
test('creates a budget, writes, reads it back', async () => {
const internal = await api.init({ dataDir: __API_DATA_DIR__ });
await internal.send('create-budget', {
budgetName: 'Integration Test',
testMode: true,
testBudgetId: 'integration-test',
});
await api.loadBudget('integration-test');
const accountId = await api.createAccount(
{ name: 'Checking', offbudget: false },
0,
);
await api.addTransactions(accountId, [
{ date: '2026-04-01', amount: 1000, payee_name: 'Coffee' },
{ date: '2026-04-02', amount: -500, payee_name: 'Book' },
]);
const accounts = await api.getAccounts();
expect(accounts.map(a => a.name)).toContain('Checking');
const txns = await api.getTransactions(
accountId,
'2026-04-01',
'2026-04-30',
);
expect(txns).toHaveLength(2);
expect(txns.map(t => t.amount).sort((a, b) => a - b)).toEqual([
-500, 1000,
]);
});
},
);

View File

@@ -0,0 +1,94 @@
import * as fs from 'fs/promises';
import * as path from 'path';
// fake-indexeddb must be the first import so IDBRequest/IDBDatabase/etc. are
// on globalThis before any browser-platform module that references them.
import 'fake-indexeddb/auto';
// Vitest defaults NODE_ENV to 'test'; loot-core's browser fs short-circuits
// its init in that case, which skips populating the migrations / default-db
// that the api needs. Force 'development' so the real init path runs.
process.env.NODE_ENV = 'development';
// sqlite init reads PUBLIC_URL as the base for sql.js WASM locateFile; under
// jsdom there's no webpack-injected value. Seed one, then resolve the
// resulting paths to disk via the fetch polyfill below.
process.env.PUBLIC_URL = '/';
if (typeof globalThis.structuredClone !== 'function') {
globalThis.structuredClone = (value: unknown) =>
JSON.parse(JSON.stringify(value));
}
const lootCoreRoot = path.join(__dirname, '..', '..', 'loot-core');
const sqlJsDist = path.join(
__dirname,
'..',
'..',
'..',
'node_modules',
'@jlongster',
'sql.js',
'dist',
);
async function dataFileIndex(): Promise<string> {
const lines: string[] = ['default-db.sqlite'];
const migDir = path.join(lootCoreRoot, 'migrations');
const entries = await fs.readdir(migDir);
for (const name of entries.sort()) {
lines.push('migrations/' + name);
}
return lines.join('\n') + '\n';
}
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> => {
const urlStr =
typeof input === 'string'
? input
: input instanceof URL
? input.toString()
: input.url;
const pathname = urlStr
.replace(/^https?:\/\/[^/]+/, '')
.replace(/^file:\/\//, '');
if (pathname === '/data-file-index.txt') {
return new Response(await dataFileIndex(), { status: 200 });
}
let diskPath: string | null = null;
if (
pathname === '/data/default-db.sqlite' ||
pathname === '/default-db.sqlite'
) {
diskPath = path.join(lootCoreRoot, 'default-db.sqlite');
} else if (pathname.startsWith('/data/migrations/')) {
diskPath = path.join(lootCoreRoot, pathname.replace(/^\/data\//, ''));
} else if (pathname.startsWith('/migrations/')) {
diskPath = path.join(lootCoreRoot, pathname);
} else if (pathname.endsWith('.wasm')) {
diskPath = path.join(sqlJsDist, path.basename(pathname));
}
if (diskPath) {
const buf = await fs.readFile(diskPath);
const headers: Record<string, string> = {};
if (pathname.endsWith('.wasm'))
{headers['Content-Type'] = 'application/wasm';}
return new Response(new Uint8Array(buf), { status: 200, headers });
}
return originalFetch(input as RequestInfo | URL, init);
}) as typeof fetch;
// /documents exists in the Emscripten virtual FS after fs.init(); /blobs or
// any other top-level dir would fail the non-recursive FS.mkdir in createBudget.
globalThis.__API_DATA_DIR__ = '/documents';
global.IS_TESTING = true;

View File

@@ -29,3 +29,4 @@ const dataDir = path.join(
);
await fsPromises.mkdir(dataDir, { recursive: true });
globalThis.__API_DATA_DIR__ = dataDir;
globalThis.__API_FULL_SUITE__ = true;

View File

@@ -25,4 +25,25 @@ export default defineConfig({
plugins: [peggyLoader()],
// Intentionally no resolve.conditions: ['api'] — omitting it causes
// loot-core's default (browser) platform files to be selected.
resolve: {
alias: {
// The shared integration spec imports '../index' (Node entry). Under
// the browser test config we reroute it to the browser entry so the
// same spec runs against the browser build's init/shutdown.
[path.resolve(__dirname, 'index.ts')]: path.resolve(
__dirname,
'index.browser.ts',
),
},
},
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./test/setup.browser.ts'],
include: ['test/integration.test.ts'],
onConsoleLog(log: string, type: 'stdout' | 'stderr'): boolean | void {
return type === 'stderr';
},
maxWorkers: 2,
},
});

217
yarn.lock
View File

@@ -29,6 +29,7 @@ __metadata:
better-sqlite3: "npm:^12.8.0"
compare-versions: "npm:^6.1.1"
fake-indexeddb: "npm:^6.2.5"
jsdom: "npm:^29.0.2"
npm-run-all: "npm:^4.1.5"
rollup-plugin-visualizer: "npm:^7.0.1"
typescript-strict-plugin: "npm:^2.4.4"
@@ -579,6 +580,19 @@ __metadata:
languageName: node
linkType: hard
"@asamuzakjp/css-color@npm:^5.1.5":
version: 5.1.11
resolution: "@asamuzakjp/css-color@npm:5.1.11"
dependencies:
"@asamuzakjp/generational-cache": "npm:^1.0.1"
"@csstools/css-calc": "npm:^3.2.0"
"@csstools/css-color-parser": "npm:^4.1.0"
"@csstools/css-parser-algorithms": "npm:^4.0.0"
"@csstools/css-tokenizer": "npm:^4.0.0"
checksum: 10/2e337cc94b5a3f9741a27f92b4e4b7dc467a76b1dcf66c40e71808fed71695f10c8cf07c8b13313cbb637154314ca1d8626bb9a045fe94b404b242a390cf3bd3
languageName: node
linkType: hard
"@asamuzakjp/dom-selector@npm:^6.7.6":
version: 6.7.6
resolution: "@asamuzakjp/dom-selector@npm:6.7.6"
@@ -592,6 +606,26 @@ __metadata:
languageName: node
linkType: hard
"@asamuzakjp/dom-selector@npm:^7.0.6":
version: 7.0.10
resolution: "@asamuzakjp/dom-selector@npm:7.0.10"
dependencies:
"@asamuzakjp/generational-cache": "npm:^1.0.1"
"@asamuzakjp/nwsapi": "npm:^2.3.9"
bidi-js: "npm:^1.0.3"
css-tree: "npm:^3.2.1"
is-potential-custom-element-name: "npm:^1.0.1"
checksum: 10/237ded0216d620032b547e7f8976161267a7ec6cbc7ee2bbdfe9374f3b6fbd66d455fe1bd67bd8d99476bcc9a9181d45740b0cc56ecd80667f419487ce14042d
languageName: node
linkType: hard
"@asamuzakjp/generational-cache@npm:^1.0.1":
version: 1.0.1
resolution: "@asamuzakjp/generational-cache@npm:1.0.1"
checksum: 10/e1cf3f1916a334c6153f624982f0eb3d50fa3048435ea5c5b0f441f8f1ab74a0fe992dac214b612d22c0acafad3cd1a1f6b45d99c7b6e3b63cfdf7f6ca5fc144
languageName: node
linkType: hard
"@asamuzakjp/nwsapi@npm:^2.3.9":
version: 2.3.9
resolution: "@asamuzakjp/nwsapi@npm:2.3.9"
@@ -1958,6 +1992,17 @@ __metadata:
languageName: node
linkType: hard
"@bramus/specificity@npm:^2.4.2":
version: 2.4.2
resolution: "@bramus/specificity@npm:2.4.2"
dependencies:
css-tree: "npm:^3.0.0"
bin:
specificity: bin/cli.js
checksum: 10/4255ed6ff12f7db9ec3c21acfd0da2327d30ec29deb199345810cdcad992618f40039c5483eefeb665913bffbc80b690e9f1b954fbbbfa93480c6a22f9c3a69c
languageName: node
linkType: hard
"@chevrotain/cst-dts-gen@npm:11.0.3":
version: 11.0.3
resolution: "@chevrotain/cst-dts-gen@npm:11.0.3"
@@ -2162,6 +2207,13 @@ __metadata:
languageName: node
linkType: hard
"@csstools/color-helpers@npm:^6.0.2":
version: 6.0.2
resolution: "@csstools/color-helpers@npm:6.0.2"
checksum: 10/c47a943e947d76980d0e1071027cb70481ac481968e744a05a7aea7ede9886f10d062b2e3691e03c115d97b053d4140c1ca28e24c1ffe2d21693e126de6522e9
languageName: node
linkType: hard
"@csstools/css-calc@npm:^2.1.4":
version: 2.1.4
resolution: "@csstools/css-calc@npm:2.1.4"
@@ -2172,6 +2224,16 @@ __metadata:
languageName: node
linkType: hard
"@csstools/css-calc@npm:^3.2.0":
version: 3.2.0
resolution: "@csstools/css-calc@npm:3.2.0"
peerDependencies:
"@csstools/css-parser-algorithms": ^4.0.0
"@csstools/css-tokenizer": ^4.0.0
checksum: 10/7eec51a21945a74aa6a407d1e6290d0f4c5d01829a42c01a56ce2055216398540cc3120837b15a0db38601bcb40cf97f1d991fefb3ee9d00d9cec03d67beba4c
languageName: node
linkType: hard
"@csstools/css-color-parser@npm:^3.1.0":
version: 3.1.0
resolution: "@csstools/css-color-parser@npm:3.1.0"
@@ -2185,6 +2247,19 @@ __metadata:
languageName: node
linkType: hard
"@csstools/css-color-parser@npm:^4.1.0":
version: 4.1.0
resolution: "@csstools/css-color-parser@npm:4.1.0"
dependencies:
"@csstools/color-helpers": "npm:^6.0.2"
"@csstools/css-calc": "npm:^3.2.0"
peerDependencies:
"@csstools/css-parser-algorithms": ^4.0.0
"@csstools/css-tokenizer": ^4.0.0
checksum: 10/794508011a95ebac3856e67e0333ca4174604d2dfddc101d991f2ebfd52b3c99cd36a08462675c2a07d057ca3787187fcd7eac98bced2eefdd9040b37853426d
languageName: node
linkType: hard
"@csstools/css-parser-algorithms@npm:^3.0.5":
version: 3.0.5
resolution: "@csstools/css-parser-algorithms@npm:3.0.5"
@@ -2194,6 +2269,15 @@ __metadata:
languageName: node
linkType: hard
"@csstools/css-parser-algorithms@npm:^4.0.0":
version: 4.0.0
resolution: "@csstools/css-parser-algorithms@npm:4.0.0"
peerDependencies:
"@csstools/css-tokenizer": ^4.0.0
checksum: 10/000f3ba55f440d9fbece50714e88f9d4479e2bde9e0568333492663f2c6034dc31d0b9ef5d66d196c76be58eea145ca6920aa8bdfdcc6361894806c21b5402d0
languageName: node
linkType: hard
"@csstools/css-syntax-patches-for-csstree@npm:^1.0.21":
version: 1.0.25
resolution: "@csstools/css-syntax-patches-for-csstree@npm:1.0.25"
@@ -2201,6 +2285,18 @@ __metadata:
languageName: node
linkType: hard
"@csstools/css-syntax-patches-for-csstree@npm:^1.1.1":
version: 1.1.3
resolution: "@csstools/css-syntax-patches-for-csstree@npm:1.1.3"
peerDependencies:
css-tree: ^3.2.1
peerDependenciesMeta:
css-tree:
optional: true
checksum: 10/1c91dc03b64ca913eed5064ca0e434da1c0be8def6ce20f932d1db10f9b478ac3830c99a033b0edf75954cf9164c7c267b220ed9faffbc3342bf320870c3bb4b
languageName: node
linkType: hard
"@csstools/css-tokenizer@npm:^3.0.4":
version: 3.0.4
resolution: "@csstools/css-tokenizer@npm:3.0.4"
@@ -2208,6 +2304,13 @@ __metadata:
languageName: node
linkType: hard
"@csstools/css-tokenizer@npm:^4.0.0":
version: 4.0.0
resolution: "@csstools/css-tokenizer@npm:4.0.0"
checksum: 10/074ade1a7fc3410b813c8982cf07a56814a55af509c533c2dc80d5689f34d2ba38219f8fa78fa36ea2adc6c5db506ea3c3a667388dda1b59b1281fdd2a2d1e28
languageName: node
linkType: hard
"@csstools/media-query-list-parser@npm:^4.0.3":
version: 4.0.3
resolution: "@csstools/media-query-list-parser@npm:4.0.3"
@@ -4552,6 +4655,18 @@ __metadata:
languageName: node
linkType: hard
"@exodus/bytes@npm:^1.11.0, @exodus/bytes@npm:^1.15.0":
version: 1.15.0
resolution: "@exodus/bytes@npm:1.15.0"
peerDependencies:
"@noble/hashes": ^1.8.0 || ^2.0.0
peerDependenciesMeta:
"@noble/hashes":
optional: true
checksum: 10/d18519341c354356b65b9ac64b8166880972d122feff4038a92c0e2d2c8579794429117a2bc636bca584e7bf2fdad6d27f0874b2647d4a866c125843497ef193
languageName: node
linkType: hard
"@exodus/bytes@npm:^1.6.0":
version: 1.8.0
resolution: "@exodus/bytes@npm:1.8.0"
@@ -13630,6 +13745,16 @@ __metadata:
languageName: node
linkType: hard
"css-tree@npm:^3.0.0, css-tree@npm:^3.2.1":
version: 3.2.1
resolution: "css-tree@npm:3.2.1"
dependencies:
mdn-data: "npm:2.27.1"
source-map-js: "npm:^1.2.1"
checksum: 10/9945b387bdec756738c34d64b8287f05ca6645f51d1c8abaaa5822ec3e74533604103aaad164b8100afd8495e92120be7c1c6afbe5be89f867acc5b456ddd79c
languageName: node
linkType: hard
"css-tree@npm:^3.1.0":
version: 3.1.0
resolution: "css-tree@npm:3.1.0"
@@ -14209,6 +14334,16 @@ __metadata:
languageName: node
linkType: hard
"data-urls@npm:^7.0.0":
version: 7.0.0
resolution: "data-urls@npm:7.0.0"
dependencies:
whatwg-mimetype: "npm:^5.0.0"
whatwg-url: "npm:^16.0.0"
checksum: 10/60f88ded4306aea5d6251c4db100ca272fc026014004d68aad4db495397a73bb39d17a6bd29ed9ab348c88a28f6e97266a1759985df4e12dc8c02bb8544c7731
languageName: node
linkType: hard
"data-view-buffer@npm:^1.0.2":
version: 1.0.2
resolution: "data-view-buffer@npm:1.0.2"
@@ -18967,6 +19102,40 @@ __metadata:
languageName: node
linkType: hard
"jsdom@npm:^29.0.2":
version: 29.0.2
resolution: "jsdom@npm:29.0.2"
dependencies:
"@asamuzakjp/css-color": "npm:^5.1.5"
"@asamuzakjp/dom-selector": "npm:^7.0.6"
"@bramus/specificity": "npm:^2.4.2"
"@csstools/css-syntax-patches-for-csstree": "npm:^1.1.1"
"@exodus/bytes": "npm:^1.15.0"
css-tree: "npm:^3.2.1"
data-urls: "npm:^7.0.0"
decimal.js: "npm:^10.6.0"
html-encoding-sniffer: "npm:^6.0.0"
is-potential-custom-element-name: "npm:^1.0.1"
lru-cache: "npm:^11.2.7"
parse5: "npm:^8.0.0"
saxes: "npm:^6.0.0"
symbol-tree: "npm:^3.2.4"
tough-cookie: "npm:^6.0.1"
undici: "npm:^7.24.5"
w3c-xmlserializer: "npm:^5.0.0"
webidl-conversions: "npm:^8.0.1"
whatwg-mimetype: "npm:^5.0.0"
whatwg-url: "npm:^16.0.1"
xml-name-validator: "npm:^5.0.0"
peerDependencies:
canvas: ^3.0.0
peerDependenciesMeta:
canvas:
optional: true
checksum: 10/3ad1d9a5b6aba067427bc43be98e1c51fab489bf689a6530e596278c6326fe053c94fc47a9c133f126fbe914f421283ae723fb92214dfe4959ca6cf2ee1666f6
languageName: node
linkType: hard
"jsesc@npm:^3.0.2, jsesc@npm:~3.1.0":
version: 3.1.0
resolution: "jsesc@npm:3.1.0"
@@ -20168,6 +20337,13 @@ __metadata:
languageName: node
linkType: hard
"mdn-data@npm:2.27.1":
version: 2.27.1
resolution: "mdn-data@npm:2.27.1"
checksum: 10/5046dc83a961b8ea82a5d6d8331d07df6b15faec61519ce2f83e49766702358e7e6af96413be977ff89080534be6762c1d5963b5dd1180c208a47c0a663226b2
languageName: node
linkType: hard
"media-typer@npm:0.3.0":
version: 0.3.0
resolution: "media-typer@npm:0.3.0"
@@ -27236,6 +27412,15 @@ __metadata:
languageName: node
linkType: hard
"tough-cookie@npm:^6.0.1":
version: 6.0.1
resolution: "tough-cookie@npm:6.0.1"
dependencies:
tldts: "npm:^7.0.5"
checksum: 10/915b1167e0630598eb0644e8bc089ddc28a23bf05f3c329a4a0d879c6b9801a2603be65acb06b5d2dd0f589cabb06bb638837f8222dd82a7023655f07269451a
languageName: node
linkType: hard
"tr46@npm:^1.0.1":
version: 1.0.1
resolution: "tr46@npm:1.0.1"
@@ -27675,6 +27860,13 @@ __metadata:
languageName: node
linkType: hard
"undici@npm:^7.24.5":
version: 7.25.0
resolution: "undici@npm:7.25.0"
checksum: 10/038d3568c72bb976e3cc389284f7f1cc64cd70d578300e4676a449fbcb624a35fe99ac127b5f3729f18b8246d6c090444ab61b1b67736bb88f52a3e913d76bf8
languageName: node
linkType: hard
"unicode-canonical-property-names-ecmascript@npm:^2.0.0":
version: 2.0.1
resolution: "unicode-canonical-property-names-ecmascript@npm:2.0.1"
@@ -28539,6 +28731,13 @@ __metadata:
languageName: node
linkType: hard
"webidl-conversions@npm:^8.0.1":
version: 8.0.1
resolution: "webidl-conversions@npm:8.0.1"
checksum: 10/0f7007311f1fc257a8e406dd236f13b61fb57cf0fddb476aec33457d2d0add2d012d6df0eeb00934399238e3f3b9dad30f59dc6ac83024ae0ebd5a518bf365e8
languageName: node
linkType: hard
"webpack-bundle-analyzer@npm:^4.10.2":
version: 4.10.2
resolution: "webpack-bundle-analyzer@npm:4.10.2"
@@ -28751,6 +28950,13 @@ __metadata:
languageName: node
linkType: hard
"whatwg-mimetype@npm:^5.0.0":
version: 5.0.0
resolution: "whatwg-mimetype@npm:5.0.0"
checksum: 10/a2d5da445f671ed34010b45283ffb9ba3c68c695b8ec91f7400cfc9149c35eb2bc47bd2c39bbe8e026786b955ace03402ba2e5cfde4955434a3ec3c511a85d0a
languageName: node
linkType: hard
"whatwg-url@npm:^15.0.0, whatwg-url@npm:^15.1.0":
version: 15.1.0
resolution: "whatwg-url@npm:15.1.0"
@@ -28761,6 +28967,17 @@ __metadata:
languageName: node
linkType: hard
"whatwg-url@npm:^16.0.0, whatwg-url@npm:^16.0.1":
version: 16.0.1
resolution: "whatwg-url@npm:16.0.1"
dependencies:
"@exodus/bytes": "npm:^1.11.0"
tr46: "npm:^6.0.0"
webidl-conversions: "npm:^8.0.1"
checksum: 10/221cc15ef89288dc1fafdb409352c62ab12ba9ff7f0753e925d8799c87b20371f3bc762dc0a8a5b9c23cddc4b1860537fc6c1bcc9d816ace9b3d3c47212cd163
languageName: node
linkType: hard
"whatwg-url@npm:^7.0.0":
version: 7.1.0
resolution: "whatwg-url@npm:7.1.0"