schmelczer-dev/scripts/check-overflow.mjs
Andras Schmelczer e9b6035c58
Some checks failed
Deploy to Pages / build (pull_request) Failing after 1m5s
AI fixes
2026-05-24 10:34:24 +01:00

172 lines
4.8 KiB
JavaScript

import { createServer } from 'node:http';
import { readdir, readFile, stat } from 'node:fs/promises';
import path from 'node:path';
import { chromium } from 'playwright';
const dist = path.resolve('dist');
const INDEX_FILE = 'index.html';
const MAX_NAV_RETRIES = 3;
// 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 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',
'.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;
// /writing/* are meta-refresh redirect stubs to /articles/*, not real
// pages — measuring them would just remeasure /articles/.
if (rel.startsWith('writing/')) 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.');
}
const routes = await discoverRoutes();
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();
const browser = await chromium.launch({ headless: true });
const failures = [];
async function measureViewport(page) {
for (let attempt = 0; attempt < MAX_NAV_RETRIES; attempt += 1) {
try {
await page.waitForLoadState('load');
return await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth,
}));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const isLast = attempt === MAX_NAV_RETRIES - 1;
if (isLast || !/Execution context was destroyed|navigation/i.test(message)) {
throw error;
}
await page.waitForLoadState('load').catch(() => {});
}
}
}
try {
for (const width of VIEWPORT_WIDTHS) {
const page = await browser.newPage({
viewport: { width, height: 900 },
javaScriptEnabled: false,
});
for (const route of routes) {
await page.goto(`http://127.0.0.1:${port}${route}`, { waitUntil: 'load' });
const result = await measureViewport(page);
if (result.scrollWidth > result.clientWidth + 1) {
failures.push(
`${route} overflows at ${width}px: ${result.scrollWidth}px > ${result.clientWidth}px`
);
}
}
await page.close();
}
} finally {
await browser.close();
server.close();
}
if (failures.length > 0) {
console.error(failures.join('\n'));
process.exit(1);
}
console.log(
`No horizontal overflow detected at ${VIEWPORT_WIDTHS.join(', ')}px across ${routes.length} routes.`
);