Files
vacuum-wall/tests/test-dirty.js
T
mteehan 89b64960f3 ui: amber pending-edit markers for unapplied config changes
Add hoover/dirty.js: line-matching helpers that flag UI rows/cards
edited (saved to config) but not yet applied, consuming the pending
state the daemon already streams — status.pending_diff for hash
subsystems, firewall pending zone+type for firewalld. Visual language
is amber (.config-dirty + PendingDot), distinct from the red
.pending-delete; orphanInfo surfaces removed entries (e.g. WireGuard
peers) on their container table. Wired into the backends, dhcp,
interfaces, nat, proxy, rules, wireguard, and zones pages; Card and
Table gain cls/title props. Covered by 27 node tests
(tests/test-dirty.js).
2026-09-01 20:17:15 +00:00

271 lines
12 KiB
JavaScript

/**
* Tests for hoover/dirty.js — pending-edit marker matching.
*
* dirty.js has no imports — DOM-free at import, so the tests run under
* plain node (same pattern as test-model-set.js).
*
* Run with `node tests/test-dirty.js`.
*/
import { dirtySet, isDirty, dirtyTitle, dirtyInfo, orphanInfo, fwDirty, fwIsDirty, fwTitle, fwInfo } from '../webui/static/hoover/dirty.js';
let passed = 0;
let failed = 0;
const tests = [];
function test(name, fn) {
tests.push({ name, fn });
}
function assert(cond, msg) {
if (!cond) throw new Error(msg || 'Assertion failed');
}
function assertEq(a, b, msg) {
if (a !== b) throw new Error((msg || 'Assertion failed') + `: got ${JSON.stringify(a)}, want ${JSON.stringify(b)}`);
}
/* ── dirtySet ────────────────────────────────────────────────── */
test('dirtySet collects pending paths from pending_diff', () => {
const set = dirtySet({ pending_diff: [
{ path: 'dhcp.ranges[0].start', action: 'changed' },
{ path: 'dns.domain', action: 'added' },
]});
assert(set.has('dhcp.ranges[0].start'), 'first path collected');
assert(set.has('dns.domain'), 'second path collected');
assertEq(set.size, 2, 'exactly two paths');
});
test('dirtySet skips diff entries without a path', () => {
const set = dirtySet({ pending_diff: [null, {}, { action: 'changed' }, { path: 'a.b' }] });
assertEq(set.size, 1, 'only well-formed entries');
assert(set.has('a.b'), 'valid path collected');
});
test('dirtySet is empty when pending_diff is absent', () => {
assertEq(dirtySet(null).size, 0, 'null status');
assertEq(dirtySet({}).size, 0, 'empty status');
assertEq(dirtySet({ pending_diff: 'nope' }).size, 0, 'non-array pending_diff');
});
/* ── never-applied sentinel ──────────────────────────────────── */
test('dirtySet marks everything dirty when saved but never applied', () => {
const set = dirtySet({ pending_changes: true, pending_diff: [] });
assertEq(set.size, 1, 'sentinel only');
assert(isDirty(set, 'dhcp.ranges[0].start'), 'any path is dirty');
assert(isDirty(set, 'interface.listen_port'), 'any other path is dirty');
assertEq(dirtyTitle(set, 'dhcp.ranges[0].start'), 'Configuration saved but not applied yet', 'sentinel tooltip');
});
test('dirtySet has no sentinel when there is no pending state', () => {
const set = dirtySet({ pending_changes: false, pending_diff: [] });
assert(!isDirty(set, 'dhcp.ranges'), 'clean when nothing is pending');
assertEq(dirtyTitle(set, 'dhcp.ranges'), '', 'no tooltip when clean');
});
test('dirtySet has no sentinel when a real diff exists', () => {
const set = dirtySet({
pending_changes: true,
pending_diff: [{ path: 'dns.domain', action: 'changed' }],
});
assert(isDirty(set, 'dns.domain'), 'matching path is dirty');
assert(!isDirty(set, 'dhcp.ranges'), 'unrelated path stays clean');
assertEq(dirtyTitle(set, 'dns.domain'), 'Unapplied changes: dns.domain', 'normal tooltip, not the sentinel');
});
/* ── line matching ───────────────────────────────────────────── */
test('isDirty matches an exact pending leaf', () => {
const set = dirtySet({ pending_diff: [{ path: 'interface.listen_port', action: 'changed' }] });
assert(isDirty(set, 'interface.listen_port'), 'equal path is dirty');
});
test('a pending list marks every indexed row (ancestor of element)', () => {
const set = dirtySet({ pending_diff: [{ path: 'dhcp.ranges', action: 'changed' }] });
for (const i of [0, 1, 12]) {
assert(isDirty(set, `dhcp.ranges[${i}]`), `row ${i} is dirty`);
assert(isDirty(set, `dhcp.ranges[${i}].start`), `row ${i} field is dirty`);
}
});
test('a pending row field marks the list (descendant of element)', () => {
const set = dirtySet({ pending_diff: [{ path: 'dhcp.ranges[0].start', action: 'changed' }] });
assert(isDirty(set, 'dhcp.ranges'), 'the list container is dirty');
assert(isDirty(set, 'dhcp'), 'the top-level container is dirty');
});
test('unrelated paths do not match', () => {
const set = dirtySet({ pending_diff: [{ path: 'dns.domain', action: 'changed' }] });
assert(!isDirty(set, 'dhcp.ranges'), 'different root');
});
test('index brackets do not prefix-match across digits', () => {
const set = dirtySet({ pending_diff: [{ path: 'dhcp.ranges[1]', action: 'changed' }] });
assert(!isDirty(set, 'dhcp.ranges[12]'), 'ranges[1] must not mark row 12');
assert(!isDirty(set, 'dhcp.ranges[10]'), 'ranges[1] must not mark row 10');
assert(isDirty(set, 'dhcp.ranges[1]'), 'the exact row is dirty');
});
test('plain keys do not prefix-match similar names', () => {
const set = dirtySet({ pending_diff: [{ path: 'interface.listen_port', action: 'changed' }] });
assert(!isDirty(set, 'interfaces.eth0'), 'interface must not mark interfaces.eth0');
assert(!isDirty(set, 'interface2.port'), 'interface must not mark interface2');
});
test('isDirty is false for an empty or missing set', () => {
assert(!isDirty(new Set(), 'a.b'), 'empty set');
assert(!isDirty(null, 'a.b'), 'null set');
assert(!isDirty(dirtySet({}), 'a.b'), 'status with no pending');
});
test('isDirty tolerates an empty path', () => {
const set = dirtySet({ pending_diff: [{ path: 'a.b', action: 'changed' }] });
assert(!isDirty(set, ''), 'empty element path is not dirty');
assert(!isDirty(set, null), 'null element path is not dirty');
});
/* ── dirtyTitle / dirtyInfo ──────────────────────────────────── */
test('dirtyTitle lists all matching pending paths sorted', () => {
const set = dirtySet({ pending_diff: [
{ path: 'dhcp.ranges[1].start', action: 'changed' },
{ path: 'dhcp.ranges[0].end', action: 'changed' },
{ path: 'dns.domain', action: 'changed' },
]});
assertEq(
dirtyTitle(set, 'dhcp.ranges'),
'Unapplied changes: dhcp.ranges[0].end, dhcp.ranges[1].start',
'both rows listed, sorted, unrelated path excluded',
);
});
test('dirtyTitle is empty when the element is clean', () => {
const set = dirtySet({ pending_diff: [{ path: 'dns.domain', action: 'changed' }] });
assertEq(dirtyTitle(set, 'dhcp.ranges'), '', 'no tooltip for unrelated element');
});
test('dirtyInfo returns the full marker object', () => {
const set = dirtySet({ pending_diff: [{ path: 'dns.domain', action: 'changed' }] });
const hit = dirtyInfo(set, 'dns.domain');
assertEq(hit.dirty, true, 'dirty flag');
assertEq(hit.class, 'config-dirty', 'class');
assertEq(hit.title, 'Unapplied changes: dns.domain', 'tooltip');
const miss = dirtyInfo(set, 'dhcp.ranges');
assertEq(miss.dirty, false, 'clean flag');
assertEq(miss.class, '', 'clean class');
assertEq(miss.title, '', 'clean title');
});
/* ── orphanInfo (removed dict keys) ──────────────────────────── */
test('orphanInfo flags a removed peer with no live row', () => {
const set = dirtySet({ pending_diff: [{ path: 'peers.p1', action: 'removed' }] });
const info = orphanInfo(set, 'peers', ['peers.p2', 'peers.p3']);
assertEq(info.dirty, true, 'orphan is dirty');
assertEq(info.class, 'config-dirty', 'orphan class');
assertEq(info.title, 'Unapplied changes: peers.p1', 'orphan tooltip');
});
test('orphanInfo is clean when the pending path still has a live row', () => {
const set = dirtySet({ pending_diff: [{ path: 'peers.p1.endpoint', action: 'changed' }] });
assertEq(orphanInfo(set, 'peers', ['peers.p1', 'peers.p2']).dirty, false, 'matched child is not an orphan');
});
test('orphanInfo flags a removed peer when no peers remain', () => {
const set = dirtySet({ pending_diff: [{ path: 'peers.p1', action: 'removed' }] });
assertEq(orphanInfo(set, 'peers', []).dirty, true, 'no children means the orphan stands');
});
test('orphanInfo ignores pending paths outside the root', () => {
const set = dirtySet({ pending_diff: [{ path: 'interface.listen_port', action: 'changed' }] });
assertEq(orphanInfo(set, 'peers', ['peers.p1']).dirty, false, 'unrelated root');
});
test('orphanInfo is clean when the root itself is pending', () => {
// A whole-dict `peers` change marks every child row instead; the
// container-level marker would be redundant.
const set = dirtySet({ pending_diff: [{ path: 'peers', action: 'changed' }] });
assertEq(orphanInfo(set, 'peers', ['peers.p1']).dirty, false, 'root-pending is not an orphan');
assert(isDirty(set, 'peers.p1'), 'but the rows are still marked');
});
test('orphanInfo is clean for an empty set or the never-applied sentinel', () => {
assertEq(orphanInfo(new Set(), 'peers', []).dirty, false, 'empty set');
const sentinel = dirtySet({ pending_changes: true, pending_diff: [] });
assertEq(orphanInfo(sentinel, 'peers', []).dirty, false, 'sentinel: element markers already cover it');
});
test('orphanInfo lists multiple orphans sorted', () => {
const set = dirtySet({ pending_diff: [
{ path: 'peers.b', action: 'removed' },
{ path: 'peers.a', action: 'removed' },
{ path: 'peers.c.field', action: 'changed' },
]});
const info = orphanInfo(set, 'peers', ['peers.c']);
assertEq(info.title, 'Unapplied changes: peers.a, peers.b', 'only the orphans, sorted');
});
/* ── firewall zone + type granularity ────────────────────────── */
test('fwDirty builds a zone-to-types map', () => {
const m = fwDirty({
pending: [
{ zone: 'public', type: 'services' },
{ zone: 'public', type: 'rich_rules' },
{ zone: 'dmz', type: 'interfaces' },
{ zone: null },
{ zone: 'lan' },
],
});
assertEq(m.size, 3, 'three zones (null-zone entry skipped, typeless zone kept)');
assert(m.get('public').has('services'), 'public services');
assert(m.get('public').has('rich_rules'), 'public rich_rules');
assert(m.get('dmz').has('interfaces'), 'dmz interfaces');
assert(m.get('lan').size === 0, 'typeless zone has an empty type set');
});
test('fwIsDirty by zone and by zone+type', () => {
const m = fwDirty({ pending: [{ zone: 'public', type: 'services' }] });
assert(fwIsDirty(m, 'public'), 'zone-only match');
assert(fwIsDirty(m, 'public', 'services'), 'zone+type match');
assert(!fwIsDirty(m, 'public', 'rich_rules'), 'wrong type');
assert(!fwIsDirty(m, 'dmz'), 'unknown zone');
assert(!fwIsDirty(new Map(), 'public'), 'empty map');
});
test('fwInfo and fwTitle carry the pending types', () => {
const m = fwDirty({
pending: [
{ zone: 'public', type: 'rich_rules' },
{ zone: 'public', type: 'services' },
],
});
const zone = fwInfo(m, 'public');
assertEq(zone.dirty, true, 'zone dirty');
assertEq(zone.class, 'config-dirty', 'zone class');
assertEq(zone.title, 'Unapplied changes: rich_rules, services', 'zone tooltip lists all types');
const typed = fwInfo(m, 'public', 'services');
assertEq(typed.title, 'Unapplied changes: services', 'typed tooltip lists only that type');
assertEq(fwInfo(m, 'dmz').dirty, false, 'unknown zone clean');
assertEq(fwTitle(m, 'nope'), '', 'no tooltip for unknown zone');
});
/* ── Runner ──────────────────────────────────────────────────── */
(async () => {
for (const { name, fn } of tests) {
try {
await fn();
console.log(` \u2713 ${name}`);
passed++;
} catch (e) {
console.error(` \u2717 ${name}: ${e.message}`);
failed++;
}
}
console.log(`${passed + failed} tests: ${passed} passed, ${failed} failed`);
process.exitCode = failed ? 1 : 0;
})();