/** * Shared environment for the BB frontend tests. * * The frontend is plain browser JS loaded by index.html, so testing it under * node means standing up the handful of browser APIs it touches and loading * the modules in the same order the page does. That is what this file is: the * mocks are the real work, and every suite requires it first. * * `node --test` runs each test file in its own process, so requiring this from * several suites gives each of them a fresh BB namespace rather than a shared * one that leaks state between files. */ const mockElements = {}; // Serialize a mock element to an HTML-ish string so tests can assert against // rows built from child elements (BB.ui.renderRow), not just innerHTML // template strings. Reflects className + attributes set via setAttribute. function outerHTML(el) { if (el.nodeType === 3) return el._text; const tag = (el.tagName || 'div').toLowerCase(); let attrs = ''; if (el.className) attrs += ` class="${el.className}"`; for (const [k, v] of Object.entries(el._attrs || {})) attrs += ` ${k}="${v}"`; return `<${tag}${attrs}>${el.innerHTML}`; } function createMockElement(tag) { const el = { tagName: (tag || 'div').toUpperCase(), className: '', id: '', style: { cssText: '' }, dataset: {}, _html: '', _attrs: {}, _text: '', set textContent(v) { this._text = v; this.innerHTML = String(v) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); }, get textContent() { return this._text; }, // innerHTML: explicit assignment wins; otherwise serialize children so // element-built rows are inspectable. get innerHTML() { if (this._html) return this._html; return this.children.map(outerHTML).join(''); }, set innerHTML(v) { this._html = v; }, children: [], attributes: [], _listeners: {}, setAttribute(k, v) { this._attrs[k] = v; this[k] = v; }, getAttribute(k) { return this._attrs[k] != null ? this._attrs[k] : (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; } // Reset a mock element's children array between render assertions. function resetMockElement(id) { const el = mockElements[id]; if (el) el.children = []; } // Text nodes carry no tag, so outerHTML serializes them as their text. Only // settings-sync builds one (the auto-sync toggle label). function createMockTextNode(text) { return { nodeType: 3, _text: String(text), textContent: String(text), children: [] }; } globalThis.document = { createElement: createMockElement, createTextNode: createMockTextNode, createDocumentFragment: () => createMockElement('fragment'), 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) // Every item command a test asserts on lands here as {cmd, id}. Truncate it at // the top of a test rather than replacing it, so the mocks below keep pointing // at the array the suite reads. const apiCalls = []; // The backing store the item mocks read and write. BB.items.toggleStar and // toggleRead reload from the API after the write, so a static list would hand // every read-back the old flag and no toggle could be asserted end to end. const ITEM_FIXTURES = [ { id: 'i1', title: 'First', author: 'Alice', isRead: false, isStarred: false, timeAgo: '2m' }, { id: 'i2', title: 'Second', author: 'Bob', isRead: true, isStarred: true, timeAgo: '5m' }, ]; let itemFixtures = ITEM_FIXTURES.map(i => ({ ...i })); function resetItemFixtures() { itemFixtures = ITEM_FIXTURES.map(i => ({ ...i })); } function setItemFlag(id, key, value) { const item = itemFixtures.find(i => i.id === id); if (item) item[key] = value; } // 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: itemFixtures.map(i => ({ ...i })), hasMore: true, }), markRead: async (id) => { apiCalls.push({ cmd: 'markRead', id }); setItemFlag(id, 'isRead', true); }, markUnread: async (id) => { apiCalls.push({ cmd: 'markUnread', id }); setItemFlag(id, 'isRead', false); }, star: async (id) => { apiCalls.push({ cmd: 'star', id }); setItemFlag(id, 'isStarred', true); }, unstar: async (id) => { apiCalls.push({ cmd: 'unstar', id }); setItemFlag(id, 'isStarred', false); }, }, 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: [] }) }, }; // Load the real BB.ui (renderRow, renderEmptyState, etc.); components.js has // no load-time DOM side effects. Then stub the methods that touch document.body // / overlays, matching the harness's prior no-op mock so render tests stay pure. require('../components'); BB.ui.showToast = () => {}; BB.ui.openFormModal = () => {}; BB.detail = { load() {}, collapseReader() {}, updateSavedBadge() {} }; BB.queryFeeds = { load() {}, select() {}, openBuilder() {}, deleteFeed() {} }; // sources.select ends by handing the selection to navigation for the mobile // tab switch. Nothing here tests mobile layout, so it is a no-op. BB.navigation = { onSourceSelected() {} }; // Modules that depend on BB.utils and BB.api require('../sources'); // BB.sources require('../items'); // BB.items module.exports = { BB, mockElements, createMockElement, resetMockElement, apiCalls, resetItemFixtures, };