/ tests / capture / browser-notifications.test.js
browser-notifications.test.js
  1  /**
  2   * Unit tests for Browser Notification Utilities
  3   */
  4  
  5  import { describe, test, mock } from 'node:test';
  6  import assert from 'node:assert/strict';
  7  import {
  8    showFloatingMessage,
  9    hideFloatingMessage,
 10    waitForUser,
 11  } from '../../src/utils/browser-notifications.js';
 12  
 13  /**
 14   * Create a mock Playwright page object
 15   * @returns {{ evaluate: Function }} Mock page with evaluate method
 16   */
 17  function createMockPage() {
 18    return {
 19      evaluate: mock.fn(async () => {}),
 20    };
 21  }
 22  
 23  describe('Browser Notifications', () => {
 24    describe('showFloatingMessage', () => {
 25      test('calls page.evaluate with function and args object', async () => {
 26        const page = createMockPage();
 27  
 28        await showFloatingMessage(page, 'Hello World');
 29  
 30        assert.equal(page.evaluate.mock.callCount(), 1);
 31        const call = page.evaluate.mock.calls[0];
 32        // First argument is the evaluate function
 33        assert.equal(typeof call.arguments[0], 'function');
 34        // Second argument is the args object
 35        const args = call.arguments[1];
 36        assert.equal(typeof args, 'object');
 37        assert.equal(args.msg, 'Hello World');
 38      });
 39  
 40      test('passes default options when none provided', async () => {
 41        const page = createMockPage();
 42  
 43        await showFloatingMessage(page, 'Test message');
 44  
 45        const args = page.evaluate.mock.calls[0].arguments[1];
 46        assert.equal(args.msg, 'Test message');
 47        assert.equal(args.bgColor, '#4A90E2');
 48        assert.equal(args.txtColor, 'white');
 49        assert.equal(args.pos, 'top: 20px; right: 20px;');
 50      });
 51  
 52      test('passes custom backgroundColor and textColor', async () => {
 53        const page = createMockPage();
 54  
 55        await showFloatingMessage(page, 'Custom colors', {
 56          backgroundColor: '#FF0000',
 57          textColor: '#000000',
 58        });
 59  
 60        const args = page.evaluate.mock.calls[0].arguments[1];
 61        assert.equal(args.bgColor, '#FF0000');
 62        assert.equal(args.txtColor, '#000000');
 63      });
 64  
 65      test('passes top-left position style', async () => {
 66        const page = createMockPage();
 67  
 68        await showFloatingMessage(page, 'Top left', { position: 'top-left' });
 69  
 70        const args = page.evaluate.mock.calls[0].arguments[1];
 71        assert.equal(args.pos, 'top: 20px; left: 20px;');
 72      });
 73  
 74      test('passes bottom-right position style', async () => {
 75        const page = createMockPage();
 76  
 77        await showFloatingMessage(page, 'Bottom right', {
 78          position: 'bottom-right',
 79        });
 80  
 81        const args = page.evaluate.mock.calls[0].arguments[1];
 82        assert.equal(args.pos, 'bottom: 20px; right: 20px;');
 83      });
 84  
 85      test('passes bottom-left position style', async () => {
 86        const page = createMockPage();
 87  
 88        await showFloatingMessage(page, 'Bottom left', {
 89          position: 'bottom-left',
 90        });
 91  
 92        const args = page.evaluate.mock.calls[0].arguments[1];
 93        assert.equal(args.pos, 'bottom: 20px; left: 20px;');
 94      });
 95  
 96      test('falls back to top-right for unknown position', async () => {
 97        const page = createMockPage();
 98  
 99        await showFloatingMessage(page, 'Unknown pos', {
100          position: 'center',
101        });
102  
103        const args = page.evaluate.mock.calls[0].arguments[1];
104        assert.equal(args.pos, 'top: 20px; right: 20px;');
105      });
106  
107      test('passes all options combined', async () => {
108        const page = createMockPage();
109  
110        await showFloatingMessage(page, 'Full options', {
111          position: 'bottom-left',
112          backgroundColor: '#333',
113          textColor: '#EEE',
114        });
115  
116        const args = page.evaluate.mock.calls[0].arguments[1];
117        assert.equal(args.msg, 'Full options');
118        assert.equal(args.bgColor, '#333');
119        assert.equal(args.txtColor, '#EEE');
120        assert.equal(args.pos, 'bottom: 20px; left: 20px;');
121      });
122    });
123  
124    describe('hideFloatingMessage', () => {
125      test('calls page.evaluate once', async () => {
126        const page = createMockPage();
127  
128        await hideFloatingMessage(page);
129  
130        assert.equal(page.evaluate.mock.callCount(), 1);
131      });
132  
133      test('calls page.evaluate with a function', async () => {
134        const page = createMockPage();
135  
136        await hideFloatingMessage(page);
137  
138        const call = page.evaluate.mock.calls[0];
139        assert.equal(typeof call.arguments[0], 'function');
140      });
141  
142      test('does not pass additional arguments', async () => {
143        const page = createMockPage();
144  
145        await hideFloatingMessage(page);
146  
147        const call = page.evaluate.mock.calls[0];
148        // hideFloatingMessage only passes the function, no args object
149        assert.equal(call.arguments.length, 1);
150      });
151    });
152  
153    describe('waitForUser', () => {
154      test('shows message, polls condition, then hides', async () => {
155        const page = createMockPage();
156        let callCount = 0;
157        const checkCondition = mock.fn(async () => {
158          callCount++;
159          return callCount >= 2;
160        });
161  
162        await waitForUser(page, 'user login', checkCondition, 1);
163  
164        // checkCondition called twice: first returns false, second returns true
165        assert.equal(checkCondition.mock.callCount(), 2);
166  
167        // page.evaluate called 3 times:
168        // 1) showFloatingMessage
169        // 2) hideFloatingMessage (after condition met)
170        assert.equal(page.evaluate.mock.callCount(), 2);
171      });
172  
173      test('passes waiting message with correct format', async () => {
174        const page = createMockPage();
175        const checkCondition = mock.fn(async () => true);
176  
177        await waitForUser(page, 'manual review', checkCondition, 1);
178  
179        // First evaluate call is from showFloatingMessage
180        const showArgs = page.evaluate.mock.calls[0].arguments[1];
181        assert.match(showArgs.msg, /Waiting for manual review/);
182      });
183  
184      test('uses orange background for waiting message', async () => {
185        const page = createMockPage();
186        const checkCondition = mock.fn(async () => true);
187  
188        await waitForUser(page, 'something', checkCondition, 1);
189  
190        const showArgs = page.evaluate.mock.calls[0].arguments[1];
191        assert.equal(showArgs.bgColor, '#FF9500');
192      });
193  
194      test('hides message after condition is met', async () => {
195        const page = createMockPage();
196        const checkCondition = mock.fn(async () => true);
197  
198        await waitForUser(page, 'test', checkCondition, 1);
199  
200        // Last evaluate call is hideFloatingMessage (no args object)
201        const lastCall = page.evaluate.mock.calls[page.evaluate.mock.callCount() - 1];
202        assert.equal(lastCall.arguments.length, 1);
203        assert.equal(typeof lastCall.arguments[0], 'function');
204      });
205  
206      test('condition immediately true calls evaluate twice', async () => {
207        const page = createMockPage();
208        const checkCondition = mock.fn(async () => true);
209  
210        await waitForUser(page, 'instant', checkCondition, 1);
211  
212        // Only one check needed
213        assert.equal(checkCondition.mock.callCount(), 1);
214        // show + hide = 2 evaluate calls
215        assert.equal(page.evaluate.mock.callCount(), 2);
216      });
217  
218      test('polls multiple times until condition met', async () => {
219        const page = createMockPage();
220        let callCount = 0;
221        const checkCondition = mock.fn(async () => {
222          callCount++;
223          return callCount >= 4;
224        });
225  
226        await waitForUser(page, 'multi poll', checkCondition, 1);
227  
228        assert.equal(checkCondition.mock.callCount(), 4);
229        assert.equal(page.evaluate.mock.callCount(), 2);
230      });
231  
232      test('uses default pollInterval of 1000ms', async () => {
233        const page = createMockPage();
234        const checkCondition = mock.fn(async () => true);
235  
236        // We can't easily test the actual interval value without mocking timers,
237        // but we verify the function signature accepts 3 args (no pollInterval)
238        await waitForUser(page, 'defaults', checkCondition);
239  
240        assert.equal(checkCondition.mock.callCount(), 1);
241        assert.equal(page.evaluate.mock.callCount(), 2);
242      });
243  
244      test('custom poll interval is accepted', async () => {
245        const page = createMockPage();
246        let callCount = 0;
247        const checkCondition = mock.fn(async () => {
248          callCount++;
249          return callCount >= 2;
250        });
251  
252        const start = Date.now();
253        await waitForUser(page, 'fast poll', checkCondition, 10);
254        const elapsed = Date.now() - start;
255  
256        assert.equal(checkCondition.mock.callCount(), 2);
257        // With 10ms interval and 1 wait cycle, should complete quickly
258        assert.ok(elapsed < 500, `Expected fast completion, took ${elapsed}ms`);
259      });
260    });
261  });
262  
263  /**
264   * DOM-aware tests that actually execute the browser-side callback functions.
265   *
266   * page.evaluate(fn, args) is normally executed in a Playwright browser context.
267   * To cover the callback body, we create a mock page whose evaluate method:
268   * 1. Injects a minimal fake DOM into globalThis
269   * 2. Calls the function with the provided args
270   * 3. Removes the fake DOM globals afterwards
271   *
272   * This lets c8 see the callback lines as executed while keeping tests self-contained.
273   */
274  
275  /**
276   * Build a minimal fake DOM element that records interactions
277   */
278  function createFakeElement(tag = 'div') {
279    const el = {
280      tag,
281      id: '',
282      innerHTML: '',
283      textContent: '',
284      style: {},
285      eventListeners: {},
286      children: [],
287      removed: false,
288  
289      remove() {
290        this.removed = true;
291      },
292  
293      appendChild(child) {
294        this.children.push(child);
295      },
296  
297      addEventListener(type, handler) {
298        if (!this.eventListeners[type]) {
299          this.eventListeners[type] = [];
300        }
301        this.eventListeners[type].push(handler);
302      },
303  
304      // Simulate triggering an event listener
305      trigger(type, event = {}) {
306        const handlers = this.eventListeners[type] || [];
307        handlers.forEach(h => h(event));
308      },
309    };
310    return el;
311  }
312  
313  /**
314   * Build a minimal fake document that creates trackable elements
315   */
316  function createFakeDocument(existingNotification = null) {
317    const elements = new Map();
318    const createdElements = [];
319  
320    const docEl = createFakeElement('html');
321    const headEl = createFakeElement('head');
322    const bodyEl = createFakeElement('body');
323    const docEventListeners = {};
324  
325    if (existingNotification) {
326      elements.set('claude-notification', existingNotification);
327    }
328  
329    return {
330      _elements: elements,
331      _created: createdElements,
332      head: headEl,
333      body: bodyEl,
334      documentElement: docEl,
335  
336      getElementById(id) {
337        return elements.get(id) || null;
338      },
339  
340      createElement(tag) {
341        const el = createFakeElement(tag);
342        createdElements.push(el);
343        return el;
344      },
345  
346      addEventListener(type, handler) {
347        if (!docEventListeners[type]) {
348          docEventListeners[type] = [];
349        }
350        docEventListeners[type].push(handler);
351      },
352  
353      _docListeners: docEventListeners,
354  
355      triggerDoc(type, event = {}) {
356        const handlers = docEventListeners[type] || [];
357        handlers.forEach(h => h(event));
358      },
359    };
360  }
361  
362  /**
363   * Create a mock page that ACTUALLY executes evaluate callbacks with a fake DOM.
364   * Uses globalThis injection so the browser-side code can access 'document'.
365   */
366  function createDomAwareMockPage(existingNotification = null) {
367    const fakeDoc = createFakeDocument(existingNotification);
368  
369    const page = {
370      _fakeDoc: fakeDoc,
371      evaluate: mock.fn(async (fn, args) => {
372        // Inject fake DOM into globalThis so the fn can access document
373        const prev = globalThis.document;
374        globalThis.document = fakeDoc;
375        try {
376          if (args !== undefined) {
377            return fn(args);
378          }
379          return fn();
380        } finally {
381          if (prev === undefined) {
382            delete globalThis.document;
383          } else {
384            globalThis.document = prev;
385          }
386        }
387      }),
388    };
389    return page;
390  }
391  
392  describe('Browser Notifications - DOM execution (coverage)', () => {
393    describe('showFloatingMessage DOM callbacks', () => {
394      test('removes existing notification if one exists', async () => {
395        const existingEl = createFakeElement('div');
396        existingEl.id = 'claude-notification';
397        const page = createDomAwareMockPage(existingEl);
398  
399        await showFloatingMessage(page, 'New message');
400  
401        assert.ok(existingEl.removed, 'existing notification should be removed');
402      });
403  
404      test('creates notification element with correct id', async () => {
405        const page = createDomAwareMockPage();
406        await showFloatingMessage(page, 'Test');
407  
408        // The notification div is the first createElement call (tag 'div')
409        const created = page._fakeDoc._created;
410        const notification = created.find(el => el.id === 'claude-notification');
411        assert.ok(notification, 'notification element should be created with id');
412      });
413  
414      test('sets innerHTML with message content', async () => {
415        const page = createDomAwareMockPage();
416        await showFloatingMessage(page, 'My custom message');
417  
418        const created = page._fakeDoc._created;
419        const notification = created.find(el => el.id === 'claude-notification');
420        assert.ok(
421          notification.innerHTML.includes('My custom message'),
422          'innerHTML should contain message'
423        );
424      });
425  
426      test('applies position style from pos string', async () => {
427        const page = createDomAwareMockPage();
428        await showFloatingMessage(page, 'Positioned', { position: 'top-left' });
429  
430        const created = page._fakeDoc._created;
431        const notification = created.find(el => el.id === 'claude-notification');
432        // pos = 'top: 20px; left: 20px;' should be applied
433        assert.strictEqual(notification.style.top, '20px', 'top style should be set');
434        assert.strictEqual(notification.style.left, '20px', 'left style should be set');
435      });
436  
437      test('applies bottom-right position style', async () => {
438        const page = createDomAwareMockPage();
439        await showFloatingMessage(page, 'Bottom right', { position: 'bottom-right' });
440  
441        const created = page._fakeDoc._created;
442        const notification = created.find(el => el.id === 'claude-notification');
443        assert.strictEqual(notification.style.bottom, '20px', 'bottom style should be set');
444        assert.strictEqual(notification.style.right, '20px', 'right style should be set');
445      });
446  
447      test('applies background color to notification style', async () => {
448        const page = createDomAwareMockPage();
449        await showFloatingMessage(page, 'Colored', { backgroundColor: '#FF0000' });
450  
451        const created = page._fakeDoc._created;
452        const notification = created.find(el => el.id === 'claude-notification');
453        assert.strictEqual(notification.style.background, '#FF0000', 'background should be set');
454      });
455  
456      test('appends style element for pulse animation to document.head', async () => {
457        const page = createDomAwareMockPage();
458        await showFloatingMessage(page, 'With animation');
459  
460        const headChildren = page._fakeDoc.head.children;
461        assert.ok(headChildren.length > 0, 'head should have children (style element)');
462        const styleEl = headChildren.find(el => el.tag === 'style');
463        assert.ok(styleEl, 'style element should be added to head');
464        assert.ok(styleEl.textContent.includes('pulse'), 'style should contain pulse animation');
465      });
466  
467      test('registers mousedown event listener on notification', async () => {
468        const page = createDomAwareMockPage();
469        await showFloatingMessage(page, 'Draggable');
470  
471        const created = page._fakeDoc._created;
472        const notification = created.find(el => el.id === 'claude-notification');
473        assert.ok(
474          notification.eventListeners.mousedown && notification.eventListeners.mousedown.length > 0,
475          'notification should have mousedown listener'
476        );
477      });
478  
479      test('registers mousemove and mouseup listeners on document', async () => {
480        const page = createDomAwareMockPage();
481        await showFloatingMessage(page, 'Draggable');
482  
483        const docListeners = page._fakeDoc._docListeners;
484        assert.ok(
485          docListeners.mousemove && docListeners.mousemove.length > 0,
486          'document should have mousemove listener'
487        );
488        assert.ok(
489          docListeners.mouseup && docListeners.mouseup.length > 0,
490          'document should have mouseup listener'
491        );
492      });
493  
494      test('dragging: mousedown sets isDragging, mousemove updates transform', async () => {
495        const page = createDomAwareMockPage();
496        await showFloatingMessage(page, 'Drag test');
497  
498        const created = page._fakeDoc._created;
499        const notification = created.find(el => el.id === 'claude-notification');
500  
501        // Trigger mousedown to start dragging
502        notification.trigger('mousedown', { clientX: 100, clientY: 100 });
503  
504        // Trigger mousemove - should update transform
505        page._fakeDoc.triggerDoc('mousemove', {
506          clientX: 150,
507          clientY: 120,
508          preventDefault: () => {},
509        });
510  
511        // After drag, notification.style.transform should be set
512        assert.ok(
513          notification.style.transform && notification.style.transform.includes('translate'),
514          'transform should be set after drag'
515        );
516      });
517  
518      test('mouseup stops dragging', async () => {
519        const page = createDomAwareMockPage();
520        await showFloatingMessage(page, 'Stop drag');
521  
522        const created = page._fakeDoc._created;
523        const notification = created.find(el => el.id === 'claude-notification');
524  
525        // Start dragging
526        notification.trigger('mousedown', { clientX: 100, clientY: 100 });
527        page._fakeDoc.triggerDoc('mousemove', {
528          clientX: 150,
529          clientY: 150,
530          preventDefault: () => {},
531        });
532  
533        // Stop dragging
534        page._fakeDoc.triggerDoc('mouseup', {});
535  
536        // After mouseup, a subsequent mousemove should NOT update transform again
537        const transformBefore = notification.style.transform;
538        page._fakeDoc.triggerDoc('mousemove', {
539          clientX: 200,
540          clientY: 200,
541          preventDefault: () => {},
542        });
543        // Transform should be unchanged (isDragging is false)
544        assert.strictEqual(
545          notification.style.transform,
546          transformBefore,
547          'transform should not change after mouseup'
548        );
549      });
550  
551      test('mousemove without prior mousedown does not update transform', async () => {
552        const page = createDomAwareMockPage();
553        await showFloatingMessage(page, 'No drag');
554  
555        const created = page._fakeDoc._created;
556        const notification = created.find(el => el.id === 'claude-notification');
557  
558        // Fire mousemove without mousedown (isDragging is false by default)
559        page._fakeDoc.triggerDoc('mousemove', {
560          clientX: 150,
561          clientY: 150,
562          preventDefault: () => {},
563        });
564  
565        assert.ok(
566          !notification.style.transform,
567          'transform should not be set without mousedown first'
568        );
569      });
570  
571      test('appends notification to document.body', async () => {
572        const page = createDomAwareMockPage();
573        await showFloatingMessage(page, 'Body append');
574  
575        const bodyChildren = page._fakeDoc.body.children;
576        assert.ok(bodyChildren.length > 0, 'notification should be appended to body');
577        const notification = bodyChildren.find(el => el.id === 'claude-notification');
578        assert.ok(notification, 'notification element should be in body');
579      });
580    });
581  
582    describe('hideFloatingMessage DOM callbacks', () => {
583      test('removes notification element when it exists', async () => {
584        const existingEl = createFakeElement('div');
585        existingEl.id = 'claude-notification';
586        const page = createDomAwareMockPage(existingEl);
587  
588        await hideFloatingMessage(page);
589  
590        assert.ok(existingEl.removed, 'notification should be removed when it exists');
591      });
592  
593      test('does nothing when notification does not exist', async () => {
594        // No existing notification passed to createDomAwareMockPage
595        const page = createDomAwareMockPage(null);
596  
597        // Should not throw when notification is not found
598        await assert.doesNotReject(async () => {
599          await hideFloatingMessage(page);
600        });
601      });
602    });
603  });