diff --git a/packages/api/package.json b/packages/api/package.json index db98238f90..fa3df48370 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -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", diff --git a/packages/api/test/integration.test.ts b/packages/api/test/integration.test.ts index 8fff41b186..07550476b1 100644 --- a/packages/api/test/integration.test.ts +++ b/packages/api/test/integration.test.ts @@ -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, + ]); + }); + }, +); diff --git a/packages/api/test/setup.browser.ts b/packages/api/test/setup.browser.ts new file mode 100644 index 0000000000..188037dcef --- /dev/null +++ b/packages/api/test/setup.browser.ts @@ -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 { + 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 => { + 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 = {}; + 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; diff --git a/packages/api/test/setup.node.ts b/packages/api/test/setup.node.ts index e0fd4a02cc..0b61a90346 100644 --- a/packages/api/test/setup.node.ts +++ b/packages/api/test/setup.node.ts @@ -29,3 +29,4 @@ const dataDir = path.join( ); await fsPromises.mkdir(dataDir, { recursive: true }); globalThis.__API_DATA_DIR__ = dataDir; +globalThis.__API_FULL_SUITE__ = true; diff --git a/packages/api/vite.browser.config.mts b/packages/api/vite.browser.config.mts index b2ba6f4e3e..63a19c9126 100644 --- a/packages/api/vite.browser.config.mts +++ b/packages/api/vite.browser.config.mts @@ -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, + }, }); diff --git a/yarn.lock b/yarn.lock index 3d4652c383..ef6a1f0451 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"