diff --git a/docs/features/wait-strategies.md b/docs/features/wait-strategies.md index e9c27e04a..7eee3bf4b 100644 --- a/docs/features/wait-strategies.md +++ b/docs/features/wait-strategies.md @@ -10,6 +10,16 @@ const container = await new GenericContainer("alpine") .start(); ``` +To keep waiting after the container has exited, for example when something other than a restart policy starts it again: + +```js +const { GenericContainer, Wait } = require("testcontainers"); + +const container = await new GenericContainer("alpine") + .withWaitStrategy(Wait.forListeningPorts().withAbortOnContainerExit(false)) + .start(); +``` + ## 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. @@ -125,16 +135,6 @@ const container = await new GenericContainer("redis") .start(); ``` -Stop waiting after container exited if waiting for container restart not needed. - -```js -const { GenericContainer, Wait } = require("testcontainers"); - -const container = await new GenericContainer("redis") - .withWaitStrategy(Wait.forHttp("/health", 8080, { abortOnContainerExit: true })) - .start(); -``` - ### For status code ```js @@ -174,7 +174,7 @@ const container = await new GenericContainer("redis") ```js .withWaitStrategy(Wait.forHttp("/health", 8443) .usingTls() - .insecureTls()) + .allowInsecure()) ``` ## Shell command diff --git a/packages/testcontainers/src/utils/test-helper.ts b/packages/testcontainers/src/utils/test-helper.ts index 57911b019..efa7b47a5 100644 --- a/packages/testcontainers/src/utils/test-helper.ts +++ b/packages/testcontainers/src/utils/test-helper.ts @@ -6,10 +6,8 @@ import path from "node:path"; import type { Readable } from "node:stream"; import type { GetEventsOptions, ImageInspectInfo } from "dockerode"; import { Agent, request } from "undici"; -import { IntervalRetry } from "../common"; import { getContainerRuntimeClient } from "../container-runtime"; import type { StartedDockerComposeEnvironment } from "../docker-compose-environment/started-docker-compose-environment"; -import type { GenericContainer } from "../generic-container/generic-container"; import type { StartedTestContainer } from "../test-container"; import type { HealthCheckStatus } from "../types"; import { getHealthCheckStatusFromInspect } from "../wait-strategies/utils/health-check"; @@ -142,30 +140,6 @@ export async function deleteImageByName(imageName: string): Promise { await dockerode.getImage(imageName).remove(); } -export async function stopStartingContainer(container: GenericContainer, name: string) { - const client = await getContainerRuntimeClient(); - const containerStartPromise = container.start(); - - const status = await new IntervalRetry(500).retryUntil( - () => - client.container - .getById(name) - .inspect() - .then((i) => i.State.Running) - .catch(() => false), - (running) => running, - () => false, - 10000 - ); - - if (!status) { - throw Error("failed start container"); - } - - await client.container.getById(name).stop(); - await containerStartPromise; -} - export async function createTestServer(port: number): Promise { const server = createServer((_req, res) => { res.writeHead(200); diff --git a/packages/testcontainers/src/wait-strategies/composite-wait-strategy.test.ts b/packages/testcontainers/src/wait-strategies/composite-wait-strategy.test.ts index 4c1604c6e..279fc4082 100644 --- a/packages/testcontainers/src/wait-strategies/composite-wait-strategy.test.ts +++ b/packages/testcontainers/src/wait-strategies/composite-wait-strategy.test.ts @@ -35,6 +35,16 @@ describe("CompositeWaitStrategy", { timeout: 180_000 }, () => { await expect(container.start()).rejects.toThrowError(`Shell command "exit 1" not successful after 100ms`); }); + it("should apply abortOnContainerExit to its wait strategies", async () => { + const container = new GenericContainer("cristianrgreco/testcontainer:1.1.14") + .withEntrypoint(["/bin/sh", "-c", "exit 1"]) + .withWaitStrategy( + Wait.forAll([Wait.forSuccessfulCommand("stat /tmp/never.sock")]).withAbortOnContainerExit(false) + ); + + await expect(container.start()).rejects.not.toThrowError("Container exited"); + }); + it("should work with maximum outer timeout", async () => { const container = new GenericContainer("cristianrgreco/testcontainer:1.1.14").withWaitStrategy( Wait.forAll([ diff --git a/packages/testcontainers/src/wait-strategies/composite-wait-strategy.ts b/packages/testcontainers/src/wait-strategies/composite-wait-strategy.ts index c673607bb..5c2dee63b 100644 --- a/packages/testcontainers/src/wait-strategies/composite-wait-strategy.ts +++ b/packages/testcontainers/src/wait-strategies/composite-wait-strategy.ts @@ -48,6 +48,15 @@ export class CompositeWaitStrategy extends AbstractWaitStrategy { return this; } + public override withAbortOnContainerExit(abortOnContainerExit: boolean): this { + this.waitStrategies.forEach((waitStrategy) => { + if (waitStrategy instanceof AbstractWaitStrategy) { + waitStrategy.withAbortOnContainerExit(abortOnContainerExit); + } + }); + return this; + } + public withDeadline(deadline: number): this { this.deadline = deadline; return this; diff --git a/packages/testcontainers/src/wait-strategies/health-check-wait-strategy.test.ts b/packages/testcontainers/src/wait-strategies/health-check-wait-strategy.test.ts index d0addb112..9348a1ae7 100644 --- a/packages/testcontainers/src/wait-strategies/health-check-wait-strategy.test.ts +++ b/packages/testcontainers/src/wait-strategies/health-check-wait-strategy.test.ts @@ -66,6 +66,16 @@ describe("HealthCheckWaitStrategy", { timeout: 180_000 }, () => { expect(await getRunningContainerNames()).not.toContain(containerName); }); + it("should fail when the container exits", async () => { + await expect(() => + new GenericContainer("cristianrgreco/testcontainer:1.1.14") + .withEntrypoint(["/bin/sh", "-c", "exit 1"]) + .withHealthCheck({ test: ["CMD-SHELL", "exit 0"] }) + .withWaitStrategy(Wait.forHealthCheck()) + .start() + ).rejects.toThrowError("Container exited with code 1"); + }); + it("should wait for custom health check using CMD to run the command directly without a shell", async () => { await using container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14") .withExposedPorts(8080) diff --git a/packages/testcontainers/src/wait-strategies/health-check-wait-strategy.ts b/packages/testcontainers/src/wait-strategies/health-check-wait-strategy.ts index 6066eb6fd..3be02fc93 100644 --- a/packages/testcontainers/src/wait-strategies/health-check-wait-strategy.ts +++ b/packages/testcontainers/src/wait-strategies/health-check-wait-strategy.ts @@ -1,5 +1,5 @@ import type Dockerode from "dockerode"; -import { IntervalRetry, log } from "../common"; +import { log } from "../common"; import { getContainerRuntimeClient } from "../container-runtime"; import { getHealthCheckStatusFromInspect } from "./utils/health-check"; import { AbstractWaitStrategy } from "./wait-strategy"; @@ -9,7 +9,9 @@ export class HealthCheckWaitStrategy extends AbstractWaitStrategy { log.debug(`Waiting for health check...`, { containerId: container.id }); const client = await getContainerRuntimeClient(); - const status = await new IntervalRetry(100).retryUntil( + const status = await this.retryUntil( + container, + 100, async () => getHealthCheckStatusFromInspect(await client.container.inspect(container)), (healthCheckStatus) => healthCheckStatus === "healthy" || healthCheckStatus === "unhealthy", () => { @@ -17,11 +19,11 @@ export class HealthCheckWaitStrategy extends AbstractWaitStrategy { const message = `Health check not healthy after ${timeout}ms`; log.error(message, { containerId: container.id }); throw new Error(message); - }, - this.startupTimeoutMs + } ); if (status !== "healthy") { + await this.throwIfContainerExited(container); const message = `Health check failed: ${status}`; log.error(message, { containerId: container.id }); throw new Error(message); diff --git a/packages/testcontainers/src/wait-strategies/host-port-wait-strategy.ts b/packages/testcontainers/src/wait-strategies/host-port-wait-strategy.ts index 1cc5695dc..6668eaa7e 100644 --- a/packages/testcontainers/src/wait-strategies/host-port-wait-strategy.ts +++ b/packages/testcontainers/src/wait-strategies/host-port-wait-strategy.ts @@ -1,5 +1,5 @@ import type Dockerode from "dockerode"; -import { IntervalRetry, log } from "../common"; +import { log } from "../common"; import { getContainerRuntimeClient } from "../container-runtime"; import type { BoundPorts } from "../utils/bound-ports"; import { HostPortCheck, InternalPortCheck, type PortCheck } from "./utils/port-check"; @@ -60,15 +60,16 @@ export class HostPortWaitStrategy extends AbstractWaitStrategy { port: number | string, portCheck: PortCheck ): Promise { - await new IntervalRetry(100).retryUntil( + await this.retryUntil( + container, + 100, () => portCheck.isBound(port), (isBound) => isBound, () => { const message = `Port ${port} not bound after ${this.startupTimeoutMs}ms`; log.error(message, { containerId: container.id }); throw new Error(message); - }, - this.startupTimeoutMs + } ); } } diff --git a/packages/testcontainers/src/wait-strategies/http-wait-strategy.agent-lifecycle.test.ts b/packages/testcontainers/src/wait-strategies/http-wait-strategy.agent-lifecycle.test.ts index 87a980c56..523f31175 100644 --- a/packages/testcontainers/src/wait-strategies/http-wait-strategy.agent-lifecycle.test.ts +++ b/packages/testcontainers/src/wait-strategies/http-wait-strategy.agent-lifecycle.test.ts @@ -26,7 +26,7 @@ vi.mock("../container-runtime", async (importOriginal) => { ...actual, getContainerRuntimeClient: async () => ({ info: { containerRuntime: { host: "localhost" } }, - container: { inspect: vi.fn() }, + container: { inspect: async () => ({ State: { Status: "running" } }) }, }), }; }); diff --git a/packages/testcontainers/src/wait-strategies/http-wait-strategy.test.ts b/packages/testcontainers/src/wait-strategies/http-wait-strategy.test.ts index 67a6f10b6..4c01578bf 100644 --- a/packages/testcontainers/src/wait-strategies/http-wait-strategy.test.ts +++ b/packages/testcontainers/src/wait-strategies/http-wait-strategy.test.ts @@ -1,8 +1,9 @@ -import { RandomUuid } from "../common"; import { GenericContainer } from "../generic-container/generic-container"; -import { checkContainerIsHealthy, checkContainerIsHealthyTls, stopStartingContainer } from "../utils/test-helper"; +import { checkContainerIsHealthy, checkContainerIsHealthyTls } from "../utils/test-helper"; import { Wait } from "./wait"; +const exitAfterFirstRequest = ["/bin/sh", "-c", "nc -l -p 8080; exit 1"]; + describe("HttpWaitStrategy", { timeout: 180_000 }, () => { it("should wait for 200", async () => { await using container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14") @@ -76,42 +77,25 @@ describe("HttpWaitStrategy", { timeout: 180_000 }, () => { ).rejects.toThrowError("URL /hello-world not accessible after 3000ms"); }); - describe("when options.abortOnContainerExit is true", () => { - it("should fail if container exited before waiting pass", async () => { - const name = `container-name-${new RandomUuid().nextUuid()}`; - const data = [1, 2, 3]; - const tail = 50; - const echoCmd = data.map((i) => `echo ${i}`).join(" && "); - const lastLogs = data.join("\n"); - const container = new GenericContainer("cristianrgreco/testcontainer:1.1.14") + it("should fail when the container exits before the wait succeeds", async () => { + await expect(() => + new GenericContainer("cristianrgreco/testcontainer:1.1.14") .withExposedPorts(8080) - .withStartupTimeout(20000) - .withEntrypoint(["/bin/sh", "-c", `${echoCmd} && sleep infinity`]) - .withWaitStrategy(Wait.forHttp("/hello-world", 8080, { abortOnContainerExit: true })) - .withName(name); - - await expect(stopStartingContainer(container, name)).rejects.toThrowError( - new Error(`Container exited during HTTP healthCheck, last ${tail} logs: ${lastLogs}`) - ); - }); + .withEntrypoint(exitAfterFirstRequest) + .withWaitStrategy(Wait.forHttp("/hello-world", 8080)) + .start() + ).rejects.toThrowError("Container exited with code 1"); + }); - it("should log only $tail logs if container exited before waiting pass", async () => { - const name = `container-name-${new RandomUuid().nextUuid()}`; - const tail = 50; - const data = [...Array(tail + 5).keys()]; - const echoCmd = data.map((i) => `echo ${i}`).join(" && "); - const lastLogs = data.slice(tail * -1).join("\n"); - const container = new GenericContainer("cristianrgreco/testcontainer:1.1.14") + it("should keep waiting after the container exits when abortOnContainerExit is false", async () => { + await expect(() => + new GenericContainer("cristianrgreco/testcontainer:1.1.14") .withExposedPorts(8080) - .withStartupTimeout(20000) - .withEntrypoint(["/bin/sh", "-c", `${echoCmd} && sleep infinity`]) - .withWaitStrategy(Wait.forHttp("/hello-world", 8080, { abortOnContainerExit: true })) - .withName(name); - - await expect(stopStartingContainer(container, name)).rejects.toThrowError( - new Error(`Container exited during HTTP healthCheck, last ${tail} logs: ${lastLogs}`) - ); - }); + .withStartupTimeout(3000) + .withEntrypoint(exitAfterFirstRequest) + .withWaitStrategy(Wait.forHttp("/hello-world", 8080, { abortOnContainerExit: false })) + .start() + ).rejects.toThrowError("URL /hello-world not accessible after 3000ms"); }); it("should set method", async () => { diff --git a/packages/testcontainers/src/wait-strategies/http-wait-strategy.ts b/packages/testcontainers/src/wait-strategies/http-wait-strategy.ts index d0a8e5332..50b2f3b80 100644 --- a/packages/testcontainers/src/wait-strategies/http-wait-strategy.ts +++ b/packages/testcontainers/src/wait-strategies/http-wait-strategy.ts @@ -1,7 +1,6 @@ -import { text } from "node:stream/consumers"; import type Dockerode from "dockerode"; import { Agent, request } from "undici"; -import { IntervalRetry, log } from "../common"; +import { log } from "../common"; import { getContainerRuntimeClient } from "../container-runtime"; import type { BoundPorts } from "../utils/bound-ports"; import { undiciResponseToFetchResponse } from "./utils/undici-response-parser"; @@ -22,9 +21,12 @@ export class HttpWaitStrategy extends AbstractWaitStrategy { constructor( private readonly path: string, private readonly port: number, - private readonly options: HttpWaitStrategyOptions + options: HttpWaitStrategyOptions ) { super(); + if (options.abortOnContainerExit !== undefined) { + this.withAbortOnContainerExit(options.abortOnContainerExit); + } } public forStatusCode(statusCode: number): this { @@ -76,49 +78,31 @@ export class HttpWaitStrategy extends AbstractWaitStrategy { public async waitUntilReady(container: Dockerode.Container, boundPorts: BoundPorts): Promise { log.debug(`Waiting for HTTP...`, { containerId: container.id }); - const exitStatus = "exited"; - let containerExited = false; const client = await getContainerRuntimeClient(); - const { abortOnContainerExit } = this.options; // Scoped per invocation: one strategy instance can drive concurrent waits, so a shared // agent would let one finished wait destroy a dispatcher another is still using. const agent = this.createInsecureAgent(); try { - await new IntervalRetry(this.readTimeoutMs).retryUntil( + await this.retryUntil( + container, + this.readTimeoutMs, async () => { try { const url = `${this.protocol}://${client.info.containerRuntime.host}:${boundPorts.getBinding(this.port)}${ this.path }`; - - if (abortOnContainerExit) { - const containerStatus = (await client.container.inspect(container)).State.Status; - - if (containerStatus === exitStatus) { - containerExited = true; - return; - } - } - return await this.sendRequest(url, agent); } catch { return undefined; } }, - async (response) => { - if (abortOnContainerExit && containerExited) { - return true; - } - - return this.matchesPredicates(response); - }, + (response) => this.matchesPredicates(response), () => { const message = `URL ${this.path} not accessible after ${this.startupTimeoutMs}ms`; log.error(message, { containerId: container.id }); throw new Error(message); - }, - this.startupTimeoutMs + } ); } finally { // Force-close: status-only predicates never read the body, so a graceful close() could @@ -126,10 +110,6 @@ export class HttpWaitStrategy extends AbstractWaitStrategy { await agent?.destroy(); } - if (abortOnContainerExit && containerExited) { - return this.handleContainerExit(container); - } - log.debug(`HTTP wait strategy complete`, { containerId: container.id }); } @@ -160,24 +140,6 @@ export class HttpWaitStrategy extends AbstractWaitStrategy { return true; } - private async handleContainerExit(container: Dockerode.Container) { - const tail = 50; - const client = await getContainerRuntimeClient(); - let message: string; - - try { - const lastLogs = await text(await client.container.logs(container, { tail })); - - message = `Container exited during HTTP healthCheck, last ${tail} logs: ${lastLogs.trim()}`; - } catch (_err) { - message = "Container exited during HTTP healthCheck, failed to get last logs"; - } - - log.error(message, { containerId: container.id }); - - throw new Error(message); - } - private createInsecureAgent(): Agent | undefined { if (!this._allowInsecure) { return undefined; diff --git a/packages/testcontainers/src/wait-strategies/log-wait-strategy.test.ts b/packages/testcontainers/src/wait-strategies/log-wait-strategy.test.ts index 9a101e9b5..41da57ec6 100644 --- a/packages/testcontainers/src/wait-strategies/log-wait-strategy.test.ts +++ b/packages/testcontainers/src/wait-strategies/log-wait-strategy.test.ts @@ -34,6 +34,22 @@ describe("LogWaitStrategy", { timeout: 180_000 }, () => { expect(Date.now() - start.getTime()).toBeGreaterThanOrEqual(4_000); }); + it("should wait for a log message from a container that then exits", async () => { + await using _ = await new GenericContainer("cristianrgreco/testcontainer:1.1.14") + .withEntrypoint(["/bin/sh", "-c", "echo Ready"]) + .withWaitStrategy(Wait.forLogMessage("Ready")) + .start(); + }); + + it("should fail when the container exits before logging the message", async () => { + await expect(() => + new GenericContainer("cristianrgreco/testcontainer:1.1.14") + .withEntrypoint(["/bin/sh", "-c", "exit 1"]) + .withWaitStrategy(Wait.forLogMessage("Ready")) + .start() + ).rejects.toThrowError("Container exited with code 1"); + }); + it("should stop the container when the log message wait strategy times out", async () => { const containerName = `container-${new RandomUuid().nextUuid()}`; diff --git a/packages/testcontainers/src/wait-strategies/log-wait-strategy.ts b/packages/testcontainers/src/wait-strategies/log-wait-strategy.ts index 51af54586..dc21fa0cd 100644 --- a/packages/testcontainers/src/wait-strategies/log-wait-strategy.ts +++ b/packages/testcontainers/src/wait-strategies/log-wait-strategy.ts @@ -58,9 +58,11 @@ export class LogWaitStrategy extends AbstractWaitStrategy { .on("end", () => { stream.destroy(); clearTimeout(timeout); - const message = `Log stream ended and message "${this.message}" was not received`; - log.error(message, { containerId: container.id }); - reject(new Error(message)); + this.throwIfContainerExited(container).then(() => { + const message = `Log stream ended and message "${this.message}" was not received`; + log.error(message, { containerId: container.id }); + reject(new Error(message)); + }, reject); }); }); } diff --git a/packages/testcontainers/src/wait-strategies/shell-wait-strategy.test.ts b/packages/testcontainers/src/wait-strategies/shell-wait-strategy.test.ts index 026a6eb5e..2e31c176f 100644 --- a/packages/testcontainers/src/wait-strategies/shell-wait-strategy.test.ts +++ b/packages/testcontainers/src/wait-strategies/shell-wait-strategy.test.ts @@ -17,4 +17,13 @@ describe("ShellWaitStrategy", { timeout: 180_000 }, () => { .start() ).rejects.toThrowError(`Shell command "stat /tmp/test.lock" not successful`); }); + + it("should fail when the container exits", async () => { + await expect(() => + new GenericContainer("cristianrgreco/testcontainer:1.1.14") + .withEntrypoint(["/bin/sh", "-c", "exit 1"]) + .withWaitStrategy(Wait.forSuccessfulCommand("stat /tmp/test.lock")) + .start() + ).rejects.toThrowError("Container exited with code 1"); + }); }); diff --git a/packages/testcontainers/src/wait-strategies/shell-wait-strategy.ts b/packages/testcontainers/src/wait-strategies/shell-wait-strategy.ts index 70101e535..69e3970ea 100644 --- a/packages/testcontainers/src/wait-strategies/shell-wait-strategy.ts +++ b/packages/testcontainers/src/wait-strategies/shell-wait-strategy.ts @@ -1,5 +1,5 @@ import type Dockerode from "dockerode"; -import { IntervalRetry, log } from "../common"; +import { log } from "../common"; import { getContainerRuntimeClient } from "../container-runtime"; import { AbstractWaitStrategy } from "./wait-strategy"; @@ -12,7 +12,9 @@ export class ShellWaitStrategy extends AbstractWaitStrategy { log.debug(`Waiting for successful shell command "${this.command}"...`, { containerId: container.id }); const client = await getContainerRuntimeClient(); - await new IntervalRetry(100).retryUntil( + await this.retryUntil( + container, + 100, async () => { const { exitCode } = await client.container.exec(container, ["/bin/sh", "-c", this.command], { log: false, @@ -24,8 +26,7 @@ export class ShellWaitStrategy extends AbstractWaitStrategy { const message = `Shell command "${this.command}" not successful after ${this.startupTimeoutMs}ms`; log.error(message, { containerId: container.id }); throw new Error(message); - }, - this.startupTimeoutMs + } ); log.debug(`Shell wait strategy complete`, { containerId: container.id }); diff --git a/packages/testcontainers/src/wait-strategies/wait-strategy.test.ts b/packages/testcontainers/src/wait-strategies/wait-strategy.test.ts new file mode 100644 index 000000000..e40a6cc20 --- /dev/null +++ b/packages/testcontainers/src/wait-strategies/wait-strategy.test.ts @@ -0,0 +1,78 @@ +import type Dockerode from "dockerode"; +import { AbstractWaitStrategy } from "./wait-strategy"; + +const { inspect } = vi.hoisted(() => ({ inspect: vi.fn() })); + +vi.mock("../container-runtime", async (importOriginal) => ({ + ...(await importOriginal()), + getContainerRuntimeClient: async () => ({ container: { inspect } }), +})); + +class TestWaitStrategy extends AbstractWaitStrategy { + constructor(private readonly attempt: () => Promise) { + super(); + } + + public async waitUntilReady(container: Dockerode.Container): Promise { + await this.retryUntil( + container, + 10, + this.attempt, + (ready) => ready, + () => new Error("Not ready") + ); + } +} + +const stubContainer = { id: "container-id" } as Dockerode.Container; + +const inspectResult = (Status: string, ExitCode = 0) => ({ State: { Status, ExitCode } }); + +describe("AbstractWaitStrategy", { concurrent: false }, () => { + it("fails as soon as the container has exited", async () => { + inspect.mockResolvedValue(inspectResult("exited", 1)); + const attempt = vi.fn(async () => false); + + await expect(new TestWaitStrategy(attempt).waitUntilReady(stubContainer)).rejects.toThrowError( + "Container exited with code 1" + ); + expect(attempt).toHaveBeenCalledTimes(1); + }); + + it("reports the exit when an attempt fails because the container has exited", async () => { + inspect.mockResolvedValue(inspectResult("exited", 1)); + + await expect( + new TestWaitStrategy(() => Promise.reject(new Error("container is not running"))).waitUntilReady(stubContainer) + ).rejects.toThrowError("Container exited with code 1"); + }); + + it("rethrows the error of a failed attempt while the container is running", async () => { + inspect.mockResolvedValue(inspectResult("running")); + + await expect( + new TestWaitStrategy(() => Promise.reject(new Error("attempt failed"))).waitUntilReady(stubContainer) + ).rejects.toThrowError("attempt failed"); + }); + + it("keeps waiting while the container is restarting", async () => { + inspect.mockResolvedValueOnce(inspectResult("restarting")).mockResolvedValueOnce(inspectResult("running")); + const attempt = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(false).mockResolvedValueOnce(true); + + await new TestWaitStrategy(attempt).waitUntilReady(stubContainer); + + expect(attempt).toHaveBeenCalledTimes(3); + }); + + it("keeps waiting after the container has exited when abortOnContainerExit is false", async () => { + inspect.mockResolvedValue(inspectResult("exited", 1)); + + await expect( + new TestWaitStrategy(async () => false) + .withAbortOnContainerExit(false) + .withStartupTimeout(50) + .waitUntilReady(stubContainer) + ).rejects.toThrowError("Not ready"); + expect(inspect).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/testcontainers/src/wait-strategies/wait-strategy.ts b/packages/testcontainers/src/wait-strategies/wait-strategy.ts index a93d8c482..68e36013d 100644 --- a/packages/testcontainers/src/wait-strategies/wait-strategy.ts +++ b/packages/testcontainers/src/wait-strategies/wait-strategy.ts @@ -1,4 +1,6 @@ import type Dockerode from "dockerode"; +import { IntervalRetry, log } from "../common"; +import { getContainerRuntimeClient } from "../container-runtime"; import type { BoundPorts } from "../utils/bound-ports"; export interface WaitStrategy { @@ -14,6 +16,7 @@ export interface WaitStrategy { export abstract class AbstractWaitStrategy implements WaitStrategy { protected startupTimeoutMs = 60_000; private startupTimeoutSet = false; + private abortOnContainerExit = true; public abstract waitUntilReady( container: Dockerode.Container, @@ -34,4 +37,58 @@ export abstract class AbstractWaitStrategy implements WaitStrategy { public getStartupTimeout(): number { return this.startupTimeoutMs; } + + public withAbortOnContainerExit(abortOnContainerExit: boolean): this { + this.abortOnContainerExit = abortOnContainerExit; + return this; + } + + protected async retryUntil( + container: Dockerode.Container, + intervalMs: number, + fn: () => Promise, + predicate: (result: T) => boolean | Promise, + onTimeout: () => Error + ): Promise { + const result = await new IntervalRetry(intervalMs).retryUntil( + async () => { + try { + return await fn(); + } catch (err) { + await this.throwIfContainerExited(container); + throw err; + } + }, + async (value) => { + if (await predicate(value)) { + return true; + } + await this.throwIfContainerExited(container); + return false; + }, + onTimeout, + this.startupTimeoutMs + ); + + if (result instanceof Error) { + throw result; + } + + return result; + } + + protected async throwIfContainerExited(container: Dockerode.Container): Promise { + if (!this.abortOnContainerExit) { + return; + } + + const client = await getContainerRuntimeClient(); + const { State: state } = await client.container.inspect(container); + + if (state.Status === "exited") { + const message = `Container exited with code ${state.ExitCode}`; + log.error(message, { containerId: container.id }); + throw new Error(message); + } + } } diff --git a/packages/testcontainers/src/wait-strategies/wait.ts b/packages/testcontainers/src/wait-strategies/wait.ts index 58283f8fc..f5ac9196e 100644 --- a/packages/testcontainers/src/wait-strategies/wait.ts +++ b/packages/testcontainers/src/wait-strategies/wait.ts @@ -13,15 +13,15 @@ export class Wait { return new CompositeWaitStrategy(waitStrategies); } - public static forListeningPorts(): WaitStrategy { + public static forListeningPorts(): HostPortWaitStrategy { return new HostPortWaitStrategy(); } - public static forLogMessage(message: Log | RegExp, times = 1): WaitStrategy { + public static forLogMessage(message: Log | RegExp, times = 1): LogWaitStrategy { return new LogWaitStrategy(message, times); } - public static forHealthCheck(): WaitStrategy { + public static forHealthCheck(): HealthCheckWaitStrategy { return new HealthCheckWaitStrategy(); } @@ -29,11 +29,7 @@ export class Wait { return new OneShotStartupCheckStrategy(); } - public static forHttp( - path: string, - port: number, - options: HttpWaitStrategyOptions = { abortOnContainerExit: false } - ): HttpWaitStrategy { + public static forHttp(path: string, port: number, options: HttpWaitStrategyOptions = {}): HttpWaitStrategy { return new HttpWaitStrategy(path, port, options); }