/* Unit tests for public/notifications.js
* (docs/specs/2026-10-07-node-notifications-design.md), loaded from disk into
* a vm sandbox (pattern of test-channel-proposals-ui.js). */
'use strict';
const vm = require('vm');
const fs = require('fs');
const path = require('path');
const assert = require('assert');
const ROOT = path.resolve(__dirname, '..', '..');
let passed = 0, failed = 0;
const pending = [];
function test(name, fn) {
const p = Promise.resolve().then(fn).then(
() => { passed++; console.log(' ok ' + name); },
(e) => { failed++; console.log(' FAIL ' + name + ': ' + e.message); });
pending.push(p);
return p;
}
const src = (f) => fs.readFileSync(path.join(ROOT, f), 'utf8');
const plain = (v) => JSON.parse(JSON.stringify(v));
const XSS = '
';
const PK = 'ab'.repeat(32);
function loadEscapeHtml() {
const m = src('public/app.js').match(/function escapeHtml\(s\) \{[\s\S]*?\n\}/);
assert(m, 'escapeHtml not found in app.js');
return vm.runInNewContext('(' + m[0].replace(/^function escapeHtml/, 'function') + ')');
}
// makeEl is a stand-in DOM node: innerHTML is a string, querySelector finds
// the [data-...] markers notifications.js uses (one object per marker and
// innerHTML, so a re-render yields a new element).
function makeEl(id) {
const el = { id, innerHTML: '', textContent: '', checked: false, disabled: false, handlers: {}, attrs: {}, kids: {} };
el.addEventListener = (t, fn) => { el.handlers[t] = fn; };
el.getAttribute = (n) => (n in el.attrs ? el.attrs[n] : null);
el.querySelector = (sel) => {
const m = /^\[([a-z-]+)\]$/.exec(sel);
if (!m || el.innerHTML.indexOf(m[1]) === -1) return null;
const key = sel + '|' + el.innerHTML;
if (!el.kids[key]) {
const k = makeEl(sel);
const pressed = /aria-pressed="(true|false)"/.exec(el.innerHTML);
if (pressed) k.attrs['aria-pressed'] = pressed[1];
k.disabled = m[1] === 'data-notify-toggle' && / disabled[ >]/.test(el.innerHTML);
el.kids[key] = k;
}
return el.kids[key];
};
return el;
}
const STATE = (o) => Object.assign({ enabled: true, events: ['node.offline', 'node.battery'],
availableEvents: ['node.offline', 'node.battery'], watches: [],
limits: { maxWatches: 50, perUserPerDay: 20, mailsLast24h: 0 } }, o);
function load(opts) {
opts = opts || {};
const els = {};
const document = { getElementById(id) { return els[id] || (els[id] = makeEl(id)); } };
const calls = [];
const listeners = {};
const user = { current: opts.user === undefined ? { id: 2, role: 'user' } : opts.user };
const routes = opts.routes || (() => ({ ok: true, status: 200, data: STATE() }));
const CSAuth = {
ready() { return opts.ready || Promise.resolve(); },
user() { return user.current; },
request(method, p, body) { calls.push({ method, p, body }); return Promise.resolve().then(() => routes(method, p, body)); },
say(id, text, ok) { const el = document.getElementById(id); el.textContent = text; el.ok = !!ok; },
errText(r) { return (r.data && r.data.error) || ('Request failed (HTTP ' + r.status + ')'); },
};
const ctx = { document, CSAuth, console, escapeHtml: loadEscapeHtml(),
addEventListener(t, fn) { listeners[t] = fn; },
MC_USER_MGMT: opts.flag === false ? null : { enabled: true, notifications: opts.notifications !== false } };
ctx.window = ctx;
vm.createContext(ctx);
vm.runInContext(src('public/notifications.js'), ctx);
return { N: ctx.CSNotify, els, calls, user, document, fire(t) { if (listeners[t]) listeners[t]({}); } };
}
console.log('notifications.js: toggle');
test('no toggle and no request when the feature is off or nobody is logged in', async () => {
for (const opts of [{ notifications: false }, { flag: false }, { user: null }]) {
const env = load(opts);
const slot = makeEl('s');
slot.innerHTML = 'old';
await env.N.mount(slot, PK);
assert.strictEqual(slot.innerHTML, '', JSON.stringify(opts));
assert.strictEqual(env.calls.length, 0, JSON.stringify(opts));
}
});
test('node data arriving before auth is ready still gets the toggle', async () => {
let release;
const ready = new Promise((res) => { release = res; });
const env = load({ ready, user: null });
const slot = makeEl('s');
const p = env.N.mount(slot, PK);
await Promise.resolve();
assert.strictEqual(env.calls.length, 0, 'no request before ready');
env.user.current = { id: 2, role: 'user' };
release();
await p;
assert.strictEqual(env.calls.length, 1);
assert(slot.innerHTML.indexOf('aria-pressed="false"') !== -1, slot.innerHTML);
});
test('toggle states and markup: off, on, full, hidden', () => {
const N = load().N;
assert.strictEqual(N.toggleState(null, PK), 'hidden');
assert.strictEqual(N.toggleState(STATE(), PK), 'off');
assert.strictEqual(N.toggleState(STATE({ watches: [{ pubkey: PK }] }), PK.toUpperCase()), 'on');
assert.strictEqual(N.toggleState(STATE({ watches: [{ pubkey: 'cd'.repeat(32) }], limits: { maxWatches: 1 } }), PK), 'full');
const off = N.toggleHtml('off', STATE());
assert(off.indexOf('aria-pressed="false"') !== -1 && off.indexOf('Notify me') !== -1 && off.indexOf(' disabled') === -1, off);
const on = N.toggleHtml('on', STATE());
assert(on.indexOf('aria-pressed="true"') !== -1 && on.indexOf('Notifying') !== -1, on);
// An icon like the neighbouring node-page buttons, so it gets their height.
assert(off.indexOf('