#!/usr/bin/env node /** * GO Frontend JS Test Runner * * Sets up the global environment once, loads all source modules, * then runs all test suites. * * Usage: node src-tauri/frontend/js/tests/run.js */ const { describe, test, assert, assertEqual, assertDeepEqual, report } = require('./test-runner'); console.log('Running GO frontend tests...\n'); // ============================================================ // Global environment setup (mocks for browser APIs) // ============================================================ const mockElements = {}; function createMockElement(tag) { return { tagName: (tag || 'div').toUpperCase(), className: '', id: '', style: {}, dataset: {}, innerHTML: '', _text: '', set textContent(v) { this._text = v; this.innerHTML = String(v) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); }, get textContent() { return this._text; }, children: [], classList: { _classes: new Set(), add(c) { this._classes.add(c); }, remove(c) { this._classes.delete(c); }, contains(c) { return this._classes.has(c); }, }, attributes: [], _listeners: {}, setAttribute(k, v) { this[k] = v; }, getAttribute(k) { return this[k] || null; }, removeAttribute(k) { delete this[k]; }, addEventListener(ev, fn) { this._listeners[ev] = fn; }, querySelector() { return createMockElement('span'); }, querySelectorAll() { return []; }, appendChild(child) { this.children.push(child); }, remove() {}, parentNode: { insertBefore() {} }, }; } globalThis.document = { createElement: createMockElement, getElementById: (id) => { if (!mockElements[id]) { mockElements[id] = createMockElement('div'); mockElements[id].id = id; } return mockElements[id]; }, querySelectorAll: () => [], }; globalThis.window = { GoingsOn: {} }; globalThis.GoingsOn = window.GoingsOn; // ============================================================ // Load source modules (same order as index.html) // ============================================================ require('../goingson'); // Initializes GoingsOn namespace require('../state'); // GoingsOn.state (AppStateManager) require('../utils'); // GoingsOn.utils (escapeHtml, escapeAttr, etc.) require('../pagination-manager'); // GoingsOn.PaginationManager require('../selection-manager'); // GoingsOn.SelectionManager require('../whats-new'); // GoingsOn.whatsNew (changelog parser/renderer) // ============================================================ // Test: AppStateManager / GoingsOn.state // ============================================================ describe('GoingsOn.state', () => { test('set() stores value and notifies subscribers', () => { let called = false; GoingsOn.state.subscribe('_t1', () => { called = true; }); GoingsOn.state.set('_t1', 'hello'); assert(called, 'Subscriber should fire'); assertEqual(GoingsOn.state._t1, 'hello'); }); test('subscribe() returns working unsubscribe function', () => { let count = 0; const unsub = GoingsOn.state.subscribe('_t2', () => { count++; }); GoingsOn.state.set('_t2', 'a'); assertEqual(count, 1); unsub(); GoingsOn.state.set('_t2', 'b'); assertEqual(count, 1); }); test('notify() passes new and old values to callback', () => { GoingsOn.state.set('_t3', 'first'); let capturedOld, capturedNew; GoingsOn.state.subscribe('_t3', (n, o) => { capturedNew = n; capturedOld = o; }); GoingsOn.state.set('_t3', 'second'); assertEqual(capturedOld, 'first'); assertEqual(capturedNew, 'second'); }); test('update() batch-updates multiple properties', () => { let aFired = false, bFired = false; GoingsOn.state.subscribe('_t4a', () => { aFired = true; }); GoingsOn.state.subscribe('_t4b', () => { bFired = true; }); GoingsOn.state.update({ _t4a: 'x', _t4b: 'y' }); assert(aFired && bFired, 'Both subscribers should fire'); assertEqual(GoingsOn.state._t4a, 'x'); assertEqual(GoingsOn.state._t4b, 'y'); }); test('resetPagination(task) resets taskPage to 1', () => { GoingsOn.state.set('taskPage', 5); GoingsOn.state.resetPagination('task'); assertEqual(GoingsOn.state.taskPage, 1); }); test('resetPagination(email) resets emailPage to 1', () => { GoingsOn.state.set('emailPage', 3); GoingsOn.state.resetPagination('email'); assertEqual(GoingsOn.state.emailPage, 1); }); test('clearSelection(task) clears selectedTaskIds Set', () => { GoingsOn.state.selectedTaskIds.add('t1'); GoingsOn.state.selectedTaskIds.add('t2'); GoingsOn.state.clearSelection('task'); assertEqual(GoingsOn.state.selectedTaskIds.size, 0); }); test('clearSelection(email) clears selectedEmailIds Set', () => { GoingsOn.state.selectedEmailIds.add('e1'); GoingsOn.state.clearSelection('email'); assertEqual(GoingsOn.state.selectedEmailIds.size, 0); }); test('multiple subscribers on same key all fire', () => { let a = false, b = false; GoingsOn.state.subscribe('_t5', () => { a = true; }); GoingsOn.state.subscribe('_t5', () => { b = true; }); GoingsOn.state.set('_t5', 'v'); assert(a && b, 'Both should fire'); }); test('subscriber error does not break other subscribers', () => { let secondFired = false; GoingsOn.state.subscribe('_t6', () => { throw new Error('boom'); }); GoingsOn.state.subscribe('_t6', () => { secondFired = true; }); // Suppress console.error for this test const origError = console.error; console.error = () => {}; GoingsOn.state.set('_t6', 'v'); console.error = origError; assert(secondFired, 'Second subscriber should still fire'); }); }); // ============================================================ // Test: GoingsOn.utils — escapeAttr // ============================================================ describe('GoingsOn.utils.escapeAttr', () => { test('escapes backslashes, quotes, newlines', () => { const r = GoingsOn.utils.escapeAttr('a\\b"c\'d\ne'); assert(r.includes('\\\\'), 'Should escape backslash'); assert(r.includes('\\"'), 'Should escape double quote'); assert(r.includes("\\'"), 'Should escape single quote'); assert(r.includes('\\n'), 'Should escape newline'); }); test('returns empty for null/undefined', () => { assertEqual(GoingsOn.utils.escapeAttr(null), ''); assertEqual(GoingsOn.utils.escapeAttr(undefined), ''); }); test('converts non-string to string', () => { assertEqual(GoingsOn.utils.escapeAttr(123), '123'); assertEqual(GoingsOn.utils.escapeAttr(true), 'true'); }); }); // ============================================================ // Test: GoingsOn.utils — getErrorMessage // ============================================================ describe('GoingsOn.utils.getErrorMessage', () => { test('extracts from string', () => { assertEqual(GoingsOn.utils.getErrorMessage('oops'), 'oops'); }); test('extracts from Error object (.message)', () => { assertEqual(GoingsOn.utils.getErrorMessage(new Error('fail')), 'fail'); }); test('uses fallback for unknown type', () => { assertEqual(GoingsOn.utils.getErrorMessage(42, 'fallback'), 'fallback'); }); test('uses default fallback when none provided', () => { assertEqual(GoingsOn.utils.getErrorMessage({}, undefined), 'An error occurred'); }); }); // ============================================================ // Test: GoingsOn.utils — validateLength, validateEmail // ============================================================ describe('GoingsOn.utils.validateLength', () => { test('accepts valid length', () => { assert(GoingsOn.utils.validateLength('hello', 10), 'Should accept'); }); test('rejects too long', () => { assert(!GoingsOn.utils.validateLength('hello world', 5), 'Should reject'); }); test('accepts null/empty', () => { assert(GoingsOn.utils.validateLength('', 10), 'Empty should be valid'); assert(GoingsOn.utils.validateLength(null, 10), 'Null should be valid'); }); }); describe('GoingsOn.utils.validateEmail', () => { test('accepts valid addresses', () => { assert(GoingsOn.utils.validateEmail('user@example.com'), 'Should accept valid'); }); test('rejects invalid', () => { assert(!GoingsOn.utils.validateEmail('notanemail'), 'Should reject invalid'); }); test('accepts empty (optional field)', () => { assert(GoingsOn.utils.validateEmail(''), 'Empty should be valid'); }); }); // ============================================================ // Test: GoingsOn.utils — parseEmailAddress // ============================================================ describe('GoingsOn.utils.parseEmailAddress', () => { test('extracts "Name " format', () => { const r = GoingsOn.utils.parseEmailAddress('Jane Smith '); assertEqual(r.name, 'Jane Smith'); assertEqual(r.email, 'jane@example.com'); }); test('handles bare email address', () => { const r = GoingsOn.utils.parseEmailAddress('jane@example.com'); assertEqual(r.name, null); assertEqual(r.email, 'jane@example.com'); }); test('handles null/empty input', () => { const r1 = GoingsOn.utils.parseEmailAddress(null); assertEqual(r1.name, null); assertEqual(r1.email, null); const r2 = GoingsOn.utils.parseEmailAddress(''); assertEqual(r2.name, null); assertEqual(r2.email, null); }); }); // ============================================================ // Test: GoingsOn.utils — debounce // ============================================================ describe('GoingsOn.utils.debounce', () => { test('does not fire immediately', () => { let called = false; const fn = GoingsOn.utils.debounce(() => { called = true; }, 10); fn(); assert(!called, 'Should not fire immediately'); }); test('rapid calls only execute last one', () => { let callCount = 0, lastArg = null; const origST = globalThis.setTimeout; const origCT = globalThis.clearTimeout; let pendingCb = null; globalThis.setTimeout = (cb) => { pendingCb = cb; return 1; }; globalThis.clearTimeout = () => { pendingCb = null; }; const fn = GoingsOn.utils.debounce((arg) => { callCount++; lastArg = arg; }, 100); fn('a'); fn('b'); fn('c'); if (pendingCb) pendingCb(); assertEqual(callCount, 1); assertEqual(lastArg, 'c'); globalThis.setTimeout = origST; globalThis.clearTimeout = origCT; }); }); // ============================================================ // Test: GoingsOn.utils — escapeHtml // ============================================================ describe('GoingsOn.utils.escapeHtml', () => { test('escapes angle brackets', () => { const r = GoingsOn.utils.escapeHtml('hi'); assert(r.includes('<') && r.includes('>')); }); test('returns empty for falsy', () => { assertEqual(GoingsOn.utils.escapeHtml(''), ''); assertEqual(GoingsOn.utils.escapeHtml(null), ''); }); }); // ============================================================ // Test: PaginationManager // ============================================================ describe('PaginationManager', () => { test('constructor sets defaults (page=1, totalItems=0)', () => { const pm = new GoingsOn.PaginationManager('test', 10); assertEqual(pm.currentPage, 1); assertEqual(pm.totalItems, 0); assertEqual(pm.itemsPerPage, 10); }); test('goToPage(next) increments page', () => { const pm = new GoingsOn.PaginationManager('test', 10); pm.totalItems = 30; pm.goToPage('next'); assertEqual(pm.currentPage, 2); }); test('goToPage(prev) decrements page, clamps to 1', () => { const pm = new GoingsOn.PaginationManager('test', 10); pm.totalItems = 30; pm.currentPage = 2; pm.goToPage('prev'); assertEqual(pm.currentPage, 1); pm.goToPage('prev'); assertEqual(pm.currentPage, 1); }); test('goToPage(n) sets specific page, clamped', () => { const pm = new GoingsOn.PaginationManager('test', 10); pm.totalItems = 30; pm.goToPage(3); assertEqual(pm.currentPage, 3); pm.goToPage(99); assertEqual(pm.currentPage, 3); // max page is 3 pm.goToPage(0); assertEqual(pm.currentPage, 1); }); test('getMaxPage() calculates ceil(total/perPage)', () => { const pm = new GoingsOn.PaginationManager('test', 10); pm.totalItems = 25; assertEqual(pm.getMaxPage(), 3); pm.totalItems = 30; assertEqual(pm.getMaxPage(), 3); pm.totalItems = 0; assertEqual(pm.getMaxPage(), 1); // min 1 }); test('setTotalItems() updates and clamps currentPage', () => { const pm = new GoingsOn.PaginationManager('test', 10); pm.currentPage = 5; pm.setTotalItems(20); assertEqual(pm.totalItems, 20); assertEqual(pm.currentPage, 2); // clamped to max page }); test('reset() returns to page 1', () => { const pm = new GoingsOn.PaginationManager('test', 10); pm.currentPage = 3; pm.reset(); assertEqual(pm.currentPage, 1); }); test('getOffset() returns (page-1)*perPage', () => { const pm = new GoingsOn.PaginationManager('test', 10); pm.currentPage = 3; assertEqual(pm.getOffset(), 20); }); test('paginate() slices array correctly', () => { const pm = new GoingsOn.PaginationManager('test', 3); pm.totalItems = 7; const items = ['a', 'b', 'c', 'd', 'e', 'f', 'g']; pm.currentPage = 2; assertDeepEqual(pm.paginate(items), ['d', 'e', 'f']); pm.currentPage = 3; assertDeepEqual(pm.paginate(items), ['g']); }); test('getInfo() returns correct summary object', () => { const pm = new GoingsOn.PaginationManager('test', 10); pm.totalItems = 25; pm.currentPage = 2; const info = pm.getInfo(); assertEqual(info.currentPage, 2); assertEqual(info.maxPage, 3); assertEqual(info.start, 11); assertEqual(info.end, 20); assertEqual(info.total, 25); assertEqual(info.hasPrev, true); assertEqual(info.hasNext, true); }); }); // ============================================================ // Test: SelectionManager // ============================================================ describe('SelectionManager', () => { test('constructor initializes empty Set', () => { const sm = new GoingsOn.SelectionManager('test', '.test', 'bulk-bar'); assertEqual(sm.selectedIds.size, 0); assertEqual(sm.lastClickedIndex, -1); }); test('setItems() stores items array', () => { const sm = new GoingsOn.SelectionManager('test', '.test', 'bulk-bar'); const items = [{ id: '1' }, { id: '2' }]; sm.setItems(items); assertEqual(sm.items.length, 2); }); test('getSelected() returns the Set', () => { const sm = new GoingsOn.SelectionManager('test', '.test', 'bulk-bar'); sm.selectedIds.add('x'); assert(sm.getSelected() instanceof Set); assert(sm.getSelected().has('x')); }); test('hasSelection() returns true when items selected, false when empty', () => { const sm = new GoingsOn.SelectionManager('test', '.test', 'bulk-bar'); assert(!sm.hasSelection()); sm.selectedIds.add('a'); assert(sm.hasSelection()); }); test('getCount() returns correct count', () => { const sm = new GoingsOn.SelectionManager('test', '.test', 'bulk-bar'); assertEqual(sm.getCount(), 0); sm.selectedIds.add('a'); sm.selectedIds.add('b'); assertEqual(sm.getCount(), 2); }); test('isSelected(id) returns boolean correctly', () => { const sm = new GoingsOn.SelectionManager('test', '.test', 'bulk-bar'); sm.selectedIds.add('x'); assert(sm.isSelected('x')); assert(!sm.isSelected('y')); }); test('toggle adds/removes from selectedIds', () => { const sm = new GoingsOn.SelectionManager('test', '.test', 'bulk-bar'); sm.setItems([{ id: 'a' }, { id: 'b' }]); // Simulate checking sm.toggle('a', { checked: true }, null); assert(sm.selectedIds.has('a')); // Simulate unchecking sm.toggle('a', { checked: false }, null); assert(!sm.selectedIds.has('a')); }); test('clear() empties Set and resets lastClickedIndex', () => { const sm = new GoingsOn.SelectionManager('test', '.test', 'bulk-bar'); sm.selectedIds.add('a'); sm.selectedIds.add('b'); sm.lastClickedIndex = 3; sm.clear(); assertEqual(sm.selectedIds.size, 0); assertEqual(sm.lastClickedIndex, -1); }); }); // ============================================================ // Test: GoingsOn.whatsNew (changelog parsing + rendering) // ============================================================ describe('GoingsOn.whatsNew', () => { const SAMPLE = [ '# Changelog', '', '## [0.4.0] — 2026-06-01', '', 'Polish release.', '', '### Added', '- What\'s New dialog after updates', '- Standardized empty states', '', '### Fixed', '- Project list empty copy', '', '## [0.3.0] — 2026-03-28', '', '### Added', '- Initial beta', ].join('\n'); test('extractSection pulls the requested version body, excluding its header', () => { const section = GoingsOn.whatsNew.extractSection(SAMPLE, '0.4.0'); assert(section.includes('Polish release.'), 'keeps the intro line'); assert(section.includes('### Added'), 'keeps group headers'); assert(section.includes('What\'s New dialog after updates'), 'keeps bullets'); assert(!section.includes('[0.4.0]'), 'drops the version header line'); assert(!section.includes('Initial beta'), 'stops before the next version'); }); test('extractSection returns empty string for an absent version', () => { assertEqual(GoingsOn.whatsNew.extractSection(SAMPLE, '9.9.9'), ''); }); test('renderSection escapes content and builds structural markup', () => { const html = GoingsOn.whatsNew.renderSection('### Added\n- safe