import { createServer } from 'node:http'; import { mkdir, readdir, readFile, rm, stat } from 'node:fs/promises'; import path from 'node:path'; import { chromium } from 'playwright'; const dist = path.resolve('dist'); const previewCss = path.resolve('src/styles/global.css'); const browserTmp = path.resolve('.astro', 'playwright-preview-cropping-tmp'); const INDEX_FILE = 'index.html'; const PREVIEW_SELECTOR = '[data-uncropped-preview]'; // Common device widths: iPhone SE / Galaxy S / iPhone 14 / iPad portrait / // iPad landscape / common laptop / full HD desktop. const VIEWPORT_WIDTHS = [320, 390, 430, 768, 1024, 1440, 1920]; const MAX_NAV_RETRIES = 4; const CLOSE_TIMEOUT_MS = 3000; const LAUNCH_TIMEOUT_MS = 10000; const CONTEXT_TIMEOUT_MS = 8000; const PAGE_TIMEOUT_MS = 15000; const MEASURE_TIMEOUT_MS = 30000; const CLIP_TOLERANCE_PX = 0.75; const RATIO_TOLERANCE = 0.01; const MIME = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.avif': 'image/avif', '.ico': 'image/x-icon', '.woff': 'font/woff', '.woff2': 'font/woff2', '.mp4': 'video/mp4', '.webm': 'video/webm', '.vtt': 'text/vtt; charset=utf-8', '.pdf': 'application/pdf', }; function contentType(file) { const ext = path.extname(file).toLowerCase(); return MIME[ext] ?? 'application/octet-stream'; } async function walk(dir) { const entries = await readdir(dir, { withFileTypes: true }); const files = []; for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { files.push(...(await walk(fullPath))); } else { files.push(fullPath); } } return files; } async function discoverRoutes() { const files = await walk(dist); const routes = new Set(); for (const file of files) { if (!file.endsWith('.html')) continue; const rel = path.relative(dist, file).replaceAll(path.sep, '/'); if (rel === '404.html') continue; if (rel === INDEX_FILE) { routes.add('/'); } else if (rel.endsWith(`/${INDEX_FILE}`)) { routes.add('/' + rel.slice(0, -INDEX_FILE.length)); } else { routes.add('/' + rel.replace(/\.html$/, '/')); } } return [...routes].sort(); } async function resolveFile(url) { const parsed = new URL(url, 'http://localhost'); const safePath = path .normalize(decodeURIComponent(parsed.pathname)) .replace(/^\/+/, '') .replace(/^(\.\.(\/|\\|$))+/, ''); const candidate = path.join(dist, safePath); const candidates = [ candidate, path.join(candidate, 'index.html'), path.join(dist, `${safePath}.html`), ]; for (const file of candidates) { try { const fileStat = await stat(file); if (fileStat.isFile()) return file; } catch { // Try the next candidate. } } return path.join(dist, '404.html'); } try { await stat(dist); } catch { throw new Error('dist/ does not exist. Run npm run build first.'); } function lineAndColumn(text, index) { const before = text.slice(0, index); const lines = before.split('\n'); return { line: lines.length, column: lines.at(-1).length + 1, }; } function declarationValue(body, property) { const pattern = new RegExp(`(?:^|;)\\s*${property}\\s*:\\s*([^;]+)`, 'i'); return body.match(pattern)?.[1]?.trim(); } function scaleValueExpands(value) { for (const match of value.matchAll(/\bscale(?:3d|x|y)?\(([^)]*)\)/gi)) { const name = match[0].slice(0, match[0].indexOf('(')).toLowerCase(); const values = match[1] .split(/[\s,]+/) .map(Number) .filter(Number.isFinite); if (name === 'scalex' || name === 'scaley') { if ((values[0] ?? 1) > 1) return true; continue; } if ((values[0] ?? 1) > 1 || (values[1] ?? values[0] ?? 1) > 1) { return true; } } return value .split(/[\s,]+/) .map(Number) .filter(Number.isFinite) .some((number) => number > 1); } async function checkPreviewCroppingStyles() { const css = await readFile(previewCss, 'utf8'); const styleFailures = []; const blockPattern = /([^{}]+)\{([^{}]*)\}/g; for (const match of css.matchAll(blockPattern)) { const selector = match[1].replace(/\/\*[\s\S]*?\*\//g, '').trim(); const body = match[2]; // Only inspect rules that target elements explicitly opted in to the // no-crop contract via [data-uncropped-preview]. Listing thumbnails // that intentionally cover-crop don't carry this attribute. if (!selector || !/\[data-uncropped-preview\b/.test(selector)) continue; const targetsMedia = /\b(img|picture|video|canvas)\b/i.test(selector); const objectFit = declarationValue(body, 'object-fit'); const backgroundSize = declarationValue(body, 'background-size'); const transform = declarationValue(body, 'transform'); const scale = declarationValue(body, 'scale'); const addFailure = (property, reason) => { const propertyIndex = css.indexOf(property, match.index); const { line, column } = lineAndColumn(css, propertyIndex); styleFailures.push( `${path.relative(process.cwd(), previewCss)}:${line}:${column}: ${selector} ${reason}` ); }; if (targetsMedia && /^cover\b/i.test(objectFit ?? '')) { addFailure('object-fit', 'uses object-fit: cover'); } if (/^cover\b/i.test(backgroundSize ?? '')) { addFailure('background-size', 'uses background-size: cover'); } if (targetsMedia && transform && scaleValueExpands(transform)) { addFailure('transform', 'scales preview media larger than its container'); } if (targetsMedia && scale && scaleValueExpands(scale)) { addFailure('scale', 'scales preview media larger than its container'); } } return styleFailures; } // Keep Chromium temp files inside the repo so the check is reproducible in CI // containers with very small /tmp mounts. await rm(browserTmp, { recursive: true, force: true }); await mkdir(browserTmp, { recursive: true }); process.env.TMPDIR = browserTmp; process.env.TMP = browserTmp; process.env.TEMP = browserTmp; const routes = await discoverRoutes(); const failures = await checkPreviewCroppingStyles(); const server = createServer(async (req, res) => { try { const file = await resolveFile(req.url ?? '/'); const body = await readFile(file); res.writeHead(200, { 'content-type': contentType(file) }); res.end(body); } catch (error) { res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' }); res.end(String(error)); } }); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); const { port } = server.address(); function launchBrowser() { return chromium.launch({ headless: true, env: { ...process.env, TMPDIR: browserTmp, TMP: browserTmp, TEMP: browserTmp, }, args: ['--disable-dev-shm-usage', '--disable-gpu', '--no-sandbox'], }); } async function withTimeout(promise, timeoutMs, label) { let timeout; try { return await Promise.race([ promise, new Promise((_, reject) => { timeout = setTimeout(() => reject(new Error(label)), timeoutMs); }), ]); } finally { clearTimeout(timeout); } } async function safeClosePage(page) { await withTimeout( page.close(), CLOSE_TIMEOUT_MS, 'Timed out while closing Playwright page' ).catch(() => {}); } async function safeCloseContext(context) { await withTimeout( context.close(), CLOSE_TIMEOUT_MS, 'Timed out while closing Playwright context' ).catch(() => {}); } async function safeCloseBrowser(browser) { const childProcess = browser.process?.(); try { await withTimeout( browser.close(), CLOSE_TIMEOUT_MS, 'Timed out while closing Chromium' ); } catch { childProcess?.kill('SIGKILL'); } } async function openBrowser() { return withTimeout( launchBrowser(), LAUNCH_TIMEOUT_MS, 'Timed out while launching Chromium' ); } async function newMeasurementContext(browser, width) { const context = await browser.newContext({ viewport: { width, height: 900 }, javaScriptEnabled: true, reducedMotion: 'reduce', }); await context.route('**/*', (route) => { const type = route.request().resourceType(); if (type === 'image' || type === 'media') { route.abort('blockedbyclient'); } else { route.continue(); } }); return context; } async function openMeasurementContext(browser, width) { return withTimeout( newMeasurementContext(browser, width), CONTEXT_TIMEOUT_MS, `Timed out while creating ${width}px Playwright context` ); } async function inspectPreviews(page, route, width, phase, index = null) { const findings = await page.evaluate( ({ clipTolerancePx, index, phase, ratioTolerance, selector }) => { const failures = []; const previews = [...document.querySelectorAll(selector)]; const selectedPreviews = index === null ? previews : [previews[index]].filter(Boolean); const clippingValues = new Set(['hidden', 'clip', 'scroll', 'auto']); function isClippingElement(element) { const style = getComputedStyle(element); return ( clippingValues.has(style.overflow) || clippingValues.has(style.overflowX) || clippingValues.has(style.overflowY) ); } function nearestClippingAncestor(image, preview) { let current = image.parentElement; while (current && preview.contains(current)) { if (isClippingElement(current)) return current; if (current === preview) break; current = current.parentElement; } return null; } function labelFor(preview, image) { const label = preview.getAttribute('data-preview-label') || preview.getAttribute('aria-label') || image.alt || 'preview image'; const source = image.currentSrc || image.src; let pathname = source; try { pathname = new URL(source, document.baseURI).pathname; } catch { // Keep the raw source if URL parsing fails. } return `${label} (${pathname})`; } function ratioDelta(first, second) { return Math.abs(first - second) / Math.max(first, second); } function isRectClipped(rect, clipRect) { return ( rect.left < clipRect.left - clipTolerancePx || rect.top < clipRect.top - clipTolerancePx || rect.right > clipRect.right + clipTolerancePx || rect.bottom > clipRect.bottom + clipTolerancePx ); } for (const preview of selectedPreviews) { if (!(preview instanceof HTMLElement)) continue; const image = preview.querySelector('img'); if (!(image instanceof HTMLImageElement)) { failures.push({ label: preview.getAttribute('data-preview-label') || 'preview image', reason: 'has no rendered ', }); continue; } const label = labelFor(preview, image); const sourceWidth = Number(image.getAttribute('width')) || image.naturalWidth; const sourceHeight = Number(image.getAttribute('height')) || image.naturalHeight; if (sourceWidth <= 0 || sourceHeight <= 0) { failures.push({ label, reason: 'image has no intrinsic dimensions' }); continue; } const imageRect = image.getBoundingClientRect(); const imageWidth = image.clientWidth || imageRect.width; const imageHeight = image.clientHeight || imageRect.height; if (imageWidth <= 0 || imageHeight <= 0) { failures.push({ label, reason: 'image rendered with no visible size' }); continue; } const imageStyle = getComputedStyle(image); const sourceRatio = sourceWidth / sourceHeight; const boxRatio = imageWidth / imageHeight; if ( imageStyle.objectFit === 'cover' && ratioDelta(sourceRatio, boxRatio) > ratioTolerance ) { failures.push({ label, reason: `uses object-fit: cover for a ${sourceWidth}x${sourceHeight} source in a ${Math.round(imageWidth)}x${Math.round(imageHeight)} box`, }); } const clippingAncestor = nearestClippingAncestor(image, preview); if (clippingAncestor) { const clipRect = clippingAncestor.getBoundingClientRect(); if (isRectClipped(imageRect, clipRect)) { failures.push({ label, reason: 'image bounds are clipped by an overflow container', }); } } } return failures.map((failure) => ({ ...failure, phase, })); }, { clipTolerancePx: CLIP_TOLERANCE_PX, index, phase, ratioTolerance: RATIO_TOLERANCE, selector: PREVIEW_SELECTOR, } ); return findings.map( (finding) => `${route} at ${width}px (${finding.phase}): ${finding.label}: ${finding.reason}` ); } function shouldRetryNavigation(error) { const message = error instanceof Error ? error.message : String(error); return /ERR_INSUFFICIENT_RESOURCES|Execution context was destroyed|Target.*closed|has been closed|Timed out while|navigation/i.test( message ); } async function measureRoute(context, route, width) { let page; try { page = await withTimeout( context.newPage(), PAGE_TIMEOUT_MS, `Timed out while creating page for ${route}` ); return await withTimeout( (async () => { await page.goto(`http://127.0.0.1:${port}${route}`, { waitUntil: 'domcontentloaded', timeout: 15000, }); await page.waitForLoadState('load', { timeout: 5000 }).catch(() => {}); return inspectPreviews(page, route, width, 'normal'); })(), MEASURE_TIMEOUT_MS, `Timed out while checking preview cropping for ${route}` ); } finally { if (page) await safeClosePage(page); } } try { for (const width of VIEWPORT_WIDTHS) { let browser; let context; try { browser = await openBrowser(); context = await openMeasurementContext(browser, width); for (const route of routes) { let result; for (let attempt = 0; attempt < MAX_NAV_RETRIES; attempt += 1) { try { result = await measureRoute(context, route, width); break; } catch (error) { if (!shouldRetryNavigation(error) || attempt === MAX_NAV_RETRIES - 1) { throw error; } await safeCloseContext(context); await safeCloseBrowser(browser); browser = await openBrowser(); context = await openMeasurementContext(browser, width); } } failures.push(...result); } } finally { if (context) await safeCloseContext(context); if (browser) await safeCloseBrowser(browser); } } } finally { server.close(); await rm(browserTmp, { recursive: true, force: true }).catch(() => {}); } if (failures.length > 0) { console.error(failures.join('\n')); process.exit(1); } console.log( `No cropped previews detected at ${VIEWPORT_WIDTHS.join(', ')}px across ${routes.length} routes.` );