Compare commits
1 commit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a018c36d67 |
12 changed files with 7780 additions and 22 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
node_modules/
|
||||||
|
.kiro/
|
||||||
|
.vscode/
|
||||||
444
content.js
444
content.js
|
|
@ -6,15 +6,19 @@ class ScreenshotSelector {
|
||||||
this.overlay = null;
|
this.overlay = null;
|
||||||
this.style = null;
|
this.style = null;
|
||||||
this.background = 'black';
|
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) {
|
if (this.isActive) {
|
||||||
console.log('Screenshot selector already active');
|
console.log('Screenshot selector already active');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.background = background;
|
this.background = background;
|
||||||
|
this.copyToClipboard = copyToClipboard;
|
||||||
|
this.saveToPc = saveToPc;
|
||||||
this.isActive = true;
|
this.isActive = true;
|
||||||
|
|
||||||
this.createUI();
|
this.createUI();
|
||||||
|
|
@ -363,12 +367,36 @@ class ScreenshotSelector {
|
||||||
|
|
||||||
async captureElement(element) {
|
async captureElement(element) {
|
||||||
try {
|
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
|
// Show loading state
|
||||||
this.overlay.innerHTML = `
|
this.overlay.innerHTML = `
|
||||||
<div style="position: fixed; top: 20px; left: 20px; background: rgba(0,0,0,0.9); color: white; padding: 12px 16px; border-radius: 8px; z-index: 2147483647; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 14px; box-shadow: 0 4px 20px rgba(0,0,0,0.3);">
|
<div style="position: fixed; top: 20px; left: 20px; background: rgba(0,0,0,0.9); color: white; padding: 12px 16px; border-radius: 8px; z-index: 2147483647; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 14px; box-shadow: 0 4px 20px rgba(0,0,0,0.3);">
|
||||||
<div style="display: flex; align-items: center; gap: 10px;">
|
<div style="display: flex; align-items: center; gap: 10px;">
|
||||||
<div style="width: 16px; height: 16px; border: 2px solid #fff; border-top: 2px solid transparent; border-radius: 50%; animation: spin 1s linear infinite;"></div>
|
<div style="width: 16px; height: 16px; border: 2px solid #fff; border-top: 2px solid transparent; border-radius: 50%; animation: spin 1s linear infinite;"></div>
|
||||||
<span>Capturing screenshot...</span>
|
<span>${loadingMessage}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<style>
|
<style>
|
||||||
|
|
@ -467,18 +495,82 @@ class ScreenshotSelector {
|
||||||
// Restore original styles
|
// Restore original styles
|
||||||
Object.assign(element.style, originalStyles);
|
Object.assign(element.style, originalStyles);
|
||||||
|
|
||||||
// Download image
|
// Conditionally download the image based on saveToPc preference
|
||||||
const link = document.createElement('a');
|
if (this.saveToPc) {
|
||||||
link.href = canvas.toDataURL('image/png');
|
const link = document.createElement('a');
|
||||||
link.download = `screenshot-${new Date().toISOString().slice(0, 19).replace(/[:.]/g, '-')}.png`;
|
link.href = canvas.toDataURL('image/png');
|
||||||
link.click();
|
link.download = `screenshot-${new Date().toISOString().slice(0, 19).replace(/[:.]/g, '-')}.png`;
|
||||||
|
link.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle clipboard operations if enabled (after successful canvas generation and download)
|
||||||
|
let clipboardResult = null;
|
||||||
|
if (this.copyToClipboard) {
|
||||||
|
const availability = this.checkClipboardAPIAvailability();
|
||||||
|
if (availability.available) {
|
||||||
|
try {
|
||||||
|
// Attempt clipboard operation with comprehensive error handling
|
||||||
|
clipboardResult = await this.copyCanvasToClipboard(canvas);
|
||||||
|
} catch (error) {
|
||||||
|
// Ensure clipboard errors don't break the workflow - this is a safety net
|
||||||
|
// The copyCanvasToClipboard method should handle all errors internally
|
||||||
|
console.error('Unexpected clipboard error caught in captureElement:', error);
|
||||||
|
clipboardResult = {
|
||||||
|
success: false,
|
||||||
|
error: 'Unexpected clipboard error occurred',
|
||||||
|
errorType: 'unexpected'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
clipboardResult = {
|
||||||
|
success: false,
|
||||||
|
error: availability.reason,
|
||||||
|
errorType: 'api_unavailable'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate user feedback message based on operation combinations
|
||||||
|
let successMessage = 'Screenshot captured!';
|
||||||
|
let messageColor = 'rgba(39, 174, 96, 0.95)'; // Green for success
|
||||||
|
let messageIcon = '✅';
|
||||||
|
|
||||||
|
// Determine success message based on enabled operations
|
||||||
|
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 = this.getClipboardErrorMessage(clipboardResult);
|
||||||
|
successMessage = `Screenshot saved successfully! However, ${errorMessage.toLowerCase()}`;
|
||||||
|
messageColor = 'rgba(243, 156, 18, 0.95)'; // Orange for partial success
|
||||||
|
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 = this.getClipboardErrorMessage(clipboardResult);
|
||||||
|
successMessage = `Screenshot capture failed: ${errorMessage}`;
|
||||||
|
messageColor = 'rgba(231, 76, 60, 0.95)'; // Red for failure
|
||||||
|
messageIcon = '❌';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Success message
|
|
||||||
this.overlay.innerHTML = `
|
this.overlay.innerHTML = `
|
||||||
<div style="position: fixed; top: 20px; left: 20px; background: rgba(39, 174, 96, 0.95); color: white; padding: 12px 16px; border-radius: 8px; z-index: 2147483647; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 14px; box-shadow: 0 4px 20px rgba(0,0,0,0.3);">
|
<div style="position: fixed; top: 20px; left: 20px; background: ${messageColor}; color: white; padding: 12px 16px; border-radius: 8px; z-index: 2147483647; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 14px; box-shadow: 0 4px 20px rgba(0,0,0,0.3);">
|
||||||
<div style="display: flex; align-items: center; gap: 10px;">
|
<div style="display: flex; align-items: center; gap: 10px;">
|
||||||
<span>✅</span>
|
<span>${messageIcon}</span>
|
||||||
<span>Screenshot saved successfully!</span>
|
<span>${successMessage}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
@ -490,18 +582,329 @@ class ScreenshotSelector {
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Screenshot failed:', error);
|
console.error('Screenshot failed:', error);
|
||||||
|
|
||||||
|
// Provide specific error messages for screenshot failures based on enabled operations
|
||||||
|
let errorMessage = 'Screenshot capture failed. Please try again.';
|
||||||
|
let operationContext = '';
|
||||||
|
|
||||||
|
// Add context about what operations were attempted
|
||||||
|
if (this.saveToPc && this.copyToClipboard) {
|
||||||
|
operationContext = ' Neither file download nor clipboard copy could be completed.';
|
||||||
|
} else if (this.saveToPc && !this.copyToClipboard) {
|
||||||
|
operationContext = ' File download could not be completed.';
|
||||||
|
} else if (!this.saveToPc && this.copyToClipboard) {
|
||||||
|
operationContext = ' Clipboard copy could not be completed.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.message) {
|
||||||
|
if (error.message.includes('html2canvas')) {
|
||||||
|
errorMessage = `Screenshot rendering failed. Try selecting a different element or refresh the page.${operationContext}`;
|
||||||
|
} else if (error.message.includes('timeout')) {
|
||||||
|
errorMessage = `Screenshot capture timed out. Try selecting a smaller area or simpler element.${operationContext}`;
|
||||||
|
} else if (error.message.includes('network') || error.message.includes('CORS')) {
|
||||||
|
errorMessage = `Screenshot failed: Some content is blocked by security restrictions.${operationContext}`;
|
||||||
|
} else if (error.message.includes('memory') || error.message.includes('quota')) {
|
||||||
|
errorMessage = `Screenshot failed: Not enough memory. Try capturing a smaller area.${operationContext}`;
|
||||||
|
} else if (error.message.includes('canvas')) {
|
||||||
|
errorMessage = `Screenshot failed: Unable to create image. Try a different element.${operationContext}`;
|
||||||
|
} else {
|
||||||
|
errorMessage = `Screenshot capture failed: ${error.message}${operationContext}`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
errorMessage = `Screenshot capture failed. Please try again.${operationContext}`;
|
||||||
|
}
|
||||||
|
|
||||||
this.overlay.innerHTML = `
|
this.overlay.innerHTML = `
|
||||||
<div style="position: fixed; top: 20px; left: 20px; background: rgba(231, 76, 60, 0.95); color: white; padding: 12px 16px; border-radius: 8px; z-index: 2147483647; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 14px; box-shadow: 0 4px 20px rgba(0,0,0,0.3);">
|
<div style="position: fixed; top: 20px; left: 20px; background: rgba(231, 76, 60, 0.95); color: white; padding: 12px 16px; border-radius: 8px; z-index: 2147483647; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 14px; box-shadow: 0 4px 20px rgba(0,0,0,0.3);">
|
||||||
<div style="display: flex; align-items: center; gap: 10px;">
|
<div style="display: flex; align-items: center; gap: 10px;">
|
||||||
<span>❌</span>
|
<span>❌</span>
|
||||||
<span>Screenshot failed. Please try again.</span>
|
<span>${errorMessage}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Notify popup about the failure
|
||||||
|
chrome.runtime.sendMessage({ action: 'selectorStopped', reason: 'screenshot-failed' });
|
||||||
|
|
||||||
setTimeout(() => this.cleanup(), 3000);
|
setTimeout(() => this.cleanup(), 3000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Clipboard API is available and supported
|
||||||
|
* @returns {{available: boolean, reason?: string}} Clipboard API availability status
|
||||||
|
*/
|
||||||
|
checkClipboardAPIAvailability() {
|
||||||
|
// Check for basic Clipboard API support
|
||||||
|
if (!navigator.clipboard) {
|
||||||
|
return {
|
||||||
|
available: false,
|
||||||
|
reason: 'Clipboard API not available in this browser'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for ClipboardItem constructor (required for image data)
|
||||||
|
if (!window.ClipboardItem) {
|
||||||
|
return {
|
||||||
|
available: false,
|
||||||
|
reason: 'ClipboardItem not supported in this browser'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we're in a secure context (HTTPS required for clipboard)
|
||||||
|
if (!window.isSecureContext) {
|
||||||
|
return {
|
||||||
|
available: false,
|
||||||
|
reason: 'Clipboard API requires a secure context (HTTPS)'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for write permission (this is the basic check, actual permission is checked during write)
|
||||||
|
if (!navigator.clipboard.write) {
|
||||||
|
return {
|
||||||
|
available: false,
|
||||||
|
reason: 'Clipboard write functionality not available'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
available: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user-friendly feedback message about clipboard feature availability
|
||||||
|
* @returns {{supported: boolean, message: string}} User feedback about clipboard support
|
||||||
|
*/
|
||||||
|
getClipboardSupportFeedback() {
|
||||||
|
const availability = this.checkClipboardAPIAvailability();
|
||||||
|
|
||||||
|
if (availability.available) {
|
||||||
|
return {
|
||||||
|
supported: true,
|
||||||
|
message: 'Clipboard copying is available and ready to use.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provide user-friendly messages for different scenarios
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate user-friendly error message for clipboard operation failures
|
||||||
|
* @param {Object} clipboardResult - Result object from clipboard operation
|
||||||
|
* @returns {string} User-friendly error message
|
||||||
|
*/
|
||||||
|
getClipboardErrorMessage(clipboardResult) {
|
||||||
|
if (!clipboardResult || !clipboardResult.error) {
|
||||||
|
return 'clipboard copy failed due to unknown error';
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorType = clipboardResult.errorType;
|
||||||
|
const errorMessage = clipboardResult.error;
|
||||||
|
|
||||||
|
// Provide contextual error messages based on error type
|
||||||
|
// Note: These messages are designed to work in the context of "Screenshot saved successfully! However, [message]"
|
||||||
|
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 is not available in this browser';
|
||||||
|
|
||||||
|
case 'security_error':
|
||||||
|
return 'clipboard copy was 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';
|
||||||
|
|
||||||
|
case 'network_error':
|
||||||
|
return 'clipboard copy failed due to network error. Please try again';
|
||||||
|
|
||||||
|
case 'canvas_conversion':
|
||||||
|
return 'clipboard copy failed: unable to prepare image. Try capturing a different element';
|
||||||
|
|
||||||
|
case 'clipboard_item_creation':
|
||||||
|
return 'clipboard copy is not supported: your browser doesn\'t support image clipboard operations';
|
||||||
|
|
||||||
|
case 'invalid_state':
|
||||||
|
return 'clipboard copy failed: browser clipboard is busy. Please try again';
|
||||||
|
|
||||||
|
case 'data_error':
|
||||||
|
return 'clipboard copy failed: invalid image data. Please try capturing again';
|
||||||
|
|
||||||
|
case 'unexpected':
|
||||||
|
return 'clipboard copy failed due to unexpected error';
|
||||||
|
|
||||||
|
default:
|
||||||
|
// For unknown error types, try to extract meaningful info from the error message
|
||||||
|
if (errorMessage.includes('permission') || errorMessage.includes('denied')) {
|
||||||
|
return 'clipboard copy failed: access denied. Check browser permissions';
|
||||||
|
} else if (errorMessage.includes('not supported') || errorMessage.includes('unavailable')) {
|
||||||
|
return 'clipboard copy is not supported in this browser';
|
||||||
|
} else if (errorMessage.includes('timeout') || errorMessage.includes('timed out')) {
|
||||||
|
return 'clipboard copy timed out. Try capturing a smaller area';
|
||||||
|
} else if (errorMessage.includes('large') || errorMessage.includes('size')) {
|
||||||
|
return 'clipboard copy failed: image too large for clipboard';
|
||||||
|
} else {
|
||||||
|
return `clipboard copy failed: ${errorMessage.toLowerCase()}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy canvas content to clipboard using the Clipboard API
|
||||||
|
* @param {HTMLCanvasElement} canvas - The canvas element to copy
|
||||||
|
* @returns {Promise<{success: boolean, error?: string, errorType?: string}>} Result of clipboard operation
|
||||||
|
*/
|
||||||
|
async copyCanvasToClipboard(canvas) {
|
||||||
|
try {
|
||||||
|
// Check clipboard API availability first
|
||||||
|
const availability = this.checkClipboardAPIAvailability();
|
||||||
|
if (!availability.available) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: availability.reason,
|
||||||
|
errorType: 'api_unavailable'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert canvas to blob in PNG format with timeout
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Validate blob size (prevent extremely large clipboard operations)
|
||||||
|
const maxBlobSize = 50 * 1024 * 1024; // 50MB limit
|
||||||
|
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'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create ClipboardItem with PNG image data
|
||||||
|
let clipboardItem;
|
||||||
|
try {
|
||||||
|
clipboardItem = new 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'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write to clipboard with timeout
|
||||||
|
await Promise.race([
|
||||||
|
navigator.clipboard.write([clipboardItem]),
|
||||||
|
new Promise((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error('Clipboard write operation timed out')), 15000)
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Clipboard operation failed:', error);
|
||||||
|
|
||||||
|
// Comprehensive error handling with specific error types and user-friendly messages
|
||||||
|
let errorMessage = error.message || 'Unknown clipboard error';
|
||||||
|
let errorType = 'unknown';
|
||||||
|
|
||||||
|
// Permission-related errors
|
||||||
|
if (error.name === 'NotAllowedError') {
|
||||||
|
errorMessage = 'Clipboard access denied. Please allow clipboard permissions in your browser settings.';
|
||||||
|
errorType = 'permission_denied';
|
||||||
|
}
|
||||||
|
// API support errors
|
||||||
|
else if (error.name === 'NotSupportedError') {
|
||||||
|
errorMessage = 'Clipboard API not supported in this browser context.';
|
||||||
|
errorType = 'not_supported';
|
||||||
|
}
|
||||||
|
// Security context errors
|
||||||
|
else if (error.name === 'SecurityError') {
|
||||||
|
errorMessage = 'Clipboard access blocked by security policy. Try using HTTPS.';
|
||||||
|
errorType = 'security_error';
|
||||||
|
}
|
||||||
|
// Network or resource errors
|
||||||
|
else if (error.name === 'NetworkError') {
|
||||||
|
errorMessage = 'Network error during clipboard operation. Please try again.';
|
||||||
|
errorType = 'network_error';
|
||||||
|
}
|
||||||
|
// Timeout errors
|
||||||
|
else if (error.message.includes('timed out')) {
|
||||||
|
errorMessage = 'Clipboard operation timed out. The image may be too large.';
|
||||||
|
errorType = 'timeout';
|
||||||
|
}
|
||||||
|
// Canvas conversion errors
|
||||||
|
else if (error.message.includes('canvas') || error.message.includes('blob')) {
|
||||||
|
errorMessage = 'Failed to prepare image for clipboard. Try capturing a different area.';
|
||||||
|
errorType = 'canvas_conversion';
|
||||||
|
}
|
||||||
|
// Quota or storage errors
|
||||||
|
else if (error.name === 'QuotaExceededError' || error.message.includes('quota')) {
|
||||||
|
errorMessage = 'Clipboard storage quota exceeded. Try capturing a smaller image.';
|
||||||
|
errorType = 'quota_exceeded';
|
||||||
|
}
|
||||||
|
// DOM or state errors
|
||||||
|
else if (error.name === 'InvalidStateError') {
|
||||||
|
errorMessage = 'Clipboard is in an invalid state. Please try again.';
|
||||||
|
errorType = 'invalid_state';
|
||||||
|
}
|
||||||
|
// Data errors
|
||||||
|
else if (error.name === 'DataError') {
|
||||||
|
errorMessage = 'Invalid image data for clipboard. Please try again.';
|
||||||
|
errorType = 'data_error';
|
||||||
|
}
|
||||||
|
// Generic fallback for unknown errors
|
||||||
|
else {
|
||||||
|
errorMessage = `Clipboard operation failed: ${error.message}`;
|
||||||
|
errorType = 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: errorMessage,
|
||||||
|
errorType: errorType
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
document.removeEventListener('mouseover', this.handleMouseOver);
|
document.removeEventListener('mouseover', this.handleMouseOver);
|
||||||
document.removeEventListener('click', this.handleClick, true);
|
document.removeEventListener('click', this.handleClick, true);
|
||||||
|
|
@ -540,7 +943,11 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||||
try {
|
try {
|
||||||
switch (message.action) {
|
switch (message.action) {
|
||||||
case 'startSelector':
|
case 'startSelector':
|
||||||
screenshotSelector.init(message.background);
|
// Extract clipboard preference from message, default to true for backward compatibility
|
||||||
|
const copyToClipboard = message.copyToClipboard !== undefined ? message.copyToClipboard : true;
|
||||||
|
// Extract save to PC preference from message, default to true for backward compatibility
|
||||||
|
const saveToPc = message.saveToPc !== undefined ? message.saveToPc : true;
|
||||||
|
screenshotSelector.init(message.background, copyToClipboard, saveToPc);
|
||||||
sendResponse({ success: true });
|
sendResponse({ success: true });
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|
@ -553,6 +960,15 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||||
sendResponse({ isActive: screenshotSelector.isActive });
|
sendResponse({ isActive: screenshotSelector.isActive });
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'checkClipboardSupport':
|
||||||
|
const availability = screenshotSelector.checkClipboardAPIAvailability();
|
||||||
|
const feedback = screenshotSelector.getClipboardSupportFeedback();
|
||||||
|
sendResponse({
|
||||||
|
availability: availability,
|
||||||
|
feedback: feedback
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
sendResponse({ error: 'Unknown action: ' + message.action });
|
sendResponse({ error: 'Unknown action: ' + message.action });
|
||||||
}
|
}
|
||||||
|
|
@ -569,7 +985,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||||
if (message.action === 'activateFromShortcut') {
|
if (message.action === 'activateFromShortcut') {
|
||||||
if (!screenshotSelector.isActive) {
|
if (!screenshotSelector.isActive) {
|
||||||
screenshotSelector.init('black'); // Default background for shortcut
|
screenshotSelector.init('black', true); // Default background and clipboard enabled for shortcut
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
4466
package-lock.json
generated
Normal file
4466
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
37
package.json
Normal file
37
package.json
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
{
|
||||||
|
"name": "full-screenshot-selector-tests",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Test suite for Full Screenshot Selector Chrome Extension clipboard functionality",
|
||||||
|
"scripts": {
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch",
|
||||||
|
"test:coverage": "jest --coverage",
|
||||||
|
"test:clipboard": "jest tests/clipboard-functionality.test.js",
|
||||||
|
"test:persistence": "jest tests/preference-persistence.test.js",
|
||||||
|
"test:integration": "jest tests/integration-scenarios.test.js"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/jest": "^29.5.8",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"jest-environment-jsdom": "^29.7.0"
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"testEnvironment": "jsdom",
|
||||||
|
"setupFilesAfterEnv": [
|
||||||
|
"<rootDir>/tests/setup.js"
|
||||||
|
],
|
||||||
|
"collectCoverageFrom": [
|
||||||
|
"content.js",
|
||||||
|
"popup.js",
|
||||||
|
"!node_modules/**"
|
||||||
|
],
|
||||||
|
"coverageReporters": [
|
||||||
|
"text",
|
||||||
|
"lcov",
|
||||||
|
"html"
|
||||||
|
],
|
||||||
|
"testMatch": [
|
||||||
|
"**/tests/**/*.test.js"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
129
popup.html
129
popup.html
|
|
@ -80,6 +80,76 @@
|
||||||
background: #545b62;
|
background: #545b62;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toggle-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 44px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch input {
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-slider {
|
||||||
|
position: absolute;
|
||||||
|
cursor: pointer;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: #ccc;
|
||||||
|
transition: 0.3s;
|
||||||
|
border-radius: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-slider:before {
|
||||||
|
position: absolute;
|
||||||
|
content: "";
|
||||||
|
height: 18px;
|
||||||
|
width: 18px;
|
||||||
|
left: 3px;
|
||||||
|
bottom: 3px;
|
||||||
|
background-color: white;
|
||||||
|
transition: 0.3s;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:checked + .toggle-slider {
|
||||||
|
background-color: #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus + .toggle-slider {
|
||||||
|
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
input:checked + .toggle-slider:before {
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-help {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 4px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
.shortcut-info {
|
.shortcut-info {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #666;
|
color: #666;
|
||||||
|
|
@ -107,6 +177,23 @@
|
||||||
background: #f8d7da;
|
background: #f8d7da;
|
||||||
color: #721c24;
|
color: #721c24;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status.warning {
|
||||||
|
background: #fff3cd;
|
||||||
|
color: #856404;
|
||||||
|
border: 1px solid #ffeaa7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
background: #6c757d;
|
||||||
|
color: #fff;
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled:hover {
|
||||||
|
background: #6c757d;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -124,6 +211,48 @@
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-group">
|
||||||
|
<div class="toggle-container">
|
||||||
|
<label class="toggle-label">Save to PC</label>
|
||||||
|
<label class="toggle-switch" for="save-to-pc-toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="save-to-pc-toggle"
|
||||||
|
checked
|
||||||
|
aria-describedby="save-to-pc-help"
|
||||||
|
aria-label="Toggle save screenshot to PC"
|
||||||
|
>
|
||||||
|
<span class="toggle-slider" role="presentation"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="save-to-pc-help" class="toggle-help">
|
||||||
|
Automatically download screenshots as PNG files to your default downloads folder.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-group">
|
||||||
|
<div class="toggle-container">
|
||||||
|
<label class="toggle-label">Copy to Clipboard</label>
|
||||||
|
<label class="toggle-switch" for="clipboard-toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="clipboard-toggle"
|
||||||
|
checked
|
||||||
|
aria-describedby="clipboard-help"
|
||||||
|
aria-label="Toggle copy screenshot to clipboard"
|
||||||
|
>
|
||||||
|
<span class="toggle-slider" role="presentation"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="clipboard-help" class="toggle-help">
|
||||||
|
Automatically copy screenshots to your clipboard for quick pasting.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="validation-warning" class="status warning" style="display: none;" role="alert" aria-live="polite">
|
||||||
|
⚠️ Please enable at least one output method (Save to PC or Copy to Clipboard) to capture screenshots.
|
||||||
|
</div>
|
||||||
|
|
||||||
<button id="start-selector" class="btn btn-primary">
|
<button id="start-selector" class="btn btn-primary">
|
||||||
Start Element Selector
|
Start Element Selector
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
125
popup.js
125
popup.js
|
|
@ -1,23 +1,70 @@
|
||||||
// Popup script for Chrome extension
|
// Popup script for Chrome extension
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
const backgroundSelect = document.getElementById('background-select');
|
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 startBtn = document.getElementById('start-selector');
|
||||||
const stopBtn = document.getElementById('stop-selector');
|
const stopBtn = document.getElementById('stop-selector');
|
||||||
const status = document.getElementById('status');
|
const status = document.getElementById('status');
|
||||||
|
const validationWarning = document.getElementById('validation-warning');
|
||||||
|
|
||||||
// Load saved background preference
|
// Load saved preferences
|
||||||
const saved = await chrome.storage.sync.get(['backgroundPreference']);
|
const saved = await chrome.storage.sync.get(['backgroundPreference', 'copyToClipboard', 'saveToPc']);
|
||||||
if (saved.backgroundPreference) {
|
if (saved.backgroundPreference) {
|
||||||
backgroundSelect.value = 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
|
// Save background preference when changed
|
||||||
backgroundSelect.addEventListener('change', () => {
|
backgroundSelect.addEventListener('change', () => {
|
||||||
chrome.storage.sync.set({ backgroundPreference: backgroundSelect.value });
|
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
|
// Start selector
|
||||||
startBtn.addEventListener('click', async () => {
|
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 });
|
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||||
|
|
||||||
// Check if page is compatible
|
// Check if page is compatible
|
||||||
|
|
@ -34,10 +81,23 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
try {
|
try {
|
||||||
await chrome.tabs.sendMessage(tab.id, {
|
await chrome.tabs.sendMessage(tab.id, {
|
||||||
action: 'startSelector',
|
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);
|
toggleButtons(true);
|
||||||
|
|
||||||
// Auto-close popup after starting
|
// Auto-close popup after starting
|
||||||
|
|
@ -45,11 +105,27 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Full error details:', error);
|
console.error('Full error details:', error);
|
||||||
|
|
||||||
|
let errorMessage = 'Error: Could not activate on this page.';
|
||||||
|
|
||||||
if (error.message.includes('Could not establish connection')) {
|
if (error.message.includes('Could not establish connection')) {
|
||||||
showStatus('Error: Content script not loaded. Try refreshing the page.', 'error');
|
errorMessage = 'Error: Content script not loaded. Try refreshing the page and try again.';
|
||||||
} else {
|
} else if (error.message.includes('permission')) {
|
||||||
showStatus('Error: Could not activate on this page.', 'error');
|
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
|
// Check current state
|
||||||
checkSelectorState();
|
checkSelectorState();
|
||||||
|
|
||||||
|
// Tooltips removed
|
||||||
|
|
||||||
function toggleButtons(isActive) {
|
function toggleButtons(isActive) {
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
startBtn.style.display = 'none';
|
startBtn.style.display = 'none';
|
||||||
|
|
@ -111,6 +189,8 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
console.log('Content script not ready or selector inactive:', error.message);
|
console.log('Content script not ready or selector inactive:', error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tooltips removed: no setup needed
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen for messages from content script
|
// Listen for messages from content script
|
||||||
|
|
@ -119,15 +199,44 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||||
const startBtn = document.getElementById('start-selector');
|
const startBtn = document.getElementById('start-selector');
|
||||||
const stopBtn = document.getElementById('stop-selector');
|
const stopBtn = document.getElementById('stop-selector');
|
||||||
const status = document.getElementById('status');
|
const status = document.getElementById('status');
|
||||||
|
const saveToPcToggle = document.getElementById('save-to-pc-toggle');
|
||||||
|
const clipboardToggle = document.getElementById('clipboard-toggle');
|
||||||
|
|
||||||
startBtn.style.display = 'block';
|
startBtn.style.display = 'block';
|
||||||
stopBtn.style.display = 'none';
|
stopBtn.style.display = 'none';
|
||||||
|
|
||||||
if (message.reason === 'screenshot-taken') {
|
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.className = 'status success';
|
||||||
status.style.display = 'block';
|
status.style.display = 'block';
|
||||||
setTimeout(() => status.style.display = 'none', 2000);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
480
tests/clipboard-functionality.test.js
Normal file
480
tests/clipboard-functionality.test.js
Normal file
|
|
@ -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.');
|
||||||
|
});
|
||||||
|
});
|
||||||
557
tests/integration-scenarios.test.js
Normal file
557
tests/integration-scenarios.test.js
Normal file
|
|
@ -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 = `<div>${successMessage}</div>`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
downloadSuccess: true,
|
||||||
|
clipboardResult: clipboardResult,
|
||||||
|
message: successMessage
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
this.overlay.innerHTML = `<div>Screenshot failed: ${error.message}</div>`;
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
414
tests/preference-persistence.test.js
Normal file
414
tests/preference-persistence.test.js
Normal file
|
|
@ -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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
641
tests/save-to-pc-clipboard-combinations.test.js
Normal file
641
tests/save-to-pc-clipboard-combinations.test.js
Normal file
|
|
@ -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
|
||||||
|
});
|
||||||
|
});
|
||||||
297
tests/setup.js
Normal file
297
tests/setup.js
Normal file
|
|
@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
209
tests/validation-logic.test.js
Normal file
209
tests/validation-logic.test.js
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue