From 4f051fc7691c925619ee8b5b9b9112e5e0f57bca Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:25:14 +0000 Subject: [PATCH] Extract script to app.js and add Jest tests for initWasm. Co-authored-by: paul0728 <44644609+paul0728@users.noreply.github.com> --- .gitignore | 1 + __mocks__/magick-wasm.js | 4 + babel.config.js | 5 + docs/app.js | 256 ++ docs/app.test.js | 88 + docs/index.html | 246 +- package-lock.json | 6948 ++++++++++++++++++++++++++++++++++++++ package.json | 24 + 8 files changed, 7328 insertions(+), 244 deletions(-) create mode 100644 __mocks__/magick-wasm.js create mode 100644 babel.config.js create mode 100644 docs/app.js create mode 100644 docs/app.test.js create mode 100644 package-lock.json create mode 100644 package.json diff --git a/.gitignore b/.gitignore index 7a60b85..b4bc537 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ __pycache__/ *.pyc +node_modules/ diff --git a/__mocks__/magick-wasm.js b/__mocks__/magick-wasm.js new file mode 100644 index 0000000..76ed262 --- /dev/null +++ b/__mocks__/magick-wasm.js @@ -0,0 +1,4 @@ +import { jest } from '@jest/globals'; +export const initializeImageMagick = jest.fn().mockResolvedValue(); +export const ImageMagick = { read: jest.fn() }; +export const MagickFormat = {}; diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 0000000..e56800f --- /dev/null +++ b/babel.config.js @@ -0,0 +1,5 @@ +export default { + presets: [ + ['@babel/preset-env', { targets: { node: 'current' } }] + ] +}; diff --git a/docs/app.js b/docs/app.js new file mode 100644 index 0000000..a6c208a --- /dev/null +++ b/docs/app.js @@ -0,0 +1,256 @@ +import { initializeImageMagick, ImageMagick, MagickFormat } from '@imagemagick/magick-wasm'; + + export let fileInput, targetFormatSelect, convertBtn, statusDiv, downloadsContainer, previewContainer, fileStatusText; +export function initDOM(documentObj = document) { + fileInput = documentObj.getElementById('fileInput'); + targetFormatSelect = documentObj.getElementById('targetFormat'); + convertBtn = documentObj.getElementById('convertBtn'); + statusDiv = documentObj.getElementById('status'); + downloadsContainer = documentObj.getElementById('downloads-container'); + previewContainer = documentObj.getElementById('preview-container'); + fileStatusText = documentObj.getElementById('fileStatusText'); +} + + export let isEngineReady = false; + export let selectedFiles = []; // Maintain our own list of files + + // 1. Fetch our comprehensive JSON database to populate dropdown and input filter + export async function loadFormats() { + try { + const response = await fetch('format_database.json'); + if (!response.ok) throw new Error("Network response was not ok"); + const db = await response.json(); + + targetFormatSelect.innerHTML = ''; + + // Get unique formats + const validOutputs = [...new Set(db.filter(f => f.can_write).map(f => f.extension.toLowerCase()))].sort(); + const validInputs = [...new Set(db.filter(f => f.can_read).map(f => `.${f.extension.toLowerCase()}`))]; + + // Restrict input file picker + if (validInputs.length > 0) { + fileInput.accept = validInputs.join(','); + } + + validOutputs.forEach(ext => { + const option = document.createElement('option'); + option.value = ext; + option.textContent = ext.toUpperCase(); + targetFormatSelect.appendChild(option); + }); + + targetFormatSelect.value = 'png'; // default + } catch (err) { + statusDiv.textContent = "Failed to load format database. Defaulting to basic formats."; + targetFormatSelect.innerHTML = ` + + + + + `; + fileInput.accept = ".png,.jpg,.jpeg,.webp,.pdf"; + } + } + + export function setupEventListeners() { + fileInput.addEventListener('change', (e) => { + if (e.target.files.length > 0) { + // Add new files to our array + selectedFiles = selectedFiles.concat(Array.from(e.target.files)); + renderPreviews(); + } + // Clear input so selecting the same file again triggers change event + fileInput.value = ''; + }); + + } + + // Render thumbnails + export function renderPreviews() { + previewContainer.innerHTML = ''; + + if (selectedFiles.length === 0) { + fileStatusText.textContent = "No file chosen"; + } else if (selectedFiles.length === 1) { + fileStatusText.textContent = selectedFiles[0].name; + } else { + fileStatusText.textContent = `${selectedFiles.length} files selected`; + } + + selectedFiles.forEach((file, index) => { + const wrapper = document.createElement('div'); + wrapper.className = 'thumbnail-wrapper'; + wrapper.title = file.name; + + // Create remove button + const removeBtn = document.createElement('button'); + removeBtn.className = 'remove-btn'; + removeBtn.innerHTML = '×'; + removeBtn.onclick = () => { + selectedFiles.splice(index, 1); + renderPreviews(); + }; + + // Create image preview + const img = document.createElement('img'); + img.className = 'thumbnail-img'; + + // If it's a standard web image, browsers can render it natively + if (file.type.startsWith('image/') && !file.name.toLowerCase().endsWith('.heic')) { + img.src = URL.createObjectURL(file); + img.onload = () => URL.revokeObjectURL(img.src); // Free memory + } else { + // Fallback for RAW/HEIC that browsers can't render natively + img.src = 'data:image/svg+xml;utf8,'; + } + + wrapper.appendChild(img); + wrapper.appendChild(removeBtn); + + // Overlay the name at the bottom + const nameLabel = document.createElement('div'); + nameLabel.className = 'thumbnail-name'; + nameLabel.style.position = 'absolute'; + nameLabel.style.bottom = '0'; + nameLabel.style.width = '100%'; + nameLabel.style.background = 'rgba(255,255,255,0.8)'; + nameLabel.textContent = file.name.length > 12 ? file.name.substring(0, 10) + '...' : file.name; + + wrapper.appendChild(nameLabel); + previewContainer.appendChild(wrapper); + }); + } + + // 2. Initialize the WebAssembly Engine + export async function initWasm() { + try { + // Fetch the wasm file explicitly (use unpkg to ensure raw binary response) + const wasmUrl = "https://unpkg.com/@imagemagick/magick-wasm@0.0.30/dist/magick.wasm"; + const response = await fetch(wasmUrl); + const wasmBytes = new Uint8Array(await response.arrayBuffer()); + + await initializeImageMagick(wasmBytes); + + isEngineReady = true; + convertBtn.textContent = "Convert Image"; + convertBtn.disabled = false; + statusDiv.textContent = "Engine Ready. Select a file to convert."; + } catch (err) { + console.error(err); + statusDiv.textContent = "Failed to initialize ImageMagick WASM engine."; + } + } + + export function setupConvertListener() { + convertBtn.addEventListener('click', async () => { + if (selectedFiles.length === 0) { + alert("Please select files first."); + return; + } + + const files = [...selectedFiles]; + const targetExt = targetFormatSelect.value; + const isBatch = files.length > 1; + + convertBtn.disabled = true; + downloadsContainer.innerHTML = ''; // clear previous links + + let successCount = 0; + let failCount = 0; + const zip = new JSZip(); + + for (let i = 0; i < files.length; i++) { + const file = files[i]; + statusDiv.textContent = `Processing file ${i + 1} of ${files.length}: ${file.name}...`; + + try { + const arrayBuffer = await file.arrayBuffer(); + const sourceBytes = new Uint8Array(arrayBuffer); + + // Await a Promise that resolves when the setTimeout for this image finishes + await new Promise((resolve) => { + setTimeout(() => { + try { + ImageMagick.read(sourceBytes, (image) => { + if (targetExt === 'jpg' || targetExt === 'jpeg') { + image.hasAlpha = false; + } + + // Title Case for enum + const titleCaseFormat = targetExt.charAt(0).toUpperCase() + targetExt.slice(1).toLowerCase(); + const formatEnum = MagickFormat[titleCaseFormat] || targetExt.toUpperCase(); + + image.write(formatEnum, (data) => { + let originalName = file.name.split('.').slice(0, -1).join('.'); + if (!originalName) originalName = "image"; // fallback if no extension + const outFileName = `${originalName}.${targetExt}`; + + if (isBatch) { + // Add to zip + // CRITICAL: Must clone the Uint8Array because 'data' is a view into + // WebAssembly memory which will be overwritten by the next image processing. + zip.file(outFileName, new Uint8Array(data)); + } else { + // Single file, provide direct link + const blob = new Blob([data], { type: 'application/octet-stream' }); + const url = URL.createObjectURL(blob); + const dlLink = document.createElement('a'); + dlLink.href = url; + dlLink.download = outFileName; + dlLink.textContent = `⬇️ Download ${outFileName}`; + dlLink.style.fontWeight = 'bold'; + dlLink.style.color = '#10b981'; + downloadsContainer.appendChild(dlLink); + } + successCount++; + }); + }); + } catch(err) { + console.error(`Error processing ${file.name}:`, err); + failCount++; + } + resolve(); + }, 50); + }); + } catch (err) { + console.error(`Error reading ${file.name}:`, err); + failCount++; + } + } + + if (isBatch && successCount > 0) { + statusDiv.textContent = `Compressing ${successCount} files into ZIP...`; + + try { + const content = await zip.generateAsync({ type: "blob" }); + const url = URL.createObjectURL(content); + const dlLink = document.createElement('a'); + dlLink.href = url; + dlLink.download = `converted_images.zip`; + dlLink.textContent = `📦 Download All as ZIP (${successCount} files)`; + dlLink.style.fontWeight = 'bold'; + dlLink.style.color = '#10b981'; + downloadsContainer.appendChild(dlLink); + } catch (err) { + console.error("ZIP Generation Failed:", err); + failCount += successCount; // Consider it a failure if we can't zip + } + } + + statusDiv.textContent = `Job complete! Successfully processed ${successCount} files. Failed: ${failCount}.`; + convertBtn.disabled = false; + }); + + } + +// Bootstrap +export function bootstrap() { + initDOM(); + setupEventListeners(); + setupConvertListener(); + loadFormats(); + initWasm(); +} +if (typeof process === 'undefined' || process.env.NODE_ENV !== 'test') { + bootstrap(); +} \ No newline at end of file diff --git a/docs/app.test.js b/docs/app.test.js new file mode 100644 index 0000000..07aee86 --- /dev/null +++ b/docs/app.test.js @@ -0,0 +1,88 @@ +import { initWasm, initDOM, isEngineReady } from './app.js'; +import { initializeImageMagick } from '@imagemagick/magick-wasm'; +import { jest } from '@jest/globals'; + +describe('initWasm', () => { + let mockConvertBtn, mockStatusDiv; + let originalFetch; + + beforeEach(() => { + // Reset DOM state + mockConvertBtn = document.createElement('button'); + mockConvertBtn.id = 'convertBtn'; + mockConvertBtn.disabled = true; + + mockStatusDiv = document.createElement('div'); + mockStatusDiv.id = 'status'; + + document.body.appendChild(mockConvertBtn); + document.body.appendChild(mockStatusDiv); + + // Required by initDOM + const mockFileInput = document.createElement('input'); + mockFileInput.id = 'fileInput'; + document.body.appendChild(mockFileInput); + + const mockTargetFormatSelect = document.createElement('select'); + mockTargetFormatSelect.id = 'targetFormat'; + document.body.appendChild(mockTargetFormatSelect); + + const mockDownloadsContainer = document.createElement('div'); + mockDownloadsContainer.id = 'downloads-container'; + document.body.appendChild(mockDownloadsContainer); + + const mockPreviewContainer = document.createElement('div'); + mockPreviewContainer.id = 'preview-container'; + document.body.appendChild(mockPreviewContainer); + + const mockFileStatusText = document.createElement('span'); + mockFileStatusText.id = 'fileStatusText'; + document.body.appendChild(mockFileStatusText); + + initDOM(document); + + // Reset mocks + originalFetch = global.fetch; + global.fetch = jest.fn(); + jest.clearAllMocks(); + }); + + afterEach(() => { + document.body.innerHTML = ''; + global.fetch = originalFetch; + }); + + it('successfully initializes WASM engine and updates DOM', async () => { + // Mock fetch response + global.fetch.mockResolvedValueOnce({ + arrayBuffer: jest.fn().mockResolvedValueOnce(new ArrayBuffer(8)) + }); + + initializeImageMagick.mockResolvedValueOnce(); + + await initWasm(); + + expect(global.fetch).toHaveBeenCalledWith("https://unpkg.com/@imagemagick/magick-wasm@0.0.30/dist/magick.wasm"); + expect(initializeImageMagick).toHaveBeenCalled(); + + // Cannot check export let variable easily if it is reassigned internally unless we wrap it + // expect(isEngineReady).toBe(true); + + expect(mockConvertBtn.textContent).toBe("Convert Image"); + expect(mockConvertBtn.disabled).toBe(false); + expect(mockStatusDiv.textContent).toBe("Engine Ready. Select a file to convert."); + }); + + it('handles initialization failure and updates DOM', async () => { + // Mock fetch response to fail + global.fetch.mockRejectedValueOnce(new Error("Network Error")); + + await initWasm(); + + expect(global.fetch).toHaveBeenCalled(); + + expect(mockStatusDiv.textContent).toBe("Failed to initialize ImageMagick WASM engine."); + // Button should remain in initial state + expect(mockConvertBtn.disabled).toBe(true); + }); +}); diff --git a/docs/index.html b/docs/index.html index 9d406c0..b706fdd 100644 --- a/docs/index.html +++ b/docs/index.html @@ -131,248 +131,6 @@