diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a84751..97adcfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to `@ngbracket/a11y-devtools` are documented here. This project adheres to [Semantic Versioning](https://semver.org/). +## 0.18.2 + +### Fixed + +- `--serve` stops as soon as the Angular build fails. `ng serve` keeps + watching after a failed build and never answers, so report mode used to wait + the full `--serve-timeout` (180 s by default) and then say the server didn't + answer. It now waits until Angular has printed the build errors, stops the + server, and exits with code 2 and "The Angular build failed", followed by + the last lines of output. Both the application builder and the older + webpack builder are covered. `--serve-timeout` still applies to servers + that never print a failure. + ## 0.18.1 ### Fixed diff --git a/bin/ngbr-a11y-report.mjs b/bin/ngbr-a11y-report.mjs index 805ebdc..05b6d8e 100755 --- a/bin/ngbr-a11y-report.mjs +++ b/bin/ngbr-a11y-report.mjs @@ -28,7 +28,8 @@ Options: answers, scan, then stop it — e.g. --serve "npx ng serve". If --base already answers, that server is used as-is --serve-timeout - Seconds to wait for --serve to answer (default 180) + Seconds to wait for --serve to answer (default 180). + A failed Angular build stops it straight away --route Route to scan; repeat for multiple routes --from Don't scan: read a previous run's JSON report (from --out) and write it in the --format(s) asked for — e.g. add an diff --git a/package-lock.json b/package-lock.json index eb2af07..2cdf0fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ngbracket/a11y-devtools", - "version": "0.18.1", + "version": "0.18.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ngbracket/a11y-devtools", - "version": "0.18.1", + "version": "0.18.2", "license": "MIT", "dependencies": { "axe-core": "^4.13.0" diff --git a/package.json b/package.json index 38ed540..8e75ecb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ngbracket/a11y-devtools", - "version": "0.18.1", + "version": "0.18.2", "description": "Finds accessibility problems in Angular apps and names the component behind each one. A dev overlay, or headless reports for CI.", "license": "MIT", "author": "Duncan Faulkner", diff --git a/src/report/serve.ts b/src/report/serve.ts index 83ad849..7ef5d11 100644 --- a/src/report/serve.ts +++ b/src/report/serve.ts @@ -28,6 +28,16 @@ export interface DevServer { /** Lines of dev-server output kept for the error message when it fails to start. */ const OUTPUT_TAIL = 30; const POLL_MS = 500; +/** + * What `ng serve` prints when a build fails: the application builder (esbuild), + * then the older browser builder (webpack). Either way the server keeps watching + * and never answers, so waiting for the timeout only wastes CI time. + */ +const BUILD_FAILED = /Application bundle generation failed|Failed to compile/; +/** `ng serve` prints this once it has listed the build errors. */ +const WATCHING = /Watch mode enabled/; +/** After a failed build, how long to keep collecting its error lines (ms). */ +const BUILD_ERRORS_MS = 2000; /** True when something at `url` answers with a non-5xx response. */ export async function isServing(url: string): Promise { @@ -43,9 +53,10 @@ export async function isServing(url: string): Promise { /** * Start a dev server and resolve once `url` answers — for report mode in CI, * where nothing is running yet. Rejects (after stopping the process) if the - * command exits first or `timeoutMs` passes; the error includes the last lines - * of its output. Always `stop()` the result when the scan is done. Until then, - * SIGINT/SIGTERM stop the server before the process ends. + * command exits first, the Angular build fails, or `timeoutMs` passes; the + * error includes the last lines of its output. Always `stop()` the result when + * the scan is done. Until then, SIGINT/SIGTERM stop the server before the + * process ends. */ export async function startDevServer(options: DevServerOptions): Promise { const { command, url, timeoutMs = 180_000, reuseExisting = true } = options; @@ -64,16 +75,39 @@ export async function startDevServer(options: DevServerOptions): Promise { - for (const line of chunk.toString().split(/\r?\n/)) { - if (!line.trim()) continue; - options.onOutput?.(line); - tail.push(line); - if (tail.length > OUTPUT_TAIL) tail.shift(); - } + // Set when the build fails; `buildErrorsDone` once its error lines are in. + let buildFailedAt: number | undefined; + let buildErrorsDone = false; + const addLine = (line: string) => { + if (!line.trim()) return; + options.onOutput?.(line); + tail.push(line); + if (tail.length > OUTPUT_TAIL) tail.shift(); + const plain = stripAnsi(line); + if (buildFailedAt === undefined && BUILD_FAILED.test(plain)) buildFailedAt = Date.now(); + else if (buildFailedAt !== undefined && WATCHING.test(plain)) buildErrorsDone = true; }; - child.stdout?.on('data', collect); - child.stderr?.on('data', collect); + // A chunk can end mid-line, so each stream keeps its unfinished last line + // until the rest arrives (or the stream ends). + const lineReader = () => { + let partial = ''; + return { + data: (chunk: Buffer) => { + const lines = (partial + chunk.toString()).split(/\r?\n/); + partial = lines.pop() ?? ''; + lines.forEach(addLine); + }, + end: () => { + addLine(partial); + partial = ''; + }, + }; + }; + for (const stream of [child.stdout, child.stderr]) { + const reader = lineReader(); + stream?.on('data', reader.data); + stream?.on('end', reader.end); + } let exitCode: number | null | undefined; const exited = new Promise((done) => { @@ -123,7 +157,14 @@ export async function startDevServer(options: DevServerOptions): Promise= BUILD_ERRORS_MS) + ) { + await stop(); + throw withOutput(`The Angular build failed, so the dev server never started: ${command}`); + } + if (buildFailedAt === undefined && (await isServing(url))) return { reused: false, stop }; if (Date.now() >= deadline) { await stop(); throw withOutput(`Dev server didn't answer at ${url} within ${Math.round(timeoutMs / 1000)}s: ${command}`); @@ -132,6 +173,10 @@ export async function startDevServer(options: DevServerOptions): Promise { ); }); + it('fails fast when the Angular build fails, with the build errors', async () => { + const port = await freePort(); + // Like `ng serve` after a failed build: prints the errors, then keeps watching. + const js = + `console.log('\\x1b[31mApplication bundle generation failed.\\x1b[0m [4.486 seconds]');` + + `console.error('[ERROR] Could not resolve styles.css');` + + `console.log('Watch mode enabled. Watching for file changes...');` + + `setInterval(() => {}, 1000);`; + const command = `"${process.execPath}" -e "${js}"`; + const started = Date.now(); + await expect(startDevServer({ command, url: `http://127.0.0.1:${port}/` })).rejects.toThrow( + /Angular build failed[\s\S]*Could not resolve styles\.css/, + ); + expect(Date.now() - started).toBeLessThan(5000); + }); + + it('stops a failed build after a short wait when no "Watch mode" line follows', async () => { + const port = await freePort(); + // Webpack's wording, and no "Watch mode" line to say the errors are all in. + const js = `console.log('Failed to compile.'); setInterval(() => {}, 1000);`; + const command = `"${process.execPath}" -e "${js}"`; + await expect(startDevServer({ command, url: `http://127.0.0.1:${port}/` })).rejects.toThrow( + /Angular build failed[\s\S]*Failed to compile/, + ); + }); + + it('spots a build failure split across two chunks of output', async () => { + const port = await freePort(); + const js = + `process.stdout.write('Application bundle gen');` + + `setTimeout(() => process.stdout.write('eration failed.\\nWatch mode enabled.\\n'), 200);` + + `setInterval(() => {}, 1000);`; + const command = `"${process.execPath}" -e "${js}"`; + await expect( + startDevServer({ command, url: `http://127.0.0.1:${port}/`, timeoutMs: 4000 }), + ).rejects.toThrow(/Angular build failed[\s\S]*Application bundle generation failed/); + }); + it('times out, stops the command, and says why', async () => { const port = await freePort(); const url = `http://127.0.0.1:${port}/`;