import { chromium } from 'playwright'; import { mkdir, readFile, writeFile, rm } from 'fs/promises'; import { join } from 'path'; import sharp from 'sharp'; import looksSame from 'looks-same'; const URL = 'http://localhost:4321/?viz=true'; const OUTPUT_DIR = './screenshots'; const DEVICE_SCALE_FACTOR = 4; // 4x for high-quality print const BASE_VIEWPORT = { width: 1200, height: 800 }; const TRIM_THRESHOLD = 10; // sharp.trim() color tolerance const MARGIN_PX = 15 * DEVICE_SCALE_FACTOR; // 15px margin around every screenshot const SCREENSHOT_TIMEOUT_MS = Number(process.env.SCREENSHOT_TIMEOUT_MS) || 10_000; const slugify = (value) => String(value || '') .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, ''); // ─── Helper: clone an embed element into an isolated wrapper ──────────────── // This avoids visual contamination from overlapping DOM elements. // Returns { wrapperId, cloneId } for locating/cleaning up. async function cloneEmbed(page, element, idx) { return page.evaluate(([el, idx]) => { const wrapperId = `__embed-clone-wrapper-${idx}`; const cloneId = `__embed-clone-${idx}`; // Remove any previous clone const prev = document.getElementById(wrapperId); if (prev) prev.remove(); // Create isolated wrapper at top-left of page const wrapper = document.createElement('div'); wrapper.id = wrapperId; wrapper.style.cssText = 'position:absolute;left:0;top:0;background:white;z-index:99999;isolation:isolate;'; // Clone the inner card (or the whole element if no card) const inner = el.querySelector('.html-embed__card') || el; const clone = inner.cloneNode(true); clone.id = cloneId; clone.style.cssText = `background:white;border:none;border-radius:0;box-shadow:none;width:${el.getBoundingClientRect().width}px;`; wrapper.appendChild(clone); document.body.appendChild(wrapper); return { wrapperId, cloneId }; }, [await element.evaluateHandle((el) => el), idx]); } // ─── Helper: remove a clone wrapper ───────────────────────────────────────── async function removeClone(page, wrapperId) { await page.evaluate((id) => { const el = document.getElementById(id); if (el) el.remove(); }, wrapperId); } // ─── Helper: screenshot + auto-trim whitespace ────────────────────────────── async function screenshotAndTrim(locator, filepath) { await locator.screenshot({ path: filepath, type: 'png' }); // Auto-trim uniform borders (whitespace) try { const trimmed = await sharp(filepath) .trim({ threshold: TRIM_THRESHOLD }) .toBuffer({ resolveWithObject: true }); if (trimmed.info.width > 0 && trimmed.info.height > 0) { await writeFile(filepath, trimmed.data); } } catch { // trim() can fail if the image is entirely uniform; keep original } // Add uniform margin around the image if (MARGIN_PX > 0) { const padded = await sharp(filepath) .extend({ top: MARGIN_PX, bottom: MARGIN_PX, left: MARGIN_PX, right: MARGIN_PX, background: { r: 255, g: 255, b: 255, alpha: 1 }, }) .toBuffer(); await writeFile(filepath, padded); } } // ─── Helper: set a visually (show all options) ──────────────────── async function openSelect(selectHandle) { await selectHandle.evaluate((el) => { el.dataset.__prevSize = el.getAttribute('size') ?? ''; el.dataset.__prevStyle = el.getAttribute('style') ?? ''; el.dataset.__prevMultiple = el.multiple ? '1' : '0'; const count = el.querySelectorAll('option').length; el.setAttribute('size', String(Math.min(count || 1, 8))); el.multiple = true; el.style.position = 'relative'; el.style.zIndex = '9999'; el.style.height = 'auto'; el.style.maxHeight = 'none'; el.style.background = 'white'; }); } // ─── Helper: restore a , checkbox, button-group: capture variants ─ if (type === 'embed') { const allSelects = await element.$$('select'); // ── Detect checkboxes ─────────────────────────────────────────── const allCheckboxes = await element.$$('input[type="checkbox"]'); const checkboxesInfo = await Promise.all( allCheckboxes.map(async (cb) => cb.evaluate((el) => { const label = el.labels?.[0]?.textContent?.trim() || el.closest('label')?.textContent?.trim() || el.id || 'checkbox'; return { label, checked: el.checked }; }), ), ); // If checkboxes found, wrap select logic in a loop over checkbox states const cbStates = checkboxesInfo.length > 0 ? [false, true] : [null]; // null = no checkbox to toggle for (const cbState of cbStates) { // Set checkbox state if applicable if (cbState !== null && allCheckboxes.length > 0) { const cbLabel = checkboxesInfo[0]?.label || 'checkbox'; console.log(` ☑️ Checkbox "${cbLabel}" → ${cbState ? 'ON' : 'OFF'}`); for (const cbHandle of allCheckboxes) { await setCheckbox(cbHandle, cbState); } await page.waitForTimeout(300); } const cbSuffix = cbState !== null ? `--cb-${cbState ? 'on' : 'off'}--${slugify(checkboxesInfo[0]?.label || 'toggle').slice(0, 30)}` : ''; const cbBaseName = `${baseName}${cbSuffix}`; // ── Single select → 1D grid ──────────────────────────────────── if (allSelects.length === 1) { const selectHandle = allSelects[0]; try { const options = await selectHandle.evaluate((el) => Array.from(el.querySelectorAll('option')).map((opt, idx) => ({ value: opt.value, text: opt.textContent || opt.value || `option-${idx}`, index: idx, })), ); console.log(` 📸 Capturing ${options.length} select options...`); const capturedOptionPaths = []; for (const option of options) { const optionSlug = slugify(option.text).slice(0, 50); const optionFilename = `${cbBaseName}--option-${option.index}${optionSlug ? `--${optionSlug}` : ''}.png`; const optionFilepath = join(OUTPUT_DIR, optionFilename); try { await setSelectOption(selectHandle, option.index); await page.waitForTimeout(400); const { wrapperId, cloneId } = await cloneEmbed(page, element, `opt-${i}-${option.index}`); await page.waitForTimeout(200); await screenshotAndTrim(page.locator(`#${cloneId}`), optionFilepath); await removeClone(page, wrapperId); console.log(` ✅ ${optionFilename}`); capturedOptionPaths.push(optionFilepath); totalCount++; } catch (err) { console.log(` ❌ Failed: ${optionFilename}: ${err.message}`); } } // Capture with select OPEN const openFilename = `${cbBaseName}--open-select.png`; const openFilepath = join(OUTPUT_DIR, openFilename); try { await setSelectOption(selectHandle, 0); await page.waitForTimeout(200); await openSelect(selectHandle); await page.waitForTimeout(150); const { wrapperId, cloneId } = await cloneEmbed(page, element, `open-${i}`); await page.waitForTimeout(200); await screenshotAndTrim(page.locator(`#${cloneId}`), openFilepath); await removeClone(page, wrapperId); await restoreSelect(selectHandle); console.log(` ✅ ${openFilename}`); totalCount++; } catch (err) { console.log(` ❌ Failed: ${openFilename}: ${err.message}`); } // Composite (1D grid with optional common header) if (capturedOptionPaths.length > 1) { try { const compositeFilename = `${cbBaseName}--all-options.png`; const compositeFilepath = join(OUTPUT_DIR, compositeFilename); console.log(` 🖼️ Creating composite grid image...`); await buildComposite1D(capturedOptionPaths, compositeFilepath); totalCount++; } catch (err) { console.log(` ❌ Failed composite: ${err.message}`); } } } catch (err) { console.log(` ❌ Failed select processing: ${err.message}`); } } // ── Multiple selects → 2D grid of all combinations ───────────── else if (allSelects.length >= 2) { try { const selectsInfo = await Promise.all( allSelects.map(async (sel, sIdx) => sel.evaluate((el, sIdx) => ({ sIdx, name: el.name || el.id || `select-${sIdx}`, options: Array.from(el.options).map((o, j) => ({ index: j, text: o.textContent || o.value || `opt-${j}`, })), }), sIdx), ), ); // More options → rows (vertical), fewer options → columns (horizontal) let rowSelect, colSelect, rowHandle, colHandle; if (selectsInfo[0].options.length >= selectsInfo[1].options.length) { [rowSelect, colSelect] = [selectsInfo[0], selectsInfo[1]]; [rowHandle, colHandle] = [allSelects[0], allSelects[1]]; } else { [rowSelect, colSelect] = [selectsInfo[1], selectsInfo[0]]; [rowHandle, colHandle] = [allSelects[1], allSelects[0]]; } const nRows = rowSelect.options.length; const nCols = colSelect.options.length; console.log(` 📸 Capturing ${nRows * nCols} combinations (${nRows} × ${nCols}) from ${allSelects.length} selects...`); const capturedGrid = Array.from({ length: nRows }, () => Array(nCols).fill(null)); for (let r = 0; r < nRows; r++) { for (let c = 0; c < nCols; c++) { const rowSlug = slugify(rowSelect.options[r].text).slice(0, 30); const colSlug = slugify(colSelect.options[c].text).slice(0, 30); const comboFilename = `${cbBaseName}--combo-${r}-${c}--${rowSlug}--${colSlug}.png`; const comboFilepath = join(OUTPUT_DIR, comboFilename); try { await setSelectOption(rowHandle, rowSelect.options[r].index); await setSelectOption(colHandle, colSelect.options[c].index); await page.waitForTimeout(400); const { wrapperId, cloneId } = await cloneEmbed(page, element, `combo-${i}-${r}-${c}`); await page.waitForTimeout(200); await screenshotAndTrim(page.locator(`#${cloneId}`), comboFilepath); await removeClone(page, wrapperId); console.log(` ✅ [${r},${c}] ${comboFilename}`); capturedGrid[r][c] = comboFilepath; totalCount++; } catch (err) { console.log(` ❌ Failed [${r},${c}]: ${err.message}`); } } } // Open-select screenshots (one per select) for (let sIdx = 0; sIdx < allSelects.length; sIdx++) { const openFilename = `${cbBaseName}--open-select-${sIdx}.png`; const openFilepath = join(OUTPUT_DIR, openFilename); try { await setSelectOption(allSelects[sIdx], 0); await page.waitForTimeout(100); await openSelect(allSelects[sIdx]); await page.waitForTimeout(150); const { wrapperId, cloneId } = await cloneEmbed(page, element, `open-${i}-${sIdx}`); await page.waitForTimeout(200); await screenshotAndTrim(page.locator(`#${cloneId}`), openFilepath); await removeClone(page, wrapperId); await restoreSelect(allSelects[sIdx]); console.log(` ✅ ${openFilename}`); totalCount++; } catch (err) { console.log(` ❌ Failed: ${openFilename}: ${err.message}`); } } // 2D Composite with row/col labels const allPaths = capturedGrid.flat().filter(Boolean); if (allPaths.length > 1) { try { const compositeFilename = `${cbBaseName}--all-options.png`; const compositeFilepath = join(OUTPUT_DIR, compositeFilename); console.log(` 🖼️ Creating 2D composite grid (${nRows}×${nCols}) with labels...`); await buildComposite2D(capturedGrid, compositeFilepath, rowSelect, colSelect); totalCount++; } catch (err) { console.log(` ❌ Failed 2D composite: ${err.message}`); } } } catch (err) { console.log(` ❌ Failed multi-select processing: ${err.message}`); } } // ── No selects: check for button-group or toggle-group ──────── else { const buttonGroup = await element.$('.button-group, .toggle-group'); if (buttonGroup) { try { const buttons = await buttonGroup.$$eval( 'button[data-model], button[data-value], button.active, button.toggle-btn', (btns) => btns.map((b, idx) => { // Determine the best selector for this button const parentClass = b.parentElement?.classList.contains('button-group') ? '.button-group' : '.toggle-group'; return { index: idx, text: b.textContent.trim(), selector: b.dataset.model ? `button[data-model="${b.dataset.model}"]` : b.dataset.value ? `button[data-value="${b.dataset.value}"]` : `${parentClass} button:nth-child(${idx + 1})`, }; }), ); if (buttons.length > 1) { console.log(` 🔘 Capturing ${buttons.length} button states...`); const capturedBtnPaths = []; for (const btn of buttons) { const btnSlug = slugify(btn.text).slice(0, 50); const btnFilename = `${cbBaseName}--btn-${btn.index}${btnSlug ? `--${btnSlug}` : ''}.png`; const btnFilepath = join(OUTPUT_DIR, btnFilename); try { const btnHandle = await element.$(btn.selector); if (btnHandle) { await btnHandle.click(); await page.waitForTimeout(400); const { wrapperId, cloneId } = await cloneEmbed(page, element, `btn-${i}-${btn.index}`); await page.waitForTimeout(200); await screenshotAndTrim(page.locator(`#${cloneId}`), btnFilepath); await removeClone(page, wrapperId); console.log(` ✅ ${btnFilename}`); capturedBtnPaths.push(btnFilepath); totalCount++; } } catch (err) { console.log(` ❌ Failed: ${btnFilename}: ${err.message}`); } } // Composite (1D grid with optional common header) if (capturedBtnPaths.length > 1) { try { const compositeFilename = `${cbBaseName}--all-options.png`; const compositeFilepath = join(OUTPUT_DIR, compositeFilename); console.log(` 🖼️ Creating composite grid image...`); await buildComposite1D(capturedBtnPaths, compositeFilepath); totalCount++; } catch (err) { console.log(` ❌ Failed composite: ${err.message}`); } } // Reset to first button try { const firstBtn = await element.$(buttons[0].selector); if (firstBtn) await firstBtn.click(); await page.waitForTimeout(200); } catch {} } } catch (err) { console.log(` ❌ Failed button-group processing: ${err.message}`); } } } } // end for (cbState) // Reset checkboxes to original state if (allCheckboxes.length > 0) { for (let ci = 0; ci < allCheckboxes.length; ci++) { await setCheckbox(allCheckboxes[ci], checkboxesInfo[ci].checked); } } } } catch (err) { console.log(` ❌ Failed to capture ${filename}: ${err.message}`); } } await browser.close(); console.log(`\n🎉 Done! Captured ${totalCount} screenshots in ${OUTPUT_DIR}/`); } main().catch(console.error);