Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion bin/ngbr-a11y-report.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <s>
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 <path> Route to scan; repeat for multiple routes
--from <file> 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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
71 changes: 58 additions & 13 deletions src/report/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
Expand All @@ -43,9 +53,10 @@ export async function isServing(url: string): Promise<boolean> {
/**
* 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<DevServer> {
const { command, url, timeoutMs = 180_000, reuseExisting = true } = options;
Expand All @@ -64,16 +75,39 @@ export async function startDevServer(options: DevServerOptions): Promise<DevServ
});

const tail: string[] = [];
const collect = (chunk: Buffer) => {
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<void>((done) => {
Expand Down Expand Up @@ -123,7 +157,14 @@ export async function startDevServer(options: DevServerOptions): Promise<DevServ
await stop();
throw withOutput(`Dev server command exited (code ${exitCode}) before ${url} answered: ${command}`);
}
if (await isServing(url)) return { reused: false, stop };
if (
buildFailedAt !== undefined &&
(buildErrorsDone || Date.now() - buildFailedAt >= 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}`);
Expand All @@ -132,6 +173,10 @@ export async function startDevServer(options: DevServerOptions): Promise<DevServ
}
}

function stripAnsi(text: string): string {
return text.replace(/\x1b\[[0-9;]*m/g, '');
}

function killTree(child: ChildProcess, signal: NodeJS.Signals): void {
if (child.pid === undefined) return;
try {
Expand Down
38 changes: 38 additions & 0 deletions src/testing/serve.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,44 @@ describe('startDevServer', () => {
);
});

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}/`;
Expand Down
Loading