Skip to main content

max / balanced_breakfast

26.4 KB · 720 lines History Blame Raw
1 #!/usr/bin/env node
2 /**
3 * BB Frontend JS Test Runner
4 *
5 * Sets up the global environment once, loads all source modules,
6 * then runs all test suites.
7 *
8 * Usage: node src-tauri/frontend/js/tests/run.js
9 */
10
11 const { describe, test, assert, assertEqual, assertDeepEqual, report } = require('./test-runner');
12
13 console.log('Running BB frontend tests...\n');
14
15 // ============================================================
16 // Global environment setup (mocks for browser APIs)
17 // ============================================================
18
19 const mockElements = {};
20
21 function createMockElement(tag) {
22 const el = {
23 tagName: (tag || 'div').toUpperCase(),
24 className: '',
25 id: '',
26 style: { cssText: '' },
27 dataset: {},
28 innerHTML: '',
29 _text: '',
30 set textContent(v) {
31 this._text = v;
32 this.innerHTML = String(v)
33 .replace(/&/g, '&')
34 .replace(/</g, '&lt;')
35 .replace(/>/g, '&gt;')
36 .replace(/"/g, '&quot;')
37 .replace(/'/g, '&#039;');
38 },
39 get textContent() { return this._text; },
40 children: [],
41 attributes: [],
42 _listeners: {},
43 setAttribute(k, v) { this[k] = v; },
44 getAttribute(k) { return this[k] || null; },
45 addEventListener(ev, fn) { this._listeners[ev] = fn; },
46 querySelector() { return createMockElement('span'); },
47 querySelectorAll() { return []; },
48 appendChild(child) {
49 if (child.tagName === 'FRAGMENT') {
50 this.children.push(...child.children);
51 } else {
52 this.children.push(child);
53 }
54 },
55 remove() {},
56 parentNode: { insertBefore() {} },
57 };
58 el.classList = {
59 _el: el,
60 add(cls) { if (!this._el.className.includes(cls)) this._el.className = (this._el.className + ' ' + cls).trim(); },
61 remove(cls) { this._el.className = this._el.className.replace(cls, '').trim(); },
62 toggle(cls, force) {
63 if (force === undefined) force = !this._el.className.includes(cls);
64 if (force) this.add(cls); else this.remove(cls);
65 },
66 contains(cls) { return this._el.className.includes(cls); },
67 };
68 return el;
69 }
70
71 globalThis.document = {
72 createElement: createMockElement,
73 createDocumentFragment: () => {
74 const frag = createMockElement('fragment');
75 return frag;
76 },
77 getElementById: (id) => {
78 if (!mockElements[id]) {
79 mockElements[id] = createMockElement('div');
80 mockElements[id].id = id;
81 }
82 return mockElements[id];
83 },
84 };
85
86 globalThis.window = {};
87 globalThis.BB = {};
88 globalThis.confirm = () => true;
89
90 // ============================================================
91 // Load source modules (same order as index.html)
92 // ============================================================
93
94 require('../bb'); // Initializes BB namespace
95 require('../state'); // BB.state (Proxy-based pub/sub)
96 require('../utils'); // BB.utils (escapeHtml, escapeAttr, debounce)
97
98 // Mock BB.api before loading modules that depend on it
99 BB.api = {
100 sources: {
101 list: async () => [
102 { id: 's1', name: 'Feed A', totalCount: 10, unreadCount: 3, tags: ['news'], health: 'green' },
103 { id: 's2', name: 'Feed B', totalCount: 5, unreadCount: 0, tags: [], health: 'yellow', lastError: 'timeout' },
104 ],
105 },
106 items: {
107 list: async () => ({
108 items: [
109 { id: 'i1', title: 'First', author: 'Alice', isRead: false, isStarred: false, timeAgo: '2m' },
110 { id: 'i2', title: 'Second', author: 'Bob', isRead: true, isStarred: true, timeAgo: '5m' },
111 ],
112 hasMore: true,
113 }),
114 markRead: async () => {},
115 markUnread: async () => {},
116 star: async () => {},
117 unstar: async () => {},
118 },
119 feeds: {
120 listAllTags: async () => ['news', 'tech'],
121 deleteByBusser: async () => {},
122 getByBusser: async (id) => [{ busserId: id, name: 'Test', config: {} }],
123 create: async () => {},
124 setTags: async () => {},
125 get: async () => ({ name: 'Test', config: {} }),
126 update: async () => {},
127 },
128 plugins: { schema: async () => ({ fields: [] }) },
129 };
130 BB.ui = { showToast() {}, openFormModal() {} };
131 BB.detail = { load() {}, collapseReader() {}, updateSavedBadge() {} };
132 BB.queryFeeds = { load() {}, select() {}, openBuilder() {}, deleteFeed() {} };
133
134 // Now load modules that depend on BB.utils and BB.api
135 require('../sources'); // BB.sources
136 require('../items'); // BB.items
137
138 // ============================================================
139 // Test: BB.state
140 // ============================================================
141
142 describe('BB.state', () => {
143 test('subscribe registers callback and fires on set', () => {
144 let called = false;
145 BB.state.subscribe('_t1', () => { called = true; });
146 BB.state.set('_t1', 'hello');
147 assert(called, 'Subscriber should fire');
148 });
149
150 test('set passes old and new values to subscriber', () => {
151 BB.state.set('_t2', 'first');
152 let capturedOld;
153 BB.state.subscribe('_t2', (n, o) => { capturedOld = o; });
154 BB.state.set('_t2', 'second');
155 assertEqual(capturedOld, 'first');
156 });
157
158 test('set does not trigger unrelated subscribers', () => {
159 let called = false;
160 BB.state.subscribe('_t3_a', () => { called = true; });
161 BB.state.set('_t3_b', 'val');
162 assert(!called, 'Unrelated subscriber should not fire');
163 });
164
165 test('unsubscribe removes callback', () => {
166 let count = 0;
167 const unsub = BB.state.subscribe('_t4', () => { count++; });
168 BB.state.set('_t4', 'a');
169 assertEqual(count, 1);
170 unsub();
171 BB.state.set('_t4', 'b');
172 assertEqual(count, 1);
173 });
174
175 test('set with same value still triggers', () => {
176 BB.state.set('_t5', 'same');
177 let count = 0;
178 BB.state.subscribe('_t5', () => { count++; });
179 BB.state.set('_t5', 'same');
180 assertEqual(count, 1);
181 });
182
183 test('multiple subscribers on same key all fire', () => {
184 let a = false, b = false;
185 BB.state.subscribe('_t6', () => { a = true; });
186 BB.state.subscribe('_t6', () => { b = true; });
187 BB.state.set('_t6', 'v');
188 assert(a && b, 'Both should fire');
189 });
190
191 test('direct property assignment triggers via Proxy', () => {
192 let called = false;
193 BB.state.subscribe('_t7', () => { called = true; });
194 BB.state._t7 = 'proxy';
195 assert(called, 'Proxy set should trigger');
196 assertEqual(BB.state._t7, 'proxy');
197 });
198
199 test('get returns current value', () => {
200 BB.state.set('_t8', 42);
201 assertEqual(BB.state.get('_t8'), 42);
202 });
203
204 test('initial state has expected default keys', () => {
205 assert(Array.isArray(BB.state.sources));
206 assertEqual(BB.state.currentOrder, 'chronological');
207 assertEqual(BB.state.hasMore, false);
208 assertEqual(BB.state.selectedItemId, null);
209 });
210 });
211
212 // ============================================================
213 // Test: BB.utils.escapeHtml
214 // ============================================================
215
216 describe('BB.utils.escapeHtml', () => {
217 test('escapes angle brackets', () => {
218 const r = BB.utils.escapeHtml('<b>hi</b>');
219 assert(r.includes('&lt;') && r.includes('&gt;'));
220 });
221
222 test('escapes ampersand', () => {
223 assert(BB.utils.escapeHtml('A & B').includes('&amp;'));
224 });
225
226 test('returns empty for falsy', () => {
227 assertEqual(BB.utils.escapeHtml(''), '');
228 assertEqual(BB.utils.escapeHtml(null), '');
229 assertEqual(BB.utils.escapeHtml(undefined), '');
230 });
231
232 test('passes safe strings through', () => {
233 assertEqual(BB.utils.escapeHtml('hello'), 'hello');
234 });
235 });
236
237 // ============================================================
238 // Test: BB.utils.escapeAttr
239 // ============================================================
240
241 describe('BB.utils.escapeAttr', () => {
242 test('escapes double quotes', () => {
243 assert(BB.utils.escapeAttr('a"b').includes('&quot;'));
244 });
245
246 test('escapes single quotes', () => {
247 assert(BB.utils.escapeAttr("a'b").includes('&#39;'));
248 });
249
250 test('escapes < and >', () => {
251 const r = BB.utils.escapeAttr('<>');
252 assert(r.includes('&lt;') && r.includes('&gt;'));
253 });
254
255 test('escapes ampersand', () => {
256 assertEqual(BB.utils.escapeAttr('a&b'), 'a&amp;b');
257 });
258
259 test('returns empty for falsy', () => {
260 assertEqual(BB.utils.escapeAttr(''), '');
261 assertEqual(BB.utils.escapeAttr(null), '');
262 });
263
264 test('handles all special chars together', () => {
265 const r = BB.utils.escapeAttr(`<"&'>`);
266 assert(!r.includes('<') || r.includes('&lt;'));
267 });
268
269 test('converts non-string to string', () => {
270 assertEqual(BB.utils.escapeAttr(123), '123');
271 });
272 });
273
274 // ============================================================
275 // Test: BB.utils.debounce
276 // ============================================================
277
278 describe('BB.utils.debounce', () => {
279 test('does not fire immediately', () => {
280 let called = false;
281 const fn = BB.utils.debounce(() => { called = true; }, 10);
282 fn();
283 assert(!called, 'Should not fire immediately');
284 });
285
286 test('rapid calls only execute last one', () => {
287 let callCount = 0, lastArg = null;
288 const origST = globalThis.setTimeout;
289 const origCT = globalThis.clearTimeout;
290 let pendingCb = null;
291 globalThis.setTimeout = (cb) => { pendingCb = cb; return 1; };
292 globalThis.clearTimeout = () => { pendingCb = null; };
293
294 const fn = BB.utils.debounce((arg) => { callCount++; lastArg = arg; }, 100);
295 fn('a');
296 fn('b');
297 fn('c');
298 if (pendingCb) pendingCb();
299
300 assertEqual(callCount, 1);
301 assertEqual(lastArg, 'c');
302
303 globalThis.setTimeout = origST;
304 globalThis.clearTimeout = origCT;
305 });
306 });
307
308 // ============================================================
309 // Test: BB.sources
310 // ============================================================
311
312 describe('BB.sources.select', () => {
313 test('sets currentSource state', () => {
314 BB.sources.select('s1');
315 assertEqual(BB.state.currentSource, 's1');
316 });
317
318 test('resets pagination', () => {
319 BB.state.set('currentPage', 5);
320 BB.sources.select('s2');
321 assertEqual(BB.state.currentPage, 0);
322 });
323
324 test('clears selectedItemId', () => {
325 BB.state.set('selectedItemId', 'x');
326 BB.sources.select('');
327 assertEqual(BB.state.selectedItemId, null);
328 });
329
330 test('clears currentQueryFeed', () => {
331 BB.state.set('currentQueryFeed', 'qf');
332 BB.sources.select('s1');
333 assertEqual(BB.state.currentQueryFeed, null);
334 });
335 });
336
337 describe('BB.sources.selectTag', () => {
338 test('sets currentTag', () => {
339 BB.sources.selectTag('tech');
340 assertEqual(BB.state.currentTag, 'tech');
341 });
342
343 test('resets pagination', () => {
344 BB.state.set('currentPage', 3);
345 BB.sources.selectTag('news');
346 assertEqual(BB.state.currentPage, 0);
347 });
348 });
349
350 describe('BB.sources.load', () => {
351 test('populates state', async () => {
352 await BB.sources.load();
353 assertEqual(BB.state.sources.length, 2);
354 assertDeepEqual(BB.state.allTags, ['news', 'tech']);
355 });
356 });
357
358 // ============================================================
359 // Test: BB.items
360 // ============================================================
361
362 // Track API calls
363 let apiCalls = [];
364 BB.api.items.markRead = async (id) => { apiCalls.push({ cmd: 'markRead', id }); };
365 BB.api.items.markUnread = async (id) => { apiCalls.push({ cmd: 'markUnread', id }); };
366 BB.api.items.star = async (id) => { apiCalls.push({ cmd: 'star', id }); };
367 BB.api.items.unstar = async (id) => { apiCalls.push({ cmd: 'unstar', id }); };
368
369 describe('BB.items.load', () => {
370 test('populates state with items', async () => {
371 await BB.items.load();
372 assertEqual(BB.state.items.length, 2);
373 assertEqual(BB.state.items[0].title, 'First');
374 assertEqual(BB.state.hasMore, true);
375 });
376
377 test('appends items when append=true', async () => {
378 BB.state.set('items', [{ id: 'old', title: 'Old' }]);
379 await BB.items.load(true);
380 assertEqual(BB.state.items.length, 3);
381 assertEqual(BB.state.items[0].id, 'old');
382 });
383 });
384
385 describe('BB.items.selectItem', () => {
386 test('sets selectedItemId', async () => {
387 BB.state.set('items', [{ id: 'i1', title: 'T', isRead: false, isStarred: false }]);
388 await BB.items.selectItem('i1');
389 assertEqual(BB.state.selectedItemId, 'i1');
390 });
391
392 test('marks item as read via API', async () => {
393 apiCalls = [];
394 BB.state.set('items', [{ id: 'i1', title: 'T', isRead: false, isStarred: false }]);
395 await BB.items.selectItem('i1');
396 assert(apiCalls.some(c => c.cmd === 'markRead' && c.id === 'i1'));
397 });
398 });
399
400 describe('BB.items.toggleStar', () => {
401 test('unstars a starred item', async () => {
402 apiCalls = [];
403 BB.state.set('items', [{ id: 'i1', title: 'T', isRead: true, isStarred: true }]);
404 await BB.items.toggleStar('i1', true);
405 assert(apiCalls.some(c => c.cmd === 'unstar'));
406 assertEqual(BB.state.items[0].isStarred, false);
407 });
408
409 test('stars an unstarred item', async () => {
410 apiCalls = [];
411 BB.state.set('items', [{ id: 'i1', title: 'T', isRead: true, isStarred: false }]);
412 await BB.items.toggleStar('i1', false);
413 assert(apiCalls.some(c => c.cmd === 'star'));
414 assertEqual(BB.state.items[0].isStarred, true);
415 });
416 });
417
418 describe('BB.items.toggleRead', () => {
419 test('marks read item as unread', async () => {
420 apiCalls = [];
421 BB.state.set('items', [{ id: 'i1', title: 'T', isRead: true, isStarred: false }]);
422 await BB.items.toggleRead('i1', true);
423 assert(apiCalls.some(c => c.cmd === 'markUnread'));
424 assertEqual(BB.state.items[0].isRead, false);
425 });
426 });
427
428 describe('BB.items.loadMore', () => {
429 test('increments page', async () => {
430 BB.state.set('currentPage', 0);
431 await BB.items.loadMore();
432 assertEqual(BB.state.currentPage, 1);
433 });
434 });
435
436 // ============================================================
437 // Test: BB.sources.render (rendering edge cases)
438 // ============================================================
439
440 // Helper to reset a mock element's children array
441 function resetMockElement(id) {
442 const el = mockElements[id];
443 if (el) el.children = [];
444 }
445
446 describe('BB.sources.render', () => {
447 test('renderSourceList creates correct number of source elements', () => {
448 resetMockElement('sources-list');
449 const sources = [
450 { id: 's1', name: 'Feed A', totalCount: 10, unreadCount: 3, tags: ['news'], health: 'green' },
451 { id: 's2', name: 'Feed B', totalCount: 5, unreadCount: 0, tags: [], health: 'yellow', lastError: 'timeout' },
452 { id: 's3', name: 'Feed C', totalCount: 0, unreadCount: 0, tags: [], health: 'red', lastError: 'dns fail' },
453 ];
454 BB.state.set('currentSource', '');
455 BB.state.set('queryFeeds', []);
456 BB.sources.render(sources);
457 const list = document.getElementById('sources-list');
458 // 1 All + 3 source items + 1 "+ Query Feed" button
459 assertEqual(list.children.length, 5);
460 });
461
462 test('source health indicator shows correct class for yellow', () => {
463 resetMockElement('sources-list');
464 const sources = [
465 { id: 's1', name: 'Feed A', totalCount: 5, unreadCount: 0, tags: [], health: 'yellow', lastError: 'timeout' },
466 ];
467 BB.state.set('currentSource', '');
468 BB.state.set('queryFeeds', []);
469 BB.sources.render(sources);
470 const list = document.getElementById('sources-list');
471 const sourceItem = list.children[1]; // children[0] is "All", sources start at [1]
472 assert(sourceItem.innerHTML.includes('data-health="yellow"'), 'Should have data-health="yellow" attribute');
473 });
474
475 test('source with unreadCount=0 shows checkmark and total (no slash)', () => {
476 resetMockElement('sources-list');
477 const sources = [
478 { id: 's1', name: 'Feed A', totalCount: 5, unreadCount: 0, tags: [], health: 'green' },
479 ];
480 BB.state.set('currentSource', '');
481 BB.state.set('queryFeeds', []);
482 BB.sources.render(sources);
483 const list = document.getElementById('sources-list');
484 const sourceItem = list.children[1]; // children[0] is "All"
485 assert(sourceItem.innerHTML.includes('\u2713'), 'Should show checkmark when all read');
486 assert(sourceItem.innerHTML.includes('5'), 'Should show total count');
487 assert(!sourceItem.innerHTML.includes('0/5'), 'Should not show 0/X format');
488 });
489
490 test('source with lastError shows error text in health dot title', () => {
491 resetMockElement('sources-list');
492 const sources = [
493 { id: 's1', name: 'Feed A', totalCount: 5, unreadCount: 0, tags: [], health: 'red', lastError: 'dns fail' },
494 ];
495 BB.state.set('currentSource', '');
496 BB.state.set('queryFeeds', []);
497 BB.sources.render(sources);
498 const list = document.getElementById('sources-list');
499 const sourceItem = list.children[1]; // children[0] is "All"
500 assert(sourceItem.innerHTML.includes('dns fail'), 'Should include error text');
501 });
502
503 test('empty sources array renders All item, onboarding, and + button', () => {
504 resetMockElement('sources-list');
505 BB.state.set('currentSource', '');
506 BB.state.set('queryFeeds', []);
507 BB.sources.render([]);
508 const list = document.getElementById('sources-list');
509 // All item + onboarding message + "+ Query Feed" button
510 assertEqual(list.children.length, 3);
511 assert(list.children[0].innerHTML.includes('All'), 'Should have All entry');
512 assert(list.children[1].innerHTML.includes('Add your first feed'), 'Should have onboarding message');
513 });
514 });
515
516 // ============================================================
517 // Test: BB.items.render (rendering edge cases)
518 // ============================================================
519
520 describe('BB.items.render', () => {
521 test('empty items array renders placeholder message', () => {
522 resetMockElement('items-list');
523 BB.state.set('sources', [{ id: 's1', name: 'F' }]);
524 BB.items.render([]);
525 const list = document.getElementById('items-list');
526 assert(list.innerHTML.includes('empty-state'), 'Should show empty state');
527 });
528
529 test('item with isStarred=true has starred class', () => {
530 resetMockElement('items-list');
531 BB.items.render([
532 { id: 'i1', title: 'T', author: 'A', isRead: false, isStarred: true, timeAgo: '1m' },
533 ]);
534 const list = document.getElementById('items-list');
535 const item = list.children[0];
536 assert(item.innerHTML.includes('starred'), 'Should have starred class');
537 });
538
539 test('item with isRead=true has read class', () => {
540 resetMockElement('items-list');
541 BB.items.render([
542 { id: 'i1', title: 'T', author: 'A', isRead: true, isStarred: false, timeAgo: '1m' },
543 ]);
544 const list = document.getElementById('items-list');
545 const item = list.children[0];
546 assert(item.className.includes('read'), 'Should have read class');
547 });
548
549 test('loadMore with hasMore=false is a no-op for display', async () => {
550 resetMockElement('items-list');
551 BB.state.set('hasMore', false);
552 const loadMoreEl = document.getElementById('load-more');
553 BB.items.render([{ id: 'i1', title: 'T', author: 'A', isRead: false, isStarred: false, timeAgo: '1m' }]);
554 assertEqual(loadMoreEl.style.display, 'none');
555 });
556
557 test('toggleRead on unread item calls markRead API', async () => {
558 apiCalls = [];
559 BB.state.set('items', [{ id: 'i1', title: 'T', isRead: false, isStarred: false }]);
560 await BB.items.toggleRead('i1', false);
561 assert(apiCalls.some(c => c.cmd === 'markRead' && c.id === 'i1'));
562 assertEqual(BB.state.items[0].isRead, true);
563 });
564 });
565
566 // ============================================================
567 // Test: BB.sync (settings-sync state routing)
568 // ============================================================
569
570 // Load settings-sync module with sync API mocks
571 let syncStatusResult = {};
572 let syncToasts = [];
573 const origShowToast = BB.ui.showToast;
574 BB.ui.showToast = (msg, type) => { syncToasts.push({ msg, type }); };
575 BB.ui.openModal = () => {};
576
577 BB.api.sync = {
578 status: async () => syncStatusResult,
579 startAuth: async () => ({ authUrl: 'https://test.com', state: 's', codeVerifier: 'cv', port: 8080 }),
580 completeAuth: async () => {},
581 setupEncryptionNew: async () => {},
582 setupEncryptionExisting: async () => {},
583 now: async () => ({ pushed: 1, pulled: 2 }),
584 updateSettings: async () => {},
585 disconnect: async () => {},
586 };
587
588 require('../settings-sync');
589
590 describe('BB.sync.openSettings — renderState routing', () => {
591 test('not configured shows Connect button', async () => {
592 syncStatusResult = { configured: false, authenticated: false };
593 const body = document.getElementById('modal-body');
594 body.innerHTML = '';
595 body.children = [];
596 await BB.sync.openSettings();
597 // renderConnect adds a div with class sync-connect
598 assert(
599 body.children.some(c => c.className === 'sync-connect'),
600 'Should render connect state'
601 );
602 });
603
604 test('configured but not authenticated shows Connect button', async () => {
605 syncStatusResult = { configured: true, authenticated: false };
606 const body = document.getElementById('modal-body');
607 body.innerHTML = '';
608 body.children = [];
609 await BB.sync.openSettings();
610 assert(
611 body.children.some(c => c.className === 'sync-connect'),
612 'Should render connect state when not authenticated'
613 );
614 });
615
616 test('authenticated but no encryption and no server key shows Set Password', async () => {
617 syncStatusResult = { configured: true, authenticated: true, encryptionReady: false, hasServerKey: false };
618 const body = document.getElementById('modal-body');
619 body.innerHTML = '';
620 body.children = [];
621 await BB.sync.openSettings();
622 // renderEncryption adds a div, check for Set Password button text
623 const hasForm = body.children.some(c => {
624 // The child div contains a form with submit button
625 return c.children && c.children.some(f =>
626 f.children && f.children.some(a =>
627 a.children && a.children.some(b => b._text === 'Set Password')
628 )
629 );
630 });
631 assert(hasForm, 'Should show Set Password button');
632 });
633
634 test('authenticated but no encryption with server key shows Unlock', async () => {
635 syncStatusResult = { configured: true, authenticated: true, encryptionReady: false, hasServerKey: true };
636 const body = document.getElementById('modal-body');
637 body.innerHTML = '';
638 body.children = [];
639 await BB.sync.openSettings();
640 const hasUnlock = body.children.some(c => {
641 return c.children && c.children.some(f =>
642 f.children && f.children.some(a =>
643 a.children && a.children.some(b => b._text === 'Unlock')
644 )
645 );
646 });
647 assert(hasUnlock, 'Should show Unlock button');
648 });
649
650 test('fully ready shows sync-ready with Sync Now button', async () => {
651 syncStatusResult = {
652 configured: true, authenticated: true, encryptionReady: true,
653 lastSyncAt: null, pendingChanges: 0, autoSyncEnabled: false,
654 syncIntervalMinutes: 15,
655 };
656 const body = document.getElementById('modal-body');
657 body.innerHTML = '';
658 body.children = [];
659 await BB.sync.openSettings();
660 assert(
661 body.children.some(c => c.className === 'sync-ready'),
662 'Should render ready state'
663 );
664 });
665
666 test('ready state shows Never for null lastSyncAt', async () => {
667 syncStatusResult = {
668 configured: true, authenticated: true, encryptionReady: true,
669 lastSyncAt: null, pendingChanges: 0, autoSyncEnabled: true,
670 syncIntervalMinutes: 15,
671 };
672 const body = document.getElementById('modal-body');
673 body.innerHTML = '';
674 body.children = [];
675 await BB.sync.openSettings();
676 const readyDiv = body.children.find(c => c.className === 'sync-ready');
677 assert(readyDiv, 'Should have sync-ready div');
678 // The info div should contain "Never"
679 const infoDiv = readyDiv.children.find(c => c.className === 'sync-info');
680 assert(infoDiv && infoDiv.innerHTML.includes('Never'), 'Should show Never for null lastSyncAt');
681 });
682
683 test('openSettings shows error toast on API failure', async () => {
684 syncToasts = [];
685 BB.api.sync.status = async () => { throw new Error('network error'); };
686 await BB.sync.openSettings();
687 assert(syncToasts.some(t => t.type === 'error'), 'Should show error toast');
688 // Restore status mock
689 BB.api.sync.status = async () => syncStatusResult;
690 });
691
692 test('ready state has disconnect button', async () => {
693 syncStatusResult = {
694 configured: true, authenticated: true, encryptionReady: true,
695 lastSyncAt: '2026-01-01T00:00:00Z', pendingChanges: 3,
696 autoSyncEnabled: true, syncIntervalMinutes: 30,
697 };
698 const body = document.getElementById('modal-body');
699 body.innerHTML = '';
700 body.children = [];
701 await BB.sync.openSettings();
702 const readyDiv = body.children.find(c => c.className === 'sync-ready');
703 assert(readyDiv, 'Should have sync-ready div');
704 const hasDisconnect = readyDiv.children.some(c =>
705 c.className === 'btn sync-disconnect' && c._text === 'Disconnect'
706 );
707 assert(hasDisconnect, 'Should have Disconnect button');
708 });
709 });
710
711 // Restore showToast
712 BB.ui.showToast = origShowToast;
713
714 // ============================================================
715 // Report
716 // ============================================================
717
718 const success = report();
719 process.exit(success ? 0 : 1);
720