#!/usr/bin/env node /** * BB 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 BB frontend tests...\n'); // ============================================================ // Global environment setup (mocks for browser APIs) // ============================================================ const mockElements = {}; function createMockElement(tag) { const el = { tagName: (tag || 'div').toUpperCase(), className: '', id: '', style: { cssText: '' }, 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: [], attributes: [], _listeners: {}, setAttribute(k, v) { this[k] = v; }, getAttribute(k) { return this[k] || null; }, addEventListener(ev, fn) { this._listeners[ev] = fn; }, querySelector() { return createMockElement('span'); }, querySelectorAll() { return []; }, appendChild(child) { if (child.tagName === 'FRAGMENT') { this.children.push(...child.children); } else { this.children.push(child); } }, remove() {}, parentNode: { insertBefore() {} }, }; el.classList = { _el: el, add(cls) { if (!this._el.className.includes(cls)) this._el.className = (this._el.className + ' ' + cls).trim(); }, remove(cls) { this._el.className = this._el.className.replace(cls, '').trim(); }, toggle(cls, force) { if (force === undefined) force = !this._el.className.includes(cls); if (force) this.add(cls); else this.remove(cls); }, contains(cls) { return this._el.className.includes(cls); }, }; return el; } globalThis.document = { createElement: createMockElement, createDocumentFragment: () => { const frag = createMockElement('fragment'); return frag; }, getElementById: (id) => { if (!mockElements[id]) { mockElements[id] = createMockElement('div'); mockElements[id].id = id; } return mockElements[id]; }, }; globalThis.window = {}; globalThis.BB = {}; globalThis.confirm = () => true; // ============================================================ // Load source modules (same order as index.html) // ============================================================ require('../bb'); // Initializes BB namespace require('../state'); // BB.state (Proxy-based pub/sub) require('../utils'); // BB.utils (escapeHtml, escapeAttr, debounce) // Mock BB.api before loading modules that depend on it BB.api = { sources: { list: async () => [ { id: 's1', name: 'Feed A', totalCount: 10, unreadCount: 3, tags: ['news'], health: 'green' }, { id: 's2', name: 'Feed B', totalCount: 5, unreadCount: 0, tags: [], health: 'yellow', lastError: 'timeout' }, ], }, items: { list: async () => ({ items: [ { id: 'i1', title: 'First', author: 'Alice', isRead: false, isStarred: false, timeAgo: '2m' }, { id: 'i2', title: 'Second', author: 'Bob', isRead: true, isStarred: true, timeAgo: '5m' }, ], hasMore: true, }), markRead: async () => {}, markUnread: async () => {}, star: async () => {}, unstar: async () => {}, }, feeds: { listAllTags: async () => ['news', 'tech'], deleteByBusser: async () => {}, getByBusser: async (id) => [{ busserId: id, name: 'Test', config: {} }], create: async () => {}, setTags: async () => {}, get: async () => ({ name: 'Test', config: {} }), update: async () => {}, }, plugins: { schema: async () => ({ fields: [] }) }, }; BB.ui = { showToast() {}, openFormModal() {} }; BB.detail = { load() {}, collapseReader() {}, updateSavedBadge() {} }; BB.queryFeeds = { load() {}, select() {}, openBuilder() {}, deleteFeed() {} }; // Now load modules that depend on BB.utils and BB.api require('../sources'); // BB.sources require('../items'); // BB.items // ============================================================ // Test: BB.state // ============================================================ describe('BB.state', () => { test('subscribe registers callback and fires on set', () => { let called = false; BB.state.subscribe('_t1', () => { called = true; }); BB.state.set('_t1', 'hello'); assert(called, 'Subscriber should fire'); }); test('set passes old and new values to subscriber', () => { BB.state.set('_t2', 'first'); let capturedOld; BB.state.subscribe('_t2', (n, o) => { capturedOld = o; }); BB.state.set('_t2', 'second'); assertEqual(capturedOld, 'first'); }); test('set does not trigger unrelated subscribers', () => { let called = false; BB.state.subscribe('_t3_a', () => { called = true; }); BB.state.set('_t3_b', 'val'); assert(!called, 'Unrelated subscriber should not fire'); }); test('unsubscribe removes callback', () => { let count = 0; const unsub = BB.state.subscribe('_t4', () => { count++; }); BB.state.set('_t4', 'a'); assertEqual(count, 1); unsub(); BB.state.set('_t4', 'b'); assertEqual(count, 1); }); test('set with same value still triggers', () => { BB.state.set('_t5', 'same'); let count = 0; BB.state.subscribe('_t5', () => { count++; }); BB.state.set('_t5', 'same'); assertEqual(count, 1); }); test('multiple subscribers on same key all fire', () => { let a = false, b = false; BB.state.subscribe('_t6', () => { a = true; }); BB.state.subscribe('_t6', () => { b = true; }); BB.state.set('_t6', 'v'); assert(a && b, 'Both should fire'); }); test('direct property assignment triggers via Proxy', () => { let called = false; BB.state.subscribe('_t7', () => { called = true; }); BB.state._t7 = 'proxy'; assert(called, 'Proxy set should trigger'); assertEqual(BB.state._t7, 'proxy'); }); test('get returns current value', () => { BB.state.set('_t8', 42); assertEqual(BB.state.get('_t8'), 42); }); test('initial state has expected default keys', () => { assert(Array.isArray(BB.state.sources)); assertEqual(BB.state.currentOrder, 'chronological'); assertEqual(BB.state.hasMore, false); assertEqual(BB.state.selectedItemId, null); }); }); // ============================================================ // Test: BB.utils.escapeHtml // ============================================================ describe('BB.utils.escapeHtml', () => { test('escapes angle brackets', () => { const r = BB.utils.escapeHtml('hi'); assert(r.includes('<') && r.includes('>')); }); test('escapes ampersand', () => { assert(BB.utils.escapeHtml('A & B').includes('&')); }); test('returns empty for falsy', () => { assertEqual(BB.utils.escapeHtml(''), ''); assertEqual(BB.utils.escapeHtml(null), ''); assertEqual(BB.utils.escapeHtml(undefined), ''); }); test('passes safe strings through', () => { assertEqual(BB.utils.escapeHtml('hello'), 'hello'); }); }); // ============================================================ // Test: BB.utils.escapeAttr // ============================================================ describe('BB.utils.escapeAttr', () => { test('escapes double quotes', () => { assert(BB.utils.escapeAttr('a"b').includes('"')); }); test('escapes single quotes', () => { assert(BB.utils.escapeAttr("a'b").includes(''')); }); test('escapes < and >', () => { const r = BB.utils.escapeAttr('<>'); assert(r.includes('<') && r.includes('>')); }); test('escapes ampersand', () => { assertEqual(BB.utils.escapeAttr('a&b'), 'a&b'); }); test('returns empty for falsy', () => { assertEqual(BB.utils.escapeAttr(''), ''); assertEqual(BB.utils.escapeAttr(null), ''); }); test('handles all special chars together', () => { const r = BB.utils.escapeAttr(`<"&'>`); assert(!r.includes('<') || r.includes('<')); }); test('converts non-string to string', () => { assertEqual(BB.utils.escapeAttr(123), '123'); }); }); // ============================================================ // Test: BB.utils.debounce // ============================================================ describe('BB.utils.debounce', () => { test('does not fire immediately', () => { let called = false; const fn = BB.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 = BB.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: BB.sources // ============================================================ describe('BB.sources.select', () => { test('sets currentSource state', () => { BB.sources.select('s1'); assertEqual(BB.state.currentSource, 's1'); }); test('resets pagination', () => { BB.state.set('currentPage', 5); BB.sources.select('s2'); assertEqual(BB.state.currentPage, 0); }); test('clears selectedItemId', () => { BB.state.set('selectedItemId', 'x'); BB.sources.select(''); assertEqual(BB.state.selectedItemId, null); }); test('clears currentQueryFeed', () => { BB.state.set('currentQueryFeed', 'qf'); BB.sources.select('s1'); assertEqual(BB.state.currentQueryFeed, null); }); }); describe('BB.sources.selectTag', () => { test('sets currentTag', () => { BB.sources.selectTag('tech'); assertEqual(BB.state.currentTag, 'tech'); }); test('resets pagination', () => { BB.state.set('currentPage', 3); BB.sources.selectTag('news'); assertEqual(BB.state.currentPage, 0); }); }); describe('BB.sources.load', () => { test('populates state', async () => { await BB.sources.load(); assertEqual(BB.state.sources.length, 2); assertDeepEqual(BB.state.allTags, ['news', 'tech']); }); }); // ============================================================ // Test: BB.items // ============================================================ // Track API calls let apiCalls = []; BB.api.items.markRead = async (id) => { apiCalls.push({ cmd: 'markRead', id }); }; BB.api.items.markUnread = async (id) => { apiCalls.push({ cmd: 'markUnread', id }); }; BB.api.items.star = async (id) => { apiCalls.push({ cmd: 'star', id }); }; BB.api.items.unstar = async (id) => { apiCalls.push({ cmd: 'unstar', id }); }; describe('BB.items.load', () => { test('populates state with items', async () => { await BB.items.load(); assertEqual(BB.state.items.length, 2); assertEqual(BB.state.items[0].title, 'First'); assertEqual(BB.state.hasMore, true); }); test('appends items when append=true', async () => { BB.state.set('items', [{ id: 'old', title: 'Old' }]); await BB.items.load(true); assertEqual(BB.state.items.length, 3); assertEqual(BB.state.items[0].id, 'old'); }); }); describe('BB.items.selectItem', () => { test('sets selectedItemId', async () => { BB.state.set('items', [{ id: 'i1', title: 'T', isRead: false, isStarred: false }]); await BB.items.selectItem('i1'); assertEqual(BB.state.selectedItemId, 'i1'); }); test('marks item as read via API', async () => { apiCalls = []; BB.state.set('items', [{ id: 'i1', title: 'T', isRead: false, isStarred: false }]); await BB.items.selectItem('i1'); assert(apiCalls.some(c => c.cmd === 'markRead' && c.id === 'i1')); }); }); describe('BB.items.toggleStar', () => { test('unstars a starred item', async () => { apiCalls = []; BB.state.set('items', [{ id: 'i1', title: 'T', isRead: true, isStarred: true }]); await BB.items.toggleStar('i1', true); assert(apiCalls.some(c => c.cmd === 'unstar')); assertEqual(BB.state.items[0].isStarred, false); }); test('stars an unstarred item', async () => { apiCalls = []; BB.state.set('items', [{ id: 'i1', title: 'T', isRead: true, isStarred: false }]); await BB.items.toggleStar('i1', false); assert(apiCalls.some(c => c.cmd === 'star')); assertEqual(BB.state.items[0].isStarred, true); }); }); describe('BB.items.toggleRead', () => { test('marks read item as unread', async () => { apiCalls = []; BB.state.set('items', [{ id: 'i1', title: 'T', isRead: true, isStarred: false }]); await BB.items.toggleRead('i1', true); assert(apiCalls.some(c => c.cmd === 'markUnread')); assertEqual(BB.state.items[0].isRead, false); }); }); describe('BB.items.loadMore', () => { test('increments page', async () => { BB.state.set('currentPage', 0); await BB.items.loadMore(); assertEqual(BB.state.currentPage, 1); }); }); // ============================================================ // Test: BB.sources.render (rendering edge cases) // ============================================================ // Helper to reset a mock element's children array function resetMockElement(id) { const el = mockElements[id]; if (el) el.children = []; } describe('BB.sources.render', () => { test('renderSourceList creates correct number of source elements', () => { resetMockElement('sources-list'); const sources = [ { id: 's1', name: 'Feed A', totalCount: 10, unreadCount: 3, tags: ['news'], health: 'green' }, { id: 's2', name: 'Feed B', totalCount: 5, unreadCount: 0, tags: [], health: 'yellow', lastError: 'timeout' }, { id: 's3', name: 'Feed C', totalCount: 0, unreadCount: 0, tags: [], health: 'red', lastError: 'dns fail' }, ]; BB.state.set('currentSource', ''); BB.state.set('queryFeeds', []); BB.sources.render(sources); const list = document.getElementById('sources-list'); // 1 All + 3 source items + 1 "+ Query Feed" button assertEqual(list.children.length, 5); }); test('source health indicator shows correct class for yellow', () => { resetMockElement('sources-list'); const sources = [ { id: 's1', name: 'Feed A', totalCount: 5, unreadCount: 0, tags: [], health: 'yellow', lastError: 'timeout' }, ]; BB.state.set('currentSource', ''); BB.state.set('queryFeeds', []); BB.sources.render(sources); const list = document.getElementById('sources-list'); const sourceItem = list.children[1]; // children[0] is "All", sources start at [1] assert(sourceItem.innerHTML.includes('data-health="yellow"'), 'Should have data-health="yellow" attribute'); }); test('source with unreadCount=0 shows checkmark and total (no slash)', () => { resetMockElement('sources-list'); const sources = [ { id: 's1', name: 'Feed A', totalCount: 5, unreadCount: 0, tags: [], health: 'green' }, ]; BB.state.set('currentSource', ''); BB.state.set('queryFeeds', []); BB.sources.render(sources); const list = document.getElementById('sources-list'); const sourceItem = list.children[1]; // children[0] is "All" assert(sourceItem.innerHTML.includes('\u2713'), 'Should show checkmark when all read'); assert(sourceItem.innerHTML.includes('5'), 'Should show total count'); assert(!sourceItem.innerHTML.includes('0/5'), 'Should not show 0/X format'); }); test('source with lastError shows error text in health dot title', () => { resetMockElement('sources-list'); const sources = [ { id: 's1', name: 'Feed A', totalCount: 5, unreadCount: 0, tags: [], health: 'red', lastError: 'dns fail' }, ]; BB.state.set('currentSource', ''); BB.state.set('queryFeeds', []); BB.sources.render(sources); const list = document.getElementById('sources-list'); const sourceItem = list.children[1]; // children[0] is "All" assert(sourceItem.innerHTML.includes('dns fail'), 'Should include error text'); }); test('empty sources array renders All item, onboarding, and + button', () => { resetMockElement('sources-list'); BB.state.set('currentSource', ''); BB.state.set('queryFeeds', []); BB.sources.render([]); const list = document.getElementById('sources-list'); // All item + onboarding message + "+ Query Feed" button assertEqual(list.children.length, 3); assert(list.children[0].innerHTML.includes('All'), 'Should have All entry'); assert(list.children[1].innerHTML.includes('Add your first feed'), 'Should have onboarding message'); }); }); // ============================================================ // Test: BB.items.render (rendering edge cases) // ============================================================ describe('BB.items.render', () => { test('empty items array renders placeholder message', () => { resetMockElement('items-list'); BB.state.set('sources', [{ id: 's1', name: 'F' }]); BB.items.render([]); const list = document.getElementById('items-list'); assert(list.innerHTML.includes('empty-state'), 'Should show empty state'); }); test('item with isStarred=true has starred class', () => { resetMockElement('items-list'); BB.items.render([ { id: 'i1', title: 'T', author: 'A', isRead: false, isStarred: true, timeAgo: '1m' }, ]); const list = document.getElementById('items-list'); const item = list.children[0]; assert(item.innerHTML.includes('starred'), 'Should have starred class'); }); test('item with isRead=true has read class', () => { resetMockElement('items-list'); BB.items.render([ { id: 'i1', title: 'T', author: 'A', isRead: true, isStarred: false, timeAgo: '1m' }, ]); const list = document.getElementById('items-list'); const item = list.children[0]; assert(item.className.includes('read'), 'Should have read class'); }); test('loadMore with hasMore=false is a no-op for display', async () => { resetMockElement('items-list'); BB.state.set('hasMore', false); const loadMoreEl = document.getElementById('load-more'); BB.items.render([{ id: 'i1', title: 'T', author: 'A', isRead: false, isStarred: false, timeAgo: '1m' }]); assertEqual(loadMoreEl.style.display, 'none'); }); test('toggleRead on unread item calls markRead API', async () => { apiCalls = []; BB.state.set('items', [{ id: 'i1', title: 'T', isRead: false, isStarred: false }]); await BB.items.toggleRead('i1', false); assert(apiCalls.some(c => c.cmd === 'markRead' && c.id === 'i1')); assertEqual(BB.state.items[0].isRead, true); }); }); // ============================================================ // Test: BB.sync (settings-sync state routing) // ============================================================ // Load settings-sync module with sync API mocks let syncStatusResult = {}; let syncToasts = []; const origShowToast = BB.ui.showToast; BB.ui.showToast = (msg, type) => { syncToasts.push({ msg, type }); }; BB.ui.openModal = () => {}; BB.api.sync = { status: async () => syncStatusResult, startAuth: async () => ({ authUrl: 'https://test.com', state: 's', codeVerifier: 'cv', port: 8080 }), completeAuth: async () => {}, setupEncryptionNew: async () => {}, setupEncryptionExisting: async () => {}, now: async () => ({ pushed: 1, pulled: 2 }), updateSettings: async () => {}, disconnect: async () => {}, }; require('../settings-sync'); describe('BB.sync.openSettings — renderState routing', () => { test('not configured shows Connect button', async () => { syncStatusResult = { configured: false, authenticated: false }; const body = document.getElementById('modal-body'); body.innerHTML = ''; body.children = []; await BB.sync.openSettings(); // renderConnect adds a div with class sync-connect assert( body.children.some(c => c.className === 'sync-connect'), 'Should render connect state' ); }); test('configured but not authenticated shows Connect button', async () => { syncStatusResult = { configured: true, authenticated: false }; const body = document.getElementById('modal-body'); body.innerHTML = ''; body.children = []; await BB.sync.openSettings(); assert( body.children.some(c => c.className === 'sync-connect'), 'Should render connect state when not authenticated' ); }); test('authenticated but no encryption and no server key shows Set Password', async () => { syncStatusResult = { configured: true, authenticated: true, encryptionReady: false, hasServerKey: false }; const body = document.getElementById('modal-body'); body.innerHTML = ''; body.children = []; await BB.sync.openSettings(); // renderEncryption adds a div, check for Set Password button text const hasForm = body.children.some(c => { // The child div contains a form with submit button return c.children && c.children.some(f => f.children && f.children.some(a => a.children && a.children.some(b => b._text === 'Set Password') ) ); }); assert(hasForm, 'Should show Set Password button'); }); test('authenticated but no encryption with server key shows Unlock', async () => { syncStatusResult = { configured: true, authenticated: true, encryptionReady: false, hasServerKey: true }; const body = document.getElementById('modal-body'); body.innerHTML = ''; body.children = []; await BB.sync.openSettings(); const hasUnlock = body.children.some(c => { return c.children && c.children.some(f => f.children && f.children.some(a => a.children && a.children.some(b => b._text === 'Unlock') ) ); }); assert(hasUnlock, 'Should show Unlock button'); }); test('fully ready shows sync-ready with Sync Now button', async () => { syncStatusResult = { configured: true, authenticated: true, encryptionReady: true, lastSyncAt: null, pendingChanges: 0, autoSyncEnabled: false, syncIntervalMinutes: 15, }; const body = document.getElementById('modal-body'); body.innerHTML = ''; body.children = []; await BB.sync.openSettings(); assert( body.children.some(c => c.className === 'sync-ready'), 'Should render ready state' ); }); test('ready state shows Never for null lastSyncAt', async () => { syncStatusResult = { configured: true, authenticated: true, encryptionReady: true, lastSyncAt: null, pendingChanges: 0, autoSyncEnabled: true, syncIntervalMinutes: 15, }; const body = document.getElementById('modal-body'); body.innerHTML = ''; body.children = []; await BB.sync.openSettings(); const readyDiv = body.children.find(c => c.className === 'sync-ready'); assert(readyDiv, 'Should have sync-ready div'); // The info div should contain "Never" const infoDiv = readyDiv.children.find(c => c.className === 'sync-info'); assert(infoDiv && infoDiv.innerHTML.includes('Never'), 'Should show Never for null lastSyncAt'); }); test('openSettings shows error toast on API failure', async () => { syncToasts = []; BB.api.sync.status = async () => { throw new Error('network error'); }; await BB.sync.openSettings(); assert(syncToasts.some(t => t.type === 'error'), 'Should show error toast'); // Restore status mock BB.api.sync.status = async () => syncStatusResult; }); test('ready state has disconnect button', async () => { syncStatusResult = { configured: true, authenticated: true, encryptionReady: true, lastSyncAt: '2026-01-01T00:00:00Z', pendingChanges: 3, autoSyncEnabled: true, syncIntervalMinutes: 30, }; const body = document.getElementById('modal-body'); body.innerHTML = ''; body.children = []; await BB.sync.openSettings(); const readyDiv = body.children.find(c => c.className === 'sync-ready'); assert(readyDiv, 'Should have sync-ready div'); const hasDisconnect = readyDiv.children.some(c => c.className === 'btn sync-disconnect' && c._text === 'Disconnect' ); assert(hasDisconnect, 'Should have Disconnect button'); }); }); // Restore showToast BB.ui.showToast = origShowToast; // ============================================================ // Report // ============================================================ const success = report(); process.exit(success ? 0 : 1);