152 lines
4.4 KiB
JavaScript
152 lines
4.4 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 widths = [320, 390, 430, 768, 1024, 1440, 1920];
|
|
|
|
function contentType(file) {
|
|
if (file.endsWith('.html')) return 'text/html; charset=utf-8';
|
|
if (file.endsWith('.css')) return 'text/css; charset=utf-8';
|
|
if (file.endsWith('.js')) return 'text/javascript; charset=utf-8';
|
|
if (file.endsWith('.svg')) return 'image/svg+xml';
|
|
if (file.endsWith('.png')) return 'image/png';
|
|
if (file.endsWith('.jpg') || file.endsWith('.jpeg')) return 'image/jpeg';
|
|
if (file.endsWith('.webp')) return 'image/webp';
|
|
if (file.endsWith('.woff2')) return 'font/woff2';
|
|
return '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.endsWith('/index.html')) {
|
|
routes.add('/' + rel.slice(0, -'index.html'.length));
|
|
} else if (rel === 'index.html') {
|
|
routes.add('/');
|
|
} 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');
|
|
}
|
|
|
|
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 < 3; 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);
|
|
if (attempt === 2 || !/Execution context was destroyed|navigation/i.test(message)) {
|
|
throw error;
|
|
}
|
|
await page.waitForLoadState('load').catch(() => {});
|
|
}
|
|
}
|
|
}
|
|
|
|
try {
|
|
for (const width of 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' });
|
|
if (route.startsWith('/writing/')) {
|
|
await page
|
|
.waitForURL((url) => url.pathname.startsWith('/articles/'), { timeout: 1000 })
|
|
.catch(() => {});
|
|
}
|
|
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 ${widths.join(', ')}px across ${routes.length} routes.`
|
|
);
|