Skip to main content

max / balanced_breakfast

7.4 KB · 198 lines History Blame Raw
1 /**
2 * Shared environment for the BB frontend tests.
3 *
4 * The frontend is plain browser JS loaded by index.html, so testing it under
5 * node means standing up the handful of browser APIs it touches and loading
6 * the modules in the same order the page does. That is what this file is: the
7 * mocks are the real work, and every suite requires it first.
8 *
9 * `node --test` runs each test file in its own process, so requiring this from
10 * several suites gives each of them a fresh BB namespace rather than a shared
11 * one that leaks state between files.
12 */
13
14 const mockElements = {};
15
16 // Serialize a mock element to an HTML-ish string so tests can assert against
17 // rows built from child elements (BB.ui.renderRow), not just innerHTML
18 // template strings. Reflects className + attributes set via setAttribute.
19 function outerHTML(el) {
20 if (el.nodeType === 3) return el._text;
21 const tag = (el.tagName || 'div').toLowerCase();
22 let attrs = '';
23 if (el.className) attrs += ` class="${el.className}"`;
24 for (const [k, v] of Object.entries(el._attrs || {})) attrs += ` ${k}="${v}"`;
25 return `<${tag}${attrs}>${el.innerHTML}</${tag}>`;
26 }
27
28 function createMockElement(tag) {
29 const el = {
30 tagName: (tag || 'div').toUpperCase(),
31 className: '',
32 id: '',
33 style: { cssText: '' },
34 dataset: {},
35 _html: '',
36 _attrs: {},
37 _text: '',
38 set textContent(v) {
39 this._text = v;
40 this.innerHTML = String(v)
41 .replace(/&/g, '&amp;')
42 .replace(/</g, '&lt;')
43 .replace(/>/g, '&gt;')
44 .replace(/"/g, '&quot;')
45 .replace(/'/g, '&#039;');
46 },
47 get textContent() { return this._text; },
48 // innerHTML: explicit assignment wins; otherwise serialize children so
49 // element-built rows are inspectable.
50 get innerHTML() {
51 if (this._html) return this._html;
52 return this.children.map(outerHTML).join('');
53 },
54 set innerHTML(v) { this._html = v; },
55 children: [],
56 attributes: [],
57 _listeners: {},
58 setAttribute(k, v) { this._attrs[k] = v; this[k] = v; },
59 getAttribute(k) { return this._attrs[k] != null ? this._attrs[k] : (this[k] || null); },
60 addEventListener(ev, fn) { this._listeners[ev] = fn; },
61 querySelector() { return createMockElement('span'); },
62 querySelectorAll() { return []; },
63 appendChild(child) {
64 if (child.tagName === 'FRAGMENT') {
65 this.children.push(...child.children);
66 } else {
67 this.children.push(child);
68 }
69 },
70 remove() {},
71 parentNode: { insertBefore() {} },
72 };
73 el.classList = {
74 _el: el,
75 add(cls) { if (!this._el.className.includes(cls)) this._el.className = (this._el.className + ' ' + cls).trim(); },
76 remove(cls) { this._el.className = this._el.className.replace(cls, '').trim(); },
77 toggle(cls, force) {
78 if (force === undefined) force = !this._el.className.includes(cls);
79 if (force) this.add(cls); else this.remove(cls);
80 },
81 contains(cls) { return this._el.className.includes(cls); },
82 };
83 return el;
84 }
85
86 // Reset a mock element's children array between render assertions.
87 function resetMockElement(id) {
88 const el = mockElements[id];
89 if (el) el.children = [];
90 }
91
92 // Text nodes carry no tag, so outerHTML serializes them as their text. Only
93 // settings-sync builds one (the auto-sync toggle label).
94 function createMockTextNode(text) {
95 return { nodeType: 3, _text: String(text), textContent: String(text), children: [] };
96 }
97
98 globalThis.document = {
99 createElement: createMockElement,
100 createTextNode: createMockTextNode,
101 createDocumentFragment: () => createMockElement('fragment'),
102 getElementById: (id) => {
103 if (!mockElements[id]) {
104 mockElements[id] = createMockElement('div');
105 mockElements[id].id = id;
106 }
107 return mockElements[id];
108 },
109 };
110
111 globalThis.window = {};
112 globalThis.BB = {};
113 globalThis.confirm = () => true;
114
115 // Load source modules (same order as index.html)
116
117 require('../bb'); // Initializes BB namespace
118 require('../state'); // BB.state (Proxy-based pub/sub)
119 require('../utils'); // BB.utils (escapeHtml, escapeAttr, debounce)
120
121 // Every item command a test asserts on lands here as {cmd, id}. Truncate it at
122 // the top of a test rather than replacing it, so the mocks below keep pointing
123 // at the array the suite reads.
124 const apiCalls = [];
125
126 // The backing store the item mocks read and write. BB.items.toggleStar and
127 // toggleRead reload from the API after the write, so a static list would hand
128 // every read-back the old flag and no toggle could be asserted end to end.
129 const ITEM_FIXTURES = [
130 { id: 'i1', title: 'First', author: 'Alice', isRead: false, isStarred: false, timeAgo: '2m' },
131 { id: 'i2', title: 'Second', author: 'Bob', isRead: true, isStarred: true, timeAgo: '5m' },
132 ];
133 let itemFixtures = ITEM_FIXTURES.map(i => ({ ...i }));
134
135 function resetItemFixtures() {
136 itemFixtures = ITEM_FIXTURES.map(i => ({ ...i }));
137 }
138
139 function setItemFlag(id, key, value) {
140 const item = itemFixtures.find(i => i.id === id);
141 if (item) item[key] = value;
142 }
143
144 // Mock BB.api before loading modules that depend on it
145 BB.api = {
146 sources: {
147 list: async () => [
148 { id: 's1', name: 'Feed A', totalCount: 10, unreadCount: 3, tags: ['news'], health: 'green' },
149 { id: 's2', name: 'Feed B', totalCount: 5, unreadCount: 0, tags: [], health: 'yellow', lastError: 'timeout' },
150 ],
151 },
152 items: {
153 list: async () => ({
154 items: itemFixtures.map(i => ({ ...i })),
155 hasMore: true,
156 }),
157 markRead: async (id) => { apiCalls.push({ cmd: 'markRead', id }); setItemFlag(id, 'isRead', true); },
158 markUnread: async (id) => { apiCalls.push({ cmd: 'markUnread', id }); setItemFlag(id, 'isRead', false); },
159 star: async (id) => { apiCalls.push({ cmd: 'star', id }); setItemFlag(id, 'isStarred', true); },
160 unstar: async (id) => { apiCalls.push({ cmd: 'unstar', id }); setItemFlag(id, 'isStarred', false); },
161 },
162 feeds: {
163 listAllTags: async () => ['news', 'tech'],
164 deleteByBusser: async () => {},
165 getByBusser: async (id) => [{ busserId: id, name: 'Test', config: {} }],
166 create: async () => {},
167 setTags: async () => {},
168 get: async () => ({ name: 'Test', config: {} }),
169 update: async () => {},
170 },
171 plugins: { schema: async () => ({ fields: [] }) },
172 };
173
174 // Load the real BB.ui (renderRow, renderEmptyState, etc.); components.js has
175 // no load-time DOM side effects. Then stub the methods that touch document.body
176 // / overlays, matching the harness's prior no-op mock so render tests stay pure.
177 require('../components');
178 BB.ui.showToast = () => {};
179 BB.ui.openFormModal = () => {};
180 BB.detail = { load() {}, collapseReader() {}, updateSavedBadge() {} };
181 BB.queryFeeds = { load() {}, select() {}, openBuilder() {}, deleteFeed() {} };
182 // sources.select ends by handing the selection to navigation for the mobile
183 // tab switch. Nothing here tests mobile layout, so it is a no-op.
184 BB.navigation = { onSourceSelected() {} };
185
186 // Modules that depend on BB.utils and BB.api
187 require('../sources'); // BB.sources
188 require('../items'); // BB.items
189
190 module.exports = {
191 BB,
192 mockElements,
193 createMockElement,
194 resetMockElement,
195 apiCalls,
196 resetItemFixtures,
197 };
198