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 });