476 lines
14 KiB
JavaScript
476 lines
14 KiB
JavaScript
import { spawn } from 'node:child_process';
|
|
import { once } from 'node:events';
|
|
import { mkdir, readdir, rm, stat, writeFile } from 'node:fs/promises';
|
|
import { createServer as createNetServer } from 'node:net';
|
|
import path from 'node:path';
|
|
import { chromium } from 'playwright';
|
|
|
|
const dist = path.resolve('dist');
|
|
const browserTmp = path.resolve('.astro', 'playwright-astro-audit-tmp');
|
|
const outputJson = path.resolve(
|
|
process.env.ASTRO_AUDIT_OUTPUT_JSON ?? '.astro/astro-audit-results.json'
|
|
);
|
|
const outputMarkdown = path.resolve(
|
|
process.env.ASTRO_AUDIT_OUTPUT_MD ?? '.astro/astro-audit-results.md'
|
|
);
|
|
const astroBin = path.resolve(
|
|
'node_modules',
|
|
'.bin',
|
|
process.platform === 'win32' ? 'astro.cmd' : 'astro'
|
|
);
|
|
const HOST = '127.0.0.1';
|
|
const INDEX_FILE = 'index.html';
|
|
const SERVER_START_TIMEOUT_MS = 60000;
|
|
const CLOSE_TIMEOUT_MS = 3000;
|
|
const NAV_TIMEOUT_MS = 20000;
|
|
const AUDIT_TIMEOUT_MS = 30000;
|
|
const DEFAULT_VIEWPORTS = '1440x900';
|
|
const failOnIssues =
|
|
process.argv.includes('--fail-on-issues') ||
|
|
process.env.ASTRO_AUDIT_FAIL_ON_ISSUES === '1';
|
|
|
|
function sleep(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function parseViewports(raw = process.env.ASTRO_AUDIT_VIEWPORTS ?? DEFAULT_VIEWPORTS) {
|
|
return raw.split(',').map((entry) => {
|
|
const match = entry.trim().match(/^(\d+)x(\d+)$/i);
|
|
if (!match) {
|
|
throw new Error(
|
|
`Invalid ASTRO_AUDIT_VIEWPORTS entry "${entry}". Use values like 1440x900,390x844.`
|
|
);
|
|
}
|
|
return { width: Number(match[1]), height: Number(match[2]) };
|
|
});
|
|
}
|
|
|
|
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() {
|
|
if (process.env.ASTRO_AUDIT_ROUTES) {
|
|
return process.env.ASTRO_AUDIT_ROUTES.split(',')
|
|
.map((route) => route.trim())
|
|
.filter(Boolean)
|
|
.map((route) => (route.startsWith('/') ? route : `/${route}`));
|
|
}
|
|
|
|
try {
|
|
await stat(dist);
|
|
} catch {
|
|
throw new Error('dist/ does not exist. Run npm run build first.');
|
|
}
|
|
|
|
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 getFreePort() {
|
|
const server = createNetServer();
|
|
await new Promise((resolve, reject) => {
|
|
server.once('error', reject);
|
|
server.listen(0, HOST, resolve);
|
|
});
|
|
const { port } = server.address();
|
|
await new Promise((resolve, reject) => {
|
|
server.close((error) => (error ? reject(error) : resolve()));
|
|
});
|
|
return port;
|
|
}
|
|
|
|
function startAstroDev(port) {
|
|
const child = spawn(astroBin, ['dev', '--host', HOST, '--port', String(port)], {
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
env: {
|
|
...process.env,
|
|
ASTRO_TELEMETRY_DISABLED: '1',
|
|
NO_COLOR: '1',
|
|
TMPDIR: browserTmp,
|
|
TMP: browserTmp,
|
|
TEMP: browserTmp,
|
|
},
|
|
});
|
|
|
|
let output = '';
|
|
const append = (chunk) => {
|
|
output = `${output}${chunk.toString()}`.slice(-20000);
|
|
};
|
|
child.stdout.on('data', append);
|
|
child.stderr.on('data', append);
|
|
child.getRecentOutput = () => output.trim();
|
|
return child;
|
|
}
|
|
|
|
async function waitForDevServer(baseUrl, child) {
|
|
const deadline = Date.now() + SERVER_START_TIMEOUT_MS;
|
|
|
|
while (Date.now() < deadline) {
|
|
if (child.exitCode !== null) {
|
|
throw new Error(
|
|
`Astro dev server exited before it was ready.\n${child.getRecentOutput()}`
|
|
);
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(baseUrl);
|
|
if (response.status < 500) return;
|
|
} catch {
|
|
// Server is not listening yet.
|
|
}
|
|
|
|
await sleep(250);
|
|
}
|
|
|
|
throw new Error(
|
|
`Timed out waiting for Astro dev server at ${baseUrl}.\n${child.getRecentOutput()}`
|
|
);
|
|
}
|
|
|
|
async function stopProcess(child) {
|
|
if (!child || child.exitCode !== null) return;
|
|
child.kill('SIGTERM');
|
|
const exited = once(child, 'exit');
|
|
const timedOut = sleep(CLOSE_TIMEOUT_MS).then(() => 'timeout');
|
|
if ((await Promise.race([exited, timedOut])) === 'timeout') {
|
|
child.kill('SIGKILL');
|
|
}
|
|
}
|
|
|
|
async function safeCloseBrowser(browser) {
|
|
const childProcess = browser?.process?.();
|
|
try {
|
|
await Promise.race([
|
|
browser.close(),
|
|
sleep(CLOSE_TIMEOUT_MS).then(() => {
|
|
throw new Error('Timed out while closing Chromium');
|
|
}),
|
|
]);
|
|
} catch {
|
|
childProcess?.kill('SIGKILL');
|
|
}
|
|
}
|
|
|
|
function viewportLabel(viewport) {
|
|
return `${viewport.width}x${viewport.height}`;
|
|
}
|
|
|
|
async function extractAuditResults(page) {
|
|
return page.evaluate(
|
|
async ({ auditTimeoutMs }) => {
|
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
const deadline = Date.now() + auditTimeoutMs;
|
|
|
|
function describeElement(element) {
|
|
if (!element) return '';
|
|
const pieces = [];
|
|
let current = element;
|
|
while (
|
|
current instanceof Element &&
|
|
current !== document.documentElement &&
|
|
pieces.length < 5
|
|
) {
|
|
let piece = current.localName;
|
|
if (current.id) {
|
|
piece += `#${current.id}`;
|
|
} else {
|
|
const classes = [...current.classList].slice(0, 2);
|
|
if (classes.length > 0) piece += `.${classes.join('.')}`;
|
|
const parent = current.parentElement;
|
|
if (parent) {
|
|
const siblings = [...parent.children].filter(
|
|
(child) => child.localName === current.localName
|
|
);
|
|
if (siblings.length > 1) {
|
|
piece += `:nth-of-type(${siblings.indexOf(current) + 1})`;
|
|
}
|
|
}
|
|
}
|
|
pieces.unshift(piece);
|
|
current = current.parentElement;
|
|
}
|
|
return pieces.join(' > ');
|
|
}
|
|
|
|
function resolveRuleValue(value, element) {
|
|
try {
|
|
if (typeof value === 'function') return String(value(element) ?? '');
|
|
return String(value ?? '');
|
|
} catch (error) {
|
|
return `Error resolving audit value: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`;
|
|
}
|
|
}
|
|
|
|
function sourceForAudit(audit) {
|
|
const tooltip = audit.highlight?.shadowRoot?.querySelector(
|
|
'astro-dev-toolbar-tooltip'
|
|
);
|
|
const sourceSection = tooltip?.sections?.find(
|
|
(section) => section.clickDescription === 'Click to go to file'
|
|
);
|
|
return sourceSection?.content ?? null;
|
|
}
|
|
|
|
while (Date.now() < deadline) {
|
|
const toolbar = document.querySelector('astro-dev-toolbar');
|
|
const button = toolbar?.shadowRoot?.querySelector('[data-app-id="astro:audit"]');
|
|
|
|
if (button && !button.classList.contains('active')) {
|
|
button.click();
|
|
}
|
|
|
|
const auditCanvas = toolbar?.shadowRoot?.querySelector(
|
|
'astro-dev-toolbar-app-canvas[data-app-id="astro:audit"]'
|
|
);
|
|
const auditWindow = auditCanvas?.shadowRoot?.querySelector(
|
|
'astro-dev-toolbar-audit-window'
|
|
);
|
|
|
|
if (button?.classList.contains('active') && auditWindow) {
|
|
await sleep(100);
|
|
return {
|
|
results: (auditWindow.audits ?? []).map((audit) => {
|
|
const element = audit.auditedElement;
|
|
return {
|
|
code: audit.rule.code,
|
|
category: audit.rule.code?.split('-')[0] ?? '',
|
|
title: resolveRuleValue(audit.rule.title, element),
|
|
message: resolveRuleValue(audit.rule.message, element),
|
|
description: resolveRuleValue(audit.rule.description, element),
|
|
ruleSelector: audit.rule.selector,
|
|
source: sourceForAudit(audit),
|
|
element: {
|
|
selector: describeElement(element),
|
|
tagName: element?.tagName?.toLowerCase() ?? '',
|
|
html: element?.outerHTML?.replace(/\s+/g, ' ').slice(0, 800) ?? '',
|
|
},
|
|
};
|
|
}),
|
|
};
|
|
}
|
|
|
|
await sleep(250);
|
|
}
|
|
|
|
return {
|
|
error:
|
|
'Timed out waiting for the Astro Dev Toolbar Audit app. Make sure devToolbar is enabled.',
|
|
};
|
|
},
|
|
{ auditTimeoutMs: AUDIT_TIMEOUT_MS }
|
|
);
|
|
}
|
|
|
|
async function auditRoute(context, baseUrl, route, viewport) {
|
|
const page = await context.newPage();
|
|
try {
|
|
await page.goto(new URL(route, baseUrl).href, {
|
|
waitUntil: 'domcontentloaded',
|
|
timeout: NAV_TIMEOUT_MS,
|
|
});
|
|
await page.waitForSelector('astro-dev-toolbar', {
|
|
state: 'attached',
|
|
timeout: AUDIT_TIMEOUT_MS,
|
|
});
|
|
await page.waitForLoadState('load', { timeout: 5000 }).catch(() => {});
|
|
|
|
const audit = await extractAuditResults(page);
|
|
if (audit.error) {
|
|
throw new Error(`${route} at ${viewportLabel(viewport)}: ${audit.error}`);
|
|
}
|
|
|
|
return audit.results.map((result) => ({
|
|
route,
|
|
url: page.url(),
|
|
viewport: viewportLabel(viewport),
|
|
...result,
|
|
}));
|
|
} finally {
|
|
await page.close().catch(() => {});
|
|
}
|
|
}
|
|
|
|
function markdownEscape(value) {
|
|
return String(value ?? '').replaceAll('|', '\\|');
|
|
}
|
|
|
|
function renderMarkdown(report) {
|
|
const lines = [
|
|
'# Astro Audit Results',
|
|
'',
|
|
`Generated: ${report.generatedAt}`,
|
|
`Routes checked: ${report.routes.length}`,
|
|
`Viewports: ${report.viewports.map(viewportLabel).join(', ')}`,
|
|
`Issues found: ${report.results.length}`,
|
|
'',
|
|
];
|
|
|
|
if (report.results.length === 0) {
|
|
lines.push('No accessibility or performance issues detected.');
|
|
return `${lines.join('\n')}\n`;
|
|
}
|
|
|
|
const byRoute = Map.groupBy(report.results, (result) => result.route);
|
|
for (const [route, routeResults] of byRoute) {
|
|
lines.push(`## ${route}`, '');
|
|
lines.push('| Viewport | Code | Title | Source | Element |');
|
|
lines.push('| --- | --- | --- | --- | --- |');
|
|
for (const result of routeResults) {
|
|
lines.push(
|
|
`| ${markdownEscape(result.viewport)} | \`${markdownEscape(result.code)}\` | ${markdownEscape(
|
|
result.title
|
|
)} | ${markdownEscape(result.source ?? '')} | \`${markdownEscape(
|
|
result.element.selector
|
|
)}\` |`
|
|
);
|
|
}
|
|
lines.push('');
|
|
}
|
|
|
|
lines.push('## Details', '');
|
|
for (const result of report.results) {
|
|
lines.push(
|
|
`### ${result.route} ${result.viewport} ${result.code}`,
|
|
'',
|
|
`- Title: ${result.title}`,
|
|
`- Source: ${result.source ?? 'unknown'}`,
|
|
`- Message: ${result.message}`,
|
|
`- Description: ${result.description || 'none'}`,
|
|
`- Element: \`${result.element.selector}\``,
|
|
'',
|
|
'```html',
|
|
result.element.html,
|
|
'```',
|
|
''
|
|
);
|
|
}
|
|
|
|
return `${lines.join('\n')}\n`;
|
|
}
|
|
|
|
const viewports = parseViewports();
|
|
const routes = await discoverRoutes();
|
|
|
|
if (routes.length === 0) {
|
|
throw new Error('No HTML routes found to audit.');
|
|
}
|
|
|
|
await rm(browserTmp, { recursive: true, force: true });
|
|
await mkdir(browserTmp, { recursive: true });
|
|
await mkdir(path.dirname(outputJson), { recursive: true });
|
|
await mkdir(path.dirname(outputMarkdown), { recursive: true });
|
|
process.env.TMPDIR = browserTmp;
|
|
process.env.TMP = browserTmp;
|
|
process.env.TEMP = browserTmp;
|
|
|
|
const port = await getFreePort();
|
|
const baseUrl = `http://${HOST}:${port}/`;
|
|
let devServer;
|
|
let browser;
|
|
const results = [];
|
|
|
|
try {
|
|
devServer = startAstroDev(port);
|
|
await waitForDevServer(baseUrl, devServer);
|
|
|
|
browser = await chromium.launch({
|
|
headless: true,
|
|
env: {
|
|
...process.env,
|
|
TMPDIR: browserTmp,
|
|
TMP: browserTmp,
|
|
TEMP: browserTmp,
|
|
},
|
|
args: ['--disable-dev-shm-usage', '--disable-gpu', '--no-sandbox'],
|
|
});
|
|
|
|
for (const viewport of viewports) {
|
|
const context = await browser.newContext({
|
|
viewport,
|
|
javaScriptEnabled: true,
|
|
});
|
|
await context.route('**/*', (route) => {
|
|
if (route.request().resourceType() === 'media') {
|
|
route.abort('blockedbyclient');
|
|
} else {
|
|
route.continue();
|
|
}
|
|
});
|
|
|
|
try {
|
|
for (const route of routes) {
|
|
results.push(...(await auditRoute(context, baseUrl, route, viewport)));
|
|
}
|
|
} finally {
|
|
await context.close().catch(() => {});
|
|
}
|
|
}
|
|
|
|
const report = {
|
|
generatedAt: new Date().toISOString(),
|
|
baseUrl,
|
|
routes,
|
|
viewports,
|
|
results,
|
|
};
|
|
|
|
await writeFile(outputJson, `${JSON.stringify(report, null, 2)}\n`);
|
|
await writeFile(outputMarkdown, renderMarkdown(report));
|
|
|
|
console.log(
|
|
`Exported ${results.length} Astro audit issue${
|
|
results.length === 1 ? '' : 's'
|
|
} across ${routes.length} routes.`
|
|
);
|
|
console.log(`JSON: ${path.relative(process.cwd(), outputJson)}`);
|
|
console.log(`Markdown: ${path.relative(process.cwd(), outputMarkdown)}`);
|
|
|
|
if (results.length > 0) {
|
|
for (const result of results.slice(0, 20)) {
|
|
console.log(
|
|
`- ${result.route} [${result.viewport}] ${result.code}: ${result.title}${
|
|
result.source ? ` (${result.source})` : ''
|
|
}`
|
|
);
|
|
}
|
|
if (results.length > 20) {
|
|
console.log(`...and ${results.length - 20} more. See the report files.`);
|
|
}
|
|
}
|
|
|
|
if (failOnIssues && results.length > 0) {
|
|
process.exitCode = 1;
|
|
}
|
|
} finally {
|
|
if (browser) await safeCloseBrowser(browser);
|
|
if (devServer) await stopProcess(devServer);
|
|
await rm(browserTmp, { recursive: true, force: true }).catch(() => {});
|
|
}
|