/* 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('