From d6b1c37cad9d009d7f2e6344349b93e63350e03c Mon Sep 17 00:00:00 2001 From: Cristian Greco Date: Mon, 12 Oct 2026 15:29:37 +0100 Subject: [PATCH 1/5] Include container logs in the error when a container fails to start When a container fails its wait strategy, waitForContainer now reads the last 50 lines of its logs before removing it and appends them to the error. The logs are skipped when the error message already contains them, and a failed restart reports only the logs since the restart. --- docs/features/containers.md | 2 +- docs/features/wait-strategies.md | 9 +++++ .../generic-container-restart.test.ts | 18 +++++++++ .../generic-container.test.ts | 19 +++++++++ .../src/wait-strategies/wait-for-container.ts | 39 ++++++++++++++++++- 5 files changed, 85 insertions(+), 2 deletions(-) diff --git a/docs/features/containers.md b/docs/features/containers.md index 6310b872c..5e3ec01b0 100644 --- a/docs/features/containers.md +++ b/docs/features/containers.md @@ -744,7 +744,7 @@ const container = await new GenericContainer("alpine").start(); .on("end", () => console.log("Stream closed")); ``` -Or a consumer can be provided before start. This is useful for example if your container is failing to start: +Or a consumer can be provided before start. This is useful for example if your container is failing to start and you need more than the [last lines of its logs included in the error](wait-strategies.md): ```js const container = await new GenericContainer("alpine") diff --git a/docs/features/wait-strategies.md b/docs/features/wait-strategies.md index e9c27e04a..05760b90a 100644 --- a/docs/features/wait-strategies.md +++ b/docs/features/wait-strategies.md @@ -10,6 +10,15 @@ const container = await new GenericContainer("alpine") .start(); ``` +If a container does not become ready, it is stopped and removed, and the error thrown includes the last 50 lines of its logs: + +``` +URL /health not accessible after 120000ms +Container logs (last 50 lines): +Error: connect ECONNREFUSED 10.0.0.5:5432 +Retrying database connection in 5 seconds +``` + ## Default wait strategy By default, Testcontainers waits for a container health check when one is defined by the image or configured with `withHealthCheck`. If no health check is defined, or the image disables health checks with `HEALTHCHECK NONE`, it waits up to 60 seconds for mapped network ports to be bound. diff --git a/packages/testcontainers/src/generic-container/generic-container-restart.test.ts b/packages/testcontainers/src/generic-container/generic-container-restart.test.ts index 36d7970a8..b15cd6724 100644 --- a/packages/testcontainers/src/generic-container/generic-container-restart.test.ts +++ b/packages/testcontainers/src/generic-container/generic-container-restart.test.ts @@ -1,5 +1,6 @@ import { RandomUuid } from "../common"; import { checkContainerIsHealthy } from "../utils/test-helper"; +import { Wait } from "../wait-strategies/wait"; import { GenericContainer } from "./generic-container"; describe("GenericContainer restart", { timeout: 180_000 }, () => { @@ -28,4 +29,21 @@ describe("GenericContainer restart", { timeout: 180_000 }, () => { await checkContainerIsHealthy(container); expect((await container.exec(["cat", "config.txt"])).output).toEqual(expect.stringContaining("testconfig")); }); + + it("should include the container logs since the restart in the error when the restart fails", async () => { + const container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14") + .withCommand([ + "/bin/sh", + "-c", + "if [ -f /tmp/started ]; then echo 'second start'; exit 1; fi; touch /tmp/started; echo 'first start'; sleep infinity", + ]) + .withWaitStrategy(Wait.forLogMessage("first start")) + .start(); + + await expect(container.restart()).rejects.toThrow( + new Error( + 'Log stream ended and message "first start" was not received\nContainer logs (last 50 lines):\nsecond start' + ) + ); + }); }); diff --git a/packages/testcontainers/src/generic-container/generic-container.test.ts b/packages/testcontainers/src/generic-container/generic-container.test.ts index eccf94c0b..2e5b459e5 100644 --- a/packages/testcontainers/src/generic-container/generic-container.test.ts +++ b/packages/testcontainers/src/generic-container/generic-container.test.ts @@ -1,8 +1,10 @@ import path from "node:path"; import archiver from "archiver"; +import type { Container } from "dockerode"; import getPort from "get-port"; import { RandomUuid } from "../common"; import { getContainerRuntimeClient } from "../container-runtime"; +import type { BoundPorts } from "../utils/bound-ports"; import { PullPolicy } from "../utils/pull-policy"; import { checkContainerIsHealthy, @@ -13,6 +15,7 @@ import { waitForDockerEvent, } from "../utils/test-helper"; import { Wait } from "../wait-strategies/wait"; +import { AbstractWaitStrategy } from "../wait-strategies/wait-strategy"; import { GenericContainer } from "./generic-container"; describe("GenericContainer", { timeout: 180_000 }, () => { @@ -692,6 +695,22 @@ describe("GenericContainer", { timeout: 180_000 }, () => { expect(container.getHostname()).toEqual("hostname"); }); + it("should include the container logs in the error when the container fails to become ready", async () => { + const waitStrategy = new (class extends AbstractWaitStrategy { + public async waitUntilReady(container: Container, boundPorts: BoundPorts): Promise { + await Wait.forLogMessage("connecting to database").waitUntilReady(container, boundPorts); + throw new Error("Container is not ready"); + } + })(); + const container = new GenericContainer("cristianrgreco/testcontainer:1.1.14") + .withCommand(["/bin/sh", "-c", "echo 'connecting to database'; sleep infinity"]) + .withWaitStrategy(waitStrategy); + + await expect(container.start()).rejects.toThrow( + new Error("Container is not ready\nContainer logs (last 50 lines):\nconnecting to database") + ); + }); + // failing to build an image hangs within the DockerImageClient.build method, // that change might be larger so leave it out of this commit but skip the failing test it.skip("should throw an error for a target stage that does not exist", async () => { diff --git a/packages/testcontainers/src/wait-strategies/wait-for-container.ts b/packages/testcontainers/src/wait-strategies/wait-for-container.ts index ca8a3f190..22378928c 100644 --- a/packages/testcontainers/src/wait-strategies/wait-for-container.ts +++ b/packages/testcontainers/src/wait-strategies/wait-for-container.ts @@ -1,9 +1,11 @@ import type { Container } from "dockerode"; -import { log } from "../common"; +import { log, streamToString } from "../common"; import type { ContainerRuntimeClient } from "../container-runtime"; import type { BoundPorts } from "../utils/bound-ports"; import type { WaitStrategy } from "./wait-strategy"; +const LOG_TAIL = 50; + export const waitForContainer = async ( client: ContainerRuntimeClient, container: Container, @@ -20,6 +22,7 @@ export const waitForContainer = async ( log.error(`Container failed to be ready: ${err}`, { containerId: container.id }); try { await client.container.stop(container, { timeout: 0 }); + await appendContainerLogs(client, container, err, startTime); await client.container.remove(container, { removeVolumes: true }); } catch (stopErr) { log.error(`Failed to stop container after it failed to be ready: ${stopErr}`, { containerId: container.id }); @@ -27,3 +30,37 @@ export const waitForContainer = async ( throw err; } }; + +const appendContainerLogs = async ( + client: ContainerRuntimeClient, + container: Container, + err: unknown, + startTime?: Date +): Promise => { + if (!(err instanceof Error)) { + return; + } + + try { + const stream = await client.container.logs(container, { + tail: LOG_TAIL, + since: startTime ? startTime.getTime() / 1000 : 0, + }); + const logs = (await streamToString(stream)).trimEnd(); + + if (logs.length === 0 || withoutWhitespace(err.message).includes(withoutWhitespace(logs))) { + return; + } + + const header = String(err); + const { stack } = err; + err.message = `${err.message}\nContainer logs (last ${LOG_TAIL} lines):\n${logs}`; + if (stack?.startsWith(header)) { + err.stack = `${err}${stack.slice(header.length)}`; + } + } catch (logsErr) { + log.warn(`Failed to add container logs to the error: ${logsErr}`, { containerId: container.id }); + } +}; + +const withoutWhitespace = (value: string): string => value.replace(/\s+/g, ""); From 9719052a8e48121757eea26049d3911b73b7ab58 Mon Sep 17 00:00:00 2001 From: Cristian Greco Date: Tue, 13 Oct 2026 10:36:30 +0100 Subject: [PATCH 2/5] Read container logs without following when a container fails to start Following the logs of a stopped container can hang on Podman, which timed out the health check tests. The logs are now read without follow and before the container is stopped, so the read ends whatever state the container is in. Match the SQL Server invalid password error by substring now that the error carries the container logs. --- .../src/mssqlserver-container.test.ts | 2 +- .../container/docker-container-client.test.ts | 36 +++++++++++++++++++ .../container/docker-container-client.ts | 28 ++++++++------- .../src/wait-strategies/wait-for-container.ts | 3 +- 4 files changed, 55 insertions(+), 14 deletions(-) diff --git a/packages/modules/mssqlserver/src/mssqlserver-container.test.ts b/packages/modules/mssqlserver/src/mssqlserver-container.test.ts index 5c888c244..abb4af737 100644 --- a/packages/modules/mssqlserver/src/mssqlserver-container.test.ts +++ b/packages/modules/mssqlserver/src/mssqlserver-container.test.ts @@ -58,7 +58,7 @@ describe("MSSQLServerContainer", { timeout: 180_000 }, () => { it("should throw error with invalid password", async () => { const container = new MSSQLServerContainer(IMAGE).acceptLicense().withPassword("password"); await expect(container.start()).rejects.toThrow( - Error('Log stream ended and message "/.*Recovery is complete.*/" was not received') + 'Log stream ended and message "/.*Recovery is complete.*/" was not received' ); }); diff --git a/packages/testcontainers/src/container-runtime/clients/container/docker-container-client.test.ts b/packages/testcontainers/src/container-runtime/clients/container/docker-container-client.test.ts index 7d55b4715..a48961936 100644 --- a/packages/testcontainers/src/container-runtime/clients/container/docker-container-client.test.ts +++ b/packages/testcontainers/src/container-runtime/clients/container/docker-container-client.test.ts @@ -1,5 +1,6 @@ import { PassThrough, type Readable } from "node:stream"; import type Dockerode from "dockerode"; +import { streamToString } from "../../../common"; import { DockerContainerClient } from "./docker-container-client"; describe("DockerContainerClient", () => { @@ -22,6 +23,41 @@ describe("DockerContainerClient", () => { await vi.waitFor(() => expect(actualLogStream.destroyed).toBe(true)); }); + + it("should end the log stream after the current logs when not following", async () => { + const container = { + id: "container-id", + logs: vi.fn(async () => Buffer.from("first line\nsecond line\n")), + }; + const dockerode = { + modem: { + demuxStream: (raw: Readable, stdout: PassThrough) => raw.on("data", (chunk) => stdout.write(chunk)), + }, + } as unknown as Dockerode; + const client = new DockerContainerClient(dockerode); + + const stream = await client.logs(container as unknown as Dockerode.Container, { follow: false }); + + expect(await streamToString(stream)).toBe("first line\nsecond line\n"); + expect(container.logs).toHaveBeenCalledWith(expect.objectContaining({ follow: false })); + }); + + it("should return logs that docker-modem parsed as JSON when not following", async () => { + const container = { + id: "container-id", + logs: vi.fn(async () => ({ level: "fatal" })), + }; + const dockerode = { + modem: { + demuxStream: (raw: Readable, stdout: PassThrough) => raw.on("data", (chunk) => stdout.write(chunk)), + }, + } as unknown as Dockerode; + const client = new DockerContainerClient(dockerode); + + const stream = await client.logs(container as unknown as Dockerode.Container, { follow: false }); + + expect(await streamToString(stream)).toBe('{"level":"fatal"}'); + }); }); describe("exec", () => { diff --git a/packages/testcontainers/src/container-runtime/clients/container/docker-container-client.ts b/packages/testcontainers/src/container-runtime/clients/container/docker-container-client.ts index 6fe7c503b..ee68886d2 100644 --- a/packages/testcontainers/src/container-runtime/clients/container/docker-container-client.ts +++ b/packages/testcontainers/src/container-runtime/clients/container/docker-container-client.ts @@ -181,18 +181,22 @@ export class DockerContainerClient implements ContainerClient { const proxyStream = new PassThrough(); proxyStream.setEncoding("utf8"); - container - .logs({ - follow: true, - stdout: true, - stderr: true, - tail: opts?.tail ?? -1, - since: opts?.since ?? 0, - }) - .then(async (stream) => { - const actualLogStream = stream as IncomingMessage; - actualLogStream.socket?.unref(); - + const logsOpts = { stdout: true, stderr: true, tail: opts?.tail ?? -1, since: opts?.since ?? 0 }; + // docker-modem parses a non-streamed body that is valid JSON, which unframed (TTY) logs can be. + const toBuffer = (logs: unknown) => (Buffer.isBuffer(logs) ? logs : Buffer.from(JSON.stringify(logs))); + const logStream: Promise = + opts?.follow === false + ? container + .logs({ ...logsOpts, follow: false }) + .then((logs) => Readable.from([toBuffer(logs)], { objectMode: false })) + : container.logs({ ...logsOpts, follow: true }).then((stream) => { + const actualLogStream = stream as IncomingMessage; + actualLogStream.socket?.unref(); + return actualLogStream; + }); + + logStream + .then(async (actualLogStream) => { const demuxedStream = await this.demuxStream(container.id, actualLogStream); if (proxyStream.destroyed) { demuxedStream.destroy(); diff --git a/packages/testcontainers/src/wait-strategies/wait-for-container.ts b/packages/testcontainers/src/wait-strategies/wait-for-container.ts index 22378928c..25035b0c5 100644 --- a/packages/testcontainers/src/wait-strategies/wait-for-container.ts +++ b/packages/testcontainers/src/wait-strategies/wait-for-container.ts @@ -20,9 +20,9 @@ export const waitForContainer = async ( log.info(`Container is ready`, { containerId: container.id }); } catch (err) { log.error(`Container failed to be ready: ${err}`, { containerId: container.id }); + await appendContainerLogs(client, container, err, startTime); try { await client.container.stop(container, { timeout: 0 }); - await appendContainerLogs(client, container, err, startTime); await client.container.remove(container, { removeVolumes: true }); } catch (stopErr) { log.error(`Failed to stop container after it failed to be ready: ${stopErr}`, { containerId: container.id }); @@ -43,6 +43,7 @@ const appendContainerLogs = async ( try { const stream = await client.container.logs(container, { + follow: false, tail: LOG_TAIL, since: startTime ? startTime.getTime() / 1000 : 0, }); From 575901205db49b1d122eb205d9d2e296370ef3db Mon Sep 17 00:00:00 2001 From: Cristian Greco Date: Tue, 13 Oct 2026 10:50:13 +0100 Subject: [PATCH 3/5] Read container logs with the node:stream/consumers text helper main removed streamToString from common and made Readable a type-only import in the container client, so the merged branch no longer compiled. --- .../clients/container/docker-container-client.test.ts | 6 +++--- .../clients/container/docker-container-client.ts | 2 +- .../src/wait-strategies/wait-for-container.ts | 5 +++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/testcontainers/src/container-runtime/clients/container/docker-container-client.test.ts b/packages/testcontainers/src/container-runtime/clients/container/docker-container-client.test.ts index a48961936..c36c57679 100644 --- a/packages/testcontainers/src/container-runtime/clients/container/docker-container-client.test.ts +++ b/packages/testcontainers/src/container-runtime/clients/container/docker-container-client.test.ts @@ -1,6 +1,6 @@ import { PassThrough, type Readable } from "node:stream"; +import { text } from "node:stream/consumers"; import type Dockerode from "dockerode"; -import { streamToString } from "../../../common"; import { DockerContainerClient } from "./docker-container-client"; describe("DockerContainerClient", () => { @@ -38,7 +38,7 @@ describe("DockerContainerClient", () => { const stream = await client.logs(container as unknown as Dockerode.Container, { follow: false }); - expect(await streamToString(stream)).toBe("first line\nsecond line\n"); + expect(await text(stream)).toBe("first line\nsecond line\n"); expect(container.logs).toHaveBeenCalledWith(expect.objectContaining({ follow: false })); }); @@ -56,7 +56,7 @@ describe("DockerContainerClient", () => { const stream = await client.logs(container as unknown as Dockerode.Container, { follow: false }); - expect(await streamToString(stream)).toBe('{"level":"fatal"}'); + expect(await text(stream)).toBe('{"level":"fatal"}'); }); }); diff --git a/packages/testcontainers/src/container-runtime/clients/container/docker-container-client.ts b/packages/testcontainers/src/container-runtime/clients/container/docker-container-client.ts index 9a6dc4bba..8efcf52b2 100644 --- a/packages/testcontainers/src/container-runtime/clients/container/docker-container-client.ts +++ b/packages/testcontainers/src/container-runtime/clients/container/docker-container-client.ts @@ -1,5 +1,5 @@ import type { IncomingMessage } from "node:http"; -import { PassThrough, type Readable } from "node:stream"; +import { PassThrough, Readable } from "node:stream"; import { finished } from "node:stream/promises"; import type Dockerode from "dockerode"; import type { diff --git a/packages/testcontainers/src/wait-strategies/wait-for-container.ts b/packages/testcontainers/src/wait-strategies/wait-for-container.ts index 25035b0c5..156822642 100644 --- a/packages/testcontainers/src/wait-strategies/wait-for-container.ts +++ b/packages/testcontainers/src/wait-strategies/wait-for-container.ts @@ -1,5 +1,6 @@ +import { text } from "node:stream/consumers"; import type { Container } from "dockerode"; -import { log, streamToString } from "../common"; +import { log } from "../common"; import type { ContainerRuntimeClient } from "../container-runtime"; import type { BoundPorts } from "../utils/bound-ports"; import type { WaitStrategy } from "./wait-strategy"; @@ -47,7 +48,7 @@ const appendContainerLogs = async ( tail: LOG_TAIL, since: startTime ? startTime.getTime() / 1000 : 0, }); - const logs = (await streamToString(stream)).trimEnd(); + const logs = (await text(stream)).trimEnd(); if (logs.length === 0 || withoutWhitespace(err.message).includes(withoutWhitespace(logs))) { return; From 2194851741ce3165fec162e9613ff6376d0d7d69 Mon Sep 17 00:00:00 2001 From: Cristian Greco Date: Tue, 13 Oct 2026 17:05:46 +0100 Subject: [PATCH 4/5] Remove the docs changes --- docs/features/containers.md | 2 +- docs/features/wait-strategies.md | 9 --------- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/docs/features/containers.md b/docs/features/containers.md index 5e3ec01b0..6310b872c 100644 --- a/docs/features/containers.md +++ b/docs/features/containers.md @@ -744,7 +744,7 @@ const container = await new GenericContainer("alpine").start(); .on("end", () => console.log("Stream closed")); ``` -Or a consumer can be provided before start. This is useful for example if your container is failing to start and you need more than the [last lines of its logs included in the error](wait-strategies.md): +Or a consumer can be provided before start. This is useful for example if your container is failing to start: ```js const container = await new GenericContainer("alpine") diff --git a/docs/features/wait-strategies.md b/docs/features/wait-strategies.md index 05760b90a..e9c27e04a 100644 --- a/docs/features/wait-strategies.md +++ b/docs/features/wait-strategies.md @@ -10,15 +10,6 @@ const container = await new GenericContainer("alpine") .start(); ``` -If a container does not become ready, it is stopped and removed, and the error thrown includes the last 50 lines of its logs: - -``` -URL /health not accessible after 120000ms -Container logs (last 50 lines): -Error: connect ECONNREFUSED 10.0.0.5:5432 -Retrying database connection in 5 seconds -``` - ## Default wait strategy By default, Testcontainers waits for a container health check when one is defined by the image or configured with `withHealthCheck`. If no health check is defined, or the image disables health checks with `HEALTHCHECK NONE`, it waits up to 60 seconds for mapped network ports to be bound. From 8c4e392c89c461c7e071327138fdf3f9c587f38d Mon Sep 17 00:00:00 2001 From: Cristian Greco Date: Tue, 13 Oct 2026 17:12:06 +0100 Subject: [PATCH 5/5] Avoid a shadowed variable in the failed start test --- .../src/generic-container/generic-container.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/testcontainers/src/generic-container/generic-container.test.ts b/packages/testcontainers/src/generic-container/generic-container.test.ts index 75378fc55..151b9d0ae 100644 --- a/packages/testcontainers/src/generic-container/generic-container.test.ts +++ b/packages/testcontainers/src/generic-container/generic-container.test.ts @@ -702,13 +702,13 @@ describe("GenericContainer", { timeout: 180_000 }, () => { throw new Error("Container is not ready"); } })(); - const container = new GenericContainer("cristianrgreco/testcontainer:1.1.14") - .withCommand(["/bin/sh", "-c", "echo 'connecting to database'; sleep infinity"]) - .withWaitStrategy(waitStrategy); - await expect(container.start()).rejects.toThrow( - new Error("Container is not ready\nContainer logs (last 50 lines):\nconnecting to database") - ); + await expect( + new GenericContainer("cristianrgreco/testcontainer:1.1.14") + .withCommand(["/bin/sh", "-c", "echo 'connecting to database'; sleep infinity"]) + .withWaitStrategy(waitStrategy) + .start() + ).rejects.toThrow(new Error("Container is not ready\nContainer logs (last 50 lines):\nconnecting to database")); }); // failing to build an image hangs within the DockerImageClient.build method,