From d3e367de899838445cfa1d94171170d95c0a20bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Rzepecki?= Date: Fri, 4 Sep 2026 07:01:02 +0200 Subject: [PATCH 1/3] build: Type check the test code in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing was type checking the tests: prepack builds tsconfig.build.json, which excludes __tests__ and test/, and jest transpiles with @swc/jest, which strips types without checking them. eslint doesn't cover the gap either — typescript-eslint builds a Program to answer questions its rules ask, but discards the compiler's own diagnostics. Consequently four genuine type errors had accumulated in the unit tests, all harmless at runtime, so the suite stayed green. Fix them and add a typecheck script over the root tsconfig, run in the lint job. The Next.js test apps also needed jsx: preserve. They are built by next with their own tsconfig, but the root config includes them, and it had no jsx setting. Assisted-by: Claude:claude-opus-5[1m] --- .github/workflows/ci.yml | 1 + package.json | 1 + src/__tests__/config.test.ts | 4 +++- src/__tests__/helpers.ts | 1 - src/__tests__/jsxInstrument.test.ts | 3 ++- src/__tests__/parameter.test.ts | 2 +- tsconfig.json | 3 +++ 7 files changed, 11 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80518cff..a11fc09c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,7 @@ jobs: path: appmap-node-${{ env.SHORT_SHA }}.tgz - run: yarn pkg-pr-new publish --commentWithDev --yarn - run: yarn lint + - run: yarn typecheck test: needs: build-and-lint diff --git a/package.json b/package.json index 610ba42e..4ef01a23 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "scripts": { "prepack": "tsc -p tsconfig.build.json", "lint": "eslint src test", + "typecheck": "tsc --noEmit", "test": "jest", "watch": "tsc --watch -p tsconfig.build.json" }, diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 8b60e30b..fa11e537 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -154,7 +154,9 @@ describe(Config, () => { let dir: string; beforeEach(() => { chdir((dir = tmp.dirSync().name)); - jest.replaceProperty(process, "env", {}); + // The cast is needed because next's types (pulled in through the test apps) + // augment ProcessEnv to make NODE_ENV required. + jest.replaceProperty(process, "env", {} as NodeJS.ProcessEnv); }); const origCwd = cwd(); diff --git a/src/__tests__/helpers.ts b/src/__tests__/helpers.ts index f16b1954..b3df3e5c 100644 --- a/src/__tests__/helpers.ts +++ b/src/__tests__/helpers.ts @@ -8,7 +8,6 @@ export function createTestFn(name: string, ...args: string[]): FunctionInfo { generator: false, id: identifier(name), params: args.map(identifier), - type: "FunctionDeclaration", }, { path: "test.js", lineno: 42 }, ); diff --git a/src/__tests__/jsxInstrument.test.ts b/src/__tests__/jsxInstrument.test.ts index 17ac79e0..daa7ab1e 100644 --- a/src/__tests__/jsxInstrument.test.ts +++ b/src/__tests__/jsxInstrument.test.ts @@ -19,7 +19,8 @@ function isInstrumented(program: ReturnType): boolean { return program.body.some( (n) => n.type === "VariableDeclaration" && - n.declarations?.[0]?.id?.name === "__appmapFunctionRegistry", + n.declarations[0]?.id.type === "Identifier" && + n.declarations[0].id.name === "__appmapFunctionRegistry", ); } diff --git a/src/__tests__/parameter.test.ts b/src/__tests__/parameter.test.ts index 571fe91a..4e36ee07 100644 --- a/src/__tests__/parameter.test.ts +++ b/src/__tests__/parameter.test.ts @@ -1,4 +1,4 @@ -import AppMap from "../AppMap"; +import type * as AppMap from "../AppMap"; import { objectId, optParameter, parameter } from "../parameter"; class Klass { diff --git a/tsconfig.json b/tsconfig.json index 03966f0d..fb448b1b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,6 +2,9 @@ "extends": "@tsconfig/node18", "compilerOptions": { "declaration": true, + // Only needed to type check the .tsx pages in the Next.js test apps; the + // apps themselves are built by next with their own tsconfig. + "jsx": "preserve", "outDir": "dist", "noImplicitAny": true, "sourceMap": true, From e6ef65864579674d0ff413db61c4cc995bcfec47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Rzepecki?= Date: Fri, 4 Sep 2026 07:20:54 +0200 Subject: [PATCH 2/3] test: Fix flaky Next.js integration tests on Windows The Next.js tests failed intermittently on Windows CI in two ways. First, readiness detection matched "Ready" against individual stdout chunks. next dev interleaves spinner frames and escape sequences with its output, so the message can be split across a chunk boundary; when that happened the promise never resolved and the test hung until Jest killed it. Match against the accumulated output instead, in a small watchOutput() helper. Wait for the appmaps to be reported as written with it too: an appmap is written after its response has been sent, so a finished request does not mean its appmap is on disk yet. Second, even a passing run could fail the suite with "Cannot log after tests are done". On Windows appmap-node spawns the target with shell: true, so the real next process is a grandchild which keeps the inherited stdio pipes open after the shell has exited, and the escape sequence it wrote on the way out arrived after teardown. Drop the logging listeners once the process is gone, via a new detachOutput() helper. Use it in the other tests spawning long-running processes too, where the same race is possible. The grandchild also has to be killed explicitly, and on Windows that is an immediate TerminateProcess rather than a graceful Ctrl-C, which is the other reason to wait for the appmaps before tearing anything down. Also bump the next.test.ts timeout to 60s to match next16.test.ts; the test legitimately takes over half of the previous 20s budget on a slow runner. Since both Next.js tests were now identical apart from the app directory, move the shared body to test/nextApp.ts. It stays a plain function passed to integrationTest() rather than defining the test itself, because integrationTest() derives the target app directory from its caller's file name. Assisted-by: Claude:claude-opus-5[1m] --- test/helpers.ts | 17 +++++++ test/httpServer.test.ts | 6 ++- test/next.test.ts | 75 ++-------------------------- test/next16.test.ts | 79 ++--------------------------- test/nextApp.ts | 107 ++++++++++++++++++++++++++++++++++++++++ test/simple.test.ts | 3 ++ 6 files changed, 139 insertions(+), 148 deletions(-) create mode 100644 test/nextApp.ts diff --git a/test/helpers.ts b/test/helpers.ts index 0ca1c1a2..3cc4fb2d 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -50,6 +50,23 @@ export function spawnAppmapNodeWithOptions( return result; } +/** + * Stop handling a spawned process' output, removing every stdout and stderr + * data listener: the logging ones installed by spawnAppmapNode as well as any + * the caller has added itself. The process is expected to be gone by now, so + * there should be nothing left to listen for. + * + * Call this once a spawned process is no longer needed: on Windows appmap-node + * spawns the target with shell: true, so the actual process is a grandchild + * which keeps the stdio pipes open even after the shell has exited. Anything it + * writes on the way out (eg. terminal escapes) would otherwise be logged after + * the test has finished, which makes Jest fail the whole run. + */ +export function detachOutput(child: ChildProcessWithoutNullStreams) { + child.stdout.removeAllListeners("data"); + child.stderr.removeAllListeners("data"); +} + let target = fwdSlashPath(cwd()); export function testDir(path: string) { diff --git a/test/httpServer.test.ts b/test/httpServer.test.ts index 98ad3a45..c45eaef9 100644 --- a/test/httpServer.test.ts +++ b/test/httpServer.test.ts @@ -3,6 +3,7 @@ import { setTimeout as sleep } from "node:timers/promises"; import { SpawnAppmapNodeOptions, + detachOutput, fixAppmap, integrationTest, readAppmaps, @@ -141,7 +142,8 @@ async function makeRequest( return Buffer.concat(chunks).toString(); } -function killServer(server: ChildProcessWithoutNullStreams) { +async function killServer(server: ChildProcessWithoutNullStreams) { server.kill("SIGINT"); - return new Promise((r) => server.on("close", () => r())); + await new Promise((r) => server.on("close", () => r())); + detachOutput(server); } diff --git a/test/next.test.ts b/test/next.test.ts index 1f778b42..04d3c7b5 100644 --- a/test/next.test.ts +++ b/test/next.test.ts @@ -1,73 +1,4 @@ -import { IncomingMessage, request } from "node:http"; -import { resolve } from "node:path"; +import { integrationTest } from "./helpers"; +import testNextApp from "./nextApp"; -import { getFreePort, integrationTest, readAppmaps, spawnAppmapNode } from "./helpers"; - -const nextBin = require.resolve("next/dist/bin/next", { - paths: [resolve(__dirname, "next")], -}); - -async function spawnNextJsApp(port: number) { - // On Windows, we give "node" argument explicitly because next is a js file with - // shebang (#!/usr/bin/env node) which does not work on Windows. - const app = - process.platform == "win32" - ? spawnAppmapNode("node", nextBin, "dev", "-p", String(port)) - : spawnAppmapNode(nextBin, "dev", "-p", String(port)); - - await new Promise((r) => { - const onData = (chunk: Buffer) => { - console.log("CHUNK", chunk.toString()); - if (chunk.toString().includes("Ready")) { - app.stdout.removeListener("data", onData); - r(); - } - }; - app.stdout.on("data", onData); - }); - return app; -} - -integrationTest( - "mapping a Next.js appmap", - async () => { - const port = await getFreePort(); - const app = await spawnNextJsApp(port); - const response = await makeRequest(port, "/hello"); - console.log("Response", response); - const pid = parseInt((JSON.parse(response) as unknown as { pid: string }).pid); - - await makeRequest(port, "/about"); - - app.kill("SIGINT"); - await new Promise((r) => app.once("exit", r)); - const appMaps = readAppmaps(); - // Delete response body captures because they will be different in every run - Object.values(appMaps).forEach( - (a) => - a.events?.forEach((e) => { - if ("http_server_response" in e) delete e.http_server_response.return_value; - if ("http_client_response" in e) delete e.http_client_response.return_value; - }), - ); - - expect(appMaps).toMatchSnapshot(); - - // We need to kill the next process explicitly on Windows - // because it's spawn-ed with "shell: true" and app is the shell process. - if (process.platform == "win32") process.kill(pid, "SIGINT"); - }, - 20000, -); - -async function makeRequest(port: number, path: string, method = "GET") { - const url = new URL(path, `http://localhost:${port}`); - const response = new Promise((resolve, reject) => { - const req = request(url, { method }, resolve).once("error", reject); - req.end(); - }); - - const chunks: Buffer[] = []; - for await (const chunk of await response) chunks.push(chunk as Buffer); - return Buffer.concat(chunks).toString(); -} +integrationTest("mapping a Next.js appmap", testNextApp, 60000); diff --git a/test/next16.test.ts b/test/next16.test.ts index d9164d77..caa5326a 100644 --- a/test/next16.test.ts +++ b/test/next16.test.ts @@ -1,34 +1,5 @@ -import { IncomingMessage, request } from "node:http"; -import { resolve } from "node:path"; - -import { getFreePort, integrationTest, readAppmaps, spawnAppmapNode } from "./helpers"; - -const nextBin = require.resolve("next/dist/bin/next", { - paths: [resolve(__dirname, "next16")], -}); - -async function spawnNextJsApp(port: number) { - // On Windows, we give "node" argument explicitly because next is a js file with - // shebang (#!/usr/bin/env node) which does not work on Windows. - // Turbopack (the default bundler in Next.js 16) respects webpack loaders configured - // via next.config turbopack.rules, which we inject in src/hooks/next.ts. - const app = - process.platform == "win32" - ? spawnAppmapNode("node", nextBin, "dev", "-p", String(port)) - : spawnAppmapNode(nextBin, "dev", "-p", String(port)); - - await new Promise((r) => { - const onData = (chunk: Buffer) => { - console.log("CHUNK", chunk.toString()); - if (chunk.toString().includes("Ready")) { - app.stdout.removeListener("data", onData); - r(); - } - }; - app.stdout.on("data", onData); - }); - return app; -} +import { integrationTest } from "./helpers"; +import testNextApp from "./nextApp"; // Next.js 16 requires Node.js >= 20.9.0 const nodeSupported = (() => { @@ -36,46 +7,6 @@ const nodeSupported = (() => { return major > 20 || (major === 20 && minor >= 9); })(); -integrationTest.if(nodeSupported)( - "mapping a Next.js 16 appmap", - async () => { - const port = await getFreePort(); - const app = await spawnNextJsApp(port); - const response = await makeRequest(port, "/hello"); - console.log("Response", response); - const pid = parseInt((JSON.parse(response) as unknown as { pid: string }).pid); - - await makeRequest(port, "/about"); - - app.kill("SIGINT"); - await new Promise((r) => app.once("exit", r)); - const appMaps = readAppmaps(); - // Delete response body captures because they will be different in every run - Object.values(appMaps).forEach( - (a) => - a.events?.forEach((e) => { - if ("http_server_response" in e) delete e.http_server_response.return_value; - if ("http_client_response" in e) delete e.http_client_response.return_value; - }), - ); - - expect(appMaps).toMatchSnapshot(); - - // We need to kill the next process explicitly on Windows - // because it's spawn-ed with "shell: true" and app is the shell process. - if (process.platform == "win32") process.kill(pid, "SIGINT"); - }, - 60000, -); - -async function makeRequest(port: number, path: string, method = "GET") { - const url = new URL(path, `http://localhost:${port}`); - const response = new Promise((resolve, reject) => { - const req = request(url, { method }, resolve).once("error", reject); - req.end(); - }); - - const chunks: Buffer[] = []; - for await (const chunk of await response) chunks.push(chunk as Buffer); - return Buffer.concat(chunks).toString(); -} +// Turbopack (the default bundler in Next.js 16) respects webpack loaders configured +// via next.config turbopack.rules, which we inject in src/hooks/next.ts. +integrationTest.if(nodeSupported)("mapping a Next.js 16 appmap", testNextApp, 60000); diff --git a/test/nextApp.ts b/test/nextApp.ts new file mode 100644 index 00000000..e9576d53 --- /dev/null +++ b/test/nextApp.ts @@ -0,0 +1,107 @@ +import { ChildProcessWithoutNullStreams } from "node:child_process"; +import { IncomingMessage, request } from "node:http"; + +import { detachOutput, getFreePort, readAppmaps, resolveTarget, spawnAppmapNode } from "./helpers"; + +/** + * The body shared by the Next.js integration tests: run `next dev` on the app + * in the test target directory, request a couple of pages and snapshot the + * resulting appmaps. + */ +export default async function testNextApp() { + const port = await getFreePort(); + const { app, waitForOutput } = await spawnNextJsApp(port); + const response = await makeRequest(port, "/hello"); + console.log("Response", response); + const { pid } = JSON.parse(response) as { pid: number }; + + await makeRequest(port, "/about"); + + // An appmap is written after its response has been sent, so a request being + // finished doesn't mean its appmap is on disk yet. Wait for next to report + // both of them before killing it, or we can lose the last one. + await waitForOutput("-hello.appmap.json"); + await waitForOutput("-about.appmap.json"); + + // We need to kill the next process explicitly on Windows + // because it's spawn-ed with "shell: true" and app is the shell process. + if (process.platform == "win32") process.kill(pid, "SIGINT"); + app.kill("SIGINT"); + await new Promise((r) => app.once("exit", r)); + detachOutput(app); + + const appMaps = readAppmaps(); + // Delete response body captures because they will be different in every run + Object.values(appMaps).forEach( + (a) => + a.events?.forEach((e) => { + if ("http_server_response" in e) delete e.http_server_response.return_value; + if ("http_client_response" in e) delete e.http_client_response.return_value; + }), + ); + + expect(appMaps).toMatchSnapshot(); +} + +async function spawnNextJsApp(port: number) { + const nextBin = require.resolve("next/dist/bin/next", { paths: [resolveTarget()] }); + + // On Windows, we give "node" argument explicitly because next is a js file with + // shebang (#!/usr/bin/env node) which does not work on Windows. + const app = + process.platform == "win32" + ? spawnAppmapNode("node", nextBin, "dev", "-p", String(port)) + : spawnAppmapNode(nextBin, "dev", "-p", String(port)); + + const waitForOutput = watchOutput(app); + await waitForOutput("Ready"); + return { app, waitForOutput }; +} + +/** + * Watch the process' stdout, returning a function which waits for a fragment of + * it to appear. + * + * Matching is against everything seen so far rather than individual chunks: + * next dev interleaves spinner frames and escape sequences with its output, so + * a message can both be split across chunk boundaries and have arrived before + * we start waiting for it. + */ +function watchOutput(app: ChildProcessWithoutNullStreams, timeout = 30000) { + let output = ""; + const waiters = new Set<() => void>(); + + app.stdout.on("data", (chunk: Buffer) => { + output += chunk.toString(); + for (const check of waiters) check(); + }); + + return (needle: string) => + new Promise((resolve, reject) => { + // Fail before the test times out, so that we get to say what we waited for. + const timer = setTimeout(() => { + waiters.delete(check); + reject(new Error(`Timed out waiting for ${needle} in the output:\n${output}`)); + }, timeout); + const check = () => { + if (!output.includes(needle)) return; + clearTimeout(timer); + waiters.delete(check); + resolve(); + }; + waiters.add(check); + check(); + }); +} + +async function makeRequest(port: number, path: string, method = "GET") { + const url = new URL(path, `http://localhost:${port}`); + const response = new Promise((resolve, reject) => { + const req = request(url, { method }, resolve).once("error", reject); + req.end(); + }); + + const chunks: Buffer[] = []; + for await (const chunk of await response) chunks.push(chunk as Buffer); + return Buffer.concat(chunks).toString(); +} diff --git a/test/simple.test.ts b/test/simple.test.ts index 33ad478a..47ee0c9c 100644 --- a/test/simple.test.ts +++ b/test/simple.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import tmp from "tmp"; import { + detachOutput, getAppMapBinPath, integrationTest, readAppmap, @@ -46,6 +47,7 @@ integrationTestSkipOnWindows("forwarding signals to the child", async () => { daemon.kill("SIGINT"); await new Promise((r) => daemon.once("exit", r)); + detachOutput(daemon); expect(daemon.exitCode).toBe(42); expect(readAppmap()).toMatchSnapshot(); @@ -103,6 +105,7 @@ integrationTestSkipOnWindows("finish signal is handled", async () => { server.kill("SIGINT"); await new Promise((r) => server.once("exit", r)); + detachOutput(server); expect(readAppmap()).toMatchSnapshot(); }); From dac25f29712f2be6ed323e356e4afaa13e9741ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Rzepecki?= Date: Fri, 4 Sep 2026 07:36:21 +0200 Subject: [PATCH 3/3] fix(next): Don't inject into the raw Next.js config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turbopack's config validation loads the config with rawConfig, in which mode next returns the config module's namespace object rather than a resolved configuration. Assigning webpack to it threw, since a namespace object is not extensible, and every `next dev` reported ⨯ Unexpected error occurred while checking config TypeError: Cannot add property webpack, object is not extensible Recording was unaffected — next catches the exception — but it silently loses the validation, so the user no longer gets told about the config options Turbopack ignores. Worse, had the object been extensible we would have made next believe the user wrote a webpack config, which it refuses to combine with the default bundler. Skip the injection there. Skip it for any other non-extensible config as well: nothing can be injected into one, and throwing is worse than not instrumenting. The Next.js tests now assert that nothing was thrown out of the hook, which they can do incidentally: next reports it with an injectConfig frame in the stack, and they already collect the output. Assisted-by: Claude:claude-opus-5[1m] --- src/hooks/next.ts | 29 +++++++++++++++++++++++++++-- test/nextApp.ts | 22 +++++++++++++++------- 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/src/hooks/next.ts b/src/hooks/next.ts index 3b8aba83..c0e615a9 100644 --- a/src/hooks/next.ts +++ b/src/hooks/next.ts @@ -20,6 +20,11 @@ interface NextConfig { webpack?: (config: WebpackConfiguration, context: WebpackContext) => WebpackConfiguration; } +// The third argument of next's loadConfig(phase, dir, opts). +interface LoadConfigOptions { + rawConfig?: boolean; +} + import { call_, identifier, literal, member, ret } from "../generate"; import { warn } from "../message"; @@ -47,7 +52,9 @@ export function transform(program: ESTree.Program): ESTree.Program { }; const thisFile = call_(identifier("require"), literal(__filename)); const injectConfig = member(thisFile, "injectConfig"); - fun.body.body = [ret(call_(injectConfig, orig))]; + // Forward the arguments rather than the declared parameters, so that we + // don't depend on how a given version of next happens to name them. + fun.body.body = [ret(call_(injectConfig, orig, identifier("arguments")))]; }, }); return program; @@ -55,8 +62,26 @@ export function transform(program: ESTree.Program): ESTree.Program { const jsExtensions = ["tsx", "ts", "js", "cjs", "mjs", "jsx"]; -export async function injectConfig(loadConfig: () => Promise): Promise { +export async function injectConfig( + loadConfig: () => Promise, + args?: IArguments, +): Promise { const result = await loadConfig(); + + // With rawConfig next asks for the config module's exports rather than the + // resolved configuration, to validate what the user configured. That object + // is a module namespace and hence not extensible, and injecting a webpack + // config into it would in any case make next believe the user has one. + const opts = args?.[2] as LoadConfigOptions | undefined; + if (opts?.rawConfig) return result; + + // Any other config we can't extend is unexpected. Don't throw, the caller + // handles it poorly, but do say so: nothing would be instrumented otherwise. + if (!Object.isExtensible(result)) { + warn("The Next.js configuration cannot be modified, AppMap will not be injected into it."); + return result; + } + const loaderPath = resolve(__dirname, "../webpack.js"); // Webpack loader injection (Next.js with --webpack or older Next.js) diff --git a/test/nextApp.ts b/test/nextApp.ts index e9576d53..cea6ce30 100644 --- a/test/nextApp.ts +++ b/test/nextApp.ts @@ -10,7 +10,7 @@ import { detachOutput, getFreePort, readAppmaps, resolveTarget, spawnAppmapNode */ export default async function testNextApp() { const port = await getFreePort(); - const { app, waitForOutput } = await spawnNextJsApp(port); + const { app, waitForOutput, errorOutput } = await spawnNextJsApp(port); const response = await makeRequest(port, "/hello"); console.log("Response", response); const { pid } = JSON.parse(response) as { pid: number }; @@ -30,6 +30,10 @@ export default async function testNextApp() { await new Promise((r) => app.once("exit", r)); detachOutput(app); + // next reports an exception thrown out of the config hooks with this frame in + // the stack and then carries on regardless, so nothing else would notice. + expect(errorOutput()).not.toContain("injectConfig"); + const appMaps = readAppmaps(); // Delete response body captures because they will be different in every run Object.values(appMaps).forEach( @@ -53,30 +57,32 @@ async function spawnNextJsApp(port: number) { ? spawnAppmapNode("node", nextBin, "dev", "-p", String(port)) : spawnAppmapNode(nextBin, "dev", "-p", String(port)); - const waitForOutput = watchOutput(app); + const { waitForOutput, errorOutput } = watchOutput(app); await waitForOutput("Ready"); - return { app, waitForOutput }; + return { app, waitForOutput, errorOutput }; } /** - * Watch the process' stdout, returning a function which waits for a fragment of - * it to appear. + * Watch the process' output, returning a function which waits for a fragment of + * its stdout to appear, and one giving everything it has said on stderr. * - * Matching is against everything seen so far rather than individual chunks: + * Waiting matches against everything seen so far rather than individual chunks: * next dev interleaves spinner frames and escape sequences with its output, so * a message can both be split across chunk boundaries and have arrived before * we start waiting for it. */ function watchOutput(app: ChildProcessWithoutNullStreams, timeout = 30000) { let output = ""; + let errors = ""; const waiters = new Set<() => void>(); app.stdout.on("data", (chunk: Buffer) => { output += chunk.toString(); for (const check of waiters) check(); }); + app.stderr.on("data", (chunk: Buffer) => (errors += chunk.toString())); - return (needle: string) => + const waitForOutput = (needle: string) => new Promise((resolve, reject) => { // Fail before the test times out, so that we get to say what we waited for. const timer = setTimeout(() => { @@ -92,6 +98,8 @@ function watchOutput(app: ChildProcessWithoutNullStreams, timeout = 30000) { waiters.add(check); check(); }); + + return { waitForOutput, errorOutput: () => errors }; } async function makeRequest(port: number, path: string, method = "GET") {