293 lines
7.7 KiB
JavaScript
293 lines
7.7 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 = 4;
|
|
// 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 CLOSE_TIMEOUT_MS = 3000;
|
|
const LAUNCH_TIMEOUT_MS = 10000;
|
|
const CONTEXT_TIMEOUT_MS = 8000;
|
|
const PAGE_TIMEOUT_MS = 15000;
|
|
const MEASURE_TIMEOUT_MS = 25000;
|
|
|
|
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 failures = [];
|
|
|
|
function launchBrowser() {
|
|
return chromium.launch({
|
|
headless: true,
|
|
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: false,
|
|
});
|
|
await context.route('**/*', (route) => {
|
|
const type = route.request().resourceType();
|
|
if (['font', 'image', 'media'].includes(type)) {
|
|
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 measureViewport(page) {
|
|
await page.waitForLoadState('load', { timeout: 5000 }).catch(() => {});
|
|
return page.evaluate(() => ({
|
|
scrollWidth: document.documentElement.scrollWidth,
|
|
clientWidth: document.documentElement.clientWidth,
|
|
}));
|
|
}
|
|
|
|
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) {
|
|
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,
|
|
});
|
|
return measureViewport(page);
|
|
})(),
|
|
MEASURE_TIMEOUT_MS,
|
|
`Timed out while measuring ${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);
|
|
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);
|
|
}
|
|
}
|
|
|
|
if (result.scrollWidth > result.clientWidth + 1) {
|
|
failures.push(
|
|
`${route} overflows at ${width}px: ${result.scrollWidth}px > ${result.clientWidth}px`
|
|
);
|
|
}
|
|
}
|
|
} finally {
|
|
if (context) await safeCloseContext(context);
|
|
if (browser) await safeCloseBrowser(browser);
|
|
}
|
|
}
|
|
} finally {
|
|
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.`
|
|
);
|