361 lines
11 KiB
JavaScript
361 lines
11 KiB
JavaScript
import { readFile } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import {
|
|
CONTEXT_TIMEOUT_MS,
|
|
PAGE_TIMEOUT_MS,
|
|
cleanupBrowserTmp,
|
|
openBrowser,
|
|
safeCloseBrowser,
|
|
safeCloseContext,
|
|
safeClosePage,
|
|
setupBrowserTmp,
|
|
shouldRetryNavigation,
|
|
withTimeout,
|
|
} from './lib/browser.mjs';
|
|
import { discoverRoutes, requireDist, startDistServer } from './lib/dist.mjs';
|
|
|
|
const previewCss = path.resolve('src/styles/global.css');
|
|
const browserTmp = path.resolve('.astro', 'playwright-preview-cropping-tmp');
|
|
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 MEASURE_TIMEOUT_MS = 30000;
|
|
const CLIP_TOLERANCE_PX = 0.75;
|
|
const RATIO_TOLERANCE = 0.01;
|
|
|
|
await requireDist();
|
|
|
|
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;
|
|
}
|
|
|
|
await setupBrowserTmp(browserTmp);
|
|
|
|
const routes = await discoverRoutes();
|
|
const failures = await checkPreviewCroppingStyles();
|
|
const { server, port } = await startDistServer();
|
|
|
|
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}`
|
|
);
|
|
}
|
|
|
|
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(browserTmp);
|
|
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(browserTmp);
|
|
context = await openMeasurementContext(browser, width);
|
|
}
|
|
}
|
|
|
|
failures.push(...result);
|
|
}
|
|
} finally {
|
|
if (context) await safeCloseContext(context);
|
|
if (browser) await safeCloseBrowser(browser);
|
|
}
|
|
}
|
|
} finally {
|
|
server.close();
|
|
await cleanupBrowserTmp(browserTmp);
|
|
}
|
|
|
|
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.`
|
|
);
|