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.
+
+
+
+
+ ⚠️ Please enable at least one output method (Save to PC or Copy to Clipboard) to capture screenshots.
+
+
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