/* Unit tests for frontend helper functions (tested via VM sandbox) */ 'use strict'; const vm = require('vm'); const fs = require('fs'); const assert = require('assert'); let passed = 0, failed = 0; const pendingTests = []; function test(name, fn) { try { const out = fn(); if (out && typeof out.then === 'function') { pendingTests.push( out.then(() => { passed++; console.log(` ✅ ${name}`); }).catch((e) => { failed++; console.log(` ❌ ${name}: ${e.message}`); }) ); return; } passed++; console.log(` ✅ ${name}`); } catch (e) { failed++; console.log(` ❌ ${name}: ${e.message}`); } } // --- Build a browser-like sandbox --- function makeSandbox() { const ctx = { window: { addEventListener: () => {}, dispatchEvent: () => {} }, document: { readyState: 'complete', createElement: () => ({ id: '', textContent: '', innerHTML: '' }), head: { appendChild: () => {} }, getElementById: () => null, addEventListener: () => {}, querySelectorAll: () => [], querySelector: () => null, }, console, Date, Infinity, Math, Array, Object, String, Number, JSON, RegExp, Error, TypeError, parseInt, parseFloat, isNaN, isFinite, encodeURIComponent, decodeURIComponent, setTimeout: () => {}, clearTimeout: () => {}, setInterval: () => {}, clearInterval: () => {}, fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }), performance: { now: () => Date.now() }, localStorage: (() => { const store = {}; return { getItem: k => store[k] || null, setItem: (k, v) => { store[k] = String(v); }, removeItem: k => { delete store[k]; }, }; })(), location: { hash: '' }, getHashParams: function() { return new URLSearchParams((ctx.location.hash.split('?')[1] || '')); }, CustomEvent: class CustomEvent {}, Map, Promise, URLSearchParams, addEventListener: () => {}, dispatchEvent: () => {}, requestAnimationFrame: (cb) => setTimeout(cb, 0), }; vm.createContext(ctx); return ctx; } function loadInCtx(ctx, file) { vm.runInContext(fs.readFileSync(file, 'utf8'), ctx); // Copy window.* to global context so bare references work for (const k of Object.keys(ctx.window)) { ctx[k] = ctx.window[k]; } } // ===== APP.JS TESTS ===== console.log('\n=== app.js: timeAgo ==='); { const ctx = makeSandbox(); loadInCtx(ctx, 'public/roles.js'); loadInCtx(ctx, 'public/app.js'); const timeAgo = ctx.timeAgo; test('null returns dash', () => assert.strictEqual(timeAgo(null), '—')); test('undefined returns dash', () => assert.strictEqual(timeAgo(undefined), '—')); test('empty string returns dash', () => assert.strictEqual(timeAgo(''), '—')); test('30 seconds ago', () => { const d = new Date(Date.now() - 30000).toISOString(); assert.strictEqual(timeAgo(d), '30s ago'); }); test('5 minutes ago', () => { const d = new Date(Date.now() - 300000).toISOString(); assert.strictEqual(timeAgo(d), '5m ago'); }); test('2 hours ago', () => { const d = new Date(Date.now() - 7200000).toISOString(); assert.strictEqual(timeAgo(d), '2h ago'); }); test('3 days ago', () => { const d = new Date(Date.now() - 259200000).toISOString(); assert.strictEqual(timeAgo(d), '3d ago'); }); test('future timestamp returns in-format', () => { const d = new Date(Date.now() + 120000).toISOString(); assert.strictEqual(timeAgo(d), 'in 2m'); }); } console.log('\n=== app.js: formatTimestamp / formatTimestampWithTooltip ==='); { const ctx = makeSandbox(); loadInCtx(ctx, 'public/roles.js'); loadInCtx(ctx, 'public/app.js'); const formatTimestamp = ctx.formatTimestamp; const formatTimestampWithTooltip = ctx.formatTimestampWithTooltip; test('formatTimestamp null returns dash', () => { assert.strictEqual(formatTimestamp(null, 'ago'), '—'); }); test('formatTimestamp ago returns relative string', () => { const d = new Date(Date.now() - 300000).toISOString(); assert.strictEqual(formatTimestamp(d, 'ago'), '5m ago'); }); test('formatTimestamp absolute returns formatted timestamp', () => { const d = '2024-01-02T03:04:05.000Z'; const out = formatTimestamp(d, 'absolute'); assert.ok(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(out)); }); test('formatTimestamp absolute with timezone utc uses UTC fields', () => { const d = '2024-01-02T03:04:05.123Z'; ctx.localStorage.setItem('meshcore-timestamp-timezone', 'utc'); ctx.localStorage.setItem('meshcore-timestamp-format', 'iso'); assert.strictEqual(formatTimestamp(d, 'absolute'), '2024-01-02 03:04:05'); }); test('formatTimestamp absolute with timezone local uses local fields', () => { const d = '2024-01-02T03:04:05.123Z'; ctx.localStorage.setItem('meshcore-timestamp-timezone', 'local'); ctx.localStorage.setItem('meshcore-timestamp-format', 'iso'); const out = formatTimestamp(d, 'absolute'); const expected = d.replace('T', ' ').slice(0, 19); assert.strictEqual(out.length, 19); assert.ok(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(out)); if (new Date(d).getTimezoneOffset() === 0) assert.strictEqual(out, expected); else assert.notStrictEqual(out, expected); }); test('formatTimestamp absolute iso-seconds includes milliseconds', () => { const d = '2024-01-02T03:04:05.123Z'; ctx.localStorage.setItem('meshcore-timestamp-timezone', 'utc'); ctx.localStorage.setItem('meshcore-timestamp-format', 'iso-seconds'); assert.strictEqual(formatTimestamp(d, 'absolute'), '2024-01-02 03:04:05.123'); }); test('formatTimestamp absolute locale uses toLocaleString', () => { const d = '2024-01-02T03:04:05.123Z'; ctx.localStorage.setItem('meshcore-timestamp-timezone', 'local'); ctx.localStorage.setItem('meshcore-timestamp-format', 'locale'); assert.strictEqual(formatTimestamp(d, 'absolute'), new Date(d).toLocaleString()); }); test('formatTimestampWithTooltip future returns isFuture true', () => { const d = new Date(Date.now() + 120000).toISOString(); const out = formatTimestampWithTooltip(d, 'ago'); assert.strictEqual(out.isFuture, true); assert.ok(typeof out.text === 'string' && out.text.length > 0); assert.strictEqual(out.tooltip, 'in 2m'); }); test('tooltip is opposite format', () => { const d = '2024-01-02T03:04:05.000Z'; const ago = formatTimestampWithTooltip(d, 'ago'); const absolute = formatTimestampWithTooltip(d, 'absolute'); assert.ok(typeof ago.tooltip === 'string' && ago.tooltip.length > 0); assert.ok(absolute.tooltip.endsWith('ago') || absolute.tooltip.startsWith('in ')); }); } console.log('\n=== app.js: escapeHtml ==='); { const ctx = makeSandbox(); loadInCtx(ctx, 'public/roles.js'); loadInCtx(ctx, 'public/app.js'); const escapeHtml = ctx.escapeHtml; test('escapes < and >', () => assert.strictEqual(escapeHtml('', role: 'repeater', status: 'unknown', evidence: '', maxHashSize: 1, lastSeen: '' }]); assert.ok(!html.includes('