Add static site QA checks
All checks were successful
Deploy to Pages / build (pull_request) Successful in 5m22s
Deploy to Pages / build (push) Successful in 3m59s

This commit is contained in:
Andras Schmelczer 2026-05-25 13:12:33 +01:00
parent f27e9ec3fd
commit 0be50b6c24
5 changed files with 564 additions and 2 deletions

View file

@ -35,8 +35,13 @@ jobs:
exit 1
fi
- name: Build
run: npm run build
- name: Typecheck
run: npm run typecheck
- name: Build & QA
run: |
npx playwright install chromium
npm run qa
- name: Copy build to host pages mount
if: github.event_name == 'push' && github.ref == 'refs/heads/main'

136
scripts/check-links.mjs Normal file
View file

@ -0,0 +1,136 @@
import { readdir, readFile, stat } from 'node:fs/promises';
import path from 'node:path';
const dist = path.resolve('dist');
const allowedPreservedRoutes = new Set(['/fleeting/', '/reconcile/']);
const failures = [];
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 exists(file) {
try {
return (await stat(file)).isFile();
} catch {
return false;
}
}
async function targetExists(pathname) {
if (allowedPreservedRoutes.has(pathname)) return true;
const safePath = path
.normalize(decodeURIComponent(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) {
if (await exists(file)) return true;
}
return false;
}
try {
await stat(dist);
} catch {
throw new Error('dist/ does not exist. Run npm run build first.');
}
const files = await walk(dist);
const checkedFiles = files.filter((file) => /\.(html|xml|css|webmanifest)$/.test(file));
function pagePathname(file) {
const rel = path.relative(dist, file).replaceAll(path.sep, '/');
if (rel === 'index.html') return '/';
if (rel.endsWith('/index.html')) return `/${rel.slice(0, -'index.html'.length)}`;
return `/${rel}`;
}
function collectUrlReferences(body, rel) {
const urls = [];
for (const match of body.matchAll(/\b(?:href|src|poster)=["']([^"']+)["']/g)) {
urls.push(match[1]);
}
for (const match of body.matchAll(/\bsrcset=["']([^"']+)["']/g)) {
for (const candidate of match[1].split(',')) {
const url = candidate.trim().split(/\s+/)[0];
if (url) urls.push(url);
}
}
if (rel.endsWith('.css')) {
for (const match of body.matchAll(/url\(\s*['"]?([^'")]+)['"]?\s*\)/g)) {
urls.push(match[1]);
}
}
if (rel.endsWith('.webmanifest')) {
try {
const manifest = JSON.parse(body);
for (const key of ['start_url', 'scope']) {
if (typeof manifest[key] === 'string') urls.push(manifest[key]);
}
for (const icon of manifest.icons ?? []) {
if (typeof icon?.src === 'string') urls.push(icon.src);
}
for (const screenshot of manifest.screenshots ?? []) {
if (typeof screenshot?.src === 'string') urls.push(screenshot.src);
}
} catch {
failures.push(`${rel}: invalid web manifest JSON`);
}
}
return urls;
}
for (const file of checkedFiles) {
const body = await readFile(file, 'utf8');
const rel = path.relative(dist, file);
const baseUrl = new URL(pagePathname(file), 'https://schmelczer.dev');
for (const raw of collectUrlReferences(body, rel)) {
if (/^(mailto:|tel:|data:)/i.test(raw)) continue;
let parsed;
try {
parsed = new URL(raw, baseUrl);
} catch {
failures.push(`${rel}: invalid URL ${raw}`);
continue;
}
if (parsed.origin !== 'https://schmelczer.dev') continue;
if (!(await targetExists(parsed.pathname))) {
failures.push(`${rel}: missing local target ${parsed.pathname}`);
}
}
}
if (failures.length > 0) {
console.error(failures.join('\n'));
process.exit(1);
}
console.log('No missing local href/src targets found in dist/.');

80
scripts/check-no-js.mjs Normal file
View file

@ -0,0 +1,80 @@
import { readdir, readFile, stat } from 'node:fs/promises';
import path from 'node:path';
const dist = path.resolve('dist');
const failures = [];
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;
}
try {
await stat(dist);
} catch {
throw new Error('dist/ does not exist. Run npm run build first.');
}
const files = await walk(dist);
const jsFiles = files.filter((file) => file.endsWith('.js'));
if (jsFiles.length > 0) {
failures.push(
`Unexpected JavaScript assets:\n${jsFiles.map((file) => `- ${file}`).join('\n')}`
);
}
// Script tags are only allowed if they declare one of these safe `type`
// attributes (or are tagged with `data-theme-script`). All other scripts —
// including untyped ones, which default to executable JavaScript — are
// flagged.
const SAFE_SCRIPT_TYPES = new Set([
'application/ld+json',
'importmap',
'speculationrules',
]);
function isSafeScriptTag(tag) {
if (tag.includes('data-theme-script')) return true;
const typeMatch = tag.match(/\btype=["']([^"']+)["']/i);
if (!typeMatch) return false;
return SAFE_SCRIPT_TYPES.has(typeMatch[1].trim().toLowerCase());
}
for (const file of files.filter((candidate) => candidate.endsWith('.html'))) {
const html = await readFile(file, 'utf8');
const scripts = (html.match(/<script\b[^>]*>/gi) ?? []).filter(
(tag) => !isSafeScriptTag(tag)
);
if (scripts.length) {
failures.push(`Unexpected script tag in ${file}:\n${scripts.join('\n')}`);
}
// Inline event handlers (onclick=, onload=, etc.) execute JavaScript even
// without a <script> tag, so flag any attribute matching `on*=`. We strip
// <script> blocks first to avoid false positives from JSON-LD payloads.
const stripped = html.replace(/<script\b[\s\S]*?<\/script>/gi, '');
const handlerMatches = stripped.match(/\son\w+=/gi);
if (handlerMatches?.length) {
const unique = [...new Set(handlerMatches.map((m) => m.trim()))];
failures.push(`Unexpected inline event handler in ${file}:\n${unique.join('\n')}`);
}
}
if (failures.length > 0) {
console.error(failures.join('\n\n'));
process.exit(1);
}
console.log('No unexpected JavaScript found in dist/.');

309
scripts/check-overflow.mjs Normal file
View file

@ -0,0 +1,309 @@
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 browserTmp = path.resolve('.astro', 'playwright-overflow-tmp');
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',
'.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.');
}
// Some CI/dev containers mount /tmp as a very small tmpfs. Chromium uses the
// process temp directory for profiles and internal files; putting it under the
// already-ignored .astro/ directory keeps the overflow check reproducible even
// when the system temp mount is full.
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 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,
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,
});
await context.route('**/*', (route) => {
const type = route.request().resourceType();
if (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 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();
await rm(browserTmp, { recursive: true, force: true }).catch(() => {});
}
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.`
);

View file

@ -0,0 +1,32 @@
import { spawnSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { chromium } from 'playwright';
const isLinux = process.platform === 'linux';
if (!isLinux) {
process.exit(0);
}
const executablePath = chromium.executablePath();
let needsInstall = !existsSync(executablePath);
if (!needsInstall) {
const ldd = spawnSync('ldd', [executablePath], { encoding: 'utf8' });
const output = `${ldd.stdout ?? ''}${ldd.stderr ?? ''}`;
needsInstall = ldd.status !== 0 || output.includes('not found');
}
if (!needsInstall) {
process.exit(0);
}
const result = spawnSync('playwright', ['install', '--with-deps', 'chromium'], {
stdio: 'inherit',
});
if (result.error) {
throw result.error;
}
process.exit(result.status ?? 1);