/** * Tests for hoover/components/applyconfirm.js * * Component-level tests: VNode structure, buildRows logic, * and integration behaviour. Run with `node tests/test-applyconfirm.js`. */ import { buildRows, isPending, SUBSYSTEM_LIST } from '../webui/static/hoover/components/applyconfirm.js'; const SUBSYSTEM_KEYS = SUBSYSTEM_LIST.map(s => s.key); let passed = 0; let failed = 0; function test(name, fn) { try { fn(); console.log(` \u2713 ${name}`); passed++; } catch (e) { console.error(` \u2717 ${name}: ${e.message}`); failed++; } } function assert(cond, msg) { if (!cond) throw new Error(msg || 'Assertion failed'); } function assertEq(a, b, msg) { if (a !== b) throw new Error(msg || `Expected ${b}, got ${a}`); } function assertIncludes(str, substr, msg) { if (!str.includes(substr)) throw new Error(msg || `Expected "${str}" to contain "${substr}"`); } console.log('Testing ApplyConfirm component\n'); // === isPending === test('isPending returns true for needs_apply', () => { assertEq(isPending({ needs_apply: true }), true); }); test('isPending returns true for pending_changes', () => { assertEq(isPending({ pending_changes: true }), true); }); test('isPending returns false when neither flag set', () => { assertEq(isPending({}), false); }); test('isPending returns false for explicit false', () => { assertEq(isPending({ needs_apply: false, pending_changes: false }), false); }); // === SUBSYSTEM_LIST === test('SUBSYSTEM_LIST contains 5 subsystems', () => { assertEq(SUBSYSTEM_LIST.length, 5); }); test('SUBSYSTEM_LIST uses networkd key (not network)', () => { assertIncludes(SUBSYSTEM_KEYS.join(','), 'networkd', 'SUBSYSTEM_LIST should contain networkd'); assert(SUBSYSTEM_KEYS.indexOf('network') === -1, 'SUBSYSTEM_LIST should NOT contain network'); }); test('SUBSYSTEM_LIST keys match daemon response keys', () => { const expectedKeys = ['firewall', 'dnsmasq', 'nginx', 'wireguard', 'networkd']; for (const key of expectedKeys) { assert(SUBSYSTEM_KEYS.includes(key), `SUBSYSTEM_LIST should contain ${key}`); } }); // === buildRows === test('buildRows returns 5 rows for empty subsystems', () => { const rows = buildRows({}, {}); assertEq(rows.length, 5, 'should have 5 subsystem rows for empty state'); }); test('buildRows marks pending firewall subsystem correctly', () => { const data = { firewall: { needs_apply: true, changes: [ { summary: 'Zone internal: interfaces changed', detail: '' }, ], }, }; const rows = buildRows(data, {}); const fwRow = rows[0]; assertIncludes(fwRow.props.class, 'pending', 'firewall row should have pending class'); }); test('buildRows changes are VNodes with proper structure', () => { const data = { firewall: { needs_apply: true, changes: [ { summary: 'Zone internal: interfaces changed', detail: '' }, ], }, }; const rows = buildRows(data, {}); const fwRow = rows[0]; // Status span should contain "1 pending changes" const statusSpan = fwRow.ch.find(c => c.props.class && c.props.class.includes('apply-subsystem-status')); assert(statusSpan, 'should have status span'); const textChild = statusSpan.ch.find(c => c.tag === '#text'); assert(textChild && textChild.text.includes('pending changes'), 'status should contain pending changes count'); }); test('buildRows marks pending dnsmasq subsystem correctly', () => { const data = { dnsmasq: { pending_changes: true, changes: [ { summary: 'DHCP/DNS configuration has unapplied changes', detail: '' }, ], }, }; const rows = buildRows(data, {}); const dnsmasqRow = rows[1]; assertIncludes(dnsmasqRow.props.class, 'pending', 'dnsmasq row should have pending class'); }); test('buildRows shows correct change count text', () => { const data = { dnsmasq: { pending_changes: true, changes: [ { summary: 'Range 1', detail: '' }, { summary: 'Range 2', detail: '' }, ], }, }; const rows = buildRows(data, {}); const dnsmasqRow = rows[1]; const statusSpan = dnsmasqRow.ch.find(c => c.props.class && c.props.class.includes('apply-subsystem-status')); const textChild = statusSpan.ch.find(c => c.tag === '#text'); assertEq(textChild.text, '2 pending changes'); }); test('buildRows shows up-to-date for non-pending', () => { const data = { nginx: { pending_changes: false, changes: [] }, wireguard: { pending_changes: false, changes: [] }, }; const rows = buildRows(data, {}); const nginxRow = rows[2]; assert( !nginxRow.props.class.includes('pending'), 'nginx row should not have pending class', ); const statusSpan = nginxRow.ch.find(c => c.props.class && c.props.class.includes('apply-subsystem-status')); const textChild = statusSpan.ch.find(c => c.tag === '#text'); assertEq(textChild.text, 'Up to date'); }); test('buildRows shows expand icon and details when expanded', () => { const data = { firewall: { needs_apply: true, changes: [ { summary: 'Zone internal: interfaces changed', detail: '' }, { summary: 'Zone dmz: services changed', detail: '' }, ], }, }; const rows = buildRows(data, { firewall: true }); assertEq(rows.length, 6, 'should have 6 items (5 rows + 1 detail section)'); }); test('buildRows hides expand icon when not expanded', () => { const data = { firewall: { needs_apply: true, changes: [ { summary: 'Zone internal: interfaces changed', detail: '' }, ], }, }; const rows = buildRows(data, {}); assertEq(rows.length, 5, 'should only have 5 rows, no detail section'); }); test('buildRows pending flag but no changes treated as up-to-date', () => { const data = { firewall: { needs_apply: true, changes: [] }, }; const rows = buildRows(data, {}); const fwRow = rows[0]; assert( !fwRow.props.class.includes('pending'), 'no changes = up to date', ); }); test('buildRows detail section contains item VNodes', () => { const data = { firewall: { needs_apply: true, changes: [ { summary: 'Zone internal: interfaces changed', detail: '' }, ], }, }; const rows = buildRows(data, { firewall: true }); const detailSection = rows[1]; assertIncludes(detailSection.props.class, 'apply-detail-section', 'should be detail section'); assert(detailSection.ch.length > 0, 'detail section should have children'); }); test('buildRows handles networkd key correctly', () => { const data = { networkd: { pending_changes: true, changes: [ { summary: 'Network configuration has unapplied changes', detail: '' }, ], }, }; const rows = buildRows(data, {}); const networkdRow = rows[4]; // networkd is 5th in list assertIncludes(networkdRow.props.class, 'pending', 'networkd row should have pending class'); }); test('buildRows row VNodes have correct tag', () => { const data = { firewall: { needs_apply: true, changes: [{ summary: 'test', detail: '' }] }, }; const rows = buildRows(data, {}); for (const row of rows.slice(0, 5)) { assertEq(row.tag, 'div', 'row should be a div'); assert(row.props.class.includes('apply-subsystem-row'), 'row should have apply-subsystem-row class'); } }); console.log(`\n${passed} passed, ${failed} failed`); process.exit(failed > 0 ? 1 : 0);