// The browser side, without a browser: every script the page loads is run in order in one shared scope (as the browser does), against a stand-in for the page. // This catches what only showed up in the interface before: a syntax error, a name declared twice by two files (which stops the second file and everything after it), // a script that throws as it loads, a missing file, and a script listed in index.html twice or not at all. const fs = require('fs'), vm = require('vm'), path = require('path'), assert = require('assert'); const PUB = path.join(__dirname, '..', 'public'); const html = fs.readFileSync(path.join(PUB, 'index.html'), 'utf8'); const listed = [...html.matchAll(/]*\ssrc="(\/[\w.-]+\.js)"/g)].map(m => m[1]); assert.ok(listed.length > 40, 'index.html lists the scripts'); assert.equal(new Set(listed).size, listed.length, 'no script is listed twice'); for (const f of listed) assert.ok(fs.existsSync(path.join(PUB, f)), f + ' is listed but missing'); const unlisted = fs.readdirSync(PUB).filter(f => f.endsWith('.js') && !listed.includes('/' + f) && !/^(sw|login|board|device-ui|webauthn)\.js$/.test(f)); // (the service worker and the other pages have their own) assert.deepEqual(unlisted, [], 'every script in public/ is loaded by the page (or is one of the known others)'); // a stand-in for the browser that answers anything: any property is another stand-in, calling it gives one, awaiting it gives it back const stub = () => { const f = function () {}; const p = new Proxy(f, { get: (t, k) => k === Symbol.toPrimitive ? () => '' : k === 'then' ? undefined : k === 'length' ? 0 : k === Symbol.iterator ? function* () {} : p, apply: () => p, construct: () => p, set: () => true, has: () => true }); return p; }; const store = {}, win = {}; const ctx = Object.assign(win, { console: { log() {}, warn() {}, error() {}, info() {}, debug() {} }, document: stub(), navigator: Object.assign(stub(), { onLine: true, userAgent: 'test' }), location: { hash: '', href: 'http://x/', origin: 'http://x', pathname: '/', search: '', reload() {}, replace() {} }, history: stub(), localStorage: { getItem: k => store[k] ?? null, setItem: (k, v) => { store[k] = String(v); }, removeItem: k => { delete store[k]; } }, sessionStorage: stub(), matchMedia: () => ({ matches: false, addEventListener() {}, addListener() {} }), fetch: async () => ({ ok: true, status: 200, json: async () => ({}), text: async () => '', headers: { get: () => '' } }), addEventListener() {}, removeEventListener() {}, setTimeout: () => 0, clearTimeout() {}, setInterval: () => 0, clearInterval() {}, requestAnimationFrame: () => 0, Audio: function () { return stub(); }, Image: function () { return stub(); }, MutationObserver: function () { return { observe() {}, disconnect() {} }; }, IntersectionObserver: function () { return { observe() {}, disconnect() {}, unobserve() {} }; }, ResizeObserver: function () { return { observe() {}, disconnect() {} }; }, URL, URLSearchParams, Blob, TextEncoder, TextDecoder, AbortController, Intl, Date, JSON, Math, Promise, Set, Map, WeakMap, Array, Object, String, Number, RegExp, Error, Symbol, Uint8Array, ArrayBuffer, DataView, encodeURIComponent, decodeURIComponent, parseInt, parseFloat, isNaN, structuredClone, atob, btoa, screen: {}, innerWidth: 1000, innerHeight: 800, devicePixelRatio: 1, performance: { now: () => 0 }, crypto: stub(), indexedDB: undefined, caches: stub(), Notification: stub(), alert() {}, confirm: () => false, prompt: () => null, getComputedStyle: () => stub(), CSS: stub(), }); ctx.window = ctx; ctx.self = ctx; ctx.globalThis = ctx; vm.createContext(ctx); process.on('unhandledRejection', () => {}); // (boot.js starts the app against the stand-in; what it does after loading is not under test) const failures = []; for (const f of listed) { const file = path.join(PUB, f), src = fs.readFileSync(file, 'utf8'); try { new vm.Script(src, { filename: f }); } catch (e) { failures.push(`${f}: syntax error: ${e.message}`); continue; } try { vm.runInContext(src, ctx, { filename: f }); } catch (e) { failures.push(`${f}: ${e.name}: ${e.message}`); } } assert.deepEqual(failures, [], 'every script loads:\n ' + failures.join('\n ')); // the pieces the app calls by name across files are there for (const name of ['route', 'viewHome', 'viewSettings', 'viewTasks', 'viewCalendar', 'viewBible', 'viewMail', 'viewRecipes', 'viewVideo', 'themeApply', 'autoSettings', 'bbToday', 'homeListening', 'saveToFiles', 'downloadText', 'viewPantry', 'pantryScan']) assert.equal(typeof vm.runInContext(`typeof ${name}`, ctx), 'string') && assert.equal(vm.runInContext(`typeof ${name}`, ctx), 'function', name + ' is defined'); // the watch-stats counting (a pure function) { const now = Date.UTC(2026, 5, 15, 12), at = d => Math.floor((now - d * 864e5) / 1000), agg = vm.runInContext('vdAgg', ctx); const a = agg([{ lastViewedAt: at(1), duration: 6e6, Genre: [{ tag: 'Drama' }, { tag: 'Comedy' }] }, { lastViewedAt: at(3), duration: 3e6, Genre: [{ tag: 'Drama' }] }, { lastViewedAt: at(20), duration: 6e6 }, { lastViewedAt: at(200), duration: 6e6, Genre: [{ tag: 'Drama' }] }, { lastViewedAt: at(400), duration: 6e6 }, { duration: 6e6 }], now); assert.deepEqual([a.week.n, a.week.min, a.month.n, a.year.n], [2, 150, 3, 4], 'week, month and year counts and minutes'); assert.deepEqual(a.genres[0], ['Drama', 3]); assert.equal(a.days[12], 1); assert.equal(a.days[10], 1); assert.equal(a.days.reduce((x, y) => x + y, 0), 2, 'the 14-day strip'); } // changes made offline: only the safe kinds are queued, in order, and tasks show them while they wait { const run = c => vm.runInContext(c, ctx); run("ME = { id: 'u1' }"); const body = o => JSON.stringify(o); assert.equal(run("offQueued('/api/tasks', { method: 'GET' })"), null, 'a read is not queued'); assert.equal(run(`offQueued('/api/chat/send', { method: 'POST', body: ${JSON.stringify(body({ text: 'hi' }))} })`), null, 'a chat message is not one of the safe kinds'); assert.equal(run(`offQueued('/api/tasks', { method: 'POST', body: ${JSON.stringify(body({ list: 'local:inbox', id: 'x', fields: { title: 'edit' } }))} })`), null, 'editing an existing task is not queued (it could clash)'); assert.equal(run(`offQueued('/api/tasks', { method: 'POST', body: ${JSON.stringify(body({ list: 'local:inbox', fields: { title: 'Buy milk', due: '' } }))} }).queued`), true); run(`offQueued('/api/tasks/toggle', { method: 'POST', body: ${JSON.stringify(body({ list: 'local:inbox', id: 't1', done: true }))} })`); assert.equal(run('offPending()'), 2); const shown = run("offApplyTasks([{ id: 't1', title: 'Old', done: false, list: 'local:inbox' }])"); assert.deepEqual(shown.map(t => [t.title, t.done]), [['Old', true], ['Buy milk', false]], 'a queued tick and a queued new task show right away'); assert.equal(run("offqRead().length"), 2); run("localStorage.removeItem('offq:u1')"); assert.equal(run('offPending()'), 0); } console.log('frontend test ok (' + listed.length + ' scripts)'); process.exit(0);