diff --git a/docs/features/compose.md b/docs/features/compose.md index f4207da6e..86dbfad49 100644 --- a/docs/features/compose.md +++ b/docs/features/compose.md @@ -45,12 +45,13 @@ const environment = await new DockerComposeEnvironment(composeFilePath, composeF ### With a default wait strategy -By default Testcontainers uses the "listening ports" wait strategy for all containers. If you'd like to override -the default wait strategy for all services, you can do so: +By default, Testcontainers waits for a service health check when one is defined in the Compose service or image. If no health check is defined, or the service disables health checks, it waits for listening ports. + +If you'd like to override the default wait strategy for all services, you can do so: ```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 1e06a9bbb..e9c27e04a 100644 --- a/docs/features/wait-strategies.md +++ b/docs/features/wait-strategies.md @@ -10,9 +10,15 @@ const container = await new GenericContainer("alpine") .start(); ``` +## Default wait strategy + +By default, Testcontainers waits for a container health check when one is defined by the image or configured with `withHealthCheck`. If no health check is defined, or the image disables health checks with `HEALTHCHECK NONE`, it waits up to 60 seconds for mapped network ports to be bound. + +You can override this selection with `withWaitStrategy`. + ## Listening ports -The default wait strategy used by Testcontainers. It will wait up to 60 seconds for the container's mapped network ports to be bound. +Wait up to 60 seconds for the container's mapped network ports to be bound. ```js const { GenericContainer } = require("testcontainers"); @@ -65,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"); @@ -75,10 +81,10 @@ const container = await new GenericContainer("alpine") .start(); ``` -Define your own health check. Note that time units are in seconds: +Define your own health check. Testcontainers uses this as the default wait strategy unless you explicitly set another wait strategy. Note that time units are in milliseconds: ```js -const { GenericContainer, Wait } = require("testcontainers"); +const { GenericContainer } = require("testcontainers"); const container = await new GenericContainer("alpine") .withHealthCheck({ @@ -88,7 +94,6 @@ const container = await new GenericContainer("alpine") retries: 5, startPeriod: 1000, }) - .withWaitStrategy(Wait.forHealthCheck()) .start(); ``` diff --git a/packages/modules/kafka/src/kafka-container.ts b/packages/modules/kafka/src/kafka-container.ts index d93508d46..dc6bfd7d6 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 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([ @@ -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.originalWaitinStrategy, 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 ee2025213..86c7bb50d 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 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([ @@ -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.originalWaitinStrategy, 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/fixtures/docker-compose/docker-compose-with-delayed-healthcheck.yml b/packages/testcontainers/fixtures/docker-compose/docker-compose-with-delayed-healthcheck.yml new file mode 100644 index 000000000..a783c477a --- /dev/null +++ b/packages/testcontainers/fixtures/docker-compose/docker-compose-with-delayed-healthcheck.yml @@ -0,0 +1,13 @@ +version: "3.5" + +services: + container: + image: cristianrgreco/testcontainer:1.1.14 + command: ["sh", "-c", "rm -f /tmp/ready; (sleep 4; touch /tmp/ready) & node index.js"] + ports: + - 8080 + healthcheck: + test: "test -f /tmp/ready" + interval: 1s + timeout: 1s + retries: 10 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 4782c53fd..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 @@ -5,6 +5,7 @@ import { PullPolicy } from "../utils/pull-policy"; import { checkEnvironmentContainerIsHealthy, getDockerEventStream, + getHealthCheckStatus, getRunningContainerNames, getVolumeNames, waitForDockerEvent, @@ -88,11 +89,22 @@ describe("DockerComposeEnvironment", { timeout: 180_000 }, () => { expect(responseBody["IS_OVERRIDDEN"]).toBe("true"); }); - it("should support default wait strategy", async () => { - await using startedEnvironment = await new DockerComposeEnvironment(fixtures, "docker-compose-with-healthcheck.yml") - .withDefaultWaitStrategy(Wait.forHealthCheck()) - .up(); + 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"]); + + 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"); }); 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 b646a57e3..f13578f0e 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,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 { 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"; @@ -24,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 | undefined; private waitStrategy: { [containerName: string]: WaitStrategy } = {}; private startupTimeoutMs?: number; private clientOptions: Partial = {}; @@ -174,9 +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 = this.waitStrategy[containerName] - ? this.waitStrategy[containerName] - : this.defaultWaitStrategy; + const waitStrategy = await selectWaitStrategy({ + client, + inspectResult, + waitStrategy: this.waitStrategy[containerName] ?? this.defaultWaitStrategy, + }); if (this.startupTimeoutMs !== undefined) { waitStrategy.withStartupTimeout(this.startupTimeoutMs); } diff --git a/packages/testcontainers/src/generic-container/generic-container.ts b/packages/testcontainers/src/generic-container/generic-container.ts index 2d5e02bd1..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"; @@ -31,7 +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 { 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"; @@ -50,7 +50,7 @@ export class GenericContainer implements TestContainer { protected imageName: ImageName; protected startupTimeoutMs?: number; - protected waitStrategy: WaitStrategy = Wait.forListeningPorts(); + protected waitStrategy: WaitStrategy | undefined; protected environment: Record = {}; protected exposedPorts: PortWithOptionalBinding[] = []; protected reuse = false; @@ -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,11 +167,12 @@ export class GenericContainer implements TestContainer { const boundPorts = BoundPorts.fromInspectResult(client.info.containerRuntime.hostIps, mappedInspectResult).filter( this.exposedPorts ); + const waitStrategy = await this.selectWaitStrategy(client, inspectResult); if (this.startupTimeoutMs !== undefined) { - this.waitStrategy.withStartupTimeout(this.startupTimeoutMs); + waitStrategy.withStartupTimeout(this.startupTimeoutMs); } - await waitForContainer(client, container, this.waitStrategy, boundPorts); + await waitForContainer(client, container, waitStrategy, boundPorts); return new StartedGenericContainer( container, @@ -165,7 +180,7 @@ export class GenericContainer implements TestContainer { inspectResult, boundPorts, inspectResult.Name, - this.waitStrategy, + waitStrategy, this.autoRemove ); } @@ -209,10 +224,6 @@ export class GenericContainer implements TestContainer { this.exposedPorts ); - if (this.startupTimeoutMs !== undefined) { - this.waitStrategy.withStartupTimeout(this.startupTimeoutMs); - } - if (containerLog.enabled() || this.logConsumer !== undefined) { if (this.logConsumer !== undefined) { this.logConsumer(await client.container.logs(container)); @@ -229,7 +240,12 @@ export class GenericContainer implements TestContainer { await this.containerStarting(mappedInspectResult, false); } - await waitForContainer(client, container, this.waitStrategy, boundPorts); + const waitStrategy = await this.selectWaitStrategy(client, inspectResult); + if (this.startupTimeoutMs !== undefined) { + waitStrategy.withStartupTimeout(this.startupTimeoutMs); + } + + await waitForContainer(client, container, waitStrategy, boundPorts); const startedContainer = new StartedGenericContainer( container, @@ -237,7 +253,7 @@ export class GenericContainer implements TestContainer { inspectResult, boundPorts, inspectResult.Name, - this.waitStrategy, + waitStrategy, this.autoRemove ); 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..2c8059df4 --- /dev/null +++ b/packages/testcontainers/src/utils/map-inspect-result.test.ts @@ -0,0 +1,52 @@ +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; + +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"); + }); + + 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"); + }); + + 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 67e7d65d3..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) { + 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 2ed3c000e..af52146da 100644 --- a/packages/testcontainers/src/utils/test-helper.ts +++ b/packages/testcontainers/src/utils/test-helper.ts @@ -11,6 +11,8 @@ import { getContainerRuntimeClient } from "../container-runtime"; import { StartedDockerComposeEnvironment } from "../docker-compose-environment/started-docker-compose-environment"; 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 @@ -25,6 +27,13 @@ export const checkContainerIsHealthy = async (container: StartedTestContainer): expect(response.status).toBe(200); }; +export const getHealthCheckStatus = async (container: StartedTestContainer): Promise => { + const client = await getContainerRuntimeClient(); + const dockerContainer = client.container.getById(container.getId()); + + return getHealthCheckStatusFromInspect(await client.container.inspect(dockerContainer)); +}; + export const checkContainerIsHealthyTls = async (container: StartedTestContainer): Promise => { const url = `https://${container.getHost()}:${container.getMappedPort(8443)}`; const dispatcher = new Agent({ connect: { rejectUnauthorized: false } }); 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/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 new file mode 100644 index 000000000..93382b00e --- /dev/null +++ b/packages/testcontainers/src/wait-strategies/utils/health-check.ts @@ -0,0 +1,89 @@ +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) { + return healthCheck.test; + } + return healthCheck.Test; +}; + +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; + } + + const test = getHealthCheckTest(healthCheck); + if (test === undefined || test.length === 0) { + return false; + } + + 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; +}; diff --git a/packages/testcontainers/src/wait-strategies/utils/wait-strategy-selector.test.ts b/packages/testcontainers/src/wait-strategies/utils/wait-strategy-selector.test.ts new file mode 100644 index 000000000..8fe99894e --- /dev/null +++ b/packages/testcontainers/src/wait-strategies/utils/wait-strategy-selector.test.ts @@ -0,0 +1,171 @@ +import { ContainerInspectInfo, ImageInspectInfo } from "dockerode"; +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[] }; + healthcheckStatus?: string; +}; + +const containerInspectResult = ({ + healthcheck, + healthcheckStatus, +}: ContainerInspectResultOptions = {}): 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", + ...(healthcheckStatus === undefined ? {} : { Healthcheck: { Status: healthcheckStatus } }), + }, + NetworkSettings: { + Ports: {}, + Networks: {}, + }, + }) 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: { + inspect: vi.fn().mockResolvedValue(imageInspectResult), + }, + }) as unknown as ContainerRuntimeClient; + +const clientWithImageInspectFailure = (): ContainerRuntimeClient => + ({ + image: { + inspect: vi.fn().mockRejectedValue(new Error("inspect failed")), + }, + }) as unknown as ContainerRuntimeClient; + +describe("wait strategy selector", () => { + it("should use an explicitly defined wait strategy", async () => { + const runtimeClient = client(imageInspectResultWithHealthCheck()); + const waitStrategy = Wait.forLogMessage("ready"); + + await expect( + selectWaitStrategy({ + client: runtimeClient, + inspectResult: containerInspectResult({ + healthcheck: { + Test: ["CMD-SHELL", "test -f /tmp/ready"], + }, + }), + waitStrategy, + healthCheck: { + test: ["CMD-SHELL", "test -f /tmp/ready"], + }, + imageNames: ["image:latest"], + }) + ).resolves.toBe(waitStrategy); + expect(runtimeClient.image.inspect).not.toHaveBeenCalled(); + }); + + it("should select a user-configured healthcheck", async () => { + const runtimeClient = client({} as ImageInspectInfo); + + await expect( + 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(); + }); + + it("should select a healthcheck configured on the container", async () => { + const runtimeClient = client({} as ImageInspectInfo); + + await expect( + 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(); + }); + + it("should select a healthcheck when container inspect includes healthcheck status", async () => { + const runtimeClient = client({} as ImageInspectInfo); + + await expect( + 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( + 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( + selectWaitStrategy({ + client: client(imageInspectResultWithHealthCheck()), + inspectResult: containerInspectResult(), + imageNames: ["image:latest"], + }) + ).resolves.toBeInstanceOf(HealthCheckWaitStrategy); + }); + + it("should select listening ports when image inspect fails", async () => { + await expect( + selectWaitStrategy({ + client: clientWithImageInspectFailure(), + inspectResult: containerInspectResult(), + imageNames: ["image:latest"], + }) + ).resolves.toBeInstanceOf(HostPortWaitStrategy); + }); + + it("should select listening ports when the container disables image healthchecks", async () => { + const runtimeClient = client(imageInspectResultWithHealthCheck()); + + await expect( + selectWaitStrategy({ + client: runtimeClient, + inspectResult: containerInspectResult({ healthcheck: { Test: ["NONE"] } }), + imageNames: ["image:latest"], + }) + ).resolves.toBeInstanceOf(HostPortWaitStrategy); + expect(runtimeClient.image.inspect).not.toHaveBeenCalled(); + }); +}); 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; +};