Files
hub/tools/frontend-test.js
T

48 lines
6.8 KiB
JavaScript

// 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(/<script[^>]*\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']) 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);