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..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,4 +1,5 @@ import { PassThrough, type Readable } from "node:stream"; +import { text } from "node:stream/consumers"; import type Dockerode from "dockerode"; import { DockerContainerClient } from "./docker-container-client"; @@ -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 text(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 text(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 bcf21d381..827305b74 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 { type Duplex, PassThrough, type Readable } from "node:stream"; +import { type Duplex, PassThrough, Readable } from "node:stream"; import { finished } from "node:stream/promises"; import type Dockerode from "dockerode"; import type { @@ -176,18 +176,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/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 805de6c97..151b9d0ae 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"); + } + })(); + + 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, // 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..156822642 100644 --- a/packages/testcontainers/src/wait-strategies/wait-for-container.ts +++ b/packages/testcontainers/src/wait-strategies/wait-for-container.ts @@ -1,9 +1,12 @@ +import { text } from "node:stream/consumers"; import type { Container } from "dockerode"; import { log } 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, @@ -18,6 +21,7 @@ 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 client.container.remove(container, { removeVolumes: true }); @@ -27,3 +31,38 @@ 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, { + follow: false, + tail: LOG_TAIL, + since: startTime ? startTime.getTime() / 1000 : 0, + }); + const logs = (await text(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, "");