From e743132b42dbe896c6822f03500d4cd3300fb5bf Mon Sep 17 00:00:00 2001 From: digital88 Date: Sun, 3 Aug 2025 16:09:00 +0300 Subject: [PATCH 01/13] 687 --- .../docker-compose-environment.test.ts | 14 ++++++ .../docker-compose-environment.ts | 27 ++++++++-- .../abstract-started-container.ts | 4 ++ .../generic-container-wait-strategy.test.ts | 50 +++++++++++++++++++ .../generic-container/generic-container.ts | 23 ++++++++- .../started-generic-container.ts | 4 ++ packages/testcontainers/src/test-container.ts | 1 + .../src/wait-strategies/null-wait-strategy.ts | 7 +++ 8 files changed, 123 insertions(+), 7 deletions(-) create mode 100644 packages/testcontainers/src/generic-container/generic-container-wait-strategy.test.ts create mode 100644 packages/testcontainers/src/wait-strategies/null-wait-strategy.ts diff --git a/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.test.ts b/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.test.ts index 9a077642b..f3c115764 100644 --- a/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.test.ts +++ b/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.test.ts @@ -97,6 +97,20 @@ describe("DockerComposeEnvironment", { timeout: 180_000 }, () => { await checkEnvironmentContainerIsHealthy(startedEnvironment, await composeContainerName("container")); }); + it("should use wait strategy Wait.forHealthCheck() if healthcheck is defined in service", async () => { + await using startedEnvironment = await new DockerComposeEnvironment( + fixtures, + "docker-compose-with-healthcheck.yml" + ).up(); + + await checkEnvironmentContainerIsHealthy(startedEnvironment, await composeContainerName("container")); + }); + it("should use wait strategy Wait.forListeningPorts() if healthcheck is NOT defined in service", async () => { + await using startedEnvironment = await new DockerComposeEnvironment(fixtures, "docker-compose-with-name.yml").up(); + + await checkEnvironmentContainerIsHealthy(startedEnvironment, "custom_container_name"); + }); + it("should support log message wait strategy", async () => { await using startedEnvironment = await new DockerComposeEnvironment(fixtures, "docker-compose.yml") .withWaitStrategy(await composeContainerName("container"), Wait.forLogMessage("Listening on port 8080")) diff --git a/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.ts b/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.ts index 40273b48b..ae6ff5d24 100644 --- a/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.ts +++ b/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.ts @@ -1,4 +1,4 @@ -import { ContainerInfo } from "dockerode"; +import { ContainerInfo, ContainerInspectInfo, HealthConfig } from "dockerode"; import { containerLog, log, RandomUuid, Uuid } from "../common"; import { ComposeOptions, getContainerRuntimeClient, parseComposeContainerName } from "../container-runtime"; import { StartedGenericContainer } from "../generic-container/started-generic-container"; @@ -7,6 +7,7 @@ import { Environment } from "../types"; import { BoundPorts } from "../utils/bound-ports"; import { mapInspectResult } from "../utils/map-inspect-result"; import { ImagePullPolicy, PullPolicy } from "../utils/pull-policy"; +import { NullWaitStrategy } from "../wait-strategies/null-wait-strategy"; import { Wait } from "../wait-strategies/wait"; import { waitForContainer } from "../wait-strategies/wait-for-container"; import { WaitStrategy } from "../wait-strategies/wait-strategy"; @@ -23,7 +24,7 @@ export class DockerComposeEnvironment { private profiles: string[] = []; private environment: Environment = {}; private pullPolicy: ImagePullPolicy = PullPolicy.defaultPolicy(); - private defaultWaitStrategy: WaitStrategy = Wait.forListeningPorts(); + private defaultWaitStrategy: WaitStrategy = new NullWaitStrategy(); private waitStrategy: { [containerName: string]: WaitStrategy } = {}; private startupTimeoutMs?: number; private clientOptions: Partial = {}; @@ -159,9 +160,7 @@ export class DockerComposeEnvironment { const inspectResult = await client.container.inspect(container); const mappedInspectResult = mapInspectResult(inspectResult); const boundPorts = BoundPorts.fromInspectResult(client.info.containerRuntime.hostIps, mappedInspectResult); - const waitStrategy = this.waitStrategy[containerName] - ? this.waitStrategy[containerName] - : this.defaultWaitStrategy; + const waitStrategy = this.selectWaitStrategy(containerName, inspectResult); if (this.startupTimeoutMs !== undefined) { waitStrategy.withStartupTimeout(this.startupTimeoutMs); } @@ -207,4 +206,22 @@ export class DockerComposeEnvironment { environment: this.environment, }); } + + private selectWaitStrategy(containerName: string, inspectResult: ContainerInspectInfo) { + const containerWaitStrategy = this.waitStrategy[containerName] + ? this.waitStrategy[containerName] + : this.defaultWaitStrategy; + if (!(containerWaitStrategy instanceof NullWaitStrategy)) return containerWaitStrategy; + const healthcheck = ( + inspectResult as ContainerInspectInfo & { + Config: ContainerInspectInfo["Config"] & { + Healthcheck: HealthConfig | undefined; + }; + } + ).Config.Healthcheck; + if (healthcheck?.Test) { + return Wait.forHealthCheck(); + } + return Wait.forListeningPorts(); + } } diff --git a/packages/testcontainers/src/generic-container/abstract-started-container.ts b/packages/testcontainers/src/generic-container/abstract-started-container.ts index 4ba5ccce5..2f6f02352 100644 --- a/packages/testcontainers/src/generic-container/abstract-started-container.ts +++ b/packages/testcontainers/src/generic-container/abstract-started-container.ts @@ -76,6 +76,10 @@ export class AbstractStartedContainer implements StartedTestContainer { return this.startedTestContainer.getIpAddress(networkName); } + public getWaitStrategy() { + return this.startedTestContainer.getWaitStrategy(); + } + public async copyFilesToContainer(filesToCopy: FileToCopy[]): Promise { return this.startedTestContainer.copyFilesToContainer(filesToCopy); } diff --git a/packages/testcontainers/src/generic-container/generic-container-wait-strategy.test.ts b/packages/testcontainers/src/generic-container/generic-container-wait-strategy.test.ts new file mode 100644 index 000000000..8e3518bab --- /dev/null +++ b/packages/testcontainers/src/generic-container/generic-container-wait-strategy.test.ts @@ -0,0 +1,50 @@ +import path from "path"; +import { HealthCheckWaitStrategy } from "../wait-strategies/health-check-wait-strategy"; +import { HostPortWaitStrategy } from "../wait-strategies/host-port-wait-strategy"; +import { Wait } from "../wait-strategies/wait"; +import { GenericContainer } from "./generic-container"; + +const fixtures = path.resolve(__dirname, "..", "..", "fixtures", "docker"); + +describe("GenericContainer wait strategy", { timeout: 180_000 }, () => { + it("should use Wait.forListeningPorts if healthcheck is not defined in DOCKERFILE", async () => { + await using container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14") + .withExposedPorts(8080) + .start(); + expect(container.getWaitStrategy()).toBeInstanceOf(HostPortWaitStrategy); + }); + it("should use Wait.forHealthCheck if withHealthCheck() explicitly called", async () => { + await using container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14") + .withExposedPorts(8080) + .withHealthCheck({ + test: ["CMD-SHELL", "echo 'started' && exit 0"], + }) + .start(); + expect(container.getWaitStrategy()).toBeInstanceOf(HealthCheckWaitStrategy); + }); + it("should use Wait.forHealthCheck if healthcheck is defined in DOCKERFILE", async () => { + const context = path.resolve(fixtures, "docker-with-health-check"); + const genericContainer = await GenericContainer.fromDockerfile(context).build(); + await using startedContainer = await genericContainer.start(); + expect(startedContainer.getWaitStrategy()).toBeInstanceOf(HealthCheckWaitStrategy); + }); + it("should use same WaitStrategy if it's explicitly defined in withWaitStrategy() even if image defines healthcheck", async () => { + const context = path.resolve(fixtures, "docker-with-health-check"); + const genericContainer = await GenericContainer.fromDockerfile(context).build(); + await using container = await genericContainer + .withExposedPorts(8080) + .withWaitStrategy(Wait.forListeningPorts()) + .start(); + expect(container.getWaitStrategy()).toBeInstanceOf(HostPortWaitStrategy); + }); + it("should use same WaitStrategy if it's explicitly defined in withWaitStrategy() even if withHealthCheck() is called", async () => { + await using container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14") + .withExposedPorts(8080) + .withHealthCheck({ + test: ["CMD-SHELL", "echo 'started' && exit 0"], + }) + .withWaitStrategy(Wait.forListeningPorts()) + .start(); + expect(container.getWaitStrategy()).toBeInstanceOf(HostPortWaitStrategy); + }); +}); diff --git a/packages/testcontainers/src/generic-container/generic-container.ts b/packages/testcontainers/src/generic-container/generic-container.ts index bada4e422..bdfad2d4c 100644 --- a/packages/testcontainers/src/generic-container/generic-container.ts +++ b/packages/testcontainers/src/generic-container/generic-container.ts @@ -1,6 +1,6 @@ import archiver from "archiver"; import AsyncLock from "async-lock"; -import { Container, ContainerCreateOptions, HostConfig } from "dockerode"; +import { Container, ContainerCreateOptions, HealthConfig, HostConfig, ImageInspectInfo } from "dockerode"; import { Readable } from "stream"; import { containerLog, hash, log, toNanos } from "../common"; import { ContainerRuntimeClient, getContainerRuntimeClient, ImageName } from "../container-runtime"; @@ -29,6 +29,7 @@ import { createLabels, LABEL_TESTCONTAINERS_CONTAINER_HASH, LABEL_TESTCONTAINERS import { mapInspectResult } from "../utils/map-inspect-result"; import { getContainerPort, getProtocol, hasHostBinding, PortWithOptionalBinding } from "../utils/port"; import { ImagePullPolicy, PullPolicy } from "../utils/pull-policy"; +import { NullWaitStrategy } from "../wait-strategies/null-wait-strategy"; import { Wait } from "../wait-strategies/wait"; import { waitForContainer } from "../wait-strategies/wait-for-container"; import { WaitStrategy } from "../wait-strategies/wait-strategy"; @@ -48,7 +49,7 @@ export class GenericContainer implements TestContainer { protected imageName: ImageName; protected startupTimeoutMs?: number; - protected waitStrategy: WaitStrategy = Wait.forListeningPorts(); + protected waitStrategy: WaitStrategy = new NullWaitStrategy(); protected environment: Record = {}; protected exposedPorts: PortWithOptionalBinding[] = []; protected reuse = false; @@ -94,6 +95,8 @@ export class GenericContainer implements TestContainer { await this.beforeContainerCreated(); } + this.waitStrategy = await this.selectWaitStrategy(client); + if (!this.isHelperContainer() && PortForwarderInstance.isRunning()) { const portForwarder = await PortForwarderInstance.getInstance(); this.hostConfig.ExtraHosts = [ @@ -117,6 +120,22 @@ export class GenericContainer implements TestContainer { return this.startContainer(client); } + private async selectWaitStrategy(client: ContainerRuntimeClient): Promise { + if (!(this.waitStrategy instanceof NullWaitStrategy)) return this.waitStrategy; + if (this.healthCheck) { + return Wait.forHealthCheck(); + } + const imageInfo = (await client.image.inspect(this.imageName)) as ImageInspectInfo & { + Config: ImageInspectInfo["Config"] & { + Healthcheck: HealthConfig | undefined; + }; + }; + if (imageInfo.Config.Healthcheck?.Test) { + return Wait.forHealthCheck(); + } + return Wait.forListeningPorts(); + } + private async reuseOrStartContainer(client: ContainerRuntimeClient) { const containerHash = hash(JSON.stringify(this.createOpts)); this.createOpts.Labels = { ...this.createOpts.Labels, [LABEL_TESTCONTAINERS_CONTAINER_HASH]: containerHash }; diff --git a/packages/testcontainers/src/generic-container/started-generic-container.ts b/packages/testcontainers/src/generic-container/started-generic-container.ts index 32f7d3925..fb70cd186 100644 --- a/packages/testcontainers/src/generic-container/started-generic-container.ts +++ b/packages/testcontainers/src/generic-container/started-generic-container.ts @@ -168,6 +168,10 @@ export class StartedGenericContainer implements StartedTestContainer { return this.getNetworkSettings()[networkName].ipAddress; } + public getWaitStrategy() { + return this.waitStrategy; + } + private getNetworkSettings() { return Object.entries(this.inspectResult.NetworkSettings.Networks) .map(([networkName, network]) => ({ diff --git a/packages/testcontainers/src/test-container.ts b/packages/testcontainers/src/test-container.ts index 3711b5eeb..9b4638402 100644 --- a/packages/testcontainers/src/test-container.ts +++ b/packages/testcontainers/src/test-container.ts @@ -79,6 +79,7 @@ export interface StartedTestContainer extends AsyncDisposable { getNetworkNames(): string[]; getNetworkId(networkName: string): string; getIpAddress(networkName: string): string; + getWaitStrategy(): WaitStrategy; copyArchiveFromContainer(path: string): Promise; copyArchiveToContainer(tar: Readable, target?: string): Promise; copyDirectoriesToContainer(directoriesToCopy: DirectoryToCopy[]): Promise; diff --git a/packages/testcontainers/src/wait-strategies/null-wait-strategy.ts b/packages/testcontainers/src/wait-strategies/null-wait-strategy.ts new file mode 100644 index 000000000..51b927dc9 --- /dev/null +++ b/packages/testcontainers/src/wait-strategies/null-wait-strategy.ts @@ -0,0 +1,7 @@ +import { AbstractWaitStrategy } from "./wait-strategy"; + +export class NullWaitStrategy extends AbstractWaitStrategy { + public override waitUntilReady(): Promise { + return Promise.resolve(); + } +} From 40fb443f1f44ada93c2b3b4002068e03ab7de954 Mon Sep 17 00:00:00 2001 From: digital88 Date: Sun, 3 Aug 2025 16:16:19 +0300 Subject: [PATCH 02/13] tests --- .../docker-compose-environment.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.test.ts b/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.test.ts index f3c115764..a244d8dce 100644 --- a/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.test.ts +++ b/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.test.ts @@ -10,6 +10,8 @@ import { getVolumeNames, waitForDockerEvent, } from "../utils/test-helper"; +import { HealthCheckWaitStrategy } from "../wait-strategies/health-check-wait-strategy"; +import { HostPortWaitStrategy } from "../wait-strategies/host-port-wait-strategy"; import { Wait } from "../wait-strategies/wait"; import { DockerComposeEnvironment } from "./docker-compose-environment"; @@ -104,11 +106,17 @@ describe("DockerComposeEnvironment", { timeout: 180_000 }, () => { ).up(); await checkEnvironmentContainerIsHealthy(startedEnvironment, await composeContainerName("container")); + + const waitStrategy = startedEnvironment.getContainer("container-1").getWaitStrategy(); + expect(waitStrategy).toBeInstanceOf(HealthCheckWaitStrategy); }); it("should use wait strategy Wait.forListeningPorts() if healthcheck is NOT defined in service", async () => { await using startedEnvironment = await new DockerComposeEnvironment(fixtures, "docker-compose-with-name.yml").up(); await checkEnvironmentContainerIsHealthy(startedEnvironment, "custom_container_name"); + + const waitStrategy = startedEnvironment.getContainer("custom_container_name").getWaitStrategy(); + expect(waitStrategy).toBeInstanceOf(HostPortWaitStrategy); }); it("should support log message wait strategy", async () => { From 7352220a5ff694e94c1a412f86307f263b4b292d Mon Sep 17 00:00:00 2001 From: digital88 Date: Tue, 12 Aug 2025 13:27:17 +0300 Subject: [PATCH 03/13] fixes after review --- .../docker-compose-environment.test.ts | 35 ++++---- .../docker-compose-environment.ts | 7 +- .../abstract-started-container.ts | 4 - .../generic-container-wait-strategy.test.ts | 87 ++++++++++--------- .../generic-container/generic-container.ts | 13 ++- .../started-generic-container.ts | 8 +- packages/testcontainers/src/test-container.ts | 1 - .../src/wait-strategies/null-wait-strategy.ts | 7 -- .../src/wait-strategies/wait-for-container.ts | 4 +- 9 files changed, 81 insertions(+), 85 deletions(-) delete mode 100644 packages/testcontainers/src/wait-strategies/null-wait-strategy.ts diff --git a/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.test.ts b/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.test.ts index a244d8dce..5bb3abc42 100644 --- a/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.test.ts +++ b/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.test.ts @@ -99,25 +99,30 @@ describe("DockerComposeEnvironment", { timeout: 180_000 }, () => { await checkEnvironmentContainerIsHealthy(startedEnvironment, await composeContainerName("container")); }); - it("should use wait strategy Wait.forHealthCheck() if healthcheck is defined in service", async () => { - await using startedEnvironment = await new DockerComposeEnvironment( - fixtures, - "docker-compose-with-healthcheck.yml" - ).up(); + if (!process.env.CI_PODMAN) { + it("should use wait strategy Wait.forHealthCheck() if healthcheck is defined in service", async () => { + await using startedEnvironment = await new DockerComposeEnvironment( + fixtures, + "docker-compose-with-healthcheck.yml" + ).up(); - await checkEnvironmentContainerIsHealthy(startedEnvironment, await composeContainerName("container")); + await checkEnvironmentContainerIsHealthy(startedEnvironment, await composeContainerName("container")); - const waitStrategy = startedEnvironment.getContainer("container-1").getWaitStrategy(); - expect(waitStrategy).toBeInstanceOf(HealthCheckWaitStrategy); - }); - it("should use wait strategy Wait.forListeningPorts() if healthcheck is NOT defined in service", async () => { - await using startedEnvironment = await new DockerComposeEnvironment(fixtures, "docker-compose-with-name.yml").up(); + const waitStrategy = startedEnvironment.getContainer("container-1")["getWaitStrategy"](); + expect(waitStrategy).toBeInstanceOf(HealthCheckWaitStrategy); + }); + it("should use wait strategy Wait.forListeningPorts() if healthcheck is NOT defined in service", async () => { + await using startedEnvironment = await new DockerComposeEnvironment( + fixtures, + "docker-compose-with-name.yml" + ).up(); - await checkEnvironmentContainerIsHealthy(startedEnvironment, "custom_container_name"); + await checkEnvironmentContainerIsHealthy(startedEnvironment, "custom_container_name"); - const waitStrategy = startedEnvironment.getContainer("custom_container_name").getWaitStrategy(); - expect(waitStrategy).toBeInstanceOf(HostPortWaitStrategy); - }); + const waitStrategy = startedEnvironment.getContainer("custom_container_name")["getWaitStrategy"](); + expect(waitStrategy).toBeInstanceOf(HostPortWaitStrategy); + }); + } it("should support log message wait strategy", async () => { await using startedEnvironment = await new DockerComposeEnvironment(fixtures, "docker-compose.yml") diff --git a/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.ts b/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.ts index ae6ff5d24..243e78115 100644 --- a/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.ts +++ b/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.ts @@ -7,7 +7,6 @@ import { Environment } from "../types"; import { BoundPorts } from "../utils/bound-ports"; import { mapInspectResult } from "../utils/map-inspect-result"; import { ImagePullPolicy, PullPolicy } from "../utils/pull-policy"; -import { NullWaitStrategy } from "../wait-strategies/null-wait-strategy"; import { Wait } from "../wait-strategies/wait"; import { waitForContainer } from "../wait-strategies/wait-for-container"; import { WaitStrategy } from "../wait-strategies/wait-strategy"; @@ -24,7 +23,7 @@ export class DockerComposeEnvironment { private profiles: string[] = []; private environment: Environment = {}; private pullPolicy: ImagePullPolicy = PullPolicy.defaultPolicy(); - private defaultWaitStrategy: WaitStrategy = new NullWaitStrategy(); + private defaultWaitStrategy: WaitStrategy | undefined; private waitStrategy: { [containerName: string]: WaitStrategy } = {}; private startupTimeoutMs?: number; private clientOptions: Partial = {}; @@ -207,11 +206,11 @@ export class DockerComposeEnvironment { }); } - private selectWaitStrategy(containerName: string, inspectResult: ContainerInspectInfo) { + private selectWaitStrategy(containerName: string, inspectResult: ContainerInspectInfo): WaitStrategy { const containerWaitStrategy = this.waitStrategy[containerName] ? this.waitStrategy[containerName] : this.defaultWaitStrategy; - if (!(containerWaitStrategy instanceof NullWaitStrategy)) return containerWaitStrategy; + if (containerWaitStrategy) return containerWaitStrategy; const healthcheck = ( inspectResult as ContainerInspectInfo & { Config: ContainerInspectInfo["Config"] & { diff --git a/packages/testcontainers/src/generic-container/abstract-started-container.ts b/packages/testcontainers/src/generic-container/abstract-started-container.ts index 2f6f02352..4ba5ccce5 100644 --- a/packages/testcontainers/src/generic-container/abstract-started-container.ts +++ b/packages/testcontainers/src/generic-container/abstract-started-container.ts @@ -76,10 +76,6 @@ export class AbstractStartedContainer implements StartedTestContainer { return this.startedTestContainer.getIpAddress(networkName); } - public getWaitStrategy() { - return this.startedTestContainer.getWaitStrategy(); - } - public async copyFilesToContainer(filesToCopy: FileToCopy[]): Promise { return this.startedTestContainer.copyFilesToContainer(filesToCopy); } diff --git a/packages/testcontainers/src/generic-container/generic-container-wait-strategy.test.ts b/packages/testcontainers/src/generic-container/generic-container-wait-strategy.test.ts index 8e3518bab..7348451b4 100644 --- a/packages/testcontainers/src/generic-container/generic-container-wait-strategy.test.ts +++ b/packages/testcontainers/src/generic-container/generic-container-wait-strategy.test.ts @@ -3,48 +3,53 @@ import { HealthCheckWaitStrategy } from "../wait-strategies/health-check-wait-st import { HostPortWaitStrategy } from "../wait-strategies/host-port-wait-strategy"; import { Wait } from "../wait-strategies/wait"; import { GenericContainer } from "./generic-container"; +import { StartedGenericContainer } from "./started-generic-container"; const fixtures = path.resolve(__dirname, "..", "..", "fixtures", "docker"); -describe("GenericContainer wait strategy", { timeout: 180_000 }, () => { - it("should use Wait.forListeningPorts if healthcheck is not defined in DOCKERFILE", async () => { - await using container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14") - .withExposedPorts(8080) - .start(); - expect(container.getWaitStrategy()).toBeInstanceOf(HostPortWaitStrategy); +if (!process.env.CI_PODMAN) { + describe("GenericContainer wait strategy", { timeout: 180_000 }, () => { + it("should use Wait.forListeningPorts if healthcheck is not defined in DOCKERFILE", async () => { + await using container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14") + .withExposedPorts(8080) + .start(); + expect((container as StartedGenericContainer)["getWaitStrategy"]()).toBeInstanceOf(HostPortWaitStrategy); + }); + it("should use Wait.forHealthCheck if withHealthCheck() explicitly called", async () => { + await using container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14") + .withExposedPorts(8080) + .withHealthCheck({ + test: ["CMD-SHELL", "echo 'started' && exit 0"], + }) + .start(); + expect((container as StartedGenericContainer)["getWaitStrategy"]()).toBeInstanceOf(HealthCheckWaitStrategy); + }); + it("should use Wait.forHealthCheck if healthcheck is defined in DOCKERFILE", async () => { + const context = path.resolve(fixtures, "docker-with-health-check"); + const genericContainer = await GenericContainer.fromDockerfile(context).build(); + await using startedContainer = await genericContainer.start(); + expect((startedContainer as StartedGenericContainer)["getWaitStrategy"]()).toBeInstanceOf( + HealthCheckWaitStrategy + ); + }); + it("should use same WaitStrategy if it's explicitly defined in withWaitStrategy() even if image defines healthcheck", async () => { + const context = path.resolve(fixtures, "docker-with-health-check"); + const genericContainer = await GenericContainer.fromDockerfile(context).build(); + await using container = await genericContainer + .withExposedPorts(8080) + .withWaitStrategy(Wait.forListeningPorts()) + .start(); + expect((container as StartedGenericContainer)["getWaitStrategy"]()).toBeInstanceOf(HostPortWaitStrategy); + }); + it("should use same WaitStrategy if it's explicitly defined in withWaitStrategy() even if withHealthCheck() is called", async () => { + await using container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14") + .withExposedPorts(8080) + .withHealthCheck({ + test: ["CMD-SHELL", "echo 'started' && exit 0"], + }) + .withWaitStrategy(Wait.forListeningPorts()) + .start(); + expect((container as StartedGenericContainer)["getWaitStrategy"]()).toBeInstanceOf(HostPortWaitStrategy); + }); }); - it("should use Wait.forHealthCheck if withHealthCheck() explicitly called", async () => { - await using container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14") - .withExposedPorts(8080) - .withHealthCheck({ - test: ["CMD-SHELL", "echo 'started' && exit 0"], - }) - .start(); - expect(container.getWaitStrategy()).toBeInstanceOf(HealthCheckWaitStrategy); - }); - it("should use Wait.forHealthCheck if healthcheck is defined in DOCKERFILE", async () => { - const context = path.resolve(fixtures, "docker-with-health-check"); - const genericContainer = await GenericContainer.fromDockerfile(context).build(); - await using startedContainer = await genericContainer.start(); - expect(startedContainer.getWaitStrategy()).toBeInstanceOf(HealthCheckWaitStrategy); - }); - it("should use same WaitStrategy if it's explicitly defined in withWaitStrategy() even if image defines healthcheck", async () => { - const context = path.resolve(fixtures, "docker-with-health-check"); - const genericContainer = await GenericContainer.fromDockerfile(context).build(); - await using container = await genericContainer - .withExposedPorts(8080) - .withWaitStrategy(Wait.forListeningPorts()) - .start(); - expect(container.getWaitStrategy()).toBeInstanceOf(HostPortWaitStrategy); - }); - it("should use same WaitStrategy if it's explicitly defined in withWaitStrategy() even if withHealthCheck() is called", async () => { - await using container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14") - .withExposedPorts(8080) - .withHealthCheck({ - test: ["CMD-SHELL", "echo 'started' && exit 0"], - }) - .withWaitStrategy(Wait.forListeningPorts()) - .start(); - expect(container.getWaitStrategy()).toBeInstanceOf(HostPortWaitStrategy); - }); -}); +} diff --git a/packages/testcontainers/src/generic-container/generic-container.ts b/packages/testcontainers/src/generic-container/generic-container.ts index bdfad2d4c..f7a363ae1 100644 --- a/packages/testcontainers/src/generic-container/generic-container.ts +++ b/packages/testcontainers/src/generic-container/generic-container.ts @@ -29,7 +29,6 @@ import { createLabels, LABEL_TESTCONTAINERS_CONTAINER_HASH, LABEL_TESTCONTAINERS import { mapInspectResult } from "../utils/map-inspect-result"; import { getContainerPort, getProtocol, hasHostBinding, PortWithOptionalBinding } from "../utils/port"; import { ImagePullPolicy, PullPolicy } from "../utils/pull-policy"; -import { NullWaitStrategy } from "../wait-strategies/null-wait-strategy"; import { Wait } from "../wait-strategies/wait"; import { waitForContainer } from "../wait-strategies/wait-for-container"; import { WaitStrategy } from "../wait-strategies/wait-strategy"; @@ -49,7 +48,7 @@ export class GenericContainer implements TestContainer { protected imageName: ImageName; protected startupTimeoutMs?: number; - protected waitStrategy: WaitStrategy = new NullWaitStrategy(); + protected waitStrategy: WaitStrategy | undefined; protected environment: Record = {}; protected exposedPorts: PortWithOptionalBinding[] = []; protected reuse = false; @@ -121,7 +120,7 @@ export class GenericContainer implements TestContainer { } private async selectWaitStrategy(client: ContainerRuntimeClient): Promise { - if (!(this.waitStrategy instanceof NullWaitStrategy)) return this.waitStrategy; + if (this.waitStrategy) return this.waitStrategy; if (this.healthCheck) { return Wait.forHealthCheck(); } @@ -169,7 +168,7 @@ export class GenericContainer implements TestContainer { this.exposedPorts ); if (this.startupTimeoutMs !== undefined) { - this.waitStrategy.withStartupTimeout(this.startupTimeoutMs); + this.waitStrategy?.withStartupTimeout(this.startupTimeoutMs); } await waitForContainer(client, container, this.waitStrategy, boundPorts); @@ -180,7 +179,7 @@ export class GenericContainer implements TestContainer { inspectResult, boundPorts, inspectResult.Name, - this.waitStrategy, + this.waitStrategy ?? Wait.forListeningPorts(), this.autoRemove ); } @@ -225,7 +224,7 @@ export class GenericContainer implements TestContainer { ); if (this.startupTimeoutMs !== undefined) { - this.waitStrategy.withStartupTimeout(this.startupTimeoutMs); + this.waitStrategy?.withStartupTimeout(this.startupTimeoutMs); } if (containerLog.enabled() || this.logConsumer !== undefined) { @@ -252,7 +251,7 @@ export class GenericContainer implements TestContainer { inspectResult, boundPorts, inspectResult.Name, - this.waitStrategy, + this.waitStrategy ?? Wait.forListeningPorts(), this.autoRemove ); diff --git a/packages/testcontainers/src/generic-container/started-generic-container.ts b/packages/testcontainers/src/generic-container/started-generic-container.ts index fb70cd186..5943fed4e 100644 --- a/packages/testcontainers/src/generic-container/started-generic-container.ts +++ b/packages/testcontainers/src/generic-container/started-generic-container.ts @@ -168,10 +168,6 @@ export class StartedGenericContainer implements StartedTestContainer { return this.getNetworkSettings()[networkName].ipAddress; } - public getWaitStrategy() { - return this.waitStrategy; - } - private getNetworkSettings() { return Object.entries(this.inspectResult.NetworkSettings.Networks) .map(([networkName, network]) => ({ @@ -248,4 +244,8 @@ export class StartedGenericContainer implements StartedTestContainer { async [Symbol.asyncDispose]() { await this.stop(); } + + private getWaitStrategy() { + return this.waitStrategy; + } } diff --git a/packages/testcontainers/src/test-container.ts b/packages/testcontainers/src/test-container.ts index 9b4638402..3711b5eeb 100644 --- a/packages/testcontainers/src/test-container.ts +++ b/packages/testcontainers/src/test-container.ts @@ -79,7 +79,6 @@ export interface StartedTestContainer extends AsyncDisposable { getNetworkNames(): string[]; getNetworkId(networkName: string): string; getIpAddress(networkName: string): string; - getWaitStrategy(): WaitStrategy; copyArchiveFromContainer(path: string): Promise; copyArchiveToContainer(tar: Readable, target?: string): Promise; copyDirectoriesToContainer(directoriesToCopy: DirectoryToCopy[]): Promise; diff --git a/packages/testcontainers/src/wait-strategies/null-wait-strategy.ts b/packages/testcontainers/src/wait-strategies/null-wait-strategy.ts deleted file mode 100644 index 51b927dc9..000000000 --- a/packages/testcontainers/src/wait-strategies/null-wait-strategy.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { AbstractWaitStrategy } from "./wait-strategy"; - -export class NullWaitStrategy extends AbstractWaitStrategy { - public override waitUntilReady(): Promise { - return Promise.resolve(); - } -} diff --git a/packages/testcontainers/src/wait-strategies/wait-for-container.ts b/packages/testcontainers/src/wait-strategies/wait-for-container.ts index 1c949253c..5c0e64b30 100644 --- a/packages/testcontainers/src/wait-strategies/wait-for-container.ts +++ b/packages/testcontainers/src/wait-strategies/wait-for-container.ts @@ -7,14 +7,14 @@ import { WaitStrategy } from "./wait-strategy"; export const waitForContainer = async ( client: ContainerRuntimeClient, container: Container, - waitStrategy: WaitStrategy, + waitStrategy: WaitStrategy | undefined, boundPorts: BoundPorts, startTime?: Date ): Promise => { log.debug(`Waiting for container to be ready...`, { containerId: container.id }); try { - await waitStrategy.waitUntilReady(container, boundPorts, startTime); + await waitStrategy?.waitUntilReady(container, boundPorts, startTime); log.info(`Container is ready`, { containerId: container.id }); } catch (err) { log.error(`Container failed to be ready: ${err}`, { containerId: container.id }); From f47af2ee34b5fadca2f06adf3161f41c2c384432 Mon Sep 17 00:00:00 2001 From: digital88 Date: Mon, 25 Aug 2025 20:09:49 +0300 Subject: [PATCH 04/13] update @types/dockerode --- package-lock.json | 9 +++++---- packages/testcontainers/package.json | 2 +- .../docker-compose-environment.ts | 10 ++-------- .../src/generic-container/generic-container.ts | 8 ++------ 4 files changed, 10 insertions(+), 19 deletions(-) diff --git a/package-lock.json b/package-lock.json index f56d132a1..be9b58df1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7893,9 +7893,10 @@ } }, "node_modules/@types/dockerode": { - "version": "3.3.42", - "resolved": "https://registry.npmjs.org/@types/dockerode/-/dockerode-3.3.42.tgz", - "integrity": "sha512-U1jqHMShibMEWHdxYhj3rCMNCiLx5f35i4e3CEUuW+JSSszc/tVqc6WCAPdhwBymG5R/vgbcceagK0St7Cq6Eg==", + "version": "3.3.43", + "resolved": "https://registry.npmjs.org/@types/dockerode/-/dockerode-3.3.43.tgz", + "integrity": "sha512-YCi0aKKpKeC9dhKTbuglvsWDnAyuIITd6CCJSTKiAdbDzPH4RWu0P9IK2XkJHdyplH6mzYtDYO+gB06JlzcPxg==", + "license": "MIT", "dependencies": { "@types/docker-modem": "*", "@types/node": "*", @@ -22897,7 +22898,7 @@ "license": "MIT", "dependencies": { "@balena/dockerignore": "^1.0.2", - "@types/dockerode": "^3.3.42", + "@types/dockerode": "^3.3.43", "archiver": "^7.0.1", "async-lock": "^1.4.1", "byline": "^5.0.0", diff --git a/packages/testcontainers/package.json b/packages/testcontainers/package.json index d661e1ec3..e41f5d735 100644 --- a/packages/testcontainers/package.json +++ b/packages/testcontainers/package.json @@ -31,7 +31,7 @@ }, "dependencies": { "@balena/dockerignore": "^1.0.2", - "@types/dockerode": "^3.3.42", + "@types/dockerode": "^3.3.43", "archiver": "^7.0.1", "async-lock": "^1.4.1", "byline": "^5.0.0", diff --git a/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.ts b/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.ts index 243e78115..210dc9431 100644 --- a/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.ts +++ b/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.ts @@ -1,4 +1,4 @@ -import { ContainerInfo, ContainerInspectInfo, HealthConfig } from "dockerode"; +import { ContainerInfo, ContainerInspectInfo } from "dockerode"; import { containerLog, log, RandomUuid, Uuid } from "../common"; import { ComposeOptions, getContainerRuntimeClient, parseComposeContainerName } from "../container-runtime"; import { StartedGenericContainer } from "../generic-container/started-generic-container"; @@ -211,13 +211,7 @@ export class DockerComposeEnvironment { ? this.waitStrategy[containerName] : this.defaultWaitStrategy; if (containerWaitStrategy) return containerWaitStrategy; - const healthcheck = ( - inspectResult as ContainerInspectInfo & { - Config: ContainerInspectInfo["Config"] & { - Healthcheck: HealthConfig | undefined; - }; - } - ).Config.Healthcheck; + const healthcheck = inspectResult.Config.Healthcheck; if (healthcheck?.Test) { return Wait.forHealthCheck(); } diff --git a/packages/testcontainers/src/generic-container/generic-container.ts b/packages/testcontainers/src/generic-container/generic-container.ts index f7a363ae1..4d54c48ac 100644 --- a/packages/testcontainers/src/generic-container/generic-container.ts +++ b/packages/testcontainers/src/generic-container/generic-container.ts @@ -1,6 +1,6 @@ import archiver from "archiver"; import AsyncLock from "async-lock"; -import { Container, ContainerCreateOptions, HealthConfig, HostConfig, ImageInspectInfo } from "dockerode"; +import { Container, ContainerCreateOptions, HostConfig } from "dockerode"; import { Readable } from "stream"; import { containerLog, hash, log, toNanos } from "../common"; import { ContainerRuntimeClient, getContainerRuntimeClient, ImageName } from "../container-runtime"; @@ -124,11 +124,7 @@ export class GenericContainer implements TestContainer { if (this.healthCheck) { return Wait.forHealthCheck(); } - const imageInfo = (await client.image.inspect(this.imageName)) as ImageInspectInfo & { - Config: ImageInspectInfo["Config"] & { - Healthcheck: HealthConfig | undefined; - }; - }; + const imageInfo = await client.image.inspect(this.imageName); if (imageInfo.Config.Healthcheck?.Test) { return Wait.forHealthCheck(); } From b94ce112c6d5766f3e463f98ba1af3375877a726 Mon Sep 17 00:00:00 2001 From: digital88 Date: Wed, 27 Aug 2025 19:38:33 +0300 Subject: [PATCH 05/13] move selectWaitStrategy inside startContainer --- .../generic-container-wait-strategy.test.ts | 86 +++++++++---------- .../generic-container/generic-container.ts | 22 +++-- .../src/wait-strategies/wait-for-container.ts | 4 +- 3 files changed, 56 insertions(+), 56 deletions(-) diff --git a/packages/testcontainers/src/generic-container/generic-container-wait-strategy.test.ts b/packages/testcontainers/src/generic-container/generic-container-wait-strategy.test.ts index 7348451b4..6f1e12be8 100644 --- a/packages/testcontainers/src/generic-container/generic-container-wait-strategy.test.ts +++ b/packages/testcontainers/src/generic-container/generic-container-wait-strategy.test.ts @@ -7,49 +7,45 @@ import { StartedGenericContainer } from "./started-generic-container"; const fixtures = path.resolve(__dirname, "..", "..", "fixtures", "docker"); -if (!process.env.CI_PODMAN) { - describe("GenericContainer wait strategy", { timeout: 180_000 }, () => { - it("should use Wait.forListeningPorts if healthcheck is not defined in DOCKERFILE", async () => { - await using container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14") - .withExposedPorts(8080) - .start(); - expect((container as StartedGenericContainer)["getWaitStrategy"]()).toBeInstanceOf(HostPortWaitStrategy); - }); - it("should use Wait.forHealthCheck if withHealthCheck() explicitly called", async () => { - await using container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14") - .withExposedPorts(8080) - .withHealthCheck({ - test: ["CMD-SHELL", "echo 'started' && exit 0"], - }) - .start(); - expect((container as StartedGenericContainer)["getWaitStrategy"]()).toBeInstanceOf(HealthCheckWaitStrategy); - }); - it("should use Wait.forHealthCheck if healthcheck is defined in DOCKERFILE", async () => { - const context = path.resolve(fixtures, "docker-with-health-check"); - const genericContainer = await GenericContainer.fromDockerfile(context).build(); - await using startedContainer = await genericContainer.start(); - expect((startedContainer as StartedGenericContainer)["getWaitStrategy"]()).toBeInstanceOf( - HealthCheckWaitStrategy - ); - }); - it("should use same WaitStrategy if it's explicitly defined in withWaitStrategy() even if image defines healthcheck", async () => { - const context = path.resolve(fixtures, "docker-with-health-check"); - const genericContainer = await GenericContainer.fromDockerfile(context).build(); - await using container = await genericContainer - .withExposedPorts(8080) - .withWaitStrategy(Wait.forListeningPorts()) - .start(); - expect((container as StartedGenericContainer)["getWaitStrategy"]()).toBeInstanceOf(HostPortWaitStrategy); - }); - it("should use same WaitStrategy if it's explicitly defined in withWaitStrategy() even if withHealthCheck() is called", async () => { - await using container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14") - .withExposedPorts(8080) - .withHealthCheck({ - test: ["CMD-SHELL", "echo 'started' && exit 0"], - }) - .withWaitStrategy(Wait.forListeningPorts()) - .start(); - expect((container as StartedGenericContainer)["getWaitStrategy"]()).toBeInstanceOf(HostPortWaitStrategy); - }); +describe("GenericContainer wait strategy", { timeout: 180_000 }, () => { + it("should use Wait.forListeningPorts if healthcheck is not defined in DOCKERFILE", async () => { + await using container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14") + .withExposedPorts(8080) + .start(); + expect((container as StartedGenericContainer)["getWaitStrategy"]()).toBeInstanceOf(HostPortWaitStrategy); }); -} + it("should use Wait.forHealthCheck if withHealthCheck() explicitly called", async () => { + await using container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14") + .withExposedPorts(8080) + .withHealthCheck({ + test: ["CMD-SHELL", "echo 'started' && exit 0"], + }) + .start(); + expect((container as StartedGenericContainer)["getWaitStrategy"]()).toBeInstanceOf(HealthCheckWaitStrategy); + }); + it("should use Wait.forHealthCheck if healthcheck is defined in DOCKERFILE", async () => { + const context = path.resolve(fixtures, "docker-with-health-check"); + const genericContainer = await GenericContainer.fromDockerfile(context).build(); + await using startedContainer = await genericContainer.start(); + expect((startedContainer as StartedGenericContainer)["getWaitStrategy"]()).toBeInstanceOf(HealthCheckWaitStrategy); + }); + it("should use same WaitStrategy if it's explicitly defined in withWaitStrategy() even if image defines healthcheck", async () => { + const context = path.resolve(fixtures, "docker-with-health-check"); + const genericContainer = await GenericContainer.fromDockerfile(context).build(); + await using container = await genericContainer + .withExposedPorts(8080) + .withWaitStrategy(Wait.forListeningPorts()) + .start(); + expect((container as StartedGenericContainer)["getWaitStrategy"]()).toBeInstanceOf(HostPortWaitStrategy); + }); + it("should use same WaitStrategy if it's explicitly defined in withWaitStrategy() even if withHealthCheck() is called", async () => { + await using container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14") + .withExposedPorts(8080) + .withHealthCheck({ + test: ["CMD-SHELL", "echo 'started' && exit 0"], + }) + .withWaitStrategy(Wait.forListeningPorts()) + .start(); + expect((container as StartedGenericContainer)["getWaitStrategy"]()).toBeInstanceOf(HostPortWaitStrategy); + }); +}); diff --git a/packages/testcontainers/src/generic-container/generic-container.ts b/packages/testcontainers/src/generic-container/generic-container.ts index 4d54c48ac..6a459c397 100644 --- a/packages/testcontainers/src/generic-container/generic-container.ts +++ b/packages/testcontainers/src/generic-container/generic-container.ts @@ -94,8 +94,6 @@ export class GenericContainer implements TestContainer { await this.beforeContainerCreated(); } - this.waitStrategy = await this.selectWaitStrategy(client); - if (!this.isHelperContainer() && PortForwarderInstance.isRunning()) { const portForwarder = await PortForwarderInstance.getInstance(); this.hostConfig.ExtraHosts = [ @@ -119,13 +117,13 @@ export class GenericContainer implements TestContainer { return this.startContainer(client); } - private async selectWaitStrategy(client: ContainerRuntimeClient): Promise { + private async selectWaitStrategy(client: ContainerRuntimeClient, container: Container): Promise { if (this.waitStrategy) return this.waitStrategy; if (this.healthCheck) { return Wait.forHealthCheck(); } - const imageInfo = await client.image.inspect(this.imageName); - if (imageInfo.Config.Healthcheck?.Test) { + const containerInfo = await client.container.inspect(container); + if (containerInfo.Config.Healthcheck?.Test) { return Wait.forHealthCheck(); } return Wait.forListeningPorts(); @@ -167,7 +165,9 @@ export class GenericContainer implements TestContainer { this.waitStrategy?.withStartupTimeout(this.startupTimeoutMs); } - await waitForContainer(client, container, this.waitStrategy, boundPorts); + const waitStrategy = this.waitStrategy ?? Wait.forListeningPorts(); + + await waitForContainer(client, container, waitStrategy, boundPorts); return new StartedGenericContainer( container, @@ -175,7 +175,7 @@ export class GenericContainer implements TestContainer { inspectResult, boundPorts, inspectResult.Name, - this.waitStrategy ?? Wait.forListeningPorts(), + waitStrategy, this.autoRemove ); } @@ -183,6 +183,8 @@ export class GenericContainer implements TestContainer { private async startContainer(client: ContainerRuntimeClient): Promise { const container = await client.container.create({ ...this.createOpts, HostConfig: this.hostConfig }); + this.waitStrategy = await this.selectWaitStrategy(client, container); + if (!this.isHelperContainer() && PortForwarderInstance.isRunning()) { await this.connectContainerToPortForwarder(client, container); } @@ -239,7 +241,9 @@ export class GenericContainer implements TestContainer { await this.containerStarting(mappedInspectResult, false); } - await waitForContainer(client, container, this.waitStrategy, boundPorts); + const waitStrategy = this.waitStrategy ?? Wait.forListeningPorts(); + + await waitForContainer(client, container, waitStrategy, boundPorts); const startedContainer = new StartedGenericContainer( container, @@ -247,7 +251,7 @@ export class GenericContainer implements TestContainer { inspectResult, boundPorts, inspectResult.Name, - this.waitStrategy ?? Wait.forListeningPorts(), + waitStrategy, this.autoRemove ); diff --git a/packages/testcontainers/src/wait-strategies/wait-for-container.ts b/packages/testcontainers/src/wait-strategies/wait-for-container.ts index 5c0e64b30..1c949253c 100644 --- a/packages/testcontainers/src/wait-strategies/wait-for-container.ts +++ b/packages/testcontainers/src/wait-strategies/wait-for-container.ts @@ -7,14 +7,14 @@ import { WaitStrategy } from "./wait-strategy"; export const waitForContainer = async ( client: ContainerRuntimeClient, container: Container, - waitStrategy: WaitStrategy | undefined, + waitStrategy: WaitStrategy, boundPorts: BoundPorts, startTime?: Date ): Promise => { log.debug(`Waiting for container to be ready...`, { containerId: container.id }); try { - await waitStrategy?.waitUntilReady(container, boundPorts, startTime); + await waitStrategy.waitUntilReady(container, boundPorts, startTime); log.info(`Container is ready`, { containerId: container.id }); } catch (err) { log.error(`Container failed to be ready: ${err}`, { containerId: container.id }); From e54c7bbc388b95f23a43cd9c072e10b62bd37d33 Mon Sep 17 00:00:00 2001 From: digital88 Date: Thu, 28 Aug 2025 10:36:48 +0300 Subject: [PATCH 06/13] fixed kafka and redpanda --- packages/modules/kafka/src/kafka-container.ts | 9 +++++++-- packages/modules/redpanda/src/redpanda-container.ts | 9 +++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/modules/kafka/src/kafka-container.ts b/packages/modules/kafka/src/kafka-container.ts index 0cbfa98b8..36d9bec31 100644 --- a/packages/modules/kafka/src/kafka-container.ts +++ b/packages/modules/kafka/src/kafka-container.ts @@ -61,7 +61,7 @@ export class KafkaContainer extends GenericContainer { private zooKeeperHost?: string; private zooKeeperPort?: number; private saslSslConfig?: SaslSslListenerOptions; - private originalWaitinStrategy: WaitStrategy; + private originalWaitinStrategy: WaitStrategy | undefined; constructor(image: string) { super(image); @@ -193,7 +193,12 @@ export class KafkaContainer extends GenericContainer { const boundPorts = BoundPorts.fromInspectResult(client.info.containerRuntime.hostIps, inspectResult).filter( this.exposedPorts ); - await waitForContainer(client, dockerContainer, this.originalWaitinStrategy, boundPorts); + await waitForContainer( + client, + dockerContainer, + this.originalWaitinStrategy ?? Wait.forListeningPorts(), + boundPorts + ); if (this.saslSslConfig && this.mode !== KafkaMode.KRAFT) { await this.createUser(container, this.saslSslConfig.sasl); diff --git a/packages/modules/redpanda/src/redpanda-container.ts b/packages/modules/redpanda/src/redpanda-container.ts index ee2025213..f104b76e8 100644 --- a/packages/modules/redpanda/src/redpanda-container.ts +++ b/packages/modules/redpanda/src/redpanda-container.ts @@ -21,7 +21,7 @@ const STARTER_SCRIPT = "/testcontainers_start.sh"; const WAIT_FOR_SCRIPT_MESSAGE = "Waiting for script..."; export class RedpandaContainer extends GenericContainer { - private originalWaitinStrategy: WaitStrategy; + private originalWaitinStrategy: WaitStrategy | undefined; constructor(image: string) { super(image); @@ -71,7 +71,12 @@ export class RedpandaContainer extends GenericContainer { const boundPorts = BoundPorts.fromInspectResult(client.info.containerRuntime.hostIps, inspectResult).filter( this.exposedPorts ); - await waitForContainer(client, dockerContainer, this.originalWaitinStrategy, boundPorts); + await waitForContainer( + client, + dockerContainer, + this.originalWaitinStrategy ?? Wait.forListeningPorts(), + boundPorts + ); } private renderRedpandaFile(host: string, port: number): string { From 3a10edb92107eaa7e769cab260ed827381f42570 Mon Sep 17 00:00:00 2001 From: Cristian Greco Date: Wed, 13 May 2026 22:28:10 +0100 Subject: [PATCH 07/13] Clean up healthcheck wait strategy coverage --- docs/features/compose.md | 2 +- docs/features/wait-strategies.md | 2 +- packages/modules/kafka/src/kafka-container.ts | 13 +++------ .../redpanda/src/redpanda-container.ts | 13 +++------ .../docker-compose-environment.test.ts | 6 ++--- .../generic-container-wait-strategy.test.ts | 18 +++++++++++++ .../health-check-wait-strategy.test.ts | 4 --- .../src/wait-strategies/utils/health-check.ts | 27 ++++++++++++++++--- 8 files changed, 55 insertions(+), 30 deletions(-) diff --git a/docs/features/compose.md b/docs/features/compose.md index 283ecd6d8..86dbfad49 100644 --- a/docs/features/compose.md +++ b/docs/features/compose.md @@ -51,7 +51,7 @@ If you'd like to override the default wait strategy for all services, you can do ```js const environment = await new DockerComposeEnvironment(composeFilePath, composeFile) - .withDefaultWaitStrategy(Wait.forHealthCheck()) + .withDefaultWaitStrategy(Wait.forListeningPorts()) .up(); ``` diff --git a/docs/features/wait-strategies.md b/docs/features/wait-strategies.md index 9749ba8ea..e9c27e04a 100644 --- a/docs/features/wait-strategies.md +++ b/docs/features/wait-strategies.md @@ -71,7 +71,7 @@ const container = await new GenericContainer("alpine") ## Health check -Wait until the container's health check is successful: +Explicitly wait until the container's health check is successful. This is optional when the image already defines a health check because Testcontainers uses that as the default wait strategy: ```js const { GenericContainer, Wait } = require("testcontainers"); diff --git a/packages/modules/kafka/src/kafka-container.ts b/packages/modules/kafka/src/kafka-container.ts index c21b2fb8a..3fddbeb0e 100644 --- a/packages/modules/kafka/src/kafka-container.ts +++ b/packages/modules/kafka/src/kafka-container.ts @@ -61,7 +61,7 @@ export class KafkaContainer extends GenericContainer { private zooKeeperHost?: string; private zooKeeperPort?: number; private saslSslConfig?: SaslSslListenerOptions; - private originalWaitinStrategy: WaitStrategy | undefined; + private originalWaitStrategy: WaitStrategy | undefined; constructor(image: string) { super(image); @@ -81,7 +81,7 @@ export class KafkaContainer extends GenericContainer { KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: "0", KAFKA_CONFLUENT_SUPPORT_METRICS_ENABLE: "false", }); - this.originalWaitinStrategy = this.waitStrategy; + this.originalWaitStrategy = this.waitStrategy; } public withZooKeeper(host: string, port: number): this { @@ -138,7 +138,7 @@ export class KafkaContainer extends GenericContainer { // Change the wait strategy to wait for a log message from a fake starter script // so that we can put a real starter script in place at that moment - this.originalWaitinStrategy = this.waitStrategy; + this.originalWaitStrategy = this.waitStrategy; this.waitStrategy = Wait.forLogMessage(WAIT_FOR_SCRIPT_MESSAGE); this.withEntrypoint(["sh"]); this.withCommand([ @@ -193,12 +193,7 @@ export class KafkaContainer extends GenericContainer { const boundPorts = BoundPorts.fromInspectResult(client.info.containerRuntime.hostIps, inspectResult).filter( this.exposedPorts ); - await waitForContainer( - client, - dockerContainer, - this.originalWaitinStrategy ?? Wait.forListeningPorts(), - boundPorts - ); + await waitForContainer(client, dockerContainer, this.originalWaitStrategy ?? Wait.forListeningPorts(), boundPorts); if (this.saslSslConfig && this.mode !== KafkaMode.KRAFT) { await this.createUser(container, this.saslSslConfig.sasl); diff --git a/packages/modules/redpanda/src/redpanda-container.ts b/packages/modules/redpanda/src/redpanda-container.ts index f104b76e8..02c2ed7b1 100644 --- a/packages/modules/redpanda/src/redpanda-container.ts +++ b/packages/modules/redpanda/src/redpanda-container.ts @@ -21,7 +21,7 @@ const STARTER_SCRIPT = "/testcontainers_start.sh"; const WAIT_FOR_SCRIPT_MESSAGE = "Waiting for script..."; export class RedpandaContainer extends GenericContainer { - private originalWaitinStrategy: WaitStrategy | undefined; + private originalWaitStrategy: WaitStrategy | undefined; constructor(image: string) { super(image); @@ -34,7 +34,7 @@ export class RedpandaContainer extends GenericContainer { target: "/etc/redpanda/.bootstrap.yaml", }, ]); - this.originalWaitinStrategy = this.waitStrategy; + this.originalWaitStrategy = this.waitStrategy; } public override async start(): Promise { @@ -44,7 +44,7 @@ export class RedpandaContainer extends GenericContainer { protected override async beforeContainerCreated(): Promise { // Change the wait strategy to wait for a log message from a fake starter script // so that we can put a real starter script in place at that moment - this.originalWaitinStrategy = this.waitStrategy; + this.originalWaitStrategy = this.waitStrategy; this.waitStrategy = Wait.forLogMessage(WAIT_FOR_SCRIPT_MESSAGE); this.withEntrypoint(["sh"]); this.withCommand([ @@ -71,12 +71,7 @@ export class RedpandaContainer extends GenericContainer { const boundPorts = BoundPorts.fromInspectResult(client.info.containerRuntime.hostIps, inspectResult).filter( this.exposedPorts ); - await waitForContainer( - client, - dockerContainer, - this.originalWaitinStrategy ?? Wait.forListeningPorts(), - boundPorts - ); + await waitForContainer(client, dockerContainer, this.originalWaitStrategy ?? Wait.forListeningPorts(), boundPorts); } private renderRedpandaFile(host: string, port: number): string { diff --git a/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.test.ts b/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.test.ts index 8e422f303..29ea8b4bc 100644 --- a/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.test.ts +++ b/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.test.ts @@ -90,9 +90,9 @@ describe("DockerComposeEnvironment", { timeout: 180_000 }, () => { }); it("should support default wait strategy", async () => { - await using startedEnvironment = await new DockerComposeEnvironment(fixtures, "docker-compose-with-healthcheck.yml") - .withDefaultWaitStrategy(Wait.forHealthCheck()) - .up(); + await using startedEnvironment = await new DockerComposeEnvironment(fixtures, "docker-compose.yml") + .withDefaultWaitStrategy(Wait.forLogMessage("Listening on port 8080")) + .up(["container"]); await checkEnvironmentContainerIsHealthy(startedEnvironment, "container-1"); }); diff --git a/packages/testcontainers/src/generic-container/generic-container-wait-strategy.test.ts b/packages/testcontainers/src/generic-container/generic-container-wait-strategy.test.ts index d4c50fad4..6a1432c5e 100644 --- a/packages/testcontainers/src/generic-container/generic-container-wait-strategy.test.ts +++ b/packages/testcontainers/src/generic-container/generic-container-wait-strategy.test.ts @@ -32,6 +32,24 @@ describe("GenericContainer default wait strategy", { timeout: 180_000 }, () => { await checkContainerIsHealthy(container); }); + it("should prefer a healthcheck configured with withHealthCheck over an image healthcheck", async () => { + const context = path.resolve(fixtures, "docker-with-delayed-health-check"); + const genericContainer = await GenericContainer.fromDockerfile(context).build(); + await using container = await genericContainer + .withExposedPorts(8080) + .withCommand(["sh", "-c", "rm -f /tmp/ready /tmp/custom-ready; touch /tmp/custom-ready; node index.js"]) + .withHealthCheck({ + test: ["CMD-SHELL", "test -f /tmp/custom-ready"], + interval: 1_000, + timeout: 1_000, + retries: 10, + }) + .start(); + + expect(await getHealthCheckStatus(container)).toBe("healthy"); + await checkContainerIsHealthy(container); + }); + // Podman compat inspect does not consistently expose Config.Healthcheck for built images. if (!process.env.CI_PODMAN) { it("should wait for a healthcheck defined in the image", async () => { 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 e6fa9fbd7..ab40f4f59 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 @@ -28,7 +28,6 @@ describe("HealthCheckWaitStrategy", { timeout: 180_000 }, () => { retries: 5, startPeriod: 1000, }) - .withWaitStrategy(Wait.forHealthCheck()) .start(); await checkContainerIsHealthy(container); @@ -44,7 +43,6 @@ describe("HealthCheckWaitStrategy", { timeout: 180_000 }, () => { .withName(containerName) .withExposedPorts(8080) .withHealthCheck({ test: ["CMD-SHELL", "exit 1"], interval: 1, timeout: 3, retries: 3 }) - .withWaitStrategy(Wait.forHealthCheck()) .start() ).rejects.toThrowError("Health check failed"); @@ -60,7 +58,6 @@ describe("HealthCheckWaitStrategy", { timeout: 180_000 }, () => { customGenericContainer .withName(containerName) .withExposedPorts(8080) - .withWaitStrategy(Wait.forHealthCheck()) .withHealthCheck({ test: ["CMD-SHELL", "sleep 10"], timeout: 10_000 }) .withStartupTimeout(0) .start() @@ -79,7 +76,6 @@ describe("HealthCheckWaitStrategy", { timeout: 180_000 }, () => { retries: 5, startPeriod: 1000, }) - .withWaitStrategy(Wait.forHealthCheck()) .start(); await checkContainerIsHealthy(container); diff --git a/packages/testcontainers/src/wait-strategies/utils/health-check.ts b/packages/testcontainers/src/wait-strategies/utils/health-check.ts index 3d96a5e10..39f147359 100644 --- a/packages/testcontainers/src/wait-strategies/utils/health-check.ts +++ b/packages/testcontainers/src/wait-strategies/utils/health-check.ts @@ -3,7 +3,28 @@ import { HealthCheck } from "../../types"; const DISABLED_HEALTH_CHECK_TEST = "NONE"; -export const hasHealthCheck = (healthCheck: HealthConfig | HealthCheck | undefined): boolean => { - const test = healthCheck === undefined ? undefined : "test" in healthCheck ? healthCheck.test : healthCheck.Test; - return test !== undefined && test.length > 0 && test[0].toUpperCase() !== DISABLED_HEALTH_CHECK_TEST; +type HealthCheckConfig = HealthConfig | HealthCheck; + +const getHealthCheckTest = (healthCheck: HealthCheckConfig): string[] | undefined => { + if ("test" in healthCheck) { + return healthCheck.test; + } + return healthCheck.Test; +}; + +const isDisabledHealthCheck = (test: string[]): boolean => { + return test[0].toUpperCase() === DISABLED_HEALTH_CHECK_TEST; +}; + +export const hasHealthCheck = (healthCheck: HealthCheckConfig | undefined): boolean => { + if (healthCheck === undefined) { + return false; + } + + const test = getHealthCheckTest(healthCheck); + if (test === undefined || test.length === 0) { + return false; + } + + return !isDisabledHealthCheck(test); }; From 1ba2eac393f8bd37569abfb7080950312504bb62 Mon Sep 17 00:00:00 2001 From: Cristian Greco Date: Thu, 14 May 2026 10:13:31 +0100 Subject: [PATCH 08/13] Normalize empty Podman health status --- .../src/utils/map-inspect-result.test.ts | 36 +++++++++++++++++++ .../src/utils/map-inspect-result.ts | 2 +- .../testcontainers/src/utils/test-helper.ts | 4 ++- 3 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 packages/testcontainers/src/utils/map-inspect-result.test.ts diff --git a/packages/testcontainers/src/utils/map-inspect-result.test.ts b/packages/testcontainers/src/utils/map-inspect-result.test.ts new file mode 100644 index 000000000..9727bf675 --- /dev/null +++ b/packages/testcontainers/src/utils/map-inspect-result.test.ts @@ -0,0 +1,36 @@ +import { ContainerInspectInfo } from "dockerode"; +import { mapInspectResult } from "./map-inspect-result"; + +const inspectResult = (health?: { Status: string }): ContainerInspectInfo => + ({ + Name: "container", + Config: { + Hostname: "hostname", + Labels: {}, + }, + State: { + Status: "running", + Running: true, + StartedAt: "2026-05-14T10:00:00.000Z", + FinishedAt: "0001-01-01T00:00:00.000Z", + Health: health, + }, + NetworkSettings: { + Ports: {}, + Networks: {}, + }, + }) as unknown as ContainerInspectInfo; + +describe("mapInspectResult", () => { + it("should map missing health status to none", () => { + expect(mapInspectResult(inspectResult()).healthCheckStatus).toBe("none"); + }); + + it("should map empty health status to none", () => { + expect(mapInspectResult(inspectResult({ Status: "" })).healthCheckStatus).toBe("none"); + }); + + it("should map health status", () => { + expect(mapInspectResult(inspectResult({ Status: "healthy" })).healthCheckStatus).toBe("healthy"); + }); +}); diff --git a/packages/testcontainers/src/utils/map-inspect-result.ts b/packages/testcontainers/src/utils/map-inspect-result.ts index 67e7d65d3..7f23da1eb 100644 --- a/packages/testcontainers/src/utils/map-inspect-result.ts +++ b/packages/testcontainers/src/utils/map-inspect-result.ts @@ -39,7 +39,7 @@ function mapPorts(inspectInfo: ContainerInspectInfo): Ports { function mapHealthCheckStatus(inspectResult: ContainerInspectInfo): HealthCheckStatus { const health = inspectResult.State.Health; - if (health === undefined) { + if (health === undefined || health.Status === "") { return "none"; } else { return health.Status as HealthCheckStatus; diff --git a/packages/testcontainers/src/utils/test-helper.ts b/packages/testcontainers/src/utils/test-helper.ts index 04c3ed12c..e295c7e14 100644 --- a/packages/testcontainers/src/utils/test-helper.ts +++ b/packages/testcontainers/src/utils/test-helper.ts @@ -29,7 +29,9 @@ export const checkContainerIsHealthy = async (container: StartedTestContainer): export const getHealthCheckStatus = async (container: StartedTestContainer): Promise => { const client = await getContainerRuntimeClient(); const dockerContainer = client.container.getById(container.getId()); - return (await client.container.inspect(dockerContainer)).State.Health?.Status as HealthCheckStatus | undefined; + const status = (await client.container.inspect(dockerContainer)).State.Health?.Status; + + return status === undefined || status === "" ? undefined : (status as HealthCheckStatus); }; export const checkContainerIsHealthyTls = async (container: StartedTestContainer): Promise => { From a333af3e8506b8019570ec79ca585c948966aee9 Mon Sep 17 00:00:00 2001 From: Cristian Greco Date: Fri, 15 May 2026 13:32:23 +0100 Subject: [PATCH 09/13] Fix healthcheck default wait strategy fallbacks --- packages/modules/kafka/src/kafka-container.ts | 7 +- .../redpanda/src/redpanda-container.ts | 7 +- .../docker-compose-environment.test.ts | 23 +++--- .../docker-compose-environment.ts | 55 ++++++++++++-- ...neric-container-wait-strategy-unit.test.ts | 74 +++++++++++++++++++ .../generic-container-wait-strategy.test.ts | 72 +++++++++--------- .../generic-container/generic-container.ts | 36 +++++++-- .../src/utils/map-inspect-result.test.ts | 16 ++++ .../src/utils/map-inspect-result.ts | 7 +- .../testcontainers/src/utils/test-helper.ts | 4 +- .../health-check-wait-strategy.ts | 3 +- .../utils/health-check.test.ts | 63 ++++++++++++++++ .../src/wait-strategies/utils/health-check.ts | 65 +++++++++++++++- 13 files changed, 358 insertions(+), 74 deletions(-) create mode 100644 packages/testcontainers/src/generic-container/generic-container-wait-strategy-unit.test.ts create mode 100644 packages/testcontainers/src/wait-strategies/utils/health-check.test.ts diff --git a/packages/modules/kafka/src/kafka-container.ts b/packages/modules/kafka/src/kafka-container.ts index 3fddbeb0e..dc6bfd7d6 100644 --- a/packages/modules/kafka/src/kafka-container.ts +++ b/packages/modules/kafka/src/kafka-container.ts @@ -190,10 +190,15 @@ export class KafkaContainer extends GenericContainer { const client = await getContainerRuntimeClient(); const dockerContainer = client.container.getById(container.getId()); + const dockerInspectResult = await client.container.inspect(dockerContainer); const boundPorts = BoundPorts.fromInspectResult(client.info.containerRuntime.hostIps, inspectResult).filter( this.exposedPorts ); - await waitForContainer(client, dockerContainer, this.originalWaitStrategy ?? Wait.forListeningPorts(), boundPorts); + const waitStrategy = await this.selectWaitStrategy(client, dockerInspectResult, this.originalWaitStrategy); + if (this.startupTimeoutMs !== undefined) { + waitStrategy.withStartupTimeout(this.startupTimeoutMs); + } + await waitForContainer(client, dockerContainer, waitStrategy, boundPorts); if (this.saslSslConfig && this.mode !== KafkaMode.KRAFT) { await this.createUser(container, this.saslSslConfig.sasl); diff --git a/packages/modules/redpanda/src/redpanda-container.ts b/packages/modules/redpanda/src/redpanda-container.ts index 02c2ed7b1..86c7bb50d 100644 --- a/packages/modules/redpanda/src/redpanda-container.ts +++ b/packages/modules/redpanda/src/redpanda-container.ts @@ -68,10 +68,15 @@ export class RedpandaContainer extends GenericContainer { const client = await getContainerRuntimeClient(); const dockerContainer = client.container.getById(container.getId()); + const dockerInspectResult = await client.container.inspect(dockerContainer); const boundPorts = BoundPorts.fromInspectResult(client.info.containerRuntime.hostIps, inspectResult).filter( this.exposedPorts ); - await waitForContainer(client, dockerContainer, this.originalWaitStrategy ?? Wait.forListeningPorts(), boundPorts); + const waitStrategy = await this.selectWaitStrategy(client, dockerInspectResult, this.originalWaitStrategy); + if (this.startupTimeoutMs !== undefined) { + waitStrategy.withStartupTimeout(this.startupTimeoutMs); + } + await waitForContainer(client, dockerContainer, waitStrategy, boundPorts); } private renderRedpandaFile(host: string, port: number): string { diff --git a/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.test.ts b/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.test.ts index 29ea8b4bc..c89e5acfd 100644 --- a/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.test.ts +++ b/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.test.ts @@ -97,19 +97,16 @@ describe("DockerComposeEnvironment", { timeout: 180_000 }, () => { await checkEnvironmentContainerIsHealthy(startedEnvironment, "container-1"); }); - // Podman compat inspect does not consistently expose Config.Healthcheck for compose-defined health checks. - if (!process.env.CI_PODMAN) { - it("should wait for a healthcheck defined in a service", async () => { - await using startedEnvironment = await new DockerComposeEnvironment( - fixtures, - "docker-compose-with-delayed-healthcheck.yml" - ).up(); - const container = startedEnvironment.getContainer("container-1"); - - expect(await getHealthCheckStatus(container)).toBe("healthy"); - await checkEnvironmentContainerIsHealthy(startedEnvironment, "container-1"); - }); - } + it("should wait for a healthcheck defined in a service", async () => { + await using startedEnvironment = await new DockerComposeEnvironment( + fixtures, + "docker-compose-with-delayed-healthcheck.yml" + ).up(); + const container = startedEnvironment.getContainer("container-1"); + + expect(await getHealthCheckStatus(container)).toBe("healthy"); + await checkEnvironmentContainerIsHealthy(startedEnvironment, "container-1"); + }); it("should use listening ports if healthcheck is not defined in a service", async () => { await using startedEnvironment = await new DockerComposeEnvironment(fixtures, "docker-compose-with-name.yml").up(); diff --git a/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.ts b/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.ts index 4c1cd2ca8..1e6c67ba8 100644 --- a/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.ts +++ b/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.ts @@ -1,13 +1,23 @@ import { ContainerInfo, ContainerInspectInfo } from "dockerode"; import { containerLog, log, RandomUuid, Uuid } from "../common"; -import { ComposeOptions, getContainerRuntimeClient, parseComposeContainerName } from "../container-runtime"; +import { + ComposeOptions, + ContainerRuntimeClient, + getContainerRuntimeClient, + ImageName, + parseComposeContainerName, +} from "../container-runtime"; import { StartedGenericContainer } from "../generic-container/started-generic-container"; import { getReaper } from "../reaper/reaper"; import { Environment } from "../types"; import { BoundPorts } from "../utils/bound-ports"; import { mapInspectResult } from "../utils/map-inspect-result"; import { ImagePullPolicy, PullPolicy } from "../utils/pull-policy"; -import { hasHealthCheck } from "../wait-strategies/utils/health-check"; +import { + hasDisabledHealthCheckConfig, + hasHealthCheckConfig, + hasHealthCheckStatus, +} from "../wait-strategies/utils/health-check"; import { Wait } from "../wait-strategies/wait"; import { waitForContainer } from "../wait-strategies/wait-for-container"; import { WaitStrategy } from "../wait-strategies/wait-strategy"; @@ -175,7 +185,7 @@ export class DockerComposeEnvironment { const inspectResult = await client.container.inspect(container); const mappedInspectResult = mapInspectResult(inspectResult); const boundPorts = BoundPorts.fromInspectResult(client.info.containerRuntime.hostIps, mappedInspectResult); - const waitStrategy = this.selectWaitStrategy(containerName, inspectResult); + const waitStrategy = await this.selectWaitStrategy(client, containerName, inspectResult); if (this.startupTimeoutMs !== undefined) { waitStrategy.withStartupTimeout(this.startupTimeoutMs); } @@ -222,17 +232,52 @@ export class DockerComposeEnvironment { }); } - private selectWaitStrategy(containerName: string, inspectResult: ContainerInspectInfo): WaitStrategy { + private async selectWaitStrategy( + client: ContainerRuntimeClient, + containerName: string, + inspectResult: ContainerInspectInfo + ): Promise { const containerWaitStrategy = this.waitStrategy[containerName] ? this.waitStrategy[containerName] : this.defaultWaitStrategy; if (containerWaitStrategy) return containerWaitStrategy; - if (hasHealthCheck(inspectResult.Config.Healthcheck)) { + if (hasDisabledHealthCheckConfig(inspectResult)) { + return Wait.forListeningPorts(); + } + if (hasHealthCheckConfig(inspectResult) || hasHealthCheckStatus(inspectResult)) { + return Wait.forHealthCheck(); + } + if (await this.imageHasHealthCheck(client, inspectResult)) { return Wait.forHealthCheck(); } return Wait.forListeningPorts(); } + private async imageHasHealthCheck( + client: ContainerRuntimeClient, + inspectResult: ContainerInspectInfo + ): Promise { + const imageNames = Array.from( + new Set( + [inspectResult.Config.Image, inspectResult.Image].filter( + (imageName): imageName is string => imageName !== undefined && imageName !== "" + ) + ) + ); + + for (const imageName of imageNames) { + try { + if (hasHealthCheckConfig(await client.image.inspect(ImageName.fromString(imageName)))) { + return true; + } + } catch (err) { + log.debug(`Failed to inspect image "${imageName}" for health check config: ${err}`); + } + } + + return false; + } + private warnForUnusedWaitStrategies(startedContainerNames: Set): void { const unusedWaitStrategyContainerNames = Object.keys(this.waitStrategy).filter( (configuredContainerName) => !startedContainerNames.has(configuredContainerName) diff --git a/packages/testcontainers/src/generic-container/generic-container-wait-strategy-unit.test.ts b/packages/testcontainers/src/generic-container/generic-container-wait-strategy-unit.test.ts new file mode 100644 index 000000000..827a55ae4 --- /dev/null +++ b/packages/testcontainers/src/generic-container/generic-container-wait-strategy-unit.test.ts @@ -0,0 +1,74 @@ +import { ContainerInspectInfo, ImageInspectInfo } from "dockerode"; +import { ContainerRuntimeClient } from "../container-runtime"; +import { HealthCheckWaitStrategy } from "../wait-strategies/health-check-wait-strategy"; +import { HostPortWaitStrategy } from "../wait-strategies/host-port-wait-strategy"; +import { GenericContainer } from "./generic-container"; + +class TestGenericContainer extends GenericContainer { + public selectWaitStrategyForTest(client: ContainerRuntimeClient, inspectResult: ContainerInspectInfo) { + return this.selectWaitStrategy(client, inspectResult); + } +} + +const containerInspectResult = (healthcheck?: { Test: string[] }): ContainerInspectInfo => + ({ + Config: { + Hostname: "hostname", + Image: "image:latest", + Labels: {}, + Healthcheck: healthcheck, + }, + State: { + Status: "running", + Running: true, + StartedAt: "2026-05-14T10:00:00.000Z", + FinishedAt: "0001-01-01T00:00:00.000Z", + }, + NetworkSettings: { + Ports: {}, + Networks: {}, + }, + }) as unknown as ContainerInspectInfo; + +const client = (imageInspectResult: ImageInspectInfo): ContainerRuntimeClient => + ({ + image: { + inspect: vi.fn().mockResolvedValue(imageInspectResult), + }, + }) as unknown as ContainerRuntimeClient; + +describe("GenericContainer wait strategy selection", () => { + it("should fall back to image health check config when container config does not expose it", async () => { + const imageInspectResult = { + Config: { + Healthcheck: { + Test: ["CMD-SHELL", "test -f /tmp/ready"], + }, + }, + } as unknown as ImageInspectInfo; + + await expect( + new TestGenericContainer("image:latest").selectWaitStrategyForTest( + client(imageInspectResult), + containerInspectResult() + ) + ).resolves.toBeInstanceOf(HealthCheckWaitStrategy); + }); + + it("should not fall back to image health check config when the container disables health checks", async () => { + const imageInspectResult = { + Config: { + Healthcheck: { + Test: ["CMD-SHELL", "test -f /tmp/ready"], + }, + }, + } as unknown as ImageInspectInfo; + + await expect( + new TestGenericContainer("image:latest").selectWaitStrategyForTest( + client(imageInspectResult), + containerInspectResult({ Test: ["NONE"] }) + ) + ).resolves.toBeInstanceOf(HostPortWaitStrategy); + }); +}); diff --git a/packages/testcontainers/src/generic-container/generic-container-wait-strategy.test.ts b/packages/testcontainers/src/generic-container/generic-container-wait-strategy.test.ts index 6a1432c5e..62b2ef99b 100644 --- a/packages/testcontainers/src/generic-container/generic-container-wait-strategy.test.ts +++ b/packages/testcontainers/src/generic-container/generic-container-wait-strategy.test.ts @@ -50,17 +50,14 @@ describe("GenericContainer default wait strategy", { timeout: 180_000 }, () => { await checkContainerIsHealthy(container); }); - // Podman compat inspect does not consistently expose Config.Healthcheck for built images. - if (!process.env.CI_PODMAN) { - it("should wait for a healthcheck defined in the image", async () => { - const context = path.resolve(fixtures, "docker-with-delayed-health-check"); - const genericContainer = await GenericContainer.fromDockerfile(context).build(); - await using container = await genericContainer.withExposedPorts(8080).start(); - - expect(await getHealthCheckStatus(container)).toBe("healthy"); - await checkContainerIsHealthy(container); - }); - } + it("should wait for a healthcheck defined in the image", async () => { + const context = path.resolve(fixtures, "docker-with-delayed-health-check"); + const genericContainer = await GenericContainer.fromDockerfile(context).build(); + await using container = await genericContainer.withExposedPorts(8080).start(); + + expect(await getHealthCheckStatus(container)).toBe("healthy"); + await checkContainerIsHealthy(container); + }); it("should use listening ports if the image disables healthcheck", async () => { const context = path.resolve(fixtures, "docker-with-disabled-health-check"); @@ -71,34 +68,31 @@ describe("GenericContainer default wait strategy", { timeout: 180_000 }, () => { expect(await getHealthCheckStatus(container)).toBeUndefined(); }); - // Podman compat inspect does not consistently expose Config.Healthcheck for reused built images. - if (!process.env.CI_PODMAN) { - it.sequential("should wait for an image healthcheck when reusing a stopped container", async () => { - vi.stubEnv("TESTCONTAINERS_REUSE_ENABLE", "true"); - - const imageName = `localhost/${randomUuid()}:${randomUuid()}`; - const containerName = `reusable-healthcheck-${randomUuid()}`; - const context = path.resolve(fixtures, "docker-with-delayed-health-check"); - await GenericContainer.fromDockerfile(context).build(imageName); - - const container1 = await new GenericContainer(imageName) - .withName(containerName) - .withExposedPorts(8080) - .withReuse() - .start(); - await container1.stop({ remove: false, timeout: 10_000 }); - - await using container2 = await new GenericContainer(imageName) - .withName(containerName) - .withExposedPorts(8080) - .withReuse() - .start(); - - expect(container2.getId()).toBe(container1.getId()); - expect(await getHealthCheckStatus(container2)).toBe("healthy"); - await container2.stop({ remove: true }); - }); - } + it.sequential("should wait for an image healthcheck when reusing a stopped container", async () => { + vi.stubEnv("TESTCONTAINERS_REUSE_ENABLE", "true"); + + const imageName = `localhost/${randomUuid()}:${randomUuid()}`; + const containerName = `reusable-healthcheck-${randomUuid()}`; + const context = path.resolve(fixtures, "docker-with-delayed-health-check"); + await GenericContainer.fromDockerfile(context).build(imageName); + + const container1 = await new GenericContainer(imageName) + .withName(containerName) + .withExposedPorts(8080) + .withReuse() + .start(); + await container1.stop({ remove: false, timeout: 10_000 }); + + await using container2 = await new GenericContainer(imageName) + .withName(containerName) + .withExposedPorts(8080) + .withReuse() + .start(); + + expect(container2.getId()).toBe(container1.getId()); + expect(await getHealthCheckStatus(container2)).toBe("healthy"); + await container2.stop({ remove: true }); + }); it("should use an explicitly defined wait strategy even if image defines healthcheck", async () => { const context = path.resolve(fixtures, "docker-with-delayed-health-check"); diff --git a/packages/testcontainers/src/generic-container/generic-container.ts b/packages/testcontainers/src/generic-container/generic-container.ts index 11b4d28a4..6cc1e1935 100644 --- a/packages/testcontainers/src/generic-container/generic-container.ts +++ b/packages/testcontainers/src/generic-container/generic-container.ts @@ -31,7 +31,12 @@ import { createLabels, LABEL_TESTCONTAINERS_CONTAINER_HASH, LABEL_TESTCONTAINERS import { mapInspectResult } from "../utils/map-inspect-result"; import { getContainerPort, getProtocol, hasHostBinding, PortWithOptionalBinding } from "../utils/port"; import { ImagePullPolicy, PullPolicy } from "../utils/pull-policy"; -import { hasHealthCheck } from "../wait-strategies/utils/health-check"; +import { + hasDisabledHealthCheckConfig, + hasHealthCheck, + hasHealthCheckConfig, + hasHealthCheckStatus, +} from "../wait-strategies/utils/health-check"; import { Wait } from "../wait-strategies/wait"; import { waitForContainer } from "../wait-strategies/wait-for-container"; import { WaitStrategy } from "../wait-strategies/wait-strategy"; @@ -122,17 +127,36 @@ export class GenericContainer implements TestContainer { return this.startContainer(client); } - private selectWaitStrategy(inspectResult: ContainerInspectInfo): WaitStrategy { - if (this.waitStrategy) return this.waitStrategy; + protected async selectWaitStrategy( + client: ContainerRuntimeClient, + inspectResult: ContainerInspectInfo, + waitStrategy: WaitStrategy | undefined = this.waitStrategy + ): Promise { + if (waitStrategy) return waitStrategy; if (hasHealthCheck(this.healthCheck)) { return Wait.forHealthCheck(); } - if (hasHealthCheck(inspectResult.Config.Healthcheck)) { + if (hasDisabledHealthCheckConfig(inspectResult)) { + return Wait.forListeningPorts(); + } + if (hasHealthCheckConfig(inspectResult) || hasHealthCheckStatus(inspectResult)) { + return Wait.forHealthCheck(); + } + if (await this.imageHasHealthCheck(client)) { return Wait.forHealthCheck(); } return Wait.forListeningPorts(); } + private async imageHasHealthCheck(client: ContainerRuntimeClient): Promise { + try { + return hasHealthCheckConfig(await client.image.inspect(this.imageName)); + } catch (err) { + log.debug(`Failed to inspect image "${this.imageName.string}" for health check config: ${err}`); + return false; + } + } + private async reuseOrStartContainer(client: ContainerRuntimeClient) { const containerHash = hash(JSON.stringify(this.createOpts)); this.createOpts.Labels = { ...this.createOpts.Labels, [LABEL_TESTCONTAINERS_CONTAINER_HASH]: containerHash }; @@ -165,7 +189,7 @@ export class GenericContainer implements TestContainer { const boundPorts = BoundPorts.fromInspectResult(client.info.containerRuntime.hostIps, mappedInspectResult).filter( this.exposedPorts ); - const waitStrategy = this.selectWaitStrategy(inspectResult); + const waitStrategy = await this.selectWaitStrategy(client, inspectResult); if (this.startupTimeoutMs !== undefined) { waitStrategy.withStartupTimeout(this.startupTimeoutMs); } @@ -238,7 +262,7 @@ export class GenericContainer implements TestContainer { await this.containerStarting(mappedInspectResult, false); } - const waitStrategy = this.selectWaitStrategy(inspectResult); + const waitStrategy = await this.selectWaitStrategy(client, inspectResult); if (this.startupTimeoutMs !== undefined) { waitStrategy.withStartupTimeout(this.startupTimeoutMs); } diff --git a/packages/testcontainers/src/utils/map-inspect-result.test.ts b/packages/testcontainers/src/utils/map-inspect-result.test.ts index 9727bf675..2c8059df4 100644 --- a/packages/testcontainers/src/utils/map-inspect-result.test.ts +++ b/packages/testcontainers/src/utils/map-inspect-result.test.ts @@ -21,6 +21,18 @@ const inspectResult = (health?: { Status: string }): ContainerInspectInfo => }, }) as unknown as ContainerInspectInfo; +const podmanInspectResult = (healthcheck?: { Status: string }): ContainerInspectInfo => + ({ + ...inspectResult(), + State: { + Status: "running", + Running: true, + StartedAt: "2026-05-14T10:00:00.000Z", + FinishedAt: "0001-01-01T00:00:00.000Z", + Healthcheck: healthcheck, + }, + }) as unknown as ContainerInspectInfo; + describe("mapInspectResult", () => { it("should map missing health status to none", () => { expect(mapInspectResult(inspectResult()).healthCheckStatus).toBe("none"); @@ -33,4 +45,8 @@ describe("mapInspectResult", () => { it("should map health status", () => { expect(mapInspectResult(inspectResult({ Status: "healthy" })).healthCheckStatus).toBe("healthy"); }); + + it("should map Podman health status", () => { + expect(mapInspectResult(podmanInspectResult({ Status: "starting" })).healthCheckStatus).toBe("starting"); + }); }); diff --git a/packages/testcontainers/src/utils/map-inspect-result.ts b/packages/testcontainers/src/utils/map-inspect-result.ts index 7f23da1eb..1b5cf03e6 100644 --- a/packages/testcontainers/src/utils/map-inspect-result.ts +++ b/packages/testcontainers/src/utils/map-inspect-result.ts @@ -1,5 +1,6 @@ import { ContainerInspectInfo } from "dockerode"; import { HealthCheckStatus, InspectResult, NetworkSettings, Ports } from "../types"; +import { getHealthCheckStatusFromInspect } from "../wait-strategies/utils/health-check"; export function mapInspectResult(inspectResult: ContainerInspectInfo): InspectResult { const finishedAt = new Date(inspectResult.State.FinishedAt); @@ -37,12 +38,12 @@ function mapPorts(inspectInfo: ContainerInspectInfo): Ports { } function mapHealthCheckStatus(inspectResult: ContainerInspectInfo): HealthCheckStatus { - const health = inspectResult.State.Health; + const status = getHealthCheckStatusFromInspect(inspectResult); - if (health === undefined || health.Status === "") { + if (status === undefined) { return "none"; } else { - return health.Status as HealthCheckStatus; + return status; } } diff --git a/packages/testcontainers/src/utils/test-helper.ts b/packages/testcontainers/src/utils/test-helper.ts index e295c7e14..af52146da 100644 --- a/packages/testcontainers/src/utils/test-helper.ts +++ b/packages/testcontainers/src/utils/test-helper.ts @@ -12,6 +12,7 @@ import { StartedDockerComposeEnvironment } from "../docker-compose-environment/s import { GenericContainer } from "../generic-container/generic-container"; import { StartedTestContainer } from "../test-container"; import { HealthCheckStatus } from "../types"; +import { getHealthCheckStatusFromInspect } from "../wait-strategies/utils/health-check"; export const getImage = (dirname: string, index = 0): string => { return fs @@ -29,9 +30,8 @@ export const checkContainerIsHealthy = async (container: StartedTestContainer): export const getHealthCheckStatus = async (container: StartedTestContainer): Promise => { const client = await getContainerRuntimeClient(); const dockerContainer = client.container.getById(container.getId()); - const status = (await client.container.inspect(dockerContainer)).State.Health?.Status; - return status === undefined || status === "" ? undefined : (status as HealthCheckStatus); + return getHealthCheckStatusFromInspect(await client.container.inspect(dockerContainer)); }; export const checkContainerIsHealthyTls = async (container: StartedTestContainer): Promise => { 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 7beca859d..586fe7dba 100644 --- a/packages/testcontainers/src/wait-strategies/health-check-wait-strategy.ts +++ b/packages/testcontainers/src/wait-strategies/health-check-wait-strategy.ts @@ -1,6 +1,7 @@ import Dockerode from "dockerode"; import { IntervalRetry, log } from "../common"; import { getContainerRuntimeClient } from "../container-runtime"; +import { getHealthCheckStatusFromInspect } from "./utils/health-check"; import { AbstractWaitStrategy } from "./wait-strategy"; export class HealthCheckWaitStrategy extends AbstractWaitStrategy { @@ -9,7 +10,7 @@ export class HealthCheckWaitStrategy extends AbstractWaitStrategy { const client = await getContainerRuntimeClient(); const status = await new IntervalRetry(100).retryUntil( - async () => (await client.container.inspect(container)).State.Health?.Status, + async () => getHealthCheckStatusFromInspect(await client.container.inspect(container)), (healthCheckStatus) => healthCheckStatus === "healthy" || healthCheckStatus === "unhealthy", () => { const timeout = this.startupTimeoutMs; diff --git a/packages/testcontainers/src/wait-strategies/utils/health-check.test.ts b/packages/testcontainers/src/wait-strategies/utils/health-check.test.ts new file mode 100644 index 000000000..fcdf422eb --- /dev/null +++ b/packages/testcontainers/src/wait-strategies/utils/health-check.test.ts @@ -0,0 +1,63 @@ +import { ContainerInspectInfo, ImageInspectInfo } from "dockerode"; +import { getHealthCheckStatusFromInspect, hasDisabledHealthCheckConfig, hasHealthCheckConfig } from "./health-check"; + +describe("health check utils", () => { + it("should detect Docker health check config", () => { + const inspectResult = { + Config: { + Healthcheck: { + Test: ["CMD-SHELL", "test -f /tmp/ready"], + }, + }, + } as unknown as ContainerInspectInfo; + + expect(hasHealthCheckConfig(inspectResult)).toBe(true); + }); + + it("should detect Podman image health check config", () => { + const inspectResult = { + Healthcheck: { + Test: ["CMD-SHELL", "test -f /tmp/ready"], + }, + } as unknown as ImageInspectInfo; + + expect(hasHealthCheckConfig(inspectResult)).toBe(true); + }); + + it("should ignore disabled health check config", () => { + const inspectResult = { + Config: { + Healthcheck: { + Test: ["NONE"], + }, + }, + } as unknown as ContainerInspectInfo; + + expect(hasHealthCheckConfig(inspectResult)).toBe(false); + expect(hasDisabledHealthCheckConfig(inspectResult)).toBe(true); + }); + + it("should detect Docker health check status", () => { + const inspectResult = { + State: { + Health: { + Status: "healthy", + }, + }, + } as unknown as ContainerInspectInfo; + + expect(getHealthCheckStatusFromInspect(inspectResult)).toBe("healthy"); + }); + + it("should detect Podman health check status", () => { + const inspectResult = { + State: { + Healthcheck: { + Status: "starting", + }, + }, + } as unknown as ContainerInspectInfo; + + expect(getHealthCheckStatusFromInspect(inspectResult)).toBe("starting"); + }); +}); diff --git a/packages/testcontainers/src/wait-strategies/utils/health-check.ts b/packages/testcontainers/src/wait-strategies/utils/health-check.ts index 39f147359..93382b00e 100644 --- a/packages/testcontainers/src/wait-strategies/utils/health-check.ts +++ b/packages/testcontainers/src/wait-strategies/utils/health-check.ts @@ -1,9 +1,26 @@ -import { HealthConfig } from "dockerode"; -import { HealthCheck } from "../../types"; +import { ContainerInspectInfo, HealthConfig, ImageInspectInfo } from "dockerode"; +import { HealthCheck, HealthCheckStatus } from "../../types"; const DISABLED_HEALTH_CHECK_TEST = "NONE"; type HealthCheckConfig = HealthConfig | HealthCheck; +type HealthCheckInspectInfo = ContainerInspectInfo | ImageInspectInfo; +type InspectWithHealthCheckConfig = { + Config?: { + Healthcheck?: HealthConfig; + }; + ContainerConfig?: { + Healthcheck?: HealthConfig; + }; + Healthcheck?: HealthConfig; +}; +type InspectWithHealthCheckState = { + State: ContainerInspectInfo["State"] & { + Healthcheck?: { + Status?: string; + }; + }; +}; const getHealthCheckTest = (healthCheck: HealthCheckConfig): string[] | undefined => { if ("test" in healthCheck) { @@ -16,6 +33,19 @@ const isDisabledHealthCheck = (test: string[]): boolean => { return test[0].toUpperCase() === DISABLED_HEALTH_CHECK_TEST; }; +export const isHealthCheckDisabled = (healthCheck: HealthCheckConfig | undefined): boolean => { + if (healthCheck === undefined) { + return false; + } + + const test = getHealthCheckTest(healthCheck); + if (test === undefined || test.length === 0) { + return false; + } + + return isDisabledHealthCheck(test); +}; + export const hasHealthCheck = (healthCheck: HealthCheckConfig | undefined): boolean => { if (healthCheck === undefined) { return false; @@ -26,5 +56,34 @@ export const hasHealthCheck = (healthCheck: HealthCheckConfig | undefined): bool return false; } - return !isDisabledHealthCheck(test); + return !isHealthCheckDisabled(healthCheck); +}; + +export const getHealthCheckConfig = (inspectResult: HealthCheckInspectInfo): HealthConfig | undefined => { + const inspectWithHealthCheckConfig = inspectResult as InspectWithHealthCheckConfig; + + return ( + inspectWithHealthCheckConfig.Config?.Healthcheck ?? + inspectWithHealthCheckConfig.ContainerConfig?.Healthcheck ?? + inspectWithHealthCheckConfig.Healthcheck + ); +}; + +export const hasHealthCheckConfig = (inspectResult: HealthCheckInspectInfo): boolean => { + return hasHealthCheck(getHealthCheckConfig(inspectResult)); +}; + +export const hasDisabledHealthCheckConfig = (inspectResult: HealthCheckInspectInfo): boolean => { + return isHealthCheckDisabled(getHealthCheckConfig(inspectResult)); +}; + +export const getHealthCheckStatusFromInspect = (inspectResult: ContainerInspectInfo): HealthCheckStatus | undefined => { + const state = (inspectResult as InspectWithHealthCheckState).State; + const status = state.Health?.Status ?? state.Healthcheck?.Status; + + return status === undefined || status === "" ? undefined : (status as HealthCheckStatus); +}; + +export const hasHealthCheckStatus = (inspectResult: ContainerInspectInfo): boolean => { + return getHealthCheckStatusFromInspect(inspectResult) !== undefined; }; From e00c98ef9d400fbc530b9a4011abd2ce6aa567bf Mon Sep 17 00:00:00 2001 From: Cristian Greco Date: Fri, 15 May 2026 13:55:24 +0100 Subject: [PATCH 10/13] Refine healthcheck wait strategy selection --- .../docker-compose-environment.test.ts | 9 +- .../docker-compose-environment.ts | 69 ++-------------- ...neric-container-wait-strategy-unit.test.ts | 74 ----------------- .../generic-container-wait-strategy.test.ts | 82 +++++++++++++++++-- .../generic-container/generic-container.ts | 38 ++------- .../utils/wait-strategy-selector.ts | 59 +++++++++++++ 6 files changed, 152 insertions(+), 179 deletions(-) delete mode 100644 packages/testcontainers/src/generic-container/generic-container-wait-strategy-unit.test.ts create mode 100644 packages/testcontainers/src/wait-strategies/utils/wait-strategy-selector.ts diff --git a/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.test.ts b/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.test.ts index c89e5acfd..de0d8629d 100644 --- a/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.test.ts +++ b/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.test.ts @@ -89,7 +89,7 @@ describe("DockerComposeEnvironment", { timeout: 180_000 }, () => { expect(responseBody["IS_OVERRIDDEN"]).toBe("true"); }); - it("should support default wait strategy", async () => { + it("should support configuring a default wait strategy", async () => { await using startedEnvironment = await new DockerComposeEnvironment(fixtures, "docker-compose.yml") .withDefaultWaitStrategy(Wait.forLogMessage("Listening on port 8080")) .up(["container"]); @@ -108,13 +108,6 @@ describe("DockerComposeEnvironment", { timeout: 180_000 }, () => { await checkEnvironmentContainerIsHealthy(startedEnvironment, "container-1"); }); - it("should use listening ports if healthcheck is not defined in a service", async () => { - await using startedEnvironment = await new DockerComposeEnvironment(fixtures, "docker-compose-with-name.yml").up(); - - await checkEnvironmentContainerIsHealthy(startedEnvironment, "custom_container_name"); - expect(await getHealthCheckStatus(startedEnvironment.getContainer("custom_container_name"))).toBeUndefined(); - }); - it("should use listening ports if a service disables healthcheck", async () => { await using startedEnvironment = await new DockerComposeEnvironment( fixtures, diff --git a/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.ts b/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.ts index 1e6c67ba8..f13578f0e 100644 --- a/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.ts +++ b/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.ts @@ -1,24 +1,13 @@ -import { ContainerInfo, ContainerInspectInfo } from "dockerode"; +import { ContainerInfo } from "dockerode"; import { containerLog, log, RandomUuid, Uuid } from "../common"; -import { - ComposeOptions, - ContainerRuntimeClient, - getContainerRuntimeClient, - ImageName, - parseComposeContainerName, -} from "../container-runtime"; +import { ComposeOptions, getContainerRuntimeClient, parseComposeContainerName } from "../container-runtime"; import { StartedGenericContainer } from "../generic-container/started-generic-container"; import { getReaper } from "../reaper/reaper"; import { Environment } from "../types"; import { BoundPorts } from "../utils/bound-ports"; import { mapInspectResult } from "../utils/map-inspect-result"; import { ImagePullPolicy, PullPolicy } from "../utils/pull-policy"; -import { - hasDisabledHealthCheckConfig, - hasHealthCheckConfig, - hasHealthCheckStatus, -} from "../wait-strategies/utils/health-check"; -import { Wait } from "../wait-strategies/wait"; +import { selectWaitStrategy } from "../wait-strategies/utils/wait-strategy-selector"; import { waitForContainer } from "../wait-strategies/wait-for-container"; import { WaitStrategy } from "../wait-strategies/wait-strategy"; import { StartedDockerComposeEnvironment } from "./started-docker-compose-environment"; @@ -185,7 +174,11 @@ export class DockerComposeEnvironment { const inspectResult = await client.container.inspect(container); const mappedInspectResult = mapInspectResult(inspectResult); const boundPorts = BoundPorts.fromInspectResult(client.info.containerRuntime.hostIps, mappedInspectResult); - const waitStrategy = await this.selectWaitStrategy(client, containerName, inspectResult); + const waitStrategy = await selectWaitStrategy({ + client, + inspectResult, + waitStrategy: this.waitStrategy[containerName] ?? this.defaultWaitStrategy, + }); if (this.startupTimeoutMs !== undefined) { waitStrategy.withStartupTimeout(this.startupTimeoutMs); } @@ -232,52 +225,6 @@ export class DockerComposeEnvironment { }); } - private async selectWaitStrategy( - client: ContainerRuntimeClient, - containerName: string, - inspectResult: ContainerInspectInfo - ): Promise { - const containerWaitStrategy = this.waitStrategy[containerName] - ? this.waitStrategy[containerName] - : this.defaultWaitStrategy; - if (containerWaitStrategy) return containerWaitStrategy; - if (hasDisabledHealthCheckConfig(inspectResult)) { - return Wait.forListeningPorts(); - } - if (hasHealthCheckConfig(inspectResult) || hasHealthCheckStatus(inspectResult)) { - return Wait.forHealthCheck(); - } - if (await this.imageHasHealthCheck(client, inspectResult)) { - return Wait.forHealthCheck(); - } - return Wait.forListeningPorts(); - } - - private async imageHasHealthCheck( - client: ContainerRuntimeClient, - inspectResult: ContainerInspectInfo - ): Promise { - const imageNames = Array.from( - new Set( - [inspectResult.Config.Image, inspectResult.Image].filter( - (imageName): imageName is string => imageName !== undefined && imageName !== "" - ) - ) - ); - - for (const imageName of imageNames) { - try { - if (hasHealthCheckConfig(await client.image.inspect(ImageName.fromString(imageName)))) { - return true; - } - } catch (err) { - log.debug(`Failed to inspect image "${imageName}" for health check config: ${err}`); - } - } - - return false; - } - private warnForUnusedWaitStrategies(startedContainerNames: Set): void { const unusedWaitStrategyContainerNames = Object.keys(this.waitStrategy).filter( (configuredContainerName) => !startedContainerNames.has(configuredContainerName) diff --git a/packages/testcontainers/src/generic-container/generic-container-wait-strategy-unit.test.ts b/packages/testcontainers/src/generic-container/generic-container-wait-strategy-unit.test.ts deleted file mode 100644 index 827a55ae4..000000000 --- a/packages/testcontainers/src/generic-container/generic-container-wait-strategy-unit.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { ContainerInspectInfo, ImageInspectInfo } from "dockerode"; -import { ContainerRuntimeClient } from "../container-runtime"; -import { HealthCheckWaitStrategy } from "../wait-strategies/health-check-wait-strategy"; -import { HostPortWaitStrategy } from "../wait-strategies/host-port-wait-strategy"; -import { GenericContainer } from "./generic-container"; - -class TestGenericContainer extends GenericContainer { - public selectWaitStrategyForTest(client: ContainerRuntimeClient, inspectResult: ContainerInspectInfo) { - return this.selectWaitStrategy(client, inspectResult); - } -} - -const containerInspectResult = (healthcheck?: { Test: string[] }): ContainerInspectInfo => - ({ - Config: { - Hostname: "hostname", - Image: "image:latest", - Labels: {}, - Healthcheck: healthcheck, - }, - State: { - Status: "running", - Running: true, - StartedAt: "2026-05-14T10:00:00.000Z", - FinishedAt: "0001-01-01T00:00:00.000Z", - }, - NetworkSettings: { - Ports: {}, - Networks: {}, - }, - }) as unknown as ContainerInspectInfo; - -const client = (imageInspectResult: ImageInspectInfo): ContainerRuntimeClient => - ({ - image: { - inspect: vi.fn().mockResolvedValue(imageInspectResult), - }, - }) as unknown as ContainerRuntimeClient; - -describe("GenericContainer wait strategy selection", () => { - it("should fall back to image health check config when container config does not expose it", async () => { - const imageInspectResult = { - Config: { - Healthcheck: { - Test: ["CMD-SHELL", "test -f /tmp/ready"], - }, - }, - } as unknown as ImageInspectInfo; - - await expect( - new TestGenericContainer("image:latest").selectWaitStrategyForTest( - client(imageInspectResult), - containerInspectResult() - ) - ).resolves.toBeInstanceOf(HealthCheckWaitStrategy); - }); - - it("should not fall back to image health check config when the container disables health checks", async () => { - const imageInspectResult = { - Config: { - Healthcheck: { - Test: ["CMD-SHELL", "test -f /tmp/ready"], - }, - }, - } as unknown as ImageInspectInfo; - - await expect( - new TestGenericContainer("image:latest").selectWaitStrategyForTest( - client(imageInspectResult), - containerInspectResult({ Test: ["NONE"] }) - ) - ).resolves.toBeInstanceOf(HostPortWaitStrategy); - }); -}); diff --git a/packages/testcontainers/src/generic-container/generic-container-wait-strategy.test.ts b/packages/testcontainers/src/generic-container/generic-container-wait-strategy.test.ts index 62b2ef99b..f17537238 100644 --- a/packages/testcontainers/src/generic-container/generic-container-wait-strategy.test.ts +++ b/packages/testcontainers/src/generic-container/generic-container-wait-strategy.test.ts @@ -1,19 +1,89 @@ +import { ContainerInspectInfo, ImageInspectInfo } from "dockerode"; import path from "path"; import { randomUuid } from "../common/uuid"; +import { ContainerRuntimeClient } from "../container-runtime"; import { checkContainerIsHealthy, getHealthCheckStatus } from "../utils/test-helper"; +import { HealthCheckWaitStrategy } from "../wait-strategies/health-check-wait-strategy"; +import { HostPortWaitStrategy } from "../wait-strategies/host-port-wait-strategy"; import { Wait } from "../wait-strategies/wait"; import { GenericContainer } from "./generic-container"; const fixtures = path.resolve(__dirname, "..", "..", "fixtures", "docker"); +class TestGenericContainer extends GenericContainer { + public selectWaitStrategyForTest(client: ContainerRuntimeClient, inspectResult: ContainerInspectInfo) { + return this.selectWaitStrategy(client, inspectResult); + } +} + +const containerInspectResult = (healthcheck?: { Test: string[] }): ContainerInspectInfo => + ({ + Config: { + Hostname: "hostname", + Labels: {}, + Healthcheck: healthcheck, + }, + State: { + Status: "running", + Running: true, + StartedAt: "2026-05-14T10:00:00.000Z", + FinishedAt: "0001-01-01T00:00:00.000Z", + }, + NetworkSettings: { + Ports: {}, + Networks: {}, + }, + }) as unknown as ContainerInspectInfo; + +const client = (imageInspectResult: ImageInspectInfo): ContainerRuntimeClient => + ({ + image: { + inspect: vi.fn().mockResolvedValue(imageInspectResult), + }, + }) as unknown as ContainerRuntimeClient; + describe("GenericContainer default wait strategy", { timeout: 180_000 }, () => { - it("should use listening ports if healthcheck is not defined in the image", async () => { - await using container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14") - .withExposedPorts(8080) - .start(); + it("should select listening ports when no healthcheck is configured", async () => { + await expect( + new TestGenericContainer("image:latest").selectWaitStrategyForTest( + client({} as ImageInspectInfo), + containerInspectResult() + ) + ).resolves.toBeInstanceOf(HostPortWaitStrategy); + }); - await checkContainerIsHealthy(container); - expect(await getHealthCheckStatus(container)).toBeUndefined(); + it("should select image healthcheck when container inspect omits healthcheck config", async () => { + const imageInspectResult = { + Config: { + Healthcheck: { + Test: ["CMD-SHELL", "test -f /tmp/ready"], + }, + }, + } as unknown as ImageInspectInfo; + + await expect( + new TestGenericContainer("image:latest").selectWaitStrategyForTest( + client(imageInspectResult), + containerInspectResult() + ) + ).resolves.toBeInstanceOf(HealthCheckWaitStrategy); + }); + + it("should select listening ports when the container disables image healthchecks", async () => { + const imageInspectResult = { + Config: { + Healthcheck: { + Test: ["CMD-SHELL", "test -f /tmp/ready"], + }, + }, + } as unknown as ImageInspectInfo; + + await expect( + new TestGenericContainer("image:latest").selectWaitStrategyForTest( + client(imageInspectResult), + containerInspectResult({ Test: ["NONE"] }) + ) + ).resolves.toBeInstanceOf(HostPortWaitStrategy); }); it("should wait for a healthcheck configured with withHealthCheck", async () => { diff --git a/packages/testcontainers/src/generic-container/generic-container.ts b/packages/testcontainers/src/generic-container/generic-container.ts index 6cc1e1935..da75c0022 100644 --- a/packages/testcontainers/src/generic-container/generic-container.ts +++ b/packages/testcontainers/src/generic-container/generic-container.ts @@ -31,13 +31,7 @@ import { createLabels, LABEL_TESTCONTAINERS_CONTAINER_HASH, LABEL_TESTCONTAINERS import { mapInspectResult } from "../utils/map-inspect-result"; import { getContainerPort, getProtocol, hasHostBinding, PortWithOptionalBinding } from "../utils/port"; import { ImagePullPolicy, PullPolicy } from "../utils/pull-policy"; -import { - hasDisabledHealthCheckConfig, - hasHealthCheck, - hasHealthCheckConfig, - hasHealthCheckStatus, -} from "../wait-strategies/utils/health-check"; -import { Wait } from "../wait-strategies/wait"; +import { selectWaitStrategy } from "../wait-strategies/utils/wait-strategy-selector"; import { waitForContainer } from "../wait-strategies/wait-for-container"; import { WaitStrategy } from "../wait-strategies/wait-strategy"; import { GenericContainerBuilder } from "./generic-container-builder"; @@ -132,29 +126,13 @@ export class GenericContainer implements TestContainer { inspectResult: ContainerInspectInfo, waitStrategy: WaitStrategy | undefined = this.waitStrategy ): Promise { - if (waitStrategy) return waitStrategy; - if (hasHealthCheck(this.healthCheck)) { - return Wait.forHealthCheck(); - } - if (hasDisabledHealthCheckConfig(inspectResult)) { - return Wait.forListeningPorts(); - } - if (hasHealthCheckConfig(inspectResult) || hasHealthCheckStatus(inspectResult)) { - return Wait.forHealthCheck(); - } - if (await this.imageHasHealthCheck(client)) { - return Wait.forHealthCheck(); - } - return Wait.forListeningPorts(); - } - - private async imageHasHealthCheck(client: ContainerRuntimeClient): Promise { - try { - return hasHealthCheckConfig(await client.image.inspect(this.imageName)); - } catch (err) { - log.debug(`Failed to inspect image "${this.imageName.string}" for health check config: ${err}`); - return false; - } + return selectWaitStrategy({ + client, + inspectResult, + waitStrategy, + healthCheck: this.healthCheck, + imageNames: [this.imageName.string], + }); } private async reuseOrStartContainer(client: ContainerRuntimeClient) { diff --git a/packages/testcontainers/src/wait-strategies/utils/wait-strategy-selector.ts b/packages/testcontainers/src/wait-strategies/utils/wait-strategy-selector.ts new file mode 100644 index 000000000..20b97718f --- /dev/null +++ b/packages/testcontainers/src/wait-strategies/utils/wait-strategy-selector.ts @@ -0,0 +1,59 @@ +import { ContainerInspectInfo } from "dockerode"; +import { log } from "../../common"; +import { ContainerRuntimeClient, ImageName } from "../../container-runtime"; +import { HealthCheck } from "../../types"; +import { Wait } from "../wait"; +import { WaitStrategy } from "../wait-strategy"; +import { + hasDisabledHealthCheckConfig, + hasHealthCheck, + hasHealthCheckConfig, + hasHealthCheckStatus, +} from "./health-check"; + +type WaitStrategySelectorOptions = { + client: ContainerRuntimeClient; + inspectResult: ContainerInspectInfo; + waitStrategy?: WaitStrategy; + healthCheck?: HealthCheck; + imageNames?: string[]; +}; + +export const selectWaitStrategy = async ({ + client, + inspectResult, + waitStrategy, + healthCheck, + imageNames = getImageNames(inspectResult), +}: WaitStrategySelectorOptions): Promise => { + if (waitStrategy) return waitStrategy; + if (hasHealthCheck(healthCheck)) return Wait.forHealthCheck(); + if (hasDisabledHealthCheckConfig(inspectResult)) return Wait.forListeningPorts(); + if (hasHealthCheckConfig(inspectResult) || hasHealthCheckStatus(inspectResult)) return Wait.forHealthCheck(); + if (await imageHasHealthCheck(client, imageNames)) return Wait.forHealthCheck(); + return Wait.forListeningPorts(); +}; + +const getImageNames = (inspectResult: ContainerInspectInfo): string[] => { + return Array.from( + new Set( + [inspectResult.Config.Image, inspectResult.Image].filter( + (imageName): imageName is string => imageName !== undefined && imageName !== "" + ) + ) + ); +}; + +const imageHasHealthCheck = async (client: ContainerRuntimeClient, imageNames: string[]): Promise => { + for (const imageName of imageNames) { + try { + if (hasHealthCheckConfig(await client.image.inspect(ImageName.fromString(imageName)))) { + return true; + } + } catch (err) { + log.warn(`Failed to inspect image "${imageName}" for health check config: ${err}`); + } + } + + return false; +}; From 6ec94742c7fd2055e8e0caa2c0f0d74452d19885 Mon Sep 17 00:00:00 2001 From: Cristian Greco Date: Fri, 15 May 2026 14:20:58 +0100 Subject: [PATCH 11/13] Remove redundant wait strategy integration tests --- ...cker-compose-with-disabled-healthcheck.yml | 9 - .../Dockerfile | 8 - .../Dockerfile | 5 - .../docker-compose-environment.test.ts | 13 - .../generic-container-wait-strategy.test.ts | 244 ++++++++---------- 5 files changed, 111 insertions(+), 168 deletions(-) delete mode 100644 packages/testcontainers/fixtures/docker-compose/docker-compose-with-disabled-healthcheck.yml delete mode 100644 packages/testcontainers/fixtures/docker/docker-with-delayed-health-check/Dockerfile delete mode 100644 packages/testcontainers/fixtures/docker/docker-with-disabled-health-check/Dockerfile diff --git a/packages/testcontainers/fixtures/docker-compose/docker-compose-with-disabled-healthcheck.yml b/packages/testcontainers/fixtures/docker-compose/docker-compose-with-disabled-healthcheck.yml deleted file mode 100644 index 2f039080f..000000000 --- a/packages/testcontainers/fixtures/docker-compose/docker-compose-with-disabled-healthcheck.yml +++ /dev/null @@ -1,9 +0,0 @@ -version: "3.5" - -services: - container: - image: cristianrgreco/testcontainer:1.1.14 - ports: - - 8080 - healthcheck: - test: ["NONE"] diff --git a/packages/testcontainers/fixtures/docker/docker-with-delayed-health-check/Dockerfile b/packages/testcontainers/fixtures/docker/docker-with-delayed-health-check/Dockerfile deleted file mode 100644 index 781648b82..000000000 --- a/packages/testcontainers/fixtures/docker/docker-with-delayed-health-check/Dockerfile +++ /dev/null @@ -1,8 +0,0 @@ -FROM cristianrgreco/testcontainer:1.1.14 - -EXPOSE 8080 - -CMD ["sh", "-c", "rm -f /tmp/ready; (sleep 4; touch /tmp/ready) & node index.js"] - -HEALTHCHECK --interval=1s --timeout=1s --retries=10 \ - CMD test -f /tmp/ready diff --git a/packages/testcontainers/fixtures/docker/docker-with-disabled-health-check/Dockerfile b/packages/testcontainers/fixtures/docker/docker-with-disabled-health-check/Dockerfile deleted file mode 100644 index bfa51354b..000000000 --- a/packages/testcontainers/fixtures/docker/docker-with-disabled-health-check/Dockerfile +++ /dev/null @@ -1,5 +0,0 @@ -FROM cristianrgreco/testcontainer:1.1.14 - -EXPOSE 8080 - -HEALTHCHECK NONE diff --git a/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.test.ts b/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.test.ts index de0d8629d..02b040817 100644 --- a/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.test.ts +++ b/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.test.ts @@ -108,19 +108,6 @@ describe("DockerComposeEnvironment", { timeout: 180_000 }, () => { await checkEnvironmentContainerIsHealthy(startedEnvironment, "container-1"); }); - it("should use listening ports if a service disables healthcheck", async () => { - await using startedEnvironment = await new DockerComposeEnvironment( - fixtures, - "docker-compose-with-disabled-healthcheck.yml" - ) - .withStartupTimeout(1_000) - .up(); - const container = startedEnvironment.getContainer("container-1"); - - await checkEnvironmentContainerIsHealthy(startedEnvironment, "container-1"); - expect(await getHealthCheckStatus(container)).toBeUndefined(); - }); - it("should support log message wait strategy", async () => { await using startedEnvironment = await new DockerComposeEnvironment(fixtures, "docker-compose.yml") .withWaitStrategy("container-1", Wait.forLogMessage("Listening on port 8080")) diff --git a/packages/testcontainers/src/generic-container/generic-container-wait-strategy.test.ts b/packages/testcontainers/src/generic-container/generic-container-wait-strategy.test.ts index f17537238..9af42a21f 100644 --- a/packages/testcontainers/src/generic-container/generic-container-wait-strategy.test.ts +++ b/packages/testcontainers/src/generic-container/generic-container-wait-strategy.test.ts @@ -1,25 +1,34 @@ import { ContainerInspectInfo, ImageInspectInfo } from "dockerode"; -import path from "path"; -import { randomUuid } from "../common/uuid"; import { ContainerRuntimeClient } from "../container-runtime"; -import { checkContainerIsHealthy, getHealthCheckStatus } from "../utils/test-helper"; import { HealthCheckWaitStrategy } from "../wait-strategies/health-check-wait-strategy"; import { HostPortWaitStrategy } from "../wait-strategies/host-port-wait-strategy"; import { Wait } from "../wait-strategies/wait"; +import { WaitStrategy } from "../wait-strategies/wait-strategy"; import { GenericContainer } from "./generic-container"; -const fixtures = path.resolve(__dirname, "..", "..", "fixtures", "docker"); - class TestGenericContainer extends GenericContainer { - public selectWaitStrategyForTest(client: ContainerRuntimeClient, inspectResult: ContainerInspectInfo) { - return this.selectWaitStrategy(client, inspectResult); + public selectWaitStrategyForTest( + client: ContainerRuntimeClient, + inspectResult: ContainerInspectInfo, + waitStrategy?: WaitStrategy + ) { + return this.selectWaitStrategy(client, inspectResult, waitStrategy); } } -const containerInspectResult = (healthcheck?: { Test: string[] }): ContainerInspectInfo => +type ContainerInspectResultOptions = { + healthcheck?: { Test: string[] }; + healthcheckStatus?: string; +}; + +const containerInspectResult = ({ + healthcheck, + healthcheckStatus, +}: ContainerInspectResultOptions = {}): ContainerInspectInfo => ({ Config: { Hostname: "hostname", + Image: "image:latest", Labels: {}, Healthcheck: healthcheck, }, @@ -28,6 +37,7 @@ const containerInspectResult = (healthcheck?: { Test: string[] }): ContainerInsp Running: true, StartedAt: "2026-05-14T10:00:00.000Z", FinishedAt: "0001-01-01T00:00:00.000Z", + ...(healthcheckStatus === undefined ? {} : { Healthcheck: { Status: healthcheckStatus } }), }, NetworkSettings: { Ports: {}, @@ -35,6 +45,15 @@ const containerInspectResult = (healthcheck?: { Test: string[] }): ContainerInsp }, }) as unknown as ContainerInspectInfo; +const imageInspectResultWithHealthCheck = (): ImageInspectInfo => + ({ + Config: { + Healthcheck: { + Test: ["CMD-SHELL", "test -f /tmp/ready"], + }, + }, + }) as unknown as ImageInspectInfo; + const client = (imageInspectResult: ImageInspectInfo): ContainerRuntimeClient => ({ image: { @@ -42,154 +61,113 @@ const client = (imageInspectResult: ImageInspectInfo): ContainerRuntimeClient => }, }) as unknown as ContainerRuntimeClient; -describe("GenericContainer default wait strategy", { timeout: 180_000 }, () => { - it("should select listening ports when no healthcheck is configured", async () => { +const clientWithImageInspectFailure = (): ContainerRuntimeClient => + ({ + image: { + inspect: vi.fn().mockRejectedValue(new Error("inspect failed")), + }, + }) as unknown as ContainerRuntimeClient; + +describe("GenericContainer default wait strategy", () => { + it("should use an explicitly defined wait strategy", async () => { + const runtimeClient = client(imageInspectResultWithHealthCheck()); + const waitStrategy = Wait.forLogMessage("ready"); + await expect( - new TestGenericContainer("image:latest").selectWaitStrategyForTest( - client({} as ImageInspectInfo), - containerInspectResult() - ) - ).resolves.toBeInstanceOf(HostPortWaitStrategy); + new TestGenericContainer("image:latest") + .withHealthCheck({ + test: ["CMD-SHELL", "test -f /tmp/ready"], + }) + .selectWaitStrategyForTest( + runtimeClient, + containerInspectResult({ + healthcheck: { + Test: ["CMD-SHELL", "test -f /tmp/ready"], + }, + }), + waitStrategy + ) + ).resolves.toBe(waitStrategy); + expect(runtimeClient.image.inspect).not.toHaveBeenCalled(); }); - it("should select image healthcheck when container inspect omits healthcheck config", async () => { - const imageInspectResult = { - Config: { - Healthcheck: { - Test: ["CMD-SHELL", "test -f /tmp/ready"], - }, - }, - } as unknown as ImageInspectInfo; + it("should select a healthcheck configured with withHealthCheck", async () => { + const runtimeClient = client({} as ImageInspectInfo); await expect( - new TestGenericContainer("image:latest").selectWaitStrategyForTest( - client(imageInspectResult), - containerInspectResult() - ) + new TestGenericContainer("image:latest") + .withHealthCheck({ + test: ["CMD-SHELL", "test -f /tmp/ready"], + }) + .selectWaitStrategyForTest(runtimeClient, containerInspectResult()) ).resolves.toBeInstanceOf(HealthCheckWaitStrategy); + expect(runtimeClient.image.inspect).not.toHaveBeenCalled(); }); - it("should select listening ports when the container disables image healthchecks", async () => { - const imageInspectResult = { - Config: { - Healthcheck: { - Test: ["CMD-SHELL", "test -f /tmp/ready"], - }, - }, - } as unknown as ImageInspectInfo; + it("should select a healthcheck configured on the container", async () => { + const runtimeClient = client({} as ImageInspectInfo); await expect( new TestGenericContainer("image:latest").selectWaitStrategyForTest( - client(imageInspectResult), - containerInspectResult({ Test: ["NONE"] }) + runtimeClient, + containerInspectResult({ + healthcheck: { + Test: ["CMD-SHELL", "test -f /tmp/ready"], + }, + }) ) - ).resolves.toBeInstanceOf(HostPortWaitStrategy); + ).resolves.toBeInstanceOf(HealthCheckWaitStrategy); + expect(runtimeClient.image.inspect).not.toHaveBeenCalled(); }); - it("should wait for a healthcheck configured with withHealthCheck", async () => { - await using container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14") - .withExposedPorts(8080) - .withCommand(["sh", "-c", "rm -f /tmp/ready; (sleep 4; touch /tmp/ready) & node index.js"]) - .withHealthCheck({ - test: ["CMD-SHELL", "test -f /tmp/ready"], - interval: 1_000, - timeout: 1_000, - retries: 10, - }) - .start(); - - expect(await getHealthCheckStatus(container)).toBe("healthy"); - await checkContainerIsHealthy(container); - }); + it("should select a healthcheck when container inspect includes healthcheck status", async () => { + const runtimeClient = client({} as ImageInspectInfo); - it("should prefer a healthcheck configured with withHealthCheck over an image healthcheck", async () => { - const context = path.resolve(fixtures, "docker-with-delayed-health-check"); - const genericContainer = await GenericContainer.fromDockerfile(context).build(); - await using container = await genericContainer - .withExposedPorts(8080) - .withCommand(["sh", "-c", "rm -f /tmp/ready /tmp/custom-ready; touch /tmp/custom-ready; node index.js"]) - .withHealthCheck({ - test: ["CMD-SHELL", "test -f /tmp/custom-ready"], - interval: 1_000, - timeout: 1_000, - retries: 10, - }) - .start(); - - expect(await getHealthCheckStatus(container)).toBe("healthy"); - await checkContainerIsHealthy(container); + await expect( + new TestGenericContainer("image:latest").selectWaitStrategyForTest( + runtimeClient, + containerInspectResult({ healthcheckStatus: "starting" }) + ) + ).resolves.toBeInstanceOf(HealthCheckWaitStrategy); + expect(runtimeClient.image.inspect).not.toHaveBeenCalled(); }); - it("should wait for a healthcheck defined in the image", async () => { - const context = path.resolve(fixtures, "docker-with-delayed-health-check"); - const genericContainer = await GenericContainer.fromDockerfile(context).build(); - await using container = await genericContainer.withExposedPorts(8080).start(); - - expect(await getHealthCheckStatus(container)).toBe("healthy"); - await checkContainerIsHealthy(container); + it("should select listening ports when no healthcheck is configured", async () => { + await expect( + new TestGenericContainer("image:latest").selectWaitStrategyForTest( + client({} as ImageInspectInfo), + containerInspectResult() + ) + ).resolves.toBeInstanceOf(HostPortWaitStrategy); }); - it("should use listening ports if the image disables healthcheck", async () => { - const context = path.resolve(fixtures, "docker-with-disabled-health-check"); - const genericContainer = await GenericContainer.fromDockerfile(context).build(); - await using container = await genericContainer.withExposedPorts(8080).withStartupTimeout(1_000).start(); - - await checkContainerIsHealthy(container); - expect(await getHealthCheckStatus(container)).toBeUndefined(); + it("should select image healthcheck when container inspect omits healthcheck config", async () => { + await expect( + new TestGenericContainer("image:latest").selectWaitStrategyForTest( + client(imageInspectResultWithHealthCheck()), + containerInspectResult() + ) + ).resolves.toBeInstanceOf(HealthCheckWaitStrategy); }); - it.sequential("should wait for an image healthcheck when reusing a stopped container", async () => { - vi.stubEnv("TESTCONTAINERS_REUSE_ENABLE", "true"); - - const imageName = `localhost/${randomUuid()}:${randomUuid()}`; - const containerName = `reusable-healthcheck-${randomUuid()}`; - const context = path.resolve(fixtures, "docker-with-delayed-health-check"); - await GenericContainer.fromDockerfile(context).build(imageName); - - const container1 = await new GenericContainer(imageName) - .withName(containerName) - .withExposedPorts(8080) - .withReuse() - .start(); - await container1.stop({ remove: false, timeout: 10_000 }); - - await using container2 = await new GenericContainer(imageName) - .withName(containerName) - .withExposedPorts(8080) - .withReuse() - .start(); - - expect(container2.getId()).toBe(container1.getId()); - expect(await getHealthCheckStatus(container2)).toBe("healthy"); - await container2.stop({ remove: true }); + it("should select listening ports when image inspect fails", async () => { + await expect( + new TestGenericContainer("image:latest").selectWaitStrategyForTest( + clientWithImageInspectFailure(), + containerInspectResult() + ) + ).resolves.toBeInstanceOf(HostPortWaitStrategy); }); - it("should use an explicitly defined wait strategy even if image defines healthcheck", async () => { - const context = path.resolve(fixtures, "docker-with-delayed-health-check"); - const genericContainer = await GenericContainer.fromDockerfile(context).build(); - await using container = await genericContainer - .withExposedPorts(8080) - .withWaitStrategy(Wait.forListeningPorts()) - .withStartupTimeout(1_000) - .start(); - - await checkContainerIsHealthy(container); - }); + it("should select listening ports when the container disables image healthchecks", async () => { + const runtimeClient = client(imageInspectResultWithHealthCheck()); - it("should use an explicitly defined wait strategy even if withHealthCheck is called", async () => { - await using container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14") - .withExposedPorts(8080) - .withCommand(["sh", "-c", "rm -f /tmp/ready; (sleep 4; touch /tmp/ready) & node index.js"]) - .withHealthCheck({ - test: ["CMD-SHELL", "test -f /tmp/ready"], - interval: 1_000, - timeout: 1_000, - retries: 10, - }) - .withWaitStrategy(Wait.forListeningPorts()) - .withStartupTimeout(1_000) - .start(); - - await checkContainerIsHealthy(container); + await expect( + new TestGenericContainer("image:latest").selectWaitStrategyForTest( + runtimeClient, + containerInspectResult({ healthcheck: { Test: ["NONE"] } }) + ) + ).resolves.toBeInstanceOf(HostPortWaitStrategy); + expect(runtimeClient.image.inspect).not.toHaveBeenCalled(); }); }); From 46adb052c5853d6c881b7c1e492bd4dd812fa708 Mon Sep 17 00:00:00 2001 From: Cristian Greco Date: Sun, 17 May 2026 11:07:44 +0100 Subject: [PATCH 12/13] Inline wait strategy selector usage --- .../generic-container/generic-container.ts | 32 +++-- .../utils/wait-strategy-selector.test.ts} | 116 +++++++++--------- 2 files changed, 72 insertions(+), 76 deletions(-) rename packages/testcontainers/src/{generic-container/generic-container-wait-strategy.test.ts => wait-strategies/utils/wait-strategy-selector.test.ts} (60%) diff --git a/packages/testcontainers/src/generic-container/generic-container.ts b/packages/testcontainers/src/generic-container/generic-container.ts index d9699aea1..b369756a9 100644 --- a/packages/testcontainers/src/generic-container/generic-container.ts +++ b/packages/testcontainers/src/generic-container/generic-container.ts @@ -1,6 +1,6 @@ import archiver from "archiver"; import AsyncLock from "async-lock"; -import { Container, ContainerCreateOptions, ContainerInspectInfo, HostConfig } from "dockerode"; +import { Container, ContainerCreateOptions, HostConfig } from "dockerode"; import { promises as fs } from "fs"; import { Readable } from "stream"; import { containerLog, hash, log, toNanos } from "../common"; @@ -121,20 +121,6 @@ export class GenericContainer implements TestContainer { return this.startContainer(client); } - protected async selectWaitStrategy( - client: ContainerRuntimeClient, - inspectResult: ContainerInspectInfo, - waitStrategy: WaitStrategy | undefined = this.waitStrategy - ): Promise { - return selectWaitStrategy({ - client, - inspectResult, - waitStrategy, - healthCheck: this.healthCheck, - imageNames: [this.imageName.string], - }); - } - private async reuseOrStartContainer(client: ContainerRuntimeClient) { const containerHash = hash(JSON.stringify(this.createOpts)); this.createOpts.Labels = { ...this.createOpts.Labels, [LABEL_TESTCONTAINERS_CONTAINER_HASH]: containerHash }; @@ -167,7 +153,13 @@ export class GenericContainer implements TestContainer { const boundPorts = BoundPorts.fromInspectResult(client.info.containerRuntime.hostIps, mappedInspectResult).filter( this.exposedPorts ); - const waitStrategy = await this.selectWaitStrategy(client, inspectResult); + const waitStrategy = await selectWaitStrategy({ + client, + inspectResult, + waitStrategy: this.waitStrategy, + healthCheck: this.healthCheck, + imageNames: [this.imageName.string], + }); if (this.startupTimeoutMs !== undefined) { waitStrategy.withStartupTimeout(this.startupTimeoutMs); } @@ -240,7 +232,13 @@ export class GenericContainer implements TestContainer { await this.containerStarting(mappedInspectResult, false); } - const waitStrategy = await this.selectWaitStrategy(client, inspectResult); + const waitStrategy = await selectWaitStrategy({ + client, + inspectResult, + waitStrategy: this.waitStrategy, + healthCheck: this.healthCheck, + imageNames: [this.imageName.string], + }); if (this.startupTimeoutMs !== undefined) { waitStrategy.withStartupTimeout(this.startupTimeoutMs); } diff --git a/packages/testcontainers/src/generic-container/generic-container-wait-strategy.test.ts b/packages/testcontainers/src/wait-strategies/utils/wait-strategy-selector.test.ts similarity index 60% rename from packages/testcontainers/src/generic-container/generic-container-wait-strategy.test.ts rename to packages/testcontainers/src/wait-strategies/utils/wait-strategy-selector.test.ts index 9af42a21f..8fe99894e 100644 --- a/packages/testcontainers/src/generic-container/generic-container-wait-strategy.test.ts +++ b/packages/testcontainers/src/wait-strategies/utils/wait-strategy-selector.test.ts @@ -1,20 +1,9 @@ import { ContainerInspectInfo, ImageInspectInfo } from "dockerode"; -import { ContainerRuntimeClient } from "../container-runtime"; -import { HealthCheckWaitStrategy } from "../wait-strategies/health-check-wait-strategy"; -import { HostPortWaitStrategy } from "../wait-strategies/host-port-wait-strategy"; -import { Wait } from "../wait-strategies/wait"; -import { WaitStrategy } from "../wait-strategies/wait-strategy"; -import { GenericContainer } from "./generic-container"; - -class TestGenericContainer extends GenericContainer { - public selectWaitStrategyForTest( - client: ContainerRuntimeClient, - inspectResult: ContainerInspectInfo, - waitStrategy?: WaitStrategy - ) { - return this.selectWaitStrategy(client, inspectResult, waitStrategy); - } -} +import { ContainerRuntimeClient } from "../../container-runtime"; +import { HealthCheckWaitStrategy } from "../health-check-wait-strategy"; +import { HostPortWaitStrategy } from "../host-port-wait-strategy"; +import { Wait } from "../wait"; +import { selectWaitStrategy } from "./wait-strategy-selector"; type ContainerInspectResultOptions = { healthcheck?: { Test: string[] }; @@ -68,38 +57,41 @@ const clientWithImageInspectFailure = (): ContainerRuntimeClient => }, }) as unknown as ContainerRuntimeClient; -describe("GenericContainer default wait strategy", () => { +describe("wait strategy selector", () => { it("should use an explicitly defined wait strategy", async () => { const runtimeClient = client(imageInspectResultWithHealthCheck()); const waitStrategy = Wait.forLogMessage("ready"); await expect( - new TestGenericContainer("image:latest") - .withHealthCheck({ + selectWaitStrategy({ + client: runtimeClient, + inspectResult: containerInspectResult({ + healthcheck: { + Test: ["CMD-SHELL", "test -f /tmp/ready"], + }, + }), + waitStrategy, + healthCheck: { test: ["CMD-SHELL", "test -f /tmp/ready"], - }) - .selectWaitStrategyForTest( - runtimeClient, - containerInspectResult({ - healthcheck: { - Test: ["CMD-SHELL", "test -f /tmp/ready"], - }, - }), - waitStrategy - ) + }, + imageNames: ["image:latest"], + }) ).resolves.toBe(waitStrategy); expect(runtimeClient.image.inspect).not.toHaveBeenCalled(); }); - it("should select a healthcheck configured with withHealthCheck", async () => { + it("should select a user-configured healthcheck", async () => { const runtimeClient = client({} as ImageInspectInfo); await expect( - new TestGenericContainer("image:latest") - .withHealthCheck({ + selectWaitStrategy({ + client: runtimeClient, + inspectResult: containerInspectResult(), + healthCheck: { test: ["CMD-SHELL", "test -f /tmp/ready"], - }) - .selectWaitStrategyForTest(runtimeClient, containerInspectResult()) + }, + imageNames: ["image:latest"], + }) ).resolves.toBeInstanceOf(HealthCheckWaitStrategy); expect(runtimeClient.image.inspect).not.toHaveBeenCalled(); }); @@ -108,14 +100,15 @@ describe("GenericContainer default wait strategy", () => { const runtimeClient = client({} as ImageInspectInfo); await expect( - new TestGenericContainer("image:latest").selectWaitStrategyForTest( - runtimeClient, - containerInspectResult({ + selectWaitStrategy({ + client: runtimeClient, + inspectResult: containerInspectResult({ healthcheck: { Test: ["CMD-SHELL", "test -f /tmp/ready"], }, - }) - ) + }), + imageNames: ["image:latest"], + }) ).resolves.toBeInstanceOf(HealthCheckWaitStrategy); expect(runtimeClient.image.inspect).not.toHaveBeenCalled(); }); @@ -124,38 +117,42 @@ describe("GenericContainer default wait strategy", () => { const runtimeClient = client({} as ImageInspectInfo); await expect( - new TestGenericContainer("image:latest").selectWaitStrategyForTest( - runtimeClient, - containerInspectResult({ healthcheckStatus: "starting" }) - ) + selectWaitStrategy({ + client: runtimeClient, + inspectResult: containerInspectResult({ healthcheckStatus: "starting" }), + imageNames: ["image:latest"], + }) ).resolves.toBeInstanceOf(HealthCheckWaitStrategy); expect(runtimeClient.image.inspect).not.toHaveBeenCalled(); }); it("should select listening ports when no healthcheck is configured", async () => { await expect( - new TestGenericContainer("image:latest").selectWaitStrategyForTest( - client({} as ImageInspectInfo), - containerInspectResult() - ) + selectWaitStrategy({ + client: client({} as ImageInspectInfo), + inspectResult: containerInspectResult(), + imageNames: ["image:latest"], + }) ).resolves.toBeInstanceOf(HostPortWaitStrategy); }); it("should select image healthcheck when container inspect omits healthcheck config", async () => { await expect( - new TestGenericContainer("image:latest").selectWaitStrategyForTest( - client(imageInspectResultWithHealthCheck()), - containerInspectResult() - ) + selectWaitStrategy({ + client: client(imageInspectResultWithHealthCheck()), + inspectResult: containerInspectResult(), + imageNames: ["image:latest"], + }) ).resolves.toBeInstanceOf(HealthCheckWaitStrategy); }); it("should select listening ports when image inspect fails", async () => { await expect( - new TestGenericContainer("image:latest").selectWaitStrategyForTest( - clientWithImageInspectFailure(), - containerInspectResult() - ) + selectWaitStrategy({ + client: clientWithImageInspectFailure(), + inspectResult: containerInspectResult(), + imageNames: ["image:latest"], + }) ).resolves.toBeInstanceOf(HostPortWaitStrategy); }); @@ -163,10 +160,11 @@ describe("GenericContainer default wait strategy", () => { const runtimeClient = client(imageInspectResultWithHealthCheck()); await expect( - new TestGenericContainer("image:latest").selectWaitStrategyForTest( - runtimeClient, - containerInspectResult({ healthcheck: { Test: ["NONE"] } }) - ) + selectWaitStrategy({ + client: runtimeClient, + inspectResult: containerInspectResult({ healthcheck: { Test: ["NONE"] } }), + imageNames: ["image:latest"], + }) ).resolves.toBeInstanceOf(HostPortWaitStrategy); expect(runtimeClient.image.inspect).not.toHaveBeenCalled(); }); From b9afea12696f8b8e130c537235cc08aad904ec78 Mon Sep 17 00:00:00 2001 From: Cristian Greco Date: Sun, 17 May 2026 11:19:06 +0100 Subject: [PATCH 13/13] Restore protected wait strategy selector --- .../generic-container/generic-container.ts | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/packages/testcontainers/src/generic-container/generic-container.ts b/packages/testcontainers/src/generic-container/generic-container.ts index b369756a9..d9699aea1 100644 --- a/packages/testcontainers/src/generic-container/generic-container.ts +++ b/packages/testcontainers/src/generic-container/generic-container.ts @@ -1,6 +1,6 @@ import archiver from "archiver"; import AsyncLock from "async-lock"; -import { Container, ContainerCreateOptions, HostConfig } from "dockerode"; +import { Container, ContainerCreateOptions, ContainerInspectInfo, HostConfig } from "dockerode"; import { promises as fs } from "fs"; import { Readable } from "stream"; import { containerLog, hash, log, toNanos } from "../common"; @@ -121,6 +121,20 @@ export class GenericContainer implements TestContainer { return this.startContainer(client); } + protected async selectWaitStrategy( + client: ContainerRuntimeClient, + inspectResult: ContainerInspectInfo, + waitStrategy: WaitStrategy | undefined = this.waitStrategy + ): Promise { + return selectWaitStrategy({ + client, + inspectResult, + waitStrategy, + healthCheck: this.healthCheck, + imageNames: [this.imageName.string], + }); + } + private async reuseOrStartContainer(client: ContainerRuntimeClient) { const containerHash = hash(JSON.stringify(this.createOpts)); this.createOpts.Labels = { ...this.createOpts.Labels, [LABEL_TESTCONTAINERS_CONTAINER_HASH]: containerHash }; @@ -153,13 +167,7 @@ export class GenericContainer implements TestContainer { const boundPorts = BoundPorts.fromInspectResult(client.info.containerRuntime.hostIps, mappedInspectResult).filter( this.exposedPorts ); - const waitStrategy = await selectWaitStrategy({ - client, - inspectResult, - waitStrategy: this.waitStrategy, - healthCheck: this.healthCheck, - imageNames: [this.imageName.string], - }); + const waitStrategy = await this.selectWaitStrategy(client, inspectResult); if (this.startupTimeoutMs !== undefined) { waitStrategy.withStartupTimeout(this.startupTimeoutMs); } @@ -232,13 +240,7 @@ export class GenericContainer implements TestContainer { await this.containerStarting(mappedInspectResult, false); } - const waitStrategy = await selectWaitStrategy({ - client, - inspectResult, - waitStrategy: this.waitStrategy, - healthCheck: this.healthCheck, - imageNames: [this.imageName.string], - }); + const waitStrategy = await this.selectWaitStrategy(client, inspectResult); if (this.startupTimeoutMs !== undefined) { waitStrategy.withStartupTimeout(this.startupTimeoutMs); }