// ============================================================ // Test harness — load js/app.js into a sandboxed VM context // ============================================================ // // app.js is a ~12k-line browser monofile with no module exports. It runs // only one top-level statement (a DOMContentLoaded listener); everything // else is function/const declarations. We load it in a node:vm context with // browser globals stubbed out so init() never fires, then reach the internals // we want to test through an appended export epilogue. // // Why the epilogue: in vm.runInContext, top-level `function`/`var` declarations // attach to the context's global object, but top-level `const`/`let` (like // `state`, `API`, `HASH_ALGO_V2`) do NOT. So we append a line that copies the // symbols we care about onto globalThis.__test, giving tests a stable handle. // // Reassignable seams for the merge tests: `api`, `loadEntries`, `loadFolders`, // `encryptImportEntry`, etc. are `function` declarations → global properties, // so a test can override `ctx.api = fake` and the free-variable lookup inside // applyRemoteSnapshot will pick up the fake. `state` is a `const` (lexical), // so it can't be replaced — but it CAN be mutated, and applyRemoteSnapshot // closes over that same object, so mutating ctx.__test.state is visible to it. const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); const { webcrypto } = require('node:crypto'); const APP_JS = path.join(__dirname, '..', 'app.js'); // In-memory Storage stub (Web Storage API surface used by app.js). function makeStorage() { const m = new Map(); return { getItem: (k) => (m.has(k) ? m.get(k) : null), setItem: (k, v) => { m.set(k, String(v)); }, removeItem: (k) => { m.delete(k); }, clear: () => m.clear(), key: (i) => Array.from(m.keys())[i] ?? null, get length() { return m.size; }, }; } // Minimal no-throw DOM/window stubs. app.js only *executes* one DOM call at // load (document.addEventListener for DOMContentLoaded) — everything else is // inside functions we don't call. So these just have to exist and not throw. function makeDomStubs() { const noop = () => {}; const elStub = new Proxy({}, { get: (_t, prop) => { if (prop === 'style') return {}; if (prop === 'classList') return { add: noop, remove: noop, toggle: noop, contains: () => false }; if (prop === 'addEventListener' || prop === 'removeEventListener') return noop; if (prop === 'appendChild' || prop === 'append' || prop === 'remove') return noop; if (prop === 'setAttribute' || prop === 'removeAttribute') return noop; if (prop === 'querySelector') return () => null; if (prop === 'querySelectorAll') return () => []; return undefined; }, set: () => true, }); const document = { addEventListener: noop, removeEventListener: noop, getElementById: () => null, querySelector: () => null, querySelectorAll: () => [], createElement: () => elStub, body: elStub, documentElement: elStub, }; return { document, elStub, noop }; } // Build a fresh sandbox + load app.js into it. Returns the contextified // sandbox; test internals live on ctx.__test. function loadApp(overrides = {}) { const { document, noop } = makeDomStubs(); const sandbox = { crypto: webcrypto, TextEncoder, TextDecoder, btoa, atob, console, setTimeout, clearTimeout, setInterval, clearInterval, Date, JSON, Math, Promise, URL, URLSearchParams, // Browser-ish globals used at load time location: { pathname: '/index.html', href: 'http://127.0.0.1/index.html', search: '', hash: '' }, history: { replaceState: noop, pushState: noop }, navigator: { clipboard: { writeText: async () => {}, readText: async () => '' }, userAgent: 'node-test' }, localStorage: makeStorage(), sessionStorage: makeStorage(), document, fetch: async () => { throw new Error('fetch not stubbed'); }, // Some code paths reference matchMedia / requestAnimationFrame matchMedia: () => ({ matches: false, addEventListener: noop, addListener: noop }), requestAnimationFrame: (cb) => setTimeout(cb, 0), ...overrides, }; // window / self / globalThis self-reference (app.js reads window.location etc.) sandbox.window = sandbox; sandbox.self = sandbox; sandbox.globalThis = sandbox; vm.createContext(sandbox); let src = fs.readFileSync(APP_JS, 'utf8'); // Export epilogue — surface the lexical (const) symbols we test, plus a // couple of function-decl seams for convenience. Kept in one place so the // list of "what tests can touch" is explicit. src += ` ;globalThis.__test = { state, // crypto bytesToHex, hexToBytes: (typeof hexToBytes !== 'undefined' ? hexToBytes : undefined), verifierFromKeyHex, deriveKeyAndVerifier, computeVerifier, encryptPwd, decryptPwd, sha256Hex, HASH_ALGO_V2, AUTH_VERIFIER_DOMAIN, // csv parseCSV, findColumn, parseEntriesFromCSV, // strength computeStrength: (typeof computeStrength !== 'undefined' ? computeStrength : undefined), // merge (async, coupled — tests stub the io seams below) applyRemoteSnapshot, buildSyncSnapshot, }; `; vm.runInContext(src, sandbox, { filename: 'app.js' }); return sandbox; } module.exports = { loadApp, makeStorage };