Compare commits

..

2 commits

Author SHA1 Message Date
f4b9af1572
Better image support 2025-12-20 18:26:20 -07:00
Karthikeyan N
9dd4d706fa Add copy to clipboard and save to pc toggle.
* escaped node modules from vcs

* feat: added copy to clipboard feature

* feat: added tests

* feat: added feature spec

* feat: added toggle for save to pc

* feat: added tests

* refactor: change toggle from '?' button to the 'switch' component

* Fix failing tests

* Update gitignore and remove unnecessary files

* Remove tooltips because they are unnecessary

---------

Co-authored-by: Joey Yakimowich-Payne <jyapayne@gmail.com>
2025-08-13 13:30:55 -06:00
8 changed files with 1557 additions and 197 deletions

3
.gitignore vendored
View file

@ -1,3 +1,4 @@
node_modules/ node_modules/
.kiro/ .kiro/
.vscode/ html-to-image/
.vscode/

View file

@ -369,7 +369,7 @@ class ScreenshotSelector {
try { try {
// Determine loading message based on enabled operations // Determine loading message based on enabled operations
let loadingMessage = 'Capturing screenshot...'; let loadingMessage = 'Capturing screenshot...';
if (this.saveToPc && this.copyToClipboard) { if (this.saveToPc && this.copyToClipboard) {
// Both operations enabled // Both operations enabled
const availability = this.checkClipboardAPIAvailability(); const availability = this.checkClipboardAPIAvailability();
@ -411,90 +411,42 @@ class ScreenshotSelector {
element.classList.remove('screenshot-highlight'); element.classList.remove('screenshot-highlight');
this.removeScrollableIndicators(); this.removeScrollableIndicators();
// Store original styles
const originalStyles = {
overflow: element.style.overflow,
overflowY: element.style.overflowY,
overflowX: element.style.overflowX,
height: element.style.height,
maxHeight: element.style.maxHeight,
position: element.style.position,
zIndex: element.style.zIndex,
};
// Temporarily modify element for full capture
element.style.overflow = 'visible';
element.style.overflowY = 'visible';
element.style.overflowX = 'visible';
element.style.height = `${element.scrollHeight}px`;
element.style.maxHeight = 'none';
// Wait for fonts and external resources to load // Wait for fonts and external resources to load
await this.waitForFontsAndResources(element); await this.waitForFontsAndResources(element);
// Enhanced html2canvas configuration if (!window.htmlToImage || typeof window.htmlToImage.toCanvas !== 'function') {
const canvas = await html2canvas(element, { throw new Error('Screenshot renderer not available: htmlToImage.toCanvas() missing');
useCORS: true, }
allowTaint: false,
backgroundColor: this.background === 'transparent' ? null :
this.background === 'white' ? '#ffffff' : '#000000',
// Improved rendering options const backgroundColor =
scale: window.devicePixelRatio || 1, // Use device pixel ratio for crisp images this.background === 'transparent'
logging: false, // Disable logging for cleaner console ? undefined
: this.background === 'white'
? '#ffffff'
: '#000000';
// Better handling of external resources // Use html-to-image to render the element to a canvas.
imageTimeout: 15000, // Wait longer for images to load // Important: avoid mutating the live DOM during capture (it can cause reflow/layout drift).
const canvas = await window.htmlToImage.toCanvas(element, {
// Font handling and style preservation backgroundColor,
onclone: (clonedDoc, clonedElementParam) => { pixelRatio: window.devicePixelRatio || 1,
// Ensure all stylesheets are loaded in cloned document // Brightspace/D2L and many sites have CORS-protected stylesheets/fonts which cause noisy,
const originalStyleSheets = Array.from(document.styleSheets); // expected DOMExceptions during CSS rule inspection. Keep logs clean by default.
const clonedHead = clonedDoc.head; logLevel: 'silent',
// If the page heavily caches fonts/images, enabling this can help but may slow capture
// Copy all stylesheets to cloned document cacheBust: false,
originalStyleSheets.forEach(styleSheet => { // Capture full scrollable area without reflowing the live element
try { width: element.scrollWidth,
if (styleSheet.href) { height: element.scrollHeight,
// External stylesheet style: {
const link = clonedDoc.createElement('link'); overflow: 'visible',
link.rel = 'stylesheet'; overflowX: 'visible',
link.href = styleSheet.href; overflowY: 'visible',
link.type = 'text/css'; height: `${element.scrollHeight}px`,
clonedHead.appendChild(link); maxHeight: 'none',
} else if (styleSheet.ownerNode && styleSheet.ownerNode.tagName === 'STYLE') { },
// Inline stylesheet
const style = clonedDoc.createElement('style');
style.type = 'text/css';
try {
const cssText = Array.from(styleSheet.cssRules).map(rule => rule.cssText).join('\n');
style.textContent = cssText;
} catch (e) {
// Fallback to original text content
style.textContent = styleSheet.ownerNode.textContent;
}
clonedHead.appendChild(style);
}
} catch (e) {
// Skip stylesheets that can't be accessed (CORS issues)
console.log('Skipped stylesheet due to CORS:', e);
}
});
// Apply computed styles to preserve appearance
const clonedElement = clonedElementParam;
const originalElement = element; // from outer scope
if (clonedElement && originalElement) {
this.preserveComputedStyles(clonedElement, originalElement);
}
return clonedDoc;
}
}); });
// Restore original styles
Object.assign(element.style, originalStyles);
// Conditionally download the image based on saveToPc preference // Conditionally download the image based on saveToPc preference
if (this.saveToPc) { if (this.saveToPc) {
const link = document.createElement('a'); const link = document.createElement('a');
@ -515,15 +467,15 @@ class ScreenshotSelector {
// Ensure clipboard errors don't break the workflow - this is a safety net // Ensure clipboard errors don't break the workflow - this is a safety net
// The copyCanvasToClipboard method should handle all errors internally // The copyCanvasToClipboard method should handle all errors internally
console.error('Unexpected clipboard error caught in captureElement:', error); console.error('Unexpected clipboard error caught in captureElement:', error);
clipboardResult = { clipboardResult = {
success: false, success: false,
error: 'Unexpected clipboard error occurred', error: 'Unexpected clipboard error occurred',
errorType: 'unexpected' errorType: 'unexpected'
}; };
} }
} else { } else {
clipboardResult = { clipboardResult = {
success: false, success: false,
error: availability.reason, error: availability.reason,
errorType: 'api_unavailable' errorType: 'api_unavailable'
}; };
@ -534,7 +486,7 @@ class ScreenshotSelector {
let successMessage = 'Screenshot captured!'; let successMessage = 'Screenshot captured!';
let messageColor = 'rgba(39, 174, 96, 0.95)'; // Green for success let messageColor = 'rgba(39, 174, 96, 0.95)'; // Green for success
let messageIcon = '✅'; let messageIcon = '✅';
// Determine success message based on enabled operations // Determine success message based on enabled operations
if (this.saveToPc && this.copyToClipboard) { if (this.saveToPc && this.copyToClipboard) {
// Both operations enabled // Both operations enabled
@ -582,11 +534,11 @@ 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 // Provide specific error messages for screenshot failures based on enabled operations
let errorMessage = 'Screenshot capture failed. Please try again.'; let errorMessage = 'Screenshot capture failed. Please try again.';
let operationContext = ''; let operationContext = '';
// Add context about what operations were attempted // Add context about what operations were attempted
if (this.saveToPc && this.copyToClipboard) { if (this.saveToPc && this.copyToClipboard) {
operationContext = ' Neither file download nor clipboard copy could be completed.'; operationContext = ' Neither file download nor clipboard copy could be completed.';
@ -595,9 +547,9 @@ class ScreenshotSelector {
} else if (!this.saveToPc && this.copyToClipboard) { } else if (!this.saveToPc && this.copyToClipboard) {
operationContext = ' Clipboard copy could not be completed.'; operationContext = ' Clipboard copy could not be completed.';
} }
if (error.message) { if (error.message) {
if (error.message.includes('html2canvas')) { if (error.message.includes('htmlToImage') || error.message.includes('html-to-image')) {
errorMessage = `Screenshot rendering failed. Try selecting a different element or refresh the page.${operationContext}`; errorMessage = `Screenshot rendering failed. Try selecting a different element or refresh the page.${operationContext}`;
} else if (error.message.includes('timeout')) { } else if (error.message.includes('timeout')) {
errorMessage = `Screenshot capture timed out. Try selecting a smaller area or simpler element.${operationContext}`; errorMessage = `Screenshot capture timed out. Try selecting a smaller area or simpler element.${operationContext}`;
@ -613,7 +565,7 @@ class ScreenshotSelector {
} else { } else {
errorMessage = `Screenshot capture failed. Please try again.${operationContext}`; 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;">
@ -622,10 +574,10 @@ class ScreenshotSelector {
</div> </div>
</div> </div>
`; `;
// Notify popup about the failure // Notify popup about the failure
chrome.runtime.sendMessage({ action: 'selectorStopped', reason: 'screenshot-failed' }); chrome.runtime.sendMessage({ action: 'selectorStopped', reason: 'screenshot-failed' });
setTimeout(() => this.cleanup(), 3000); setTimeout(() => this.cleanup(), 3000);
} }
} }
@ -678,7 +630,7 @@ class ScreenshotSelector {
*/ */
getClipboardSupportFeedback() { getClipboardSupportFeedback() {
const availability = this.checkClipboardAPIAvailability(); const availability = this.checkClipboardAPIAvailability();
if (availability.available) { if (availability.available) {
return { return {
supported: true, supported: true,
@ -688,7 +640,7 @@ class ScreenshotSelector {
// Provide user-friendly messages for different scenarios // Provide user-friendly messages for different scenarios
let userMessage = ''; let userMessage = '';
if (availability.reason.includes('not available') || availability.reason.includes('not supported')) { if (availability.reason.includes('not available') || availability.reason.includes('not supported')) {
userMessage = 'Your browser doesn\'t support clipboard copying. Screenshots will still be downloaded.'; userMessage = 'Your browser doesn\'t support clipboard copying. Screenshots will still be downloaded.';
} else if (availability.reason.includes('secure context')) { } else if (availability.reason.includes('secure context')) {
@ -723,39 +675,39 @@ class ScreenshotSelector {
switch (errorType) { switch (errorType) {
case 'permission_denied': case 'permission_denied':
return 'clipboard copy failed: permission denied. Please allow clipboard access in your browser settings'; return 'clipboard copy failed: permission denied. Please allow clipboard access in your browser settings';
case 'not_supported': case 'not_supported':
case 'api_unavailable': case 'api_unavailable':
return 'clipboard copy is not available in this browser'; return 'clipboard copy is not available in this browser';
case 'security_error': case 'security_error':
return 'clipboard copy was blocked by browser security. Try using HTTPS or check site permissions'; return 'clipboard copy was blocked by browser security. Try using HTTPS or check site permissions';
case 'size_limit': case 'size_limit':
case 'quota_exceeded': case 'quota_exceeded':
return 'clipboard copy failed: image too large. Try capturing a smaller area'; return 'clipboard copy failed: image too large. Try capturing a smaller area';
case 'timeout': case 'timeout':
return 'clipboard copy timed out. The image may be too large or complex'; return 'clipboard copy timed out. The image may be too large or complex';
case 'network_error': case 'network_error':
return 'clipboard copy failed due to network error. Please try again'; return 'clipboard copy failed due to network error. Please try again';
case 'canvas_conversion': case 'canvas_conversion':
return 'clipboard copy failed: unable to prepare image. Try capturing a different element'; return 'clipboard copy failed: unable to prepare image. Try capturing a different element';
case 'clipboard_item_creation': case 'clipboard_item_creation':
return 'clipboard copy is not supported: your browser doesn\'t support image clipboard operations'; return 'clipboard copy is not supported: your browser doesn\'t support image clipboard operations';
case 'invalid_state': case 'invalid_state':
return 'clipboard copy failed: browser clipboard is busy. Please try again'; return 'clipboard copy failed: browser clipboard is busy. Please try again';
case 'data_error': case 'data_error':
return 'clipboard copy failed: invalid image data. Please try capturing again'; return 'clipboard copy failed: invalid image data. Please try capturing again';
case 'unexpected': case 'unexpected':
return 'clipboard copy failed due to unexpected error'; return 'clipboard copy failed due to unexpected error';
default: default:
// For unknown error types, try to extract meaningful info from the error message // For unknown error types, try to extract meaningful info from the error message
if (errorMessage.includes('permission') || errorMessage.includes('denied')) { if (errorMessage.includes('permission') || errorMessage.includes('denied')) {
@ -782,8 +734,8 @@ class ScreenshotSelector {
// Check clipboard API availability first // Check clipboard API availability first
const availability = this.checkClipboardAPIAvailability(); const availability = this.checkClipboardAPIAvailability();
if (!availability.available) { if (!availability.available) {
return { return {
success: false, success: false,
error: availability.reason, error: availability.reason,
errorType: 'api_unavailable' errorType: 'api_unavailable'
}; };
@ -800,7 +752,7 @@ class ScreenshotSelector {
} }
}, 'image/png'); }, 'image/png');
}), }),
new Promise((_, reject) => new Promise((_, reject) =>
setTimeout(() => reject(new Error('Canvas to blob conversion timed out')), 10000) setTimeout(() => reject(new Error('Canvas to blob conversion timed out')), 10000)
) )
]); ]);
@ -832,7 +784,7 @@ class ScreenshotSelector {
// Write to clipboard with timeout // Write to clipboard with timeout
await Promise.race([ await Promise.race([
navigator.clipboard.write([clipboardItem]), navigator.clipboard.write([clipboardItem]),
new Promise((_, reject) => new Promise((_, reject) =>
setTimeout(() => reject(new Error('Clipboard write operation timed out')), 15000) setTimeout(() => reject(new Error('Clipboard write operation timed out')), 15000)
) )
]); ]);
@ -841,16 +793,16 @@ class ScreenshotSelector {
} catch (error) { } catch (error) {
console.error('Clipboard operation failed:', error); console.error('Clipboard operation failed:', error);
// Comprehensive error handling with specific error types and user-friendly messages // Comprehensive error handling with specific error types and user-friendly messages
let errorMessage = error.message || 'Unknown clipboard error'; let errorMessage = error.message || 'Unknown clipboard error';
let errorType = 'unknown'; let errorType = 'unknown';
// Permission-related errors // Permission-related errors
if (error.name === 'NotAllowedError') { if (error.name === 'NotAllowedError') {
errorMessage = 'Clipboard access denied. Please allow clipboard permissions in your browser settings.'; errorMessage = 'Clipboard access denied. Please allow clipboard permissions in your browser settings.';
errorType = 'permission_denied'; errorType = 'permission_denied';
} }
// API support errors // API support errors
else if (error.name === 'NotSupportedError') { else if (error.name === 'NotSupportedError') {
errorMessage = 'Clipboard API not supported in this browser context.'; errorMessage = 'Clipboard API not supported in this browser context.';
@ -897,8 +849,8 @@ class ScreenshotSelector {
errorType = 'unknown'; errorType = 'unknown';
} }
return { return {
success: false, success: false,
error: errorMessage, error: errorMessage,
errorType: errorType errorType: errorType
}; };
@ -963,7 +915,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
case 'checkClipboardSupport': case 'checkClipboardSupport':
const availability = screenshotSelector.checkClipboardAPIAvailability(); const availability = screenshotSelector.checkClipboardAPIAvailability();
const feedback = screenshotSelector.getClipboardSupportFeedback(); const feedback = screenshotSelector.getClipboardSupportFeedback();
sendResponse({ sendResponse({
availability: availability, availability: availability,
feedback: feedback feedback: feedback
}); });

1389
html-to-image.js Normal file

File diff suppressed because it is too large Load diff

1
html-to-image.js.map Normal file

File diff suppressed because one or more lines are too long

View file

@ -24,7 +24,7 @@
"content_scripts": [ "content_scripts": [
{ {
"matches": ["<all_urls>"], "matches": ["<all_urls>"],
"js": ["html2canvas-pro.min.js", "content.js"], "js": ["html-to-image.js", "content.js"],
"run_at": "document_idle" "run_at": "document_idle"
} }
], ],

View file

@ -53,8 +53,10 @@ global.window = {
}) })
}; };
// Mock html2canvas // Mock html-to-image
global.html2canvas = jest.fn(); global.htmlToImage = {
toCanvas: jest.fn(),
};
describe('Integration Scenarios - Clipboard with Different Backgrounds', () => { describe('Integration Scenarios - Clipboard with Different Backgrounds', () => {
let screenshotSelector; let screenshotSelector;
@ -63,7 +65,7 @@ describe('Integration Scenarios - Clipboard with Different Backgrounds', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
// Reset clipboard mock for each test // Reset clipboard mock for each test
if (!global.navigator) { if (!global.navigator) {
global.navigator = {}; global.navigator = {};
@ -75,7 +77,7 @@ describe('Integration Scenarios - Clipboard with Different Backgrounds', () => {
return { data, type: 'image/png' }; return { data, type: 'image/png' };
}); });
global.window.isSecureContext = true; global.window.isSecureContext = true;
// Mock element to capture // Mock element to capture
mockElement = { mockElement = {
classList: { classList: {
@ -102,7 +104,7 @@ describe('Integration Scenarios - Clipboard with Different Backgrounds', () => {
toDataURL: jest.fn(() => 'data:image/png;base64,mockdata') toDataURL: jest.fn(() => 'data:image/png;base64,mockdata')
}; };
global.html2canvas = jest.fn().mockResolvedValue(mockCanvas); global.htmlToImage.toCanvas = jest.fn().mockResolvedValue(mockCanvas);
@ -183,11 +185,17 @@ describe('Integration Scenarios - Clipboard with Different Backgrounds', () => {
async captureElement(element) { async captureElement(element) {
try { try {
// Generate canvas with background-specific settings // Generate canvas with background-specific settings
const canvas = await global.html2canvas(element, { const backgroundColor =
backgroundColor: this.background === 'transparent' ? null : this.background === 'transparent'
this.background === 'white' ? '#ffffff' : '#000000', ? undefined
useCORS: true, : this.background === 'white'
allowTaint: false ? '#ffffff'
: '#000000';
const canvas = await global.htmlToImage.toCanvas(element, {
backgroundColor,
pixelRatio: global.window.devicePixelRatio || 1,
cacheBust: false
}); });
// Always download first // Always download first
@ -205,7 +213,7 @@ describe('Integration Scenarios - Clipboard with Different Backgrounds', () => {
// Update UI based on results (matching new message format) // Update UI based on results (matching new message format)
let successMessage = 'Screenshot captured!'; let successMessage = 'Screenshot captured!';
let messageIcon = '✅'; let messageIcon = '✅';
if (this.saveToPc && this.copyToClipboard) { if (this.saveToPc && this.copyToClipboard) {
// Both operations enabled // Both operations enabled
if (clipboardResult && clipboardResult.success) { if (clipboardResult && clipboardResult.success) {
@ -235,7 +243,7 @@ describe('Integration Scenarios - Clipboard with Different Backgrounds', () => {
} }
this.overlay.innerHTML = `<div>${successMessage}</div>`; this.overlay.innerHTML = `<div>${successMessage}</div>`;
return { return {
success: true, success: true,
downloadSuccess: true, downloadSuccess: true,
@ -258,13 +266,11 @@ describe('Integration Scenarios - Clipboard with Different Backgrounds', () => {
test('should capture with transparent background and copy to clipboard', async () => { test('should capture with transparent background and copy to clipboard', async () => {
await screenshotSelector.init('transparent', true); await screenshotSelector.init('transparent', true);
const result = await screenshotSelector.captureElement(mockElement); const result = await screenshotSelector.captureElement(mockElement);
expect(global.html2canvas).toHaveBeenCalledWith(mockElement, expect.objectContaining({ expect(global.htmlToImage.toCanvas).toHaveBeenCalledWith(mockElement, expect.objectContaining({
backgroundColor: null, // Transparent background backgroundColor: undefined, // Transparent background
useCORS: true,
allowTaint: false
})); }));
expect(result.success).toBe(true); expect(result.success).toBe(true);
@ -275,13 +281,11 @@ describe('Integration Scenarios - Clipboard with Different Backgrounds', () => {
test('should capture with black background and copy to clipboard', async () => { test('should capture with black background and copy to clipboard', async () => {
await screenshotSelector.init('black', true); await screenshotSelector.init('black', true);
const result = await screenshotSelector.captureElement(mockElement); const result = await screenshotSelector.captureElement(mockElement);
expect(global.html2canvas).toHaveBeenCalledWith(mockElement, expect.objectContaining({ expect(global.htmlToImage.toCanvas).toHaveBeenCalledWith(mockElement, expect.objectContaining({
backgroundColor: '#000000', // Black background backgroundColor: '#000000', // Black background
useCORS: true,
allowTaint: false
})); }));
expect(result.success).toBe(true); expect(result.success).toBe(true);
@ -291,13 +295,11 @@ describe('Integration Scenarios - Clipboard with Different Backgrounds', () => {
test('should capture with white background and copy to clipboard', async () => { test('should capture with white background and copy to clipboard', async () => {
await screenshotSelector.init('white', true); await screenshotSelector.init('white', true);
const result = await screenshotSelector.captureElement(mockElement); const result = await screenshotSelector.captureElement(mockElement);
expect(global.html2canvas).toHaveBeenCalledWith(mockElement, expect.objectContaining({ expect(global.htmlToImage.toCanvas).toHaveBeenCalledWith(mockElement, expect.objectContaining({
backgroundColor: '#ffffff', // White background backgroundColor: '#ffffff', // White background
useCORS: true,
allowTaint: false
})); }));
expect(result.success).toBe(true); expect(result.success).toBe(true);
@ -307,7 +309,7 @@ describe('Integration Scenarios - Clipboard with Different Backgrounds', () => {
test('should capture without clipboard when disabled', async () => { test('should capture without clipboard when disabled', async () => {
await screenshotSelector.init('black', false, true); // saveToPc = true, copyToClipboard = false await screenshotSelector.init('black', false, true); // saveToPc = true, copyToClipboard = false
const result = await screenshotSelector.captureElement(mockElement); const result = await screenshotSelector.captureElement(mockElement);
expect(result.success).toBe(true); expect(result.success).toBe(true);
@ -324,9 +326,9 @@ describe('Integration Scenarios - Clipboard with Different Backgrounds', () => {
global.navigator.clipboard = { global.navigator.clipboard = {
write: jest.fn().mockRejectedValue(new DOMException('Permission denied', 'NotAllowedError')) write: jest.fn().mockRejectedValue(new DOMException('Permission denied', 'NotAllowedError'))
}; };
await screenshotSelector.init('black', true); await screenshotSelector.init('black', true);
const result = await screenshotSelector.captureElement(mockElement); const result = await screenshotSelector.captureElement(mockElement);
expect(result.success).toBe(true); expect(result.success).toBe(true);
@ -340,9 +342,9 @@ describe('Integration Scenarios - Clipboard with Different Backgrounds', () => {
test('should handle clipboard API unavailable scenario', async () => { test('should handle clipboard API unavailable scenario', async () => {
// Mock clipboard API not available // Mock clipboard API not available
global.navigator.clipboard = undefined; global.navigator.clipboard = undefined;
await screenshotSelector.init('transparent', true); await screenshotSelector.init('transparent', true);
const result = await screenshotSelector.captureElement(mockElement); const result = await screenshotSelector.captureElement(mockElement);
expect(result.success).toBe(true); expect(result.success).toBe(true);
@ -359,7 +361,7 @@ describe('Integration Scenarios - Complete Workflow', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
// Mock popup elements // Mock popup elements
mockPopupElements = { mockPopupElements = {
clipboardToggle: { checked: true, addEventListener: jest.fn() }, clipboardToggle: { checked: true, addEventListener: jest.fn() },
@ -399,18 +401,18 @@ describe('Integration Scenarios - Complete Workflow', () => {
const backgroundSelect = document.getElementById('background-select'); const backgroundSelect = document.getElementById('background-select');
const clipboardToggle = document.getElementById('clipboard-toggle'); const clipboardToggle = document.getElementById('clipboard-toggle');
const startBtn = document.getElementById('start-selector'); const startBtn = document.getElementById('start-selector');
// Load preferences // Load preferences
const saved = await chrome.storage.sync.get(['backgroundPreference', 'copyToClipboard']); const saved = await chrome.storage.sync.get(['backgroundPreference', 'copyToClipboard']);
if (saved.backgroundPreference) { if (saved.backgroundPreference) {
backgroundSelect.value = saved.backgroundPreference; backgroundSelect.value = saved.backgroundPreference;
} }
clipboardToggle.checked = saved.copyToClipboard !== undefined ? saved.copyToClipboard : true; clipboardToggle.checked = saved.copyToClipboard !== undefined ? saved.copyToClipboard : true;
// Start selector // Start selector
startBtn.addEventListener('click', async () => { startBtn.addEventListener('click', async () => {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
await chrome.tabs.sendMessage(tab.id, { await chrome.tabs.sendMessage(tab.id, {
action: 'startSelector', action: 'startSelector',
background: backgroundSelect.value, background: backgroundSelect.value,
@ -466,12 +468,12 @@ describe('Integration Scenarios - Complete Workflow', () => {
resolve(); resolve();
} }
}); });
const popupScript = ` const popupScript = `
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 clipboardToggle = document.getElementById('clipboard-toggle');
backgroundSelect.addEventListener('change', () => { backgroundSelect.addEventListener('change', () => {
chrome.storage.sync.set({ backgroundPreference: backgroundSelect.value }); chrome.storage.sync.set({ backgroundPreference: backgroundSelect.value });
}); });
@ -499,9 +501,9 @@ describe('Integration Scenarios - Complete Workflow', () => {
test('should handle incompatible pages gracefully', async () => { test('should handle incompatible pages gracefully', async () => {
// Mock incompatible page // Mock incompatible page
global.chrome.tabs.query.mockResolvedValue([{ global.chrome.tabs.query.mockResolvedValue([{
id: 123, id: 123,
url: 'chrome://settings/' url: 'chrome://settings/'
}]); }]);
let startButtonCallback; let startButtonCallback;
@ -514,7 +516,7 @@ describe('Integration Scenarios - Complete Workflow', () => {
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', async () => {
const startBtn = document.getElementById('start-selector'); const startBtn = document.getElementById('start-selector');
const status = document.getElementById('status'); const status = document.getElementById('status');
startBtn.addEventListener('click', async () => { startBtn.addEventListener('click', async () => {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
@ -527,7 +529,7 @@ describe('Integration Scenarios - Complete Workflow', () => {
status.style.display = 'block'; status.style.display = 'block';
return; return;
} }
// Normal flow would continue here // Normal flow would continue here
}); });
}); });

View file

@ -53,8 +53,10 @@ global.window = {
CSS: { escape: jest.fn(str => str) } CSS: { escape: jest.fn(str => str) }
}; };
// Mock html2canvas // Mock html-to-image
global.html2canvas = jest.fn(); global.htmlToImage = {
toCanvas: jest.fn(),
};
// Mock ScreenshotSelector class for testing // Mock ScreenshotSelector class for testing
class MockScreenshotSelector { class MockScreenshotSelector {
@ -126,10 +128,18 @@ class MockScreenshotSelector {
toDataURL: jest.fn(() => 'data:image/png;base64,mockdata') toDataURL: jest.fn(() => 'data:image/png;base64,mockdata')
}; };
// Mock html2canvas // Mock html-to-image
global.html2canvas.mockResolvedValue(mockCanvas); global.htmlToImage.toCanvas.mockResolvedValue(mockCanvas);
const canvas = await global.html2canvas(element); const canvas = await global.htmlToImage.toCanvas(element, {
backgroundColor: this.background === 'transparent'
? undefined
: this.background === 'white'
? '#ffffff'
: '#000000',
pixelRatio: global.window.devicePixelRatio || 1,
cacheBust: false
});
// Conditionally download based on saveToPc setting // Conditionally download based on saveToPc setting
if (this.saveToPc) { if (this.saveToPc) {
@ -159,7 +169,7 @@ describe('Save to PC and Clipboard Combinations', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
// Reset mocks // Reset mocks
mockCreateElement.mockClear(); mockCreateElement.mockClear();
mockCreateElement.mockReturnValue(mockLink); mockCreateElement.mockReturnValue(mockLink);
@ -168,7 +178,7 @@ describe('Save to PC and Clipboard Combinations', () => {
global.document.createElement = mockCreateElement; global.document.createElement = mockCreateElement;
} }
mockLink.click.mockClear(); mockLink.click.mockClear();
// Setup clipboard API mocks // Setup clipboard API mocks
global.navigator = { global.navigator = {
clipboard: { clipboard: {
@ -207,7 +217,7 @@ describe('Save to PC and Clipboard Combinations', () => {
// Verify download was triggered // Verify download was triggered
expect(mockCreateElement).toHaveBeenCalledWith('a'); expect(mockCreateElement).toHaveBeenCalledWith('a');
expect(mockLink.click).toHaveBeenCalled(); expect(mockLink.click).toHaveBeenCalled();
// Verify clipboard operation was attempted // Verify clipboard operation was attempted
expect(global.navigator.clipboard.write).toHaveBeenCalled(); expect(global.navigator.clipboard.write).toHaveBeenCalled();
}); });
@ -256,7 +266,7 @@ describe('Save to PC and Clipboard Combinations', () => {
// Verify download was triggered // Verify download was triggered
expect(mockCreateElement).toHaveBeenCalledWith('a'); expect(mockCreateElement).toHaveBeenCalledWith('a');
expect(mockLink.click).toHaveBeenCalled(); expect(mockLink.click).toHaveBeenCalled();
// Verify clipboard operation was NOT attempted // Verify clipboard operation was NOT attempted
expect(global.navigator.clipboard.write).not.toHaveBeenCalled(); expect(global.navigator.clipboard.write).not.toHaveBeenCalled();
}); });
@ -287,7 +297,7 @@ describe('Save to PC and Clipboard Combinations', () => {
// Verify download was NOT triggered // Verify download was NOT triggered
expect(mockCreateElement).not.toHaveBeenCalledWith('a'); expect(mockCreateElement).not.toHaveBeenCalledWith('a');
// Verify clipboard operation was attempted // Verify clipboard operation was attempted
expect(global.navigator.clipboard.write).toHaveBeenCalled(); expect(global.navigator.clipboard.write).toHaveBeenCalled();
}); });
@ -317,7 +327,7 @@ describe('Save to PC and Clipboard Combinations', () => {
// Verify download was NOT triggered // Verify download was NOT triggered
expect(mockCreateElement).not.toHaveBeenCalledWith('a'); expect(mockCreateElement).not.toHaveBeenCalledWith('a');
// Verify clipboard operation was NOT attempted // Verify clipboard operation was NOT attempted
expect(global.navigator.clipboard.write).not.toHaveBeenCalled(); expect(global.navigator.clipboard.write).not.toHaveBeenCalled();
}); });
@ -334,8 +344,11 @@ describe('Save to PC and Clipboard Combinations', () => {
test('should still create canvas even when both outputs are disabled', async () => { test('should still create canvas even when both outputs are disabled', async () => {
await screenshotSelector.captureElement(mockElement); await screenshotSelector.captureElement(mockElement);
// html2canvas should still be called (needed for potential future operations) // html-to-image should still be called (needed for potential future operations)
expect(global.html2canvas).toHaveBeenCalledWith(mockElement); expect(global.htmlToImage.toCanvas).toHaveBeenCalledWith(
mockElement,
expect.any(Object)
);
}); });
}); });
}); });
@ -349,24 +362,24 @@ describe('Popup Validation Logic for All Combinations', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
// Mock DOM elements // Mock DOM elements
clipboardToggle = { clipboardToggle = {
checked: true, checked: true,
addEventListener: jest.fn() addEventListener: jest.fn()
}; };
saveToPcToggle = { saveToPcToggle = {
checked: true, checked: true,
addEventListener: jest.fn() addEventListener: jest.fn()
}; };
startBtn = { startBtn = {
disabled: false, disabled: false,
setAttribute: jest.fn(), setAttribute: jest.fn(),
addEventListener: jest.fn() addEventListener: jest.fn()
}; };
validationWarning = { validationWarning = {
style: { display: 'none' } style: { display: 'none' }
}; };
@ -384,7 +397,7 @@ describe('Popup Validation Logic for All Combinations', () => {
// Define validation function // Define validation function
validateOutputMethods = function() { validateOutputMethods = function() {
const hasValidOutput = clipboardToggle.checked || saveToPcToggle.checked; const hasValidOutput = clipboardToggle.checked || saveToPcToggle.checked;
if (hasValidOutput) { if (hasValidOutput) {
validationWarning.style.display = 'none'; validationWarning.style.display = 'none';
startBtn.disabled = false; startBtn.disabled = false;
@ -394,7 +407,7 @@ describe('Popup Validation Logic for All Combinations', () => {
startBtn.disabled = true; startBtn.disabled = true;
startBtn.setAttribute('aria-describedby', 'validation-warning'); startBtn.setAttribute('aria-describedby', 'validation-warning');
} }
return hasValidOutput; return hasValidOutput;
}; };
}); });
@ -454,13 +467,13 @@ describe('Preference Persistence Across Browser Sessions', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
// Mock DOM elements // Mock DOM elements
mockClipboardToggle = { mockClipboardToggle = {
checked: false, checked: false,
addEventListener: jest.fn() addEventListener: jest.fn()
}; };
mockSaveToPcToggle = { mockSaveToPcToggle = {
checked: false, checked: false,
addEventListener: jest.fn() addEventListener: jest.fn()
@ -481,13 +494,13 @@ describe('Preference Persistence Across Browser Sessions', () => {
test('should default to both enabled for new installations', async () => { test('should default to both enabled for new installations', async () => {
// Mock no saved preferences (new installation) // Mock no saved preferences (new installation)
global.chrome.storage.sync.get.mockResolvedValueOnce({}); global.chrome.storage.sync.get.mockResolvedValueOnce({});
const popupScript = ` const popupScript = `
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', async () => {
const clipboardToggle = document.getElementById('clipboard-toggle'); const clipboardToggle = document.getElementById('clipboard-toggle');
const saveToPcToggle = document.getElementById('save-to-pc-toggle'); const saveToPcToggle = document.getElementById('save-to-pc-toggle');
const saved = await chrome.storage.sync.get(['backgroundPreference', 'copyToClipboard', 'saveToPc']); const saved = await chrome.storage.sync.get(['backgroundPreference', 'copyToClipboard', 'saveToPc']);
clipboardToggle.checked = saved.copyToClipboard !== undefined ? saved.copyToClipboard : true; clipboardToggle.checked = saved.copyToClipboard !== undefined ? saved.copyToClipboard : true;
saveToPcToggle.checked = saved.saveToPc !== undefined ? saved.saveToPc : true; saveToPcToggle.checked = saved.saveToPc !== undefined ? saved.saveToPc : true;
}); });
@ -513,13 +526,13 @@ describe('Preference Persistence Across Browser Sessions', () => {
copyToClipboard: false, copyToClipboard: false,
saveToPc: true saveToPc: true
}); });
const popupScript = ` const popupScript = `
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', async () => {
const clipboardToggle = document.getElementById('clipboard-toggle'); const clipboardToggle = document.getElementById('clipboard-toggle');
const saveToPcToggle = document.getElementById('save-to-pc-toggle'); const saveToPcToggle = document.getElementById('save-to-pc-toggle');
const saved = await chrome.storage.sync.get(['backgroundPreference', 'copyToClipboard', 'saveToPc']); const saved = await chrome.storage.sync.get(['backgroundPreference', 'copyToClipboard', 'saveToPc']);
clipboardToggle.checked = saved.copyToClipboard !== undefined ? saved.copyToClipboard : true; clipboardToggle.checked = saved.copyToClipboard !== undefined ? saved.copyToClipboard : true;
saveToPcToggle.checked = saved.saveToPc !== undefined ? saved.saveToPc : true; saveToPcToggle.checked = saved.saveToPc !== undefined ? saved.saveToPc : true;
}); });
@ -545,13 +558,13 @@ describe('Preference Persistence Across Browser Sessions', () => {
copyToClipboard: true, copyToClipboard: true,
saveToPc: false saveToPc: false
}); });
const popupScript = ` const popupScript = `
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', async () => {
const clipboardToggle = document.getElementById('clipboard-toggle'); const clipboardToggle = document.getElementById('clipboard-toggle');
const saveToPcToggle = document.getElementById('save-to-pc-toggle'); const saveToPcToggle = document.getElementById('save-to-pc-toggle');
const saved = await chrome.storage.sync.get(['backgroundPreference', 'copyToClipboard', 'saveToPc']); const saved = await chrome.storage.sync.get(['backgroundPreference', 'copyToClipboard', 'saveToPc']);
clipboardToggle.checked = saved.copyToClipboard !== undefined ? saved.copyToClipboard : true; clipboardToggle.checked = saved.copyToClipboard !== undefined ? saved.copyToClipboard : true;
saveToPcToggle.checked = saved.saveToPc !== undefined ? saved.saveToPc : true; saveToPcToggle.checked = saved.saveToPc !== undefined ? saved.saveToPc : true;
}); });
@ -583,7 +596,7 @@ describe('Preference Persistence Across Browser Sessions', () => {
const clipboardToggle = document.getElementById('clipboard-toggle'); const clipboardToggle = document.getElementById('clipboard-toggle');
const saveToPcToggle = document.getElementById('save-to-pc-toggle'); const saveToPcToggle = document.getElementById('save-to-pc-toggle');
const saved = await chrome.storage.sync.get(['backgroundPreference', 'copyToClipboard', 'saveToPc']); const saved = await chrome.storage.sync.get(['backgroundPreference', 'copyToClipboard', 'saveToPc']);
clipboardToggle.checked = saved.copyToClipboard !== undefined ? saved.copyToClipboard : true; clipboardToggle.checked = saved.copyToClipboard !== undefined ? saved.copyToClipboard : true;
saveToPcToggle.checked = saved.saveToPc !== undefined ? saved.saveToPc : true; saveToPcToggle.checked = saved.saveToPc !== undefined ? saved.saveToPc : true;
}); });
@ -601,7 +614,7 @@ describe('Preference Persistence Across Browser Sessions', () => {
expect(mockClipboardToggle.checked).toBe(false); expect(mockClipboardToggle.checked).toBe(false);
expect(mockSaveToPcToggle.checked).toBe(false); expect(mockSaveToPcToggle.checked).toBe(false);
// Validation should fail with both disabled // Validation should fail with both disabled
const isValid = mockClipboardToggle.checked || mockSaveToPcToggle.checked; const isValid = mockClipboardToggle.checked || mockSaveToPcToggle.checked;
expect(isValid).toBe(false); expect(isValid).toBe(false);
@ -613,13 +626,13 @@ describe('Preference Persistence Across Browser Sessions', () => {
copyToClipboard: true copyToClipboard: true
// saveToPc is undefined (not set in previous version) // saveToPc is undefined (not set in previous version)
}); });
const popupScript = ` const popupScript = `
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', async () => {
const clipboardToggle = document.getElementById('clipboard-toggle'); const clipboardToggle = document.getElementById('clipboard-toggle');
const saveToPcToggle = document.getElementById('save-to-pc-toggle'); const saveToPcToggle = document.getElementById('save-to-pc-toggle');
const saved = await chrome.storage.sync.get(['backgroundPreference', 'copyToClipboard', 'saveToPc']); const saved = await chrome.storage.sync.get(['backgroundPreference', 'copyToClipboard', 'saveToPc']);
clipboardToggle.checked = saved.copyToClipboard !== undefined ? saved.copyToClipboard : true; clipboardToggle.checked = saved.copyToClipboard !== undefined ? saved.copyToClipboard : true;
saveToPcToggle.checked = saved.saveToPc !== undefined ? saved.saveToPc : true; saveToPcToggle.checked = saved.saveToPc !== undefined ? saved.saveToPc : true;
}); });

View file

@ -233,13 +233,15 @@ global.mockClipboardAPI = (scenario = 'supported') => {
beforeEach(() => { beforeEach(() => {
// Clear all mocks first // Clear all mocks first
jest.clearAllMocks(); jest.clearAllMocks();
global.mockChromeAPIs(); global.mockChromeAPIs();
global.mockDOMAPIs(); global.mockDOMAPIs();
global.mockClipboardAPI('supported'); global.mockClipboardAPI('supported');
// Mock html2canvas // Mock html-to-image
global.html2canvas = jest.fn().mockResolvedValue(global.createMockCanvas()); global.htmlToImage = {
toCanvas: jest.fn().mockResolvedValue(global.createMockCanvas()),
};
}); });
// Cleanup after each test // Cleanup after each test
@ -251,13 +253,13 @@ afterEach(() => {
expect.extend({ expect.extend({
toHaveBeenCalledWithClipboardMessage(received, expectedBackground, expectedClipboard) { toHaveBeenCalledWithClipboardMessage(received, expectedBackground, expectedClipboard) {
const calls = received.mock.calls; const calls = received.mock.calls;
const matchingCall = calls.find(call => const matchingCall = calls.find(call =>
call[1] && call[1] &&
call[1].action === 'startSelector' && call[1].action === 'startSelector' &&
call[1].background === expectedBackground && call[1].background === expectedBackground &&
call[1].copyToClipboard === expectedClipboard call[1].copyToClipboard === expectedClipboard
); );
if (matchingCall) { if (matchingCall) {
return { return {
message: () => `Expected not to be called with clipboard message`, message: () => `Expected not to be called with clipboard message`,
@ -270,7 +272,7 @@ expect.extend({
}; };
} }
}, },
toHaveClipboardResult(received, expectedSuccess, expectedErrorType) { toHaveClipboardResult(received, expectedSuccess, expectedErrorType) {
if (!received.clipboardResult) { if (!received.clipboardResult) {
return { return {
@ -278,10 +280,10 @@ expect.extend({
pass: false pass: false
}; };
} }
const success = received.clipboardResult.success === expectedSuccess; const success = received.clipboardResult.success === expectedSuccess;
const errorType = !expectedErrorType || received.clipboardResult.errorType === expectedErrorType; const errorType = !expectedErrorType || received.clipboardResult.errorType === expectedErrorType;
if (success && errorType) { if (success && errorType) {
return { return {
message: () => `Expected clipboard result not to match`, message: () => `Expected clipboard result not to match`,