diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c06c56b --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.kiro/ +.vscode/ \ No newline at end of file diff --git a/content.js b/content.js index 45161dd..98b61c8 100644 --- a/content.js +++ b/content.js @@ -6,15 +6,19 @@ class ScreenshotSelector { this.overlay = null; this.style = null; this.background = 'black'; + this.copyToClipboard = true; // Default to true + this.saveToPc = true; // Default to true for backward compatibility } - async init(background = 'black') { + async init(background = 'black', copyToClipboard = true, saveToPc = true) { if (this.isActive) { console.log('Screenshot selector already active'); return; } this.background = background; + this.copyToClipboard = copyToClipboard; + this.saveToPc = saveToPc; this.isActive = true; this.createUI(); @@ -363,12 +367,36 @@ class ScreenshotSelector { async captureElement(element) { try { + // Determine loading message based on enabled operations + let loadingMessage = 'Capturing screenshot...'; + + if (this.saveToPc && this.copyToClipboard) { + // Both operations enabled + const availability = this.checkClipboardAPIAvailability(); + if (availability.available) { + loadingMessage = 'Capturing screenshot and preparing for clipboard...'; + } else { + loadingMessage = 'Capturing screenshot for download... (Clipboard not available)'; + } + } else if (this.saveToPc && !this.copyToClipboard) { + // Only save to PC enabled + loadingMessage = 'Capturing screenshot for download...'; + } else if (!this.saveToPc && this.copyToClipboard) { + // Only clipboard enabled + const availability = this.checkClipboardAPIAvailability(); + if (availability.available) { + loadingMessage = 'Capturing screenshot for clipboard...'; + } else { + loadingMessage = 'Capturing screenshot... (Clipboard not available)'; + } + } + // Show loading state this.overlay.innerHTML = `
- Capturing screenshot... + ${loadingMessage}
@@ -124,6 +211,48 @@ +
+
+ + +
+
+ Automatically download screenshots as PNG files to your default downloads folder. +
+
+ +
+
+ + +
+
+ Automatically copy screenshots to your clipboard for quick pasting. +
+
+ + + diff --git a/popup.js b/popup.js index d5fffc3..529b7e0 100644 --- a/popup.js +++ b/popup.js @@ -1,23 +1,70 @@ // Popup script for Chrome extension document.addEventListener('DOMContentLoaded', async () => { const backgroundSelect = document.getElementById('background-select'); + const clipboardToggle = document.getElementById('clipboard-toggle'); + const saveToPcToggle = document.getElementById('save-to-pc-toggle'); const startBtn = document.getElementById('start-selector'); const stopBtn = document.getElementById('stop-selector'); const status = document.getElementById('status'); + const validationWarning = document.getElementById('validation-warning'); - // Load saved background preference - const saved = await chrome.storage.sync.get(['backgroundPreference']); + // Load saved preferences + const saved = await chrome.storage.sync.get(['backgroundPreference', 'copyToClipboard', 'saveToPc']); if (saved.backgroundPreference) { backgroundSelect.value = saved.backgroundPreference; } + + // Set clipboard preference (default to true if not set) + clipboardToggle.checked = saved.copyToClipboard !== undefined ? saved.copyToClipboard : true; + + // Set save to PC preference (default to true if not set to maintain existing behavior) + saveToPcToggle.checked = saved.saveToPc !== undefined ? saved.saveToPc : true; + + // Validation function to ensure at least one output method is enabled + function validateOutputMethods() { + const hasValidOutput = clipboardToggle.checked || saveToPcToggle.checked; + + if (hasValidOutput) { + validationWarning.style.display = 'none'; + startBtn.disabled = false; + startBtn.setAttribute('aria-describedby', ''); + } else { + validationWarning.style.display = 'block'; + startBtn.disabled = true; + startBtn.setAttribute('aria-describedby', 'validation-warning'); + } + + return hasValidOutput; + } + + // Initial validation check + validateOutputMethods(); // Save background preference when changed backgroundSelect.addEventListener('change', () => { chrome.storage.sync.set({ backgroundPreference: backgroundSelect.value }); }); + // Save clipboard preference when changed + clipboardToggle.addEventListener('change', () => { + chrome.storage.sync.set({ copyToClipboard: clipboardToggle.checked }); + validateOutputMethods(); + }); + + // Save save to PC preference when changed + saveToPcToggle.addEventListener('change', () => { + chrome.storage.sync.set({ saveToPc: saveToPcToggle.checked }); + validateOutputMethods(); + }); + // Start selector startBtn.addEventListener('click', async () => { + // Validate output methods before proceeding + if (!validateOutputMethods()) { + showStatus('Error: Please enable at least one output method (Save to PC or Copy to Clipboard) to capture screenshots.', 'error'); + return; + } + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); // Check if page is compatible @@ -34,10 +81,23 @@ document.addEventListener('DOMContentLoaded', async () => { try { await chrome.tabs.sendMessage(tab.id, { action: 'startSelector', - background: backgroundSelect.value + background: backgroundSelect.value, + copyToClipboard: clipboardToggle.checked, + saveToPc: saveToPcToggle.checked }); - showStatus('Selector activated! Hover over elements and click to capture.', 'success'); + // Generate activation message based on enabled operations + let activationMessage = 'Selector activated! Hover over elements and click to capture.'; + + if (saveToPcToggle.checked && clipboardToggle.checked) { + activationMessage = 'Selector activated! Screenshots will be saved and copied to clipboard.'; + } else if (saveToPcToggle.checked && !clipboardToggle.checked) { + activationMessage = 'Selector activated! Screenshots will be saved to downloads folder.'; + } else if (!saveToPcToggle.checked && clipboardToggle.checked) { + activationMessage = 'Selector activated! Screenshots will be copied to clipboard only.'; + } + + showStatus(activationMessage, 'success'); toggleButtons(true); // Auto-close popup after starting @@ -45,11 +105,27 @@ document.addEventListener('DOMContentLoaded', async () => { } catch (error) { console.error('Full error details:', error); + + let errorMessage = 'Error: Could not activate on this page.'; + if (error.message.includes('Could not establish connection')) { - showStatus('Error: Content script not loaded. Try refreshing the page.', 'error'); - } else { - showStatus('Error: Could not activate on this page.', 'error'); + errorMessage = 'Error: Content script not loaded. Try refreshing the page and try again.'; + } else if (error.message.includes('permission')) { + errorMessage = 'Error: Permission denied. Check if the page allows extensions.'; + } else if (error.message.includes('protocol')) { + errorMessage = 'Error: Cannot capture screenshots on this page type (chrome://, file://, etc.).'; } + + // Add context about what operations were attempted + if (saveToPcToggle.checked && clipboardToggle.checked) { + errorMessage += ' Neither file download nor clipboard copy could be set up.'; + } else if (saveToPcToggle.checked && !clipboardToggle.checked) { + errorMessage += ' File download could not be set up.'; + } else if (!saveToPcToggle.checked && clipboardToggle.checked) { + errorMessage += ' Clipboard copy could not be set up.'; + } + + showStatus(errorMessage, 'error'); } }); @@ -69,6 +145,8 @@ document.addEventListener('DOMContentLoaded', async () => { // Check current state checkSelectorState(); + // Tooltips removed + function toggleButtons(isActive) { if (isActive) { startBtn.style.display = 'none'; @@ -111,6 +189,8 @@ document.addEventListener('DOMContentLoaded', async () => { console.log('Content script not ready or selector inactive:', error.message); } } + + // Tooltips removed: no setup needed }); // Listen for messages from content script @@ -119,15 +199,44 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { const startBtn = document.getElementById('start-selector'); const stopBtn = document.getElementById('stop-selector'); const status = document.getElementById('status'); + const saveToPcToggle = document.getElementById('save-to-pc-toggle'); + const clipboardToggle = document.getElementById('clipboard-toggle'); startBtn.style.display = 'block'; stopBtn.style.display = 'none'; if (message.reason === 'screenshot-taken') { - status.textContent = 'Screenshot captured!'; + // Generate success message based on current toggle states + let successMessage = 'Screenshot captured!'; + + if (saveToPcToggle.checked && clipboardToggle.checked) { + successMessage = 'Screenshot saved and copied to clipboard!'; + } else if (saveToPcToggle.checked && !clipboardToggle.checked) { + successMessage = 'Screenshot saved to downloads folder!'; + } else if (!saveToPcToggle.checked && clipboardToggle.checked) { + successMessage = 'Screenshot copied to clipboard!'; + } + + status.textContent = successMessage; status.className = 'status success'; status.style.display = 'block'; setTimeout(() => status.style.display = 'none', 2000); + } else if (message.reason === 'screenshot-failed') { + // Handle screenshot failure + let failureMessage = 'Screenshot capture failed. Please try again.'; + + if (saveToPcToggle.checked && clipboardToggle.checked) { + failureMessage = 'Screenshot capture failed. Neither file download nor clipboard copy could be completed.'; + } else if (saveToPcToggle.checked && !clipboardToggle.checked) { + failureMessage = 'Screenshot capture failed. File download could not be completed.'; + } else if (!saveToPcToggle.checked && clipboardToggle.checked) { + failureMessage = 'Screenshot capture failed. Clipboard copy could not be completed.'; + } + + status.textContent = failureMessage; + status.className = 'status error'; + status.style.display = 'block'; + setTimeout(() => status.style.display = 'none', 3000); } } }); \ No newline at end of file diff --git a/tests/clipboard-functionality.test.js b/tests/clipboard-functionality.test.js new file mode 100644 index 0000000..ffdf9bd --- /dev/null +++ b/tests/clipboard-functionality.test.js @@ -0,0 +1,480 @@ +/** + * Comprehensive test suite for clipboard functionality + * Tests clipboard API availability detection, operations with different backgrounds, + * error handling, and preference persistence + */ + +// Mock ScreenshotSelector class for testing +class MockScreenshotSelector { + constructor() { + this.copyToClipboard = true; + this.background = 'black'; + } + + checkClipboardAPIAvailability() { + if (!global.navigator.clipboard) { + return { available: false, reason: 'Clipboard API not available in this browser' }; + } + if (!global.window.ClipboardItem) { + return { available: false, reason: 'ClipboardItem not supported in this browser' }; + } + if (!global.window.isSecureContext) { + return { available: false, reason: 'Clipboard API requires a secure context (HTTPS)' }; + } + if (!global.navigator.clipboard.write) { + return { available: false, reason: 'Clipboard write functionality not available' }; + } + return { available: true }; + } + + async copyCanvasToClipboard(canvas) { + try { + const availability = this.checkClipboardAPIAvailability(); + if (!availability.available) { + return { success: false, error: availability.reason, errorType: 'api_unavailable' }; + } + + const blob = await Promise.race([ + new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + if (blob) resolve(blob); + else reject(new Error('Canvas to blob conversion returned null')); + }, 'image/png'); + }), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Canvas to blob conversion timed out')), 10000) + ) + ]); + + const maxBlobSize = 50 * 1024 * 1024; + if (blob.size > maxBlobSize) { + return { + success: false, + error: `Image too large for clipboard (${Math.round(blob.size / 1024 / 1024)}MB). Try capturing a smaller area.`, + errorType: 'size_limit' + }; + } + + let clipboardItem; + try { + clipboardItem = new global.window.ClipboardItem({ 'image/png': blob }); + } catch (clipboardItemError) { + return { + success: false, + error: 'Failed to create clipboard item. Your browser may not support image clipboard operations.', + errorType: 'clipboard_item_creation' + }; + } + + await Promise.race([ + global.navigator.clipboard.write([clipboardItem]), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Clipboard write timed out')), 5000) + ) + ]); + + return { success: true }; + } catch (error) { + if (error.name === 'NotAllowedError') { + return { success: false, error: 'Permission denied', errorType: 'permission_denied' }; + } else if (error.name === 'SecurityError') { + return { success: false, error: 'Security error', errorType: 'security_error' }; + } else if (error.message.includes('Canvas to blob conversion returned null')) { + return { success: false, error: error.message, errorType: 'canvas_conversion' }; + } else if (error.message.includes('ClipboardItem creation failed')) { + return { success: false, error: error.message, errorType: 'clipboard_item_creation' }; + } else if (error.message.includes('timed out')) { + return { success: false, error: error.message, errorType: 'timeout' }; + } + return { success: false, error: error.message, errorType: 'unexpected' }; + } + } + + getClipboardErrorMessage(clipboardResult) { + if (!clipboardResult || !clipboardResult.error) { + return 'Clipboard copy failed due to unknown error.'; + } + + const errorType = clipboardResult.errorType; + const errorMessage = clipboardResult.error; + + switch (errorType) { + case 'permission_denied': + return 'Clipboard copy failed: Permission denied. Please allow clipboard access in your browser settings.'; + case 'not_supported': + case 'api_unavailable': + return 'Clipboard copy not available in this browser. Screenshot was still downloaded.'; + case 'security_error': + return 'Clipboard copy blocked by browser security. Try using HTTPS or check site permissions.'; + case 'size_limit': + case 'quota_exceeded': + return 'Clipboard copy failed: Image too large. Try capturing a smaller area.'; + case 'timeout': + return 'Clipboard copy timed out. The image may be too large or complex.'; + default: + return `Clipboard copy failed: ${errorMessage}`; + } + } + + getClipboardSupportFeedback() { + const availability = this.checkClipboardAPIAvailability(); + + if (availability.available) { + return { + supported: true, + message: 'Clipboard copying is available and ready to use.' + }; + } + + let userMessage = ''; + if (availability.reason.includes('not available') || availability.reason.includes('not supported')) { + userMessage = 'Your browser doesn\'t support clipboard copying. Screenshots will still be downloaded.'; + } else if (availability.reason.includes('secure context')) { + userMessage = 'Clipboard copying requires HTTPS. Screenshots will still be downloaded.'; + } else if (availability.reason.includes('write functionality')) { + userMessage = 'Clipboard writing is not available. Screenshots will still be downloaded.'; + } else { + userMessage = 'Clipboard copying is not available. Screenshots will still be downloaded.'; + } + + return { + supported: false, + message: userMessage + }; + } +} + +describe('Clipboard API Availability Detection', () => { + let screenshotSelector; + + beforeEach(() => { + jest.clearAllMocks(); + screenshotSelector = new MockScreenshotSelector(); + }); + + test('should detect clipboard API availability when all APIs are present', () => { + global.navigator = { + clipboard: { + write: jest.fn() + } + }; + global.window.ClipboardItem = jest.fn(); + global.window.isSecureContext = true; + + const result = screenshotSelector.checkClipboardAPIAvailability(); + + expect(result.available).toBe(true); + expect(result.reason).toBeUndefined(); + }); + + test('should detect missing navigator.clipboard', () => { + global.navigator = {}; + + const result = screenshotSelector.checkClipboardAPIAvailability(); + + expect(result.available).toBe(false); + expect(result.reason).toBe('Clipboard API not available in this browser'); + }); + + test('should detect missing ClipboardItem', () => { + global.navigator = { + clipboard: { write: jest.fn() } + }; + global.window.ClipboardItem = undefined; + + const result = screenshotSelector.checkClipboardAPIAvailability(); + + expect(result.available).toBe(false); + expect(result.reason).toBe('ClipboardItem not supported in this browser'); + }); + + test('should detect insecure context', () => { + global.navigator = { + clipboard: { write: jest.fn() } + }; + global.window.ClipboardItem = jest.fn(); + global.window.isSecureContext = false; + + const result = screenshotSelector.checkClipboardAPIAvailability(); + + expect(result.available).toBe(false); + expect(result.reason).toBe('Clipboard API requires a secure context (HTTPS)'); + }); + + test('should detect missing clipboard.write', () => { + global.navigator = { + clipboard: {} + }; + global.window.ClipboardItem = jest.fn(); + global.window.isSecureContext = true; + + const result = screenshotSelector.checkClipboardAPIAvailability(); + + expect(result.available).toBe(false); + expect(result.reason).toBe('Clipboard write functionality not available'); + }); +}); + +describe('Clipboard Operations with Different Background Settings', () => { + let screenshotSelector; + let mockCanvas; + + beforeEach(() => { + jest.clearAllMocks(); + + global.navigator = { + clipboard: { + write: jest.fn().mockResolvedValue() + } + }; + global.window.ClipboardItem = jest.fn(); + global.window.isSecureContext = true; + + mockCanvas = { + toBlob: jest.fn((callback, format) => { + const mockBlob = new Blob(['mock image data'], { type: format }); + callback(mockBlob); + }), + toDataURL: jest.fn(() => 'data:image/png;base64,mockdata') + }; + + screenshotSelector = new MockScreenshotSelector(); + }); + + test('should copy to clipboard with transparent background', async () => { + screenshotSelector.background = 'transparent'; + screenshotSelector.copyToClipboard = true; + + const result = await screenshotSelector.copyCanvasToClipboard(mockCanvas); + + expect(result.success).toBe(true); + expect(mockCanvas.toBlob).toHaveBeenCalledWith(expect.any(Function), 'image/png'); + expect(global.navigator.clipboard.write).toHaveBeenCalled(); + }); + + test('should copy to clipboard with black background', async () => { + screenshotSelector.background = 'black'; + screenshotSelector.copyToClipboard = true; + + const result = await screenshotSelector.copyCanvasToClipboard(mockCanvas); + + expect(result.success).toBe(true); + expect(mockCanvas.toBlob).toHaveBeenCalledWith(expect.any(Function), 'image/png'); + expect(global.navigator.clipboard.write).toHaveBeenCalled(); + }); + + test('should copy to clipboard with white background', async () => { + screenshotSelector.background = 'white'; + screenshotSelector.copyToClipboard = true; + + const result = await screenshotSelector.copyCanvasToClipboard(mockCanvas); + + expect(result.success).toBe(true); + expect(mockCanvas.toBlob).toHaveBeenCalledWith(expect.any(Function), 'image/png'); + expect(global.navigator.clipboard.write).toHaveBeenCalled(); + }); + + test('should handle canvas to blob conversion failure', async () => { + mockCanvas.toBlob = jest.fn((callback) => { + callback(null); + }); + + const result = await screenshotSelector.copyCanvasToClipboard(mockCanvas); + + expect(result.success).toBe(false); + expect(result.error).toBe('Canvas to blob conversion returned null'); + expect(result.errorType).toBe('canvas_conversion'); + }); + + test('should handle large blob size limit', async () => { + const largeMockBlob = { + size: 60 * 1024 * 1024, + type: 'image/png' + }; + + mockCanvas.toBlob = jest.fn((callback) => { + callback(largeMockBlob); + }); + + const result = await screenshotSelector.copyCanvasToClipboard(mockCanvas); + + expect(result.success).toBe(false); + expect(result.error).toContain('Image too large for clipboard'); + expect(result.errorType).toBe('size_limit'); + }); +}); + +describe('Error Handling for Clipboard Operations', () => { + let screenshotSelector; + let mockCanvas; + + beforeEach(() => { + jest.clearAllMocks(); + + mockCanvas = { + toBlob: jest.fn((callback) => { + const mockBlob = new Blob(['mock data'], { type: 'image/png' }); + callback(mockBlob); + }) + }; + + screenshotSelector = new MockScreenshotSelector(); + }); + + test('should handle permission denied errors', async () => { + global.navigator = { + clipboard: { + write: jest.fn().mockRejectedValue(new DOMException('Permission denied', 'NotAllowedError')) + } + }; + global.window.ClipboardItem = jest.fn(); + global.window.isSecureContext = true; + + const result = await screenshotSelector.copyCanvasToClipboard(mockCanvas); + + expect(result.success).toBe(false); + expect(result.errorType).toBe('permission_denied'); + expect(result.error).toContain('Permission denied'); + }); + + test('should handle security errors', async () => { + global.navigator = { + clipboard: { + write: jest.fn().mockRejectedValue(new DOMException('Security error', 'SecurityError')) + } + }; + global.window.ClipboardItem = jest.fn(); + global.window.isSecureContext = true; + + const result = await screenshotSelector.copyCanvasToClipboard(mockCanvas); + + expect(result.success).toBe(false); + expect(result.errorType).toBe('security_error'); + }); + + test('should handle ClipboardItem creation failure', async () => { + global.navigator = { + clipboard: { write: jest.fn() } + }; + global.window.ClipboardItem = jest.fn(() => { + throw new Error('ClipboardItem creation failed'); + }); + global.window.isSecureContext = true; + + const result = await screenshotSelector.copyCanvasToClipboard(mockCanvas); + + expect(result.success).toBe(false); + expect(result.errorType).toBe('clipboard_item_creation'); + }); +}); + +describe('User-Friendly Error Messages', () => { + let screenshotSelector; + + beforeEach(() => { + screenshotSelector = new MockScreenshotSelector(); + }); + + test('should provide user-friendly message for permission denied', () => { + const clipboardResult = { + success: false, + error: 'Permission denied', + errorType: 'permission_denied' + }; + + const message = screenshotSelector.getClipboardErrorMessage(clipboardResult); + + expect(message).toBe('Clipboard copy failed: Permission denied. Please allow clipboard access in your browser settings.'); + }); + + test('should provide user-friendly message for API unavailable', () => { + const clipboardResult = { + success: false, + error: 'API not supported', + errorType: 'api_unavailable' + }; + + const message = screenshotSelector.getClipboardErrorMessage(clipboardResult); + + expect(message).toBe('Clipboard copy not available in this browser. Screenshot was still downloaded.'); + }); + + test('should provide user-friendly message for size limit', () => { + const clipboardResult = { + success: false, + error: 'Image too large', + errorType: 'size_limit' + }; + + const message = screenshotSelector.getClipboardErrorMessage(clipboardResult); + + expect(message).toBe('Clipboard copy failed: Image too large. Try capturing a smaller area.'); + }); + + test('should provide user-friendly message for timeout', () => { + const clipboardResult = { + success: false, + error: 'Operation timed out', + errorType: 'timeout' + }; + + const message = screenshotSelector.getClipboardErrorMessage(clipboardResult); + + expect(message).toBe('Clipboard copy timed out. The image may be too large or complex.'); + }); + + test('should handle unknown error types gracefully', () => { + const clipboardResult = { + success: false, + error: 'Unknown error occurred', + errorType: 'unknown' + }; + + const message = screenshotSelector.getClipboardErrorMessage(clipboardResult); + + expect(message).toBe('Clipboard copy failed: Unknown error occurred'); + }); +}); + +describe('Clipboard Support Feedback', () => { + let screenshotSelector; + + beforeEach(() => { + screenshotSelector = new MockScreenshotSelector(); + }); + + test('should provide positive feedback when clipboard is supported', () => { + global.navigator = { + clipboard: { write: jest.fn() } + }; + global.window.ClipboardItem = jest.fn(); + global.window.isSecureContext = true; + + const feedback = screenshotSelector.getClipboardSupportFeedback(); + + expect(feedback.supported).toBe(true); + expect(feedback.message).toBe('Clipboard copying is available and ready to use.'); + }); + + test('should provide helpful feedback when clipboard is not supported', () => { + global.navigator = {}; + + const feedback = screenshotSelector.getClipboardSupportFeedback(); + + expect(feedback.supported).toBe(false); + expect(feedback.message).toBe('Your browser doesn\'t support clipboard copying. Screenshots will still be downloaded.'); + }); + + test('should provide HTTPS requirement feedback', () => { + global.navigator = { + clipboard: { write: jest.fn() } + }; + global.window.ClipboardItem = jest.fn(); + global.window.isSecureContext = false; + + const feedback = screenshotSelector.getClipboardSupportFeedback(); + + expect(feedback.supported).toBe(false); + expect(feedback.message).toBe('Clipboard copying requires HTTPS. Screenshots will still be downloaded.'); + }); +}); \ No newline at end of file diff --git a/tests/integration-scenarios.test.js b/tests/integration-scenarios.test.js new file mode 100644 index 0000000..e32a8c1 --- /dev/null +++ b/tests/integration-scenarios.test.js @@ -0,0 +1,557 @@ +/** + * Integration test suite for clipboard functionality across different scenarios + * Tests end-to-end workflows combining clipboard operations with various settings + */ + +// Mock Chrome APIs +global.chrome = { + storage: { + sync: { + get: jest.fn(), + set: jest.fn() + } + }, + runtime: { + sendMessage: jest.fn() + }, + tabs: { + query: jest.fn(), + sendMessage: jest.fn() + } +}; + +// Mock DOM APIs +global.document = { + createElement: jest.fn(() => ({ + style: {}, + classList: { add: jest.fn(), remove: jest.fn() }, + appendChild: jest.fn(), + click: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + innerHTML: '', + textContent: '', + id: '', + className: '' + })), + head: { appendChild: jest.fn() }, + body: { appendChild: jest.fn() }, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + querySelectorAll: jest.fn(() => []), + getElementById: jest.fn() +}; + +global.window = { + getComputedStyle: jest.fn(() => ({})), + devicePixelRatio: 2, + isSecureContext: true, + CSS: { escape: jest.fn(str => str) }, + setTimeout: jest.fn((fn, delay) => { + if (delay === 0) fn(); + return 1; + }) +}; + +// Mock html2canvas +global.html2canvas = jest.fn(); + +describe('Integration Scenarios - Clipboard with Different Backgrounds', () => { + let screenshotSelector; + let mockElement; + let mockCanvas; + + beforeEach(() => { + jest.clearAllMocks(); + + // Reset clipboard mock for each test + if (!global.navigator) { + global.navigator = {}; + } + global.navigator.clipboard = { + write: jest.fn().mockResolvedValue() + }; + global.window.ClipboardItem = jest.fn().mockImplementation((data) => { + return { data, type: 'image/png' }; + }); + global.window.isSecureContext = true; + + // Mock element to capture + mockElement = { + classList: { + add: jest.fn(), + remove: jest.fn() + }, + style: {}, + getBoundingClientRect: () => ({ left: 0, top: 0, width: 100, height: 100 }), + scrollHeight: 100, + clientHeight: 100, + scrollWidth: 100, + clientWidth: 100, + children: [], + parentNode: { children: [] } + }; + + // Mock canvas + mockCanvas = { + toBlob: jest.fn((callback, format) => { + const mockBlob = new Blob(['mock image data'], { type: format }); + Object.defineProperty(mockBlob, 'size', { value: 1024 }); // 1KB + callback(mockBlob); + }), + toDataURL: jest.fn(() => 'data:image/png;base64,mockdata') + }; + + global.html2canvas = jest.fn().mockResolvedValue(mockCanvas); + + + + // Import ScreenshotSelector (simplified for testing) + const ScreenshotSelector = class { + constructor() { + this.isActive = false; + this.background = 'black'; + this.copyToClipboard = true; + this.saveToPc = true; + this.overlay = null; + } + + async init(background = 'black', copyToClipboard = true, saveToPc = true) { + this.background = background; + this.copyToClipboard = copyToClipboard; + this.saveToPc = saveToPc; + this.isActive = true; + this.createUI(); + } + + createUI() { + this.overlay = global.document.createElement('div'); + global.document.body.appendChild(this.overlay); + } + + checkClipboardAPIAvailability() { + if (!global.navigator.clipboard) { + return { available: false, reason: 'Clipboard API not available in this browser' }; + } + if (!global.window.ClipboardItem) { + return { available: false, reason: 'ClipboardItem not supported in this browser' }; + } + if (!global.window.isSecureContext) { + return { available: false, reason: 'Clipboard API requires a secure context (HTTPS)' }; + } + if (!global.navigator.clipboard.write) { + return { available: false, reason: 'Clipboard write functionality not available' }; + } + return { available: true }; + } + + async copyCanvasToClipboard(canvas) { + try { + const availability = this.checkClipboardAPIAvailability(); + if (!availability.available) { + return { success: false, error: availability.reason, errorType: 'api_unavailable' }; + } + + const blob = await new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + if (blob) resolve(blob); + else reject(new Error('Canvas to blob conversion returned null')); + }, 'image/png'); + }); + + const maxBlobSize = 50 * 1024 * 1024; + if (blob.size > maxBlobSize) { + return { + success: false, + error: `Image too large for clipboard (${Math.round(blob.size / 1024 / 1024)}MB)`, + errorType: 'size_limit' + }; + } + + const clipboardItem = new global.window.ClipboardItem({ 'image/png': blob }); + await global.navigator.clipboard.write([clipboardItem]); + + return { success: true }; + } catch (error) { + if (error.name === 'NotAllowedError') { + return { success: false, error: 'Permission denied', errorType: 'permission_denied' }; + } + return { success: false, error: error.message, errorType: 'unexpected' }; + } + } + + async captureElement(element) { + try { + // Generate canvas with background-specific settings + const canvas = await global.html2canvas(element, { + backgroundColor: this.background === 'transparent' ? null : + this.background === 'white' ? '#ffffff' : '#000000', + useCORS: true, + allowTaint: false + }); + + // Always download first + const link = global.document.createElement('a'); + link.href = canvas.toDataURL('image/png'); + link.download = `screenshot-${new Date().toISOString().slice(0, 19).replace(/[:.]/g, '-')}.png`; + link.click(); + + // Handle clipboard if enabled + let clipboardResult = null; + if (this.copyToClipboard) { + clipboardResult = await this.copyCanvasToClipboard(canvas); + } + + // Update UI based on results (matching new message format) + let successMessage = 'Screenshot captured!'; + let messageIcon = '✅'; + + if (this.saveToPc && this.copyToClipboard) { + // Both operations enabled + if (clipboardResult && clipboardResult.success) { + successMessage = 'Screenshot saved and copied to clipboard!'; + messageIcon = '✅'; + } else { + // Save succeeded but clipboard failed + const errorMessage = clipboardResult ? clipboardResult.error.toLowerCase() : 'clipboard copy failed'; + successMessage = `Screenshot saved successfully! However, ${errorMessage}`; + messageIcon = '⚠️'; + } + } else if (this.saveToPc && !this.copyToClipboard) { + // Only save to PC enabled + successMessage = 'Screenshot saved to downloads folder!'; + messageIcon = '💾'; + } else if (!this.saveToPc && this.copyToClipboard) { + // Only clipboard enabled + if (clipboardResult && clipboardResult.success) { + successMessage = 'Screenshot copied to clipboard!'; + messageIcon = '📋'; + } else { + // Clipboard failed - this is a complete failure since no other output method is enabled + const errorMessage = clipboardResult ? clipboardResult.error : 'clipboard copy failed'; + successMessage = `Screenshot capture failed: ${errorMessage}`; + messageIcon = '❌'; + } + } + + this.overlay.innerHTML = `
${successMessage}
`; + + return { + success: true, + downloadSuccess: true, + clipboardResult: clipboardResult, + message: successMessage + }; + + } catch (error) { + this.overlay.innerHTML = `
Screenshot failed: ${error.message}
`; + return { + success: false, + error: error.message + }; + } + } + }; + + screenshotSelector = new ScreenshotSelector(); + }); + + test('should capture with transparent background and copy to clipboard', async () => { + await screenshotSelector.init('transparent', true); + + const result = await screenshotSelector.captureElement(mockElement); + + expect(global.html2canvas).toHaveBeenCalledWith(mockElement, expect.objectContaining({ + backgroundColor: null, // Transparent background + useCORS: true, + allowTaint: false + })); + + expect(result.success).toBe(true); + expect(result.downloadSuccess).toBe(true); + expect(result.clipboardResult.success).toBe(true); + expect(result.message).toBe('Screenshot saved and copied to clipboard!'); + }); + + test('should capture with black background and copy to clipboard', async () => { + await screenshotSelector.init('black', true); + + const result = await screenshotSelector.captureElement(mockElement); + + expect(global.html2canvas).toHaveBeenCalledWith(mockElement, expect.objectContaining({ + backgroundColor: '#000000', // Black background + useCORS: true, + allowTaint: false + })); + + expect(result.success).toBe(true); + expect(result.clipboardResult.success).toBe(true); + expect(result.message).toBe('Screenshot saved and copied to clipboard!'); + }); + + test('should capture with white background and copy to clipboard', async () => { + await screenshotSelector.init('white', true); + + const result = await screenshotSelector.captureElement(mockElement); + + expect(global.html2canvas).toHaveBeenCalledWith(mockElement, expect.objectContaining({ + backgroundColor: '#ffffff', // White background + useCORS: true, + allowTaint: false + })); + + expect(result.success).toBe(true); + expect(result.clipboardResult.success).toBe(true); + expect(result.message).toBe('Screenshot saved and copied to clipboard!'); + }); + + test('should capture without clipboard when disabled', async () => { + await screenshotSelector.init('black', false, true); // saveToPc = true, copyToClipboard = false + + const result = await screenshotSelector.captureElement(mockElement); + + expect(result.success).toBe(true); + expect(result.downloadSuccess).toBe(true); + expect(result.clipboardResult).toBe(null); // No clipboard operation attempted + expect(result.message).toBe('Screenshot saved to downloads folder!'); + if (global.navigator.clipboard?.write) { + expect(global.navigator.clipboard.write).not.toHaveBeenCalled(); + } + }); + + test('should handle clipboard failure gracefully while maintaining download', async () => { + // Reset and mock clipboard failure + global.navigator.clipboard = { + write: jest.fn().mockRejectedValue(new DOMException('Permission denied', 'NotAllowedError')) + }; + + await screenshotSelector.init('black', true); + + const result = await screenshotSelector.captureElement(mockElement); + + expect(result.success).toBe(true); + expect(result.downloadSuccess).toBe(true); + expect(result.clipboardResult.success).toBe(false); + expect(result.clipboardResult.errorType).toBe('permission_denied'); + expect(result.message).toContain('Screenshot saved successfully!'); + expect(result.message).toContain('permission denied'); + }); + + test('should handle clipboard API unavailable scenario', async () => { + // Mock clipboard API not available + global.navigator.clipboard = undefined; + + await screenshotSelector.init('transparent', true); + + const result = await screenshotSelector.captureElement(mockElement); + + expect(result.success).toBe(true); + expect(result.downloadSuccess).toBe(true); + expect(result.clipboardResult.success).toBe(false); + expect(result.clipboardResult.errorType).toBe('api_unavailable'); + expect(result.message).toContain('Screenshot saved successfully!'); + expect(result.message).toContain('clipboard'); + }); +}); + +describe('Integration Scenarios - Complete Workflow', () => { + let mockPopupElements; + + beforeEach(() => { + jest.clearAllMocks(); + + // Mock popup elements + mockPopupElements = { + clipboardToggle: { checked: true, addEventListener: jest.fn() }, + backgroundSelect: { value: 'black', addEventListener: jest.fn() }, + startButton: { addEventListener: jest.fn(), style: { display: 'block' } }, + status: { textContent: '', className: '', style: { display: 'none' } } + }; + + global.document.getElementById = jest.fn((id) => { + switch (id) { + case 'clipboard-toggle': return mockPopupElements.clipboardToggle; + case 'background-select': return mockPopupElements.backgroundSelect; + case 'start-selector': return mockPopupElements.startButton; + case 'status': return mockPopupElements.status; + default: return null; + } + }); + + // Mock Chrome APIs + global.chrome.storage.sync.get.mockResolvedValue({ + backgroundPreference: 'black', + copyToClipboard: true + }); + global.chrome.tabs.query.mockResolvedValue([{ id: 123, url: 'https://example.com' }]); + global.chrome.tabs.sendMessage.mockResolvedValue(); + }); + + test('should complete full workflow from popup to content script', async () => { + let startButtonCallback; + mockPopupElements.startButton.addEventListener = jest.fn((event, callback) => { + if (event === 'click') startButtonCallback = callback; + }); + + // Simulate popup initialization + const popupScript = ` + document.addEventListener('DOMContentLoaded', async () => { + const backgroundSelect = document.getElementById('background-select'); + const clipboardToggle = document.getElementById('clipboard-toggle'); + const startBtn = document.getElementById('start-selector'); + + // Load preferences + const saved = await chrome.storage.sync.get(['backgroundPreference', 'copyToClipboard']); + if (saved.backgroundPreference) { + backgroundSelect.value = saved.backgroundPreference; + } + clipboardToggle.checked = saved.copyToClipboard !== undefined ? saved.copyToClipboard : true; + + // Start selector + startBtn.addEventListener('click', async () => { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + + await chrome.tabs.sendMessage(tab.id, { + action: 'startSelector', + background: backgroundSelect.value, + copyToClipboard: clipboardToggle.checked + }); + }); + }); + `; + + // Execute popup initialization + await new Promise(resolve => { + global.document.addEventListener = jest.fn((event, callback) => { + if (event === 'DOMContentLoaded') { + callback(); + resolve(); + } + }); + eval(popupScript); + }); + + // Verify preferences were loaded + expect(mockPopupElements.backgroundSelect.value).toBe('black'); + expect(mockPopupElements.clipboardToggle.checked).toBe(true); + + // Simulate start button click + await startButtonCallback(); + + // Verify message was sent to content script with correct parameters + expect(global.chrome.tabs.sendMessage).toHaveBeenCalledWith(123, { + action: 'startSelector', + background: 'black', + copyToClipboard: true + }); + }); + + test('should handle preference changes and persist them', async () => { + let clipboardChangeCallback; + let backgroundChangeCallback; + + mockPopupElements.clipboardToggle.addEventListener = jest.fn((event, callback) => { + if (event === 'change') clipboardChangeCallback = callback; + }); + + mockPopupElements.backgroundSelect.addEventListener = jest.fn((event, callback) => { + if (event === 'change') backgroundChangeCallback = callback; + }); + + // Initialize popup + await new Promise(resolve => { + global.document.addEventListener = jest.fn((event, callback) => { + if (event === 'DOMContentLoaded') { + callback(); + resolve(); + } + }); + + const popupScript = ` + document.addEventListener('DOMContentLoaded', async () => { + const backgroundSelect = document.getElementById('background-select'); + const clipboardToggle = document.getElementById('clipboard-toggle'); + + backgroundSelect.addEventListener('change', () => { + chrome.storage.sync.set({ backgroundPreference: backgroundSelect.value }); + }); + + clipboardToggle.addEventListener('change', () => { + chrome.storage.sync.set({ copyToClipboard: clipboardToggle.checked }); + }); + }); + `; + eval(popupScript); + }); + + // Change background preference + mockPopupElements.backgroundSelect.value = 'transparent'; + backgroundChangeCallback(); + + expect(global.chrome.storage.sync.set).toHaveBeenCalledWith({ backgroundPreference: 'transparent' }); + + // Change clipboard preference + mockPopupElements.clipboardToggle.checked = false; + clipboardChangeCallback(); + + expect(global.chrome.storage.sync.set).toHaveBeenCalledWith({ copyToClipboard: false }); + }); + + test('should handle incompatible pages gracefully', async () => { + // Mock incompatible page + global.chrome.tabs.query.mockResolvedValue([{ + id: 123, + url: 'chrome://settings/' + }]); + + let startButtonCallback; + mockPopupElements.startButton.addEventListener = jest.fn((event, callback) => { + if (event === 'click') startButtonCallback = callback; + }); + + // Initialize popup with incompatible page handling + const popupScript = ` + document.addEventListener('DOMContentLoaded', async () => { + const startBtn = document.getElementById('start-selector'); + const status = document.getElementById('status'); + + startBtn.addEventListener('click', async () => { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + + if (tab.url.startsWith('chrome://') || + tab.url.startsWith('chrome-extension://') || + tab.url.startsWith('edge://') || + tab.url.startsWith('about:')) { + status.textContent = 'Error: Cannot capture screenshots on this page type.'; + status.className = 'status error'; + status.style.display = 'block'; + return; + } + + // Normal flow would continue here + }); + }); + `; + + await new Promise(resolve => { + global.document.addEventListener = jest.fn((event, callback) => { + if (event === 'DOMContentLoaded') { + callback(); + resolve(); + } + }); + eval(popupScript); + }); + + // Simulate start button click on incompatible page + await startButtonCallback(); + + // Verify error message was shown + expect(mockPopupElements.status.textContent).toBe('Error: Cannot capture screenshots on this page type.'); + expect(mockPopupElements.status.className).toBe('status error'); + expect(mockPopupElements.status.style.display).toBe('block'); + + // Verify no message was sent to content script + expect(global.chrome.tabs.sendMessage).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/tests/preference-persistence.test.js b/tests/preference-persistence.test.js new file mode 100644 index 0000000..6db7941 --- /dev/null +++ b/tests/preference-persistence.test.js @@ -0,0 +1,414 @@ +/** + * Test suite for clipboard preference persistence across browser sessions + * Tests storage, retrieval, and default behavior of clipboard preferences + */ + +// Mock Chrome APIs +global.chrome = { + storage: { + sync: { + get: jest.fn(), + set: jest.fn() + } + }, + runtime: { + sendMessage: jest.fn() + }, + tabs: { + query: jest.fn(), + sendMessage: jest.fn() + } +}; + +// Mock DOM APIs +global.document = { + createElement: jest.fn(() => ({ + style: {}, + classList: { add: jest.fn(), remove: jest.fn() }, + appendChild: jest.fn(), + click: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + id: '', + checked: false, + value: '' + })), + head: { appendChild: jest.fn() }, + body: { appendChild: jest.fn() }, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + querySelectorAll: jest.fn(() => []), + getElementById: jest.fn() +}; + +global.window = { + close: jest.fn(), + setTimeout: jest.fn((fn) => fn()) +}; + +describe('Clipboard Preference Persistence', () => { + let mockClipboardToggle; + let mockBackgroundSelect; + + beforeEach(() => { + jest.clearAllMocks(); + + // Mock DOM elements + mockClipboardToggle = { + checked: false, + addEventListener: jest.fn() + }; + + mockBackgroundSelect = { + value: 'black', + addEventListener: jest.fn() + }; + + global.document.getElementById = jest.fn((id) => { + switch (id) { + case 'clipboard-toggle': + return mockClipboardToggle; + case 'background-select': + return mockBackgroundSelect; + case 'start-selector': + return { addEventListener: jest.fn(), style: { display: 'block' } }; + case 'stop-selector': + return { addEventListener: jest.fn(), style: { display: 'none' } }; + case 'status': + return { textContent: '', className: '', style: { display: 'none' } }; + default: + return null; + } + }); + }); + + test('should load default clipboard preference when no saved preference exists', async () => { + // Mock storage returning no saved preferences + global.chrome.storage.sync.get.mockResolvedValue({}); + + // Simulate popup script loading + const popupScript = ` + document.addEventListener('DOMContentLoaded', async () => { + const clipboardToggle = document.getElementById('clipboard-toggle'); + const saved = await chrome.storage.sync.get(['backgroundPreference', 'copyToClipboard']); + clipboardToggle.checked = saved.copyToClipboard !== undefined ? saved.copyToClipboard : true; + }); + `; + + // Execute the popup script logic + await new Promise(resolve => { + global.document.addEventListener = jest.fn((event, callback) => { + if (event === 'DOMContentLoaded') { + callback(); + resolve(); + } + }); + eval(popupScript); + }); + + expect(global.chrome.storage.sync.get).toHaveBeenCalledWith(['backgroundPreference', 'copyToClipboard']); + expect(mockClipboardToggle.checked).toBe(true); // Default should be true + }); + + test('should load saved clipboard preference when it exists', async () => { + // Mock storage returning saved preferences + global.chrome.storage.sync.get.mockResolvedValue({ + backgroundPreference: 'white', + copyToClipboard: false + }); + + // Simulate popup script loading + const popupScript = ` + document.addEventListener('DOMContentLoaded', async () => { + const clipboardToggle = document.getElementById('clipboard-toggle'); + const saved = await chrome.storage.sync.get(['backgroundPreference', 'copyToClipboard']); + clipboardToggle.checked = saved.copyToClipboard !== undefined ? saved.copyToClipboard : true; + }); + `; + + // Execute the popup script logic + await new Promise(resolve => { + global.document.addEventListener = jest.fn((event, callback) => { + if (event === 'DOMContentLoaded') { + callback(); + resolve(); + } + }); + eval(popupScript); + }); + + expect(mockClipboardToggle.checked).toBe(false); // Should load saved value + }); + + test('should save clipboard preference when toggle is changed', async () => { + let changeCallback; + mockClipboardToggle.addEventListener = jest.fn((event, callback) => { + if (event === 'change') { + changeCallback = callback; + } + }); + + // Simulate popup script initialization + const popupScript = ` + document.addEventListener('DOMContentLoaded', async () => { + const clipboardToggle = document.getElementById('clipboard-toggle'); + clipboardToggle.addEventListener('change', () => { + chrome.storage.sync.set({ copyToClipboard: clipboardToggle.checked }); + }); + }); + `; + + // Execute the popup script logic + await new Promise(resolve => { + global.document.addEventListener = jest.fn((event, callback) => { + if (event === 'DOMContentLoaded') { + callback(); + resolve(); + } + }); + eval(popupScript); + }); + + // Simulate user toggling the checkbox + mockClipboardToggle.checked = true; + changeCallback(); + + expect(global.chrome.storage.sync.set).toHaveBeenCalledWith({ copyToClipboard: true }); + }); + + test('should persist clipboard preference across multiple sessions', async () => { + // Simulate first session - user enables clipboard + global.chrome.storage.sync.get.mockResolvedValueOnce({}); + + let firstSessionChangeCallback; + mockClipboardToggle.addEventListener = jest.fn((event, callback) => { + if (event === 'change') { + firstSessionChangeCallback = callback; + } + }); + + // First session initialization + await new Promise(resolve => { + global.document.addEventListener = jest.fn((event, callback) => { + if (event === 'DOMContentLoaded') { + callback(); + resolve(); + } + }); + + const popupScript = ` + document.addEventListener('DOMContentLoaded', async () => { + const clipboardToggle = document.getElementById('clipboard-toggle'); + const saved = await chrome.storage.sync.get(['backgroundPreference', 'copyToClipboard']); + clipboardToggle.checked = saved.copyToClipboard !== undefined ? saved.copyToClipboard : true; + + clipboardToggle.addEventListener('change', () => { + chrome.storage.sync.set({ copyToClipboard: clipboardToggle.checked }); + }); + }); + `; + eval(popupScript); + }); + + // User disables clipboard in first session + mockClipboardToggle.checked = false; + firstSessionChangeCallback(); + + expect(global.chrome.storage.sync.set).toHaveBeenCalledWith({ copyToClipboard: false }); + + // Simulate second session - should load the saved preference + jest.clearAllMocks(); + global.chrome.storage.sync.get.mockResolvedValueOnce({ + copyToClipboard: false + }); + + // Reset mock element + mockClipboardToggle.checked = true; // Reset to opposite of expected + + // Second session initialization + await new Promise(resolve => { + global.document.addEventListener = jest.fn((event, callback) => { + if (event === 'DOMContentLoaded') { + callback(); + resolve(); + } + }); + + const popupScript = ` + document.addEventListener('DOMContentLoaded', async () => { + const clipboardToggle = document.getElementById('clipboard-toggle'); + const saved = await chrome.storage.sync.get(['backgroundPreference', 'copyToClipboard']); + clipboardToggle.checked = saved.copyToClipboard !== undefined ? saved.copyToClipboard : true; + }); + `; + eval(popupScript); + }); + + expect(mockClipboardToggle.checked).toBe(false); // Should load saved preference from first session + }); + + test('should handle storage errors gracefully', async () => { + // Mock storage error + global.chrome.storage.sync.get.mockRejectedValue(new Error('Storage error')); + + let errorOccurred = false; + + try { + const popupScript = ` + document.addEventListener('DOMContentLoaded', async () => { + try { + const clipboardToggle = document.getElementById('clipboard-toggle'); + const saved = await chrome.storage.sync.get(['backgroundPreference', 'copyToClipboard']); + clipboardToggle.checked = saved.copyToClipboard !== undefined ? saved.copyToClipboard : true; + } catch (error) { + // Should fall back to default behavior + const clipboardToggle = document.getElementById('clipboard-toggle'); + clipboardToggle.checked = true; // Default value + } + }); + `; + + await new Promise(resolve => { + global.document.addEventListener = jest.fn((event, callback) => { + if (event === 'DOMContentLoaded') { + callback(); + resolve(); + } + }); + eval(popupScript); + }); + } catch (error) { + errorOccurred = true; + } + + expect(errorOccurred).toBe(false); // Should handle error gracefully + expect(mockClipboardToggle.checked).toBe(true); // Should fall back to default + }); + + test('should save both background and clipboard preferences independently', async () => { + let clipboardChangeCallback; + let backgroundChangeCallback; + + mockClipboardToggle.addEventListener = jest.fn((event, callback) => { + if (event === 'change') { + clipboardChangeCallback = callback; + } + }); + + mockBackgroundSelect.addEventListener = jest.fn((event, callback) => { + if (event === 'change') { + backgroundChangeCallback = callback; + } + }); + + // Initialize popup script + const popupScript = ` + document.addEventListener('DOMContentLoaded', async () => { + const backgroundSelect = document.getElementById('background-select'); + const clipboardToggle = document.getElementById('clipboard-toggle'); + + backgroundSelect.addEventListener('change', () => { + chrome.storage.sync.set({ backgroundPreference: backgroundSelect.value }); + }); + + clipboardToggle.addEventListener('change', () => { + chrome.storage.sync.set({ copyToClipboard: clipboardToggle.checked }); + }); + }); + `; + + await new Promise(resolve => { + global.document.addEventListener = jest.fn((event, callback) => { + if (event === 'DOMContentLoaded') { + callback(); + resolve(); + } + }); + eval(popupScript); + }); + + // Change background preference + mockBackgroundSelect.value = 'transparent'; + backgroundChangeCallback(); + + expect(global.chrome.storage.sync.set).toHaveBeenCalledWith({ backgroundPreference: 'transparent' }); + + // Change clipboard preference + mockClipboardToggle.checked = false; + clipboardChangeCallback(); + + expect(global.chrome.storage.sync.set).toHaveBeenCalledWith({ copyToClipboard: false }); + + // Verify both calls were made independently + expect(global.chrome.storage.sync.set).toHaveBeenCalledTimes(2); + }); + + test('should include clipboard preference in message to content script', async () => { + // Mock tab query and message sending + global.chrome.tabs.query.mockResolvedValue([{ id: 123, url: 'https://example.com' }]); + global.chrome.tabs.sendMessage.mockResolvedValue(); + + let startButtonCallback; + const mockStartButton = { + addEventListener: jest.fn((event, callback) => { + if (event === 'click') { + startButtonCallback = callback; + } + }), + style: { display: 'block' } + }; + + global.document.getElementById = jest.fn((id) => { + switch (id) { + case 'start-selector': + return mockStartButton; + case 'clipboard-toggle': + return { checked: true }; // Clipboard enabled + case 'background-select': + return { value: 'white' }; + case 'status': + return { textContent: '', className: '', style: { display: 'none' } }; + default: + return mockClipboardToggle; + } + }); + + // Initialize popup script + const popupScript = ` + document.addEventListener('DOMContentLoaded', async () => { + const startBtn = document.getElementById('start-selector'); + const backgroundSelect = document.getElementById('background-select'); + const clipboardToggle = document.getElementById('clipboard-toggle'); + + startBtn.addEventListener('click', async () => { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + + await chrome.tabs.sendMessage(tab.id, { + action: 'startSelector', + background: backgroundSelect.value, + copyToClipboard: clipboardToggle.checked + }); + }); + }); + `; + + await new Promise(resolve => { + global.document.addEventListener = jest.fn((event, callback) => { + if (event === 'DOMContentLoaded') { + callback(); + resolve(); + } + }); + eval(popupScript); + }); + + // Simulate start button click + await startButtonCallback(); + + expect(global.chrome.tabs.sendMessage).toHaveBeenCalledWith(123, { + action: 'startSelector', + background: 'white', + copyToClipboard: true + }); + }); +}); \ No newline at end of file diff --git a/tests/save-to-pc-clipboard-combinations.test.js b/tests/save-to-pc-clipboard-combinations.test.js new file mode 100644 index 0000000..4506c35 --- /dev/null +++ b/tests/save-to-pc-clipboard-combinations.test.js @@ -0,0 +1,641 @@ +/** + * Comprehensive test suite for all combinations of save to PC and clipboard settings + * Tests all four combinations: both enabled, save-only, clipboard-only, both disabled + * Includes preference persistence testing across browser sessions + */ + +// Mock Chrome APIs +global.chrome = { + storage: { + sync: { + get: jest.fn(), + set: jest.fn() + } + }, + runtime: { + sendMessage: jest.fn(), + onMessage: { + addListener: jest.fn() + } + }, + tabs: { + query: jest.fn(), + sendMessage: jest.fn() + } +}; + +// Mock DOM APIs +const mockCreateElement = jest.fn(); +const mockLink = { + href: '', + download: '', + click: jest.fn(), + style: {}, + classList: { add: jest.fn(), remove: jest.fn() } +}; + +global.document = { + createElement: mockCreateElement, + head: { appendChild: jest.fn() }, + body: { appendChild: jest.fn() }, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + querySelectorAll: jest.fn(() => []), + getElementById: jest.fn() +}; + +global.window = { + close: jest.fn(), + setTimeout: jest.fn((fn) => fn()), + getComputedStyle: jest.fn(() => ({})), + devicePixelRatio: 2, + isSecureContext: true, + CSS: { escape: jest.fn(str => str) } +}; + +// Mock html2canvas +global.html2canvas = jest.fn(); + +// Mock ScreenshotSelector class for testing +class MockScreenshotSelector { + constructor() { + this.copyToClipboard = true; + this.saveToPc = true; + this.background = 'black'; + this.isActive = false; + } + + async init(background = 'black', copyToClipboard = true, saveToPc = true) { + this.background = background; + this.copyToClipboard = copyToClipboard; + this.saveToPc = saveToPc; + this.isActive = true; + } + + checkClipboardAPIAvailability() { + if (!global.navigator.clipboard) { + return { available: false, reason: 'Clipboard API not available in this browser' }; + } + if (!global.window.ClipboardItem) { + return { available: false, reason: 'ClipboardItem not supported in this browser' }; + } + if (!global.window.isSecureContext) { + return { available: false, reason: 'Clipboard API requires a secure context (HTTPS)' }; + } + if (!global.navigator.clipboard.write) { + return { available: false, reason: 'Clipboard write functionality not available' }; + } + return { available: true }; + } + + async copyCanvasToClipboard(canvas) { + try { + const availability = this.checkClipboardAPIAvailability(); + if (!availability.available) { + return { success: false, error: availability.reason, errorType: 'api_unavailable' }; + } + + const blob = await new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + if (blob) resolve(blob); + else reject(new Error('Canvas to blob conversion returned null')); + }, 'image/png'); + }); + + const clipboardItem = new global.window.ClipboardItem({ 'image/png': blob }); + await global.navigator.clipboard.write([clipboardItem]); + + return { success: true }; + } catch (error) { + if (error.name === 'NotAllowedError') { + return { success: false, error: 'Permission denied', errorType: 'permission_denied' }; + } else if (error.name === 'SecurityError') { + return { success: false, error: 'Security error', errorType: 'security_error' }; + } + return { success: false, error: error.message, errorType: 'unexpected' }; + } + } + + async captureElement(element) { + // Mock canvas creation + const mockCanvas = { + toBlob: jest.fn((callback) => { + const mockBlob = new Blob(['mock data'], { type: 'image/png' }); + callback(mockBlob); + }), + toDataURL: jest.fn(() => 'data:image/png;base64,mockdata') + }; + + // Mock html2canvas + global.html2canvas.mockResolvedValue(mockCanvas); + + const canvas = await global.html2canvas(element); + + // Conditionally download based on saveToPc setting + if (this.saveToPc) { + const link = global.document.createElement('a'); + link.href = canvas.toDataURL('image/png'); + link.download = `screenshot-${new Date().toISOString().slice(0, 19).replace(/[:.]/g, '-')}.png`; + link.click(); + } + + // Handle clipboard operations if enabled + let clipboardResult = null; + if (this.copyToClipboard) { + clipboardResult = await this.copyCanvasToClipboard(canvas); + } + + return { + success: true, + downloadPerformed: this.saveToPc, + clipboardResult: clipboardResult + }; + } +} + +describe('Save to PC and Clipboard Combinations', () => { + let screenshotSelector; + let mockElement; + + beforeEach(() => { + jest.clearAllMocks(); + + // Reset mocks + mockCreateElement.mockClear(); + mockCreateElement.mockReturnValue(mockLink); + // Ensure our mock is used even if the test environment adjusted document + if (global.document) { + global.document.createElement = mockCreateElement; + } + mockLink.click.mockClear(); + + // Setup clipboard API mocks + global.navigator = { + clipboard: { + write: jest.fn().mockResolvedValue() + } + }; + global.window.ClipboardItem = jest.fn(); + global.window.isSecureContext = true; + + screenshotSelector = new MockScreenshotSelector(); + mockElement = { + classList: { remove: jest.fn() }, + style: {}, + getBoundingClientRect: () => ({ left: 0, top: 0, width: 100, height: 100 }), + scrollHeight: 100, + clientHeight: 100, + scrollWidth: 100, + clientWidth: 100 + }; + }); + + describe('Combination 1: Both Save to PC and Clipboard Enabled (saveToPc: true, clipboard: true)', () => { + beforeEach(() => { + screenshotSelector.saveToPc = true; + screenshotSelector.copyToClipboard = true; + }); + + test('should perform both download and clipboard operations when both are enabled', async () => { + const result = await screenshotSelector.captureElement(mockElement); + + expect(result.success).toBe(true); + expect(result.downloadPerformed).toBe(true); + expect(result.clipboardResult).toBeDefined(); + expect(result.clipboardResult.success).toBe(true); + + // Verify download was triggered + expect(mockCreateElement).toHaveBeenCalledWith('a'); + expect(mockLink.click).toHaveBeenCalled(); + + // Verify clipboard operation was attempted + expect(global.navigator.clipboard.write).toHaveBeenCalled(); + }); + + test('should succeed with download even if clipboard fails when both are enabled', async () => { + // Mock clipboard failure + if (global.navigator.clipboard && global.navigator.clipboard.write) { + global.navigator.clipboard.write.mockRejectedValue(new DOMException('Permission denied', 'NotAllowedError')); + } + + const result = await screenshotSelector.captureElement(mockElement); + + expect(result.success).toBe(true); + expect(result.downloadPerformed).toBe(true); + expect(result.clipboardResult.success).toBe(false); + expect(result.clipboardResult.errorType).toBe('permission_denied'); + + // Download should still work + expect(mockCreateElement).toHaveBeenCalledWith('a'); + expect(mockLink.click).toHaveBeenCalled(); + }); + + test('should initialize with both settings correctly', async () => { + await screenshotSelector.init('white', true, true); + + expect(screenshotSelector.background).toBe('white'); + expect(screenshotSelector.copyToClipboard).toBe(true); + expect(screenshotSelector.saveToPc).toBe(true); + expect(screenshotSelector.isActive).toBe(true); + }); + }); + + describe('Combination 2: Save-only Mode (saveToPc: true, clipboard: false)', () => { + beforeEach(() => { + screenshotSelector.saveToPc = true; + screenshotSelector.copyToClipboard = false; + }); + + test('should only perform download when save-only mode is enabled', async () => { + const result = await screenshotSelector.captureElement(mockElement); + + expect(result.success).toBe(true); + expect(result.downloadPerformed).toBe(true); + expect(result.clipboardResult).toBeNull(); + + // Verify download was triggered + expect(mockCreateElement).toHaveBeenCalledWith('a'); + expect(mockLink.click).toHaveBeenCalled(); + + // Verify clipboard operation was NOT attempted + expect(global.navigator.clipboard.write).not.toHaveBeenCalled(); + }); + + test('should initialize with save-only settings correctly', async () => { + await screenshotSelector.init('transparent', false, true); + + expect(screenshotSelector.background).toBe('transparent'); + expect(screenshotSelector.copyToClipboard).toBe(false); + expect(screenshotSelector.saveToPc).toBe(true); + expect(screenshotSelector.isActive).toBe(true); + }); + }); + + describe('Combination 3: Clipboard-only Mode (saveToPc: false, clipboard: true)', () => { + beforeEach(() => { + screenshotSelector.saveToPc = false; + screenshotSelector.copyToClipboard = true; + }); + + test('should only perform clipboard operation when clipboard-only mode is enabled', async () => { + const result = await screenshotSelector.captureElement(mockElement); + + expect(result.success).toBe(true); + expect(result.downloadPerformed).toBe(false); + expect(result.clipboardResult).toBeDefined(); + expect(result.clipboardResult.success).toBe(true); + + // Verify download was NOT triggered + expect(mockCreateElement).not.toHaveBeenCalledWith('a'); + + // Verify clipboard operation was attempted + expect(global.navigator.clipboard.write).toHaveBeenCalled(); + }); + + test('should initialize with clipboard-only settings correctly', async () => { + await screenshotSelector.init('black', true, false); + + expect(screenshotSelector.background).toBe('black'); + expect(screenshotSelector.copyToClipboard).toBe(true); + expect(screenshotSelector.saveToPc).toBe(false); + expect(screenshotSelector.isActive).toBe(true); + }); + }); + + describe('Combination 4: Both Disabled (saveToPc: false, clipboard: false)', () => { + beforeEach(() => { + screenshotSelector.saveToPc = false; + screenshotSelector.copyToClipboard = false; + }); + + test('should perform neither operation when both are disabled', async () => { + const result = await screenshotSelector.captureElement(mockElement); + + expect(result.success).toBe(true); + expect(result.downloadPerformed).toBe(false); + expect(result.clipboardResult).toBeNull(); + + // Verify download was NOT triggered + expect(mockCreateElement).not.toHaveBeenCalledWith('a'); + + // Verify clipboard operation was NOT attempted + expect(global.navigator.clipboard.write).not.toHaveBeenCalled(); + }); + + test('should initialize with both disabled settings correctly', async () => { + await screenshotSelector.init('white', false, false); + + expect(screenshotSelector.background).toBe('white'); + expect(screenshotSelector.copyToClipboard).toBe(false); + expect(screenshotSelector.saveToPc).toBe(false); + expect(screenshotSelector.isActive).toBe(true); + }); + + test('should still create canvas even when both outputs are disabled', async () => { + await screenshotSelector.captureElement(mockElement); + + // html2canvas should still be called (needed for potential future operations) + expect(global.html2canvas).toHaveBeenCalledWith(mockElement); + }); + }); +}); + +describe('Popup Validation Logic for All Combinations', () => { + let clipboardToggle; + let saveToPcToggle; + let startBtn; + let validationWarning; + let validateOutputMethods; + + beforeEach(() => { + jest.clearAllMocks(); + + // Mock DOM elements + clipboardToggle = { + checked: true, + addEventListener: jest.fn() + }; + + saveToPcToggle = { + checked: true, + addEventListener: jest.fn() + }; + + startBtn = { + disabled: false, + setAttribute: jest.fn(), + addEventListener: jest.fn() + }; + + validationWarning = { + style: { display: 'none' } + }; + + global.document.getElementById = jest.fn((id) => { + switch (id) { + case 'clipboard-toggle': return clipboardToggle; + case 'save-to-pc-toggle': return saveToPcToggle; + case 'start-selector': return startBtn; + case 'validation-warning': return validationWarning; + default: return null; + } + }); + + // Define validation function + validateOutputMethods = function() { + const hasValidOutput = clipboardToggle.checked || saveToPcToggle.checked; + + if (hasValidOutput) { + validationWarning.style.display = 'none'; + startBtn.disabled = false; + startBtn.setAttribute('aria-describedby', ''); + } else { + validationWarning.style.display = 'block'; + startBtn.disabled = true; + startBtn.setAttribute('aria-describedby', 'validation-warning'); + } + + return hasValidOutput; + }; + }); + + test('should validate successfully when both save to PC and clipboard are enabled', () => { + clipboardToggle.checked = true; + saveToPcToggle.checked = true; + + const result = validateOutputMethods(); + + expect(result).toBe(true); + expect(validationWarning.style.display).toBe('none'); + expect(startBtn.disabled).toBe(false); + expect(startBtn.setAttribute).toHaveBeenCalledWith('aria-describedby', ''); + }); + + test('should validate successfully when only save to PC is enabled', () => { + clipboardToggle.checked = false; + saveToPcToggle.checked = true; + + const result = validateOutputMethods(); + + expect(result).toBe(true); + expect(validationWarning.style.display).toBe('none'); + expect(startBtn.disabled).toBe(false); + expect(startBtn.setAttribute).toHaveBeenCalledWith('aria-describedby', ''); + }); + + test('should validate successfully when only clipboard is enabled', () => { + clipboardToggle.checked = true; + saveToPcToggle.checked = false; + + const result = validateOutputMethods(); + + expect(result).toBe(true); + expect(validationWarning.style.display).toBe('none'); + expect(startBtn.disabled).toBe(false); + expect(startBtn.setAttribute).toHaveBeenCalledWith('aria-describedby', ''); + }); + + test('should fail validation when both save to PC and clipboard are disabled', () => { + clipboardToggle.checked = false; + saveToPcToggle.checked = false; + + const result = validateOutputMethods(); + + expect(result).toBe(false); + expect(validationWarning.style.display).toBe('block'); + expect(startBtn.disabled).toBe(true); + expect(startBtn.setAttribute).toHaveBeenCalledWith('aria-describedby', 'validation-warning'); + }); +}); + +describe('Preference Persistence Across Browser Sessions', () => { + let mockClipboardToggle; + let mockSaveToPcToggle; + + beforeEach(() => { + jest.clearAllMocks(); + + // Mock DOM elements + mockClipboardToggle = { + checked: false, + addEventListener: jest.fn() + }; + + mockSaveToPcToggle = { + checked: false, + addEventListener: jest.fn() + }; + + global.document.getElementById = jest.fn((id) => { + switch (id) { + case 'clipboard-toggle': + return mockClipboardToggle; + case 'save-to-pc-toggle': + return mockSaveToPcToggle; + default: + return null; + } + }); + }); + + test('should default to both enabled for new installations', async () => { + // Mock no saved preferences (new installation) + global.chrome.storage.sync.get.mockResolvedValueOnce({}); + + const popupScript = ` + document.addEventListener('DOMContentLoaded', async () => { + const clipboardToggle = document.getElementById('clipboard-toggle'); + const saveToPcToggle = document.getElementById('save-to-pc-toggle'); + const saved = await chrome.storage.sync.get(['backgroundPreference', 'copyToClipboard', 'saveToPc']); + + clipboardToggle.checked = saved.copyToClipboard !== undefined ? saved.copyToClipboard : true; + saveToPcToggle.checked = saved.saveToPc !== undefined ? saved.saveToPc : true; + }); + `; + + await new Promise(resolve => { + global.document.addEventListener = jest.fn((event, callback) => { + if (event === 'DOMContentLoaded') { + callback(); + resolve(); + } + }); + eval(popupScript); + }); + + expect(mockClipboardToggle.checked).toBe(true); // Default + expect(mockSaveToPcToggle.checked).toBe(true); // Default + }); + + test('should persist save-only preferences across sessions', async () => { + // First session - user sets save-only mode + global.chrome.storage.sync.get.mockResolvedValueOnce({ + copyToClipboard: false, + saveToPc: true + }); + + const popupScript = ` + document.addEventListener('DOMContentLoaded', async () => { + const clipboardToggle = document.getElementById('clipboard-toggle'); + const saveToPcToggle = document.getElementById('save-to-pc-toggle'); + const saved = await chrome.storage.sync.get(['backgroundPreference', 'copyToClipboard', 'saveToPc']); + + clipboardToggle.checked = saved.copyToClipboard !== undefined ? saved.copyToClipboard : true; + saveToPcToggle.checked = saved.saveToPc !== undefined ? saved.saveToPc : true; + }); + `; + + await new Promise(resolve => { + global.document.addEventListener = jest.fn((event, callback) => { + if (event === 'DOMContentLoaded') { + callback(); + resolve(); + } + }); + eval(popupScript); + }); + + expect(mockClipboardToggle.checked).toBe(false); + expect(mockSaveToPcToggle.checked).toBe(true); + }); + + test('should persist clipboard-only preferences across sessions', async () => { + // Load clipboard-only mode + global.chrome.storage.sync.get.mockResolvedValueOnce({ + copyToClipboard: true, + saveToPc: false + }); + + const popupScript = ` + document.addEventListener('DOMContentLoaded', async () => { + const clipboardToggle = document.getElementById('clipboard-toggle'); + const saveToPcToggle = document.getElementById('save-to-pc-toggle'); + const saved = await chrome.storage.sync.get(['backgroundPreference', 'copyToClipboard', 'saveToPc']); + + clipboardToggle.checked = saved.copyToClipboard !== undefined ? saved.copyToClipboard : true; + saveToPcToggle.checked = saved.saveToPc !== undefined ? saved.saveToPc : true; + }); + `; + + await new Promise(resolve => { + global.document.addEventListener = jest.fn((event, callback) => { + if (event === 'DOMContentLoaded') { + callback(); + resolve(); + } + }); + eval(popupScript); + }); + + expect(mockClipboardToggle.checked).toBe(true); + expect(mockSaveToPcToggle.checked).toBe(false); + }); + + test('should persist both disabled preferences across sessions', async () => { + // Load both disabled state + global.chrome.storage.sync.get.mockResolvedValueOnce({ + copyToClipboard: false, + saveToPc: false + }); + + const popupScript = ` + document.addEventListener('DOMContentLoaded', async () => { + const clipboardToggle = document.getElementById('clipboard-toggle'); + const saveToPcToggle = document.getElementById('save-to-pc-toggle'); + const saved = await chrome.storage.sync.get(['backgroundPreference', 'copyToClipboard', 'saveToPc']); + + clipboardToggle.checked = saved.copyToClipboard !== undefined ? saved.copyToClipboard : true; + saveToPcToggle.checked = saved.saveToPc !== undefined ? saved.saveToPc : true; + }); + `; + + await new Promise(resolve => { + global.document.addEventListener = jest.fn((event, callback) => { + if (event === 'DOMContentLoaded') { + callback(); + resolve(); + } + }); + eval(popupScript); + }); + + expect(mockClipboardToggle.checked).toBe(false); + expect(mockSaveToPcToggle.checked).toBe(false); + + // Validation should fail with both disabled + const isValid = mockClipboardToggle.checked || mockSaveToPcToggle.checked; + expect(isValid).toBe(false); + }); + + test('should maintain existing behavior when upgrading from version without saveToPc', async () => { + // Mock existing installation with only clipboard preference + global.chrome.storage.sync.get.mockResolvedValueOnce({ + copyToClipboard: true + // saveToPc is undefined (not set in previous version) + }); + + const popupScript = ` + document.addEventListener('DOMContentLoaded', async () => { + const clipboardToggle = document.getElementById('clipboard-toggle'); + const saveToPcToggle = document.getElementById('save-to-pc-toggle'); + const saved = await chrome.storage.sync.get(['backgroundPreference', 'copyToClipboard', 'saveToPc']); + + clipboardToggle.checked = saved.copyToClipboard !== undefined ? saved.copyToClipboard : true; + saveToPcToggle.checked = saved.saveToPc !== undefined ? saved.saveToPc : true; + }); + `; + + await new Promise(resolve => { + global.document.addEventListener = jest.fn((event, callback) => { + if (event === 'DOMContentLoaded') { + callback(); + resolve(); + } + }); + eval(popupScript); + }); + + expect(mockClipboardToggle.checked).toBe(true); // Existing preference + expect(mockSaveToPcToggle.checked).toBe(true); // Default for backward compatibility + }); +}); \ No newline at end of file diff --git a/tests/setup.js b/tests/setup.js new file mode 100644 index 0000000..e6a0b6e --- /dev/null +++ b/tests/setup.js @@ -0,0 +1,297 @@ +/** + * Jest setup file for clipboard functionality tests + * Provides common mocks and utilities for all test files + */ + +// Keep global and window navigator in sync regardless of direct reassignment +(() => { + try { + let navigatorStore = (typeof global !== 'undefined' && global.navigator) || (typeof window !== 'undefined' && window.navigator) || {}; + if (typeof global !== 'undefined') { + Object.defineProperty(global, 'navigator', { + configurable: true, + get: () => navigatorStore, + set: (val) => { + navigatorStore = val || {}; + if (typeof window !== 'undefined') { + try { window.navigator = navigatorStore; } catch (_) {} + } + } + }); + } + if (typeof window !== 'undefined') { + Object.defineProperty(window, 'navigator', { + configurable: true, + get: () => navigatorStore, + set: (val) => { navigatorStore = val || {}; } + }); + } + } catch (_) {} +})(); + +// Global test utilities +global.createMockBlob = (data = 'mock data', type = 'image/png', size = 1024) => { + const blob = new Blob([data], { type }); + Object.defineProperty(blob, 'size', { value: size }); + return blob; +}; + +global.createMockCanvas = (width = 100, height = 100) => { + return { + width, + height, + toBlob: jest.fn((callback, format = 'image/png') => { + const blob = global.createMockBlob('mock canvas data', format); + callback(blob); + }), + toDataURL: jest.fn((format = 'image/png') => `data:${format};base64,mockcanvasdata`), + getContext: jest.fn(() => ({ + drawImage: jest.fn(), + getImageData: jest.fn(), + putImageData: jest.fn() + })) + }; +}; + +global.createMockElement = (options = {}) => { + return { + classList: { + add: jest.fn(), + remove: jest.fn(), + contains: jest.fn(() => false) + }, + style: {}, + getBoundingClientRect: () => ({ + left: options.left || 0, + top: options.top || 0, + width: options.width || 100, + height: options.height || 100 + }), + scrollHeight: options.scrollHeight || 100, + clientHeight: options.clientHeight || 100, + scrollWidth: options.scrollWidth || 100, + clientWidth: options.clientWidth || 100, + children: options.children || [], + parentNode: { children: options.siblings || [] }, + tagName: options.tagName || 'DIV', + id: options.id || '', + addEventListener: jest.fn(), + removeEventListener: jest.fn() + }; +}; + +// Mock Chrome extension APIs consistently +global.mockChromeAPIs = () => { + const existingChrome = global.chrome || {}; + const existingStorage = (existingChrome.storage && existingChrome.storage.sync) || {}; + const existingRuntime = existingChrome.runtime || {}; + const existingTabs = existingChrome.tabs || {}; + + global.chrome = { + storage: { + sync: { + get: existingStorage.get || jest.fn().mockResolvedValue({}), + set: existingStorage.set || jest.fn().mockResolvedValue() + } + }, + runtime: { + sendMessage: existingRuntime.sendMessage || jest.fn().mockResolvedValue(), + onMessage: { + addListener: (existingRuntime.onMessage && existingRuntime.onMessage.addListener) || jest.fn() + } + }, + tabs: { + query: existingTabs.query || jest.fn().mockResolvedValue([{ id: 123, url: 'https://example.com' }]), + sendMessage: existingTabs.sendMessage || jest.fn().mockResolvedValue() + } + }; +}; + +// Mock DOM APIs consistently +global.mockDOMAPIs = () => { + // Document + const existingDocument = global.document || {}; + const existingCreateElement = existingDocument.createElement; + global.document = existingDocument; + if (!global.document.createElement) { + global.document.createElement = jest.fn(() => ({ + style: {}, + classList: { add: jest.fn(), remove: jest.fn() }, + appendChild: jest.fn(), + click: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + innerHTML: '', + textContent: '', + id: '', + className: '', + checked: false, + value: '' + })); + } + if (!global.document.head) global.document.head = { appendChild: jest.fn() }; + if (!global.document.body) global.document.body = { appendChild: jest.fn() }; + if (!global.document.addEventListener) global.document.addEventListener = jest.fn(); + if (!global.document.removeEventListener) global.document.removeEventListener = jest.fn(); + if (!global.document.querySelectorAll) global.document.querySelectorAll = jest.fn(() => []); + if (!global.document.getElementById) global.document.getElementById = jest.fn(() => null); + + // Window + const existingWindow = global.window || {}; + global.window = existingWindow; + // Ensure a shared navigator reference exists early + if (!global.navigator) global.navigator = (existingWindow && existingWindow.navigator) || {}; + if (!global.window.navigator) global.window.navigator = global.navigator; + if (!global.window.getComputedStyle) global.window.getComputedStyle = jest.fn(() => ({})); + if (typeof global.window.devicePixelRatio === 'undefined') global.window.devicePixelRatio = 2; + if (typeof global.window.isSecureContext === 'undefined') global.window.isSecureContext = true; + if (!global.window.CSS) global.window.CSS = { escape: jest.fn(str => str) }; + if (!global.window.setTimeout) { + global.window.setTimeout = jest.fn((fn, delay) => { + if (delay === 0) fn(); + return 1; + }); + } + if (!global.window.clearTimeout) global.window.clearTimeout = jest.fn(); + if (!global.window.close) global.window.close = jest.fn(); +}; + +// Mock clipboard APIs with different scenarios +global.mockClipboardAPI = (scenario = 'supported') => { + switch (scenario) { + case 'supported': { + const existingNavigator = global.navigator || {}; + const existingClipboard = existingNavigator.clipboard || {}; + if (!existingClipboard.write || !jest.isMockFunction?.(existingClipboard.write)) { + existingClipboard.write = jest.fn().mockResolvedValue(); + } + const mergedNavigator = { ...existingNavigator, clipboard: existingClipboard }; + global.navigator = mergedNavigator; + if (!global.window) global.window = {}; + global.window.navigator = mergedNavigator; + if (!global.window) global.window = {}; + if (!global.window.ClipboardItem) global.window.ClipboardItem = jest.fn(); + if (typeof global.window.isSecureContext === 'undefined') global.window.isSecureContext = true; + break; + } + case 'no-clipboard': + global.navigator = {}; + if (!global.window) global.window = {}; + global.window.navigator = global.navigator; + break; + case 'no-clipboarditem': { + const existingNavigator = global.navigator || {}; + const clipboard = existingNavigator.clipboard || { write: jest.fn() }; + const mergedNavigator = { ...existingNavigator, clipboard }; + global.navigator = mergedNavigator; + if (!global.window) global.window = {}; + global.window.navigator = mergedNavigator; + if (!global.window) global.window = {}; + global.window.ClipboardItem = undefined; + break; + } + case 'insecure-context': { + const existingNavigator = global.navigator || {}; + const clipboard = existingNavigator.clipboard || { write: jest.fn() }; + const mergedNavigator = { ...existingNavigator, clipboard }; + global.navigator = mergedNavigator; + if (!global.window) global.window = {}; + global.window.navigator = mergedNavigator; + if (!global.window) global.window = {}; + global.window.ClipboardItem = global.window.ClipboardItem || jest.fn(); + global.window.isSecureContext = false; + break; + } + case 'permission-denied': { + const existingNavigator = global.navigator || {}; + const clipboard = { write: jest.fn().mockRejectedValue(new DOMException('Permission denied', 'NotAllowedError')) }; + const mergedNavigator = { ...existingNavigator, clipboard }; + global.navigator = mergedNavigator; + if (!global.window) global.window = {}; + global.window.navigator = mergedNavigator; + if (!global.window) global.window = {}; + global.window.ClipboardItem = global.window.ClipboardItem || jest.fn(); + global.window.isSecureContext = true; + break; + } + case 'security-error': { + const existingNavigator = global.navigator || {}; + const clipboard = { write: jest.fn().mockRejectedValue(new DOMException('Security error', 'SecurityError')) }; + const mergedNavigator = { ...existingNavigator, clipboard }; + global.navigator = mergedNavigator; + if (!global.window) global.window = {}; + global.window.navigator = mergedNavigator; + if (!global.window) global.window = {}; + global.window.ClipboardItem = global.window.ClipboardItem || jest.fn(); + global.window.isSecureContext = true; + break; + } + } +}; + +// Setup default mocks before each test +beforeEach(() => { + // Clear all mocks first + jest.clearAllMocks(); + + global.mockChromeAPIs(); + global.mockDOMAPIs(); + global.mockClipboardAPI('supported'); + + // Mock html2canvas + global.html2canvas = jest.fn().mockResolvedValue(global.createMockCanvas()); +}); + +// Cleanup after each test +afterEach(() => { + jest.restoreAllMocks(); +}); + +// Custom matchers for better test assertions +expect.extend({ + toHaveBeenCalledWithClipboardMessage(received, expectedBackground, expectedClipboard) { + const calls = received.mock.calls; + const matchingCall = calls.find(call => + call[1] && + call[1].action === 'startSelector' && + call[1].background === expectedBackground && + call[1].copyToClipboard === expectedClipboard + ); + + if (matchingCall) { + return { + message: () => `Expected not to be called with clipboard message`, + pass: true + }; + } else { + return { + message: () => `Expected to be called with clipboard message (background: ${expectedBackground}, clipboard: ${expectedClipboard})`, + pass: false + }; + } + }, + + toHaveClipboardResult(received, expectedSuccess, expectedErrorType) { + if (!received.clipboardResult) { + return { + message: () => `Expected clipboard result to exist`, + pass: false + }; + } + + const success = received.clipboardResult.success === expectedSuccess; + const errorType = !expectedErrorType || received.clipboardResult.errorType === expectedErrorType; + + if (success && errorType) { + return { + message: () => `Expected clipboard result not to match`, + pass: true + }; + } else { + return { + message: () => `Expected clipboard result (success: ${expectedSuccess}, errorType: ${expectedErrorType}), got (success: ${received.clipboardResult.success}, errorType: ${received.clipboardResult.errorType})`, + pass: false + }; + } + } +}); \ No newline at end of file diff --git a/tests/validation-logic.test.js b/tests/validation-logic.test.js new file mode 100644 index 0000000..141c9eb --- /dev/null +++ b/tests/validation-logic.test.js @@ -0,0 +1,209 @@ +/** + * Tests for output method validation logic + */ + +// Mock DOM elements and Chrome APIs +const mockChrome = { + storage: { + sync: { + get: jest.fn(), + set: jest.fn() + } + }, + tabs: { + query: jest.fn(), + sendMessage: jest.fn() + }, + runtime: { + onMessage: { + addListener: jest.fn() + } + } +}; + +global.chrome = mockChrome; + +describe('Output Method Validation', () => { + let mockDocument; + let clipboardToggle; + let saveToPcToggle; + let startBtn; + let validationWarning; + let validateOutputMethods; + + beforeEach(() => { + // Reset mocks + jest.clearAllMocks(); + + // Mock DOM elements + clipboardToggle = { + checked: true, + addEventListener: jest.fn() + }; + + saveToPcToggle = { + checked: true, + addEventListener: jest.fn() + }; + + startBtn = { + disabled: false, + setAttribute: jest.fn(), + addEventListener: jest.fn() + }; + + validationWarning = { + style: { display: 'none' } + }; + + // Mock document.getElementById + const mockGetElementById = jest.fn((id) => { + switch (id) { + case 'clipboard-toggle': return clipboardToggle; + case 'save-to-pc-toggle': return saveToPcToggle; + case 'start-selector': return startBtn; + case 'validation-warning': return validationWarning; + default: return null; + } + }); + + mockDocument = { + getElementById: mockGetElementById, + addEventListener: jest.fn() + }; + + global.document = mockDocument; + + // Define validation function (extracted from popup.js logic) + validateOutputMethods = function() { + const hasValidOutput = clipboardToggle.checked || saveToPcToggle.checked; + + if (hasValidOutput) { + validationWarning.style.display = 'none'; + startBtn.disabled = false; + startBtn.setAttribute('aria-describedby', ''); + } else { + validationWarning.style.display = 'block'; + startBtn.disabled = true; + startBtn.setAttribute('aria-describedby', 'validation-warning'); + } + + return hasValidOutput; + }; + }); + + describe('Validation Logic', () => { + test('should return true when both save to PC and clipboard are enabled', () => { + clipboardToggle.checked = true; + saveToPcToggle.checked = true; + + const result = validateOutputMethods(); + + expect(result).toBe(true); + expect(validationWarning.style.display).toBe('none'); + expect(startBtn.disabled).toBe(false); + expect(startBtn.setAttribute).toHaveBeenCalledWith('aria-describedby', ''); + }); + + test('should return true when only save to PC is enabled', () => { + clipboardToggle.checked = false; + saveToPcToggle.checked = true; + + const result = validateOutputMethods(); + + expect(result).toBe(true); + expect(validationWarning.style.display).toBe('none'); + expect(startBtn.disabled).toBe(false); + expect(startBtn.setAttribute).toHaveBeenCalledWith('aria-describedby', ''); + }); + + test('should return true when only clipboard is enabled', () => { + clipboardToggle.checked = true; + saveToPcToggle.checked = false; + + const result = validateOutputMethods(); + + expect(result).toBe(true); + expect(validationWarning.style.display).toBe('none'); + expect(startBtn.disabled).toBe(false); + expect(startBtn.setAttribute).toHaveBeenCalledWith('aria-describedby', ''); + }); + + test('should return false when both save to PC and clipboard are disabled', () => { + clipboardToggle.checked = false; + saveToPcToggle.checked = false; + + const result = validateOutputMethods(); + + expect(result).toBe(false); + expect(validationWarning.style.display).toBe('block'); + expect(startBtn.disabled).toBe(true); + expect(startBtn.setAttribute).toHaveBeenCalledWith('aria-describedby', 'validation-warning'); + }); + }); + + describe('UI State Management', () => { + test('should hide warning and enable button when validation passes', () => { + clipboardToggle.checked = true; + saveToPcToggle.checked = false; + + validateOutputMethods(); + + expect(validationWarning.style.display).toBe('none'); + expect(startBtn.disabled).toBe(false); + expect(startBtn.setAttribute).toHaveBeenCalledWith('aria-describedby', ''); + }); + + test('should show warning and disable button when validation fails', () => { + clipboardToggle.checked = false; + saveToPcToggle.checked = false; + + validateOutputMethods(); + + expect(validationWarning.style.display).toBe('block'); + expect(startBtn.disabled).toBe(true); + expect(startBtn.setAttribute).toHaveBeenCalledWith('aria-describedby', 'validation-warning'); + }); + + test('should update accessibility attributes correctly', () => { + // Test valid state + clipboardToggle.checked = true; + saveToPcToggle.checked = true; + validateOutputMethods(); + expect(startBtn.setAttribute).toHaveBeenCalledWith('aria-describedby', ''); + + // Reset mock + startBtn.setAttribute.mockClear(); + + // Test invalid state + clipboardToggle.checked = false; + saveToPcToggle.checked = false; + validateOutputMethods(); + expect(startBtn.setAttribute).toHaveBeenCalledWith('aria-describedby', 'validation-warning'); + }); + }); + + describe('Edge Cases', () => { + test('should handle undefined checkbox states gracefully', () => { + clipboardToggle.checked = undefined; + saveToPcToggle.checked = true; + + const result = validateOutputMethods(); + + // undefined should be falsy, so only saveToPc (true) should make it valid + expect(result).toBe(true); + }); + + test('should handle null checkbox states gracefully', () => { + clipboardToggle.checked = null; + saveToPcToggle.checked = null; + + const result = validateOutputMethods(); + + // Both null should be falsy, so validation should fail + expect(result).toBeFalsy(); // Use toBeFalsy to handle null/false/undefined + expect(validationWarning.style.display).toBe('block'); + expect(startBtn.disabled).toBe(true); + }); + }); +}); \ No newline at end of file