schmelczer-dev/scripts/check-preview-cropping.mjs
Andras Schmelczer b554e92e9f
All checks were successful
Deploy to Pages / build (push) Successful in 2m58s
Update content & design (#75)
Reviewed-on: https://home.schmelczer.dev/git/git/andras/schmelczer-dev/pulls/75
2026-05-28 16:20:12 +01:00

536 lines
15 KiB
JavaScript

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 <img>',
});
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.`
);