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(() => {}); }