diff --git a/packages/testcontainers/src/common/time.test.ts b/packages/testcontainers/src/common/time.test.ts index 7eefc96d8..fb7866f63 100644 --- a/packages/testcontainers/src/common/time.test.ts +++ b/packages/testcontainers/src/common/time.test.ts @@ -1,4 +1,4 @@ -import { toNanos, toSeconds } from "./time"; +import { toNanos, toSeconds, toUnixSeconds } from "./time"; test.for([ [0, 0], @@ -23,3 +23,11 @@ test.for([ ])("should convert %i ms to %i ns", ([ms, ns]) => { expect(toNanos(ms)).toEqual(ns); }); + +test("toUnixSeconds leaves a Unix-seconds number unchanged", () => { + expect(toUnixSeconds(1_700_000_000)).toEqual(1_700_000_000); +}); + +test("toUnixSeconds converts a Date to Unix seconds", () => { + expect(toUnixSeconds(new Date(1_700_000_000_123))).toEqual(1_700_000_000); +}); diff --git a/packages/testcontainers/src/common/time.ts b/packages/testcontainers/src/common/time.ts index a3342d2b0..7cd13cad2 100644 --- a/packages/testcontainers/src/common/time.ts +++ b/packages/testcontainers/src/common/time.ts @@ -1,3 +1,7 @@ export const toSeconds = (ms: number) => Math.trunc(ms * 1e-3); export const toNanos = (ms: number) => ms * 1e6; + +/** Unix seconds for Docker log filters. Numbers are already seconds; Dates are converted. */ +export const toUnixSeconds = (value: number | Date): number => + value instanceof Date ? Math.trunc(value.getTime() * 1e-3) : value; diff --git a/packages/testcontainers/src/container-runtime/clients/compose/types.ts b/packages/testcontainers/src/container-runtime/clients/compose/types.ts index 02d6880d7..bc9c22818 100644 --- a/packages/testcontainers/src/container-runtime/clients/compose/types.ts +++ b/packages/testcontainers/src/container-runtime/clients/compose/types.ts @@ -24,6 +24,7 @@ type ComposeExecutableOptions = }; export type ComposeDownOptions = { + /** Compose down timeout in milliseconds. */ timeout: number; removeVolumes: boolean; }; diff --git a/packages/testcontainers/src/generic-container/abstract-started-container.ts b/packages/testcontainers/src/generic-container/abstract-started-container.ts index 39c5acb2f..5b910c664 100644 --- a/packages/testcontainers/src/generic-container/abstract-started-container.ts +++ b/packages/testcontainers/src/generic-container/abstract-started-container.ts @@ -109,7 +109,7 @@ export class AbstractStartedContainer implements StartedTestContainer { return this.startedTestContainer.exec(command, opts); } - public logs(opts?: { since?: number; tail?: number }): Promise { + public logs(opts?: { since?: number | Date; tail?: number }): Promise { return this.startedTestContainer.logs(opts); } diff --git a/packages/testcontainers/src/generic-container/generic-container-logs.test.ts b/packages/testcontainers/src/generic-container/generic-container-logs.test.ts index 05159631a..29cba3401 100644 --- a/packages/testcontainers/src/generic-container/generic-container-logs.test.ts +++ b/packages/testcontainers/src/generic-container/generic-container-logs.test.ts @@ -49,6 +49,29 @@ describe("GenericContainer logs", { timeout: 180_000 }, () => { expect(log).toBe(logAfterSleep); }); + it("should stream logs with since as a Date from a started container", async () => { + const pauseMs = 5 * 1000; + const logBeforeSleep = "first"; + const logAfterSleep = "second"; + await using container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14") + .withEntrypoint([ + "/bin/sh", + "-c", + `echo ${logBeforeSleep} && sleep ${pauseMs / 1000} && echo ${logAfterSleep} && sleep infinity`, + ]) + .withWaitStrategy(Wait.forLogMessage(logBeforeSleep)) + .start(); + + await new Promise((resolve) => setTimeout(resolve, pauseMs)); + + const since = new Date(Date.now() - pauseMs + 1000); + + const stream = await container.logs({ since }); + const log: string = await new Promise((resolve) => stream.on("data", (line) => resolve(line.trim()))); + + expect(log).toBe(logAfterSleep); + }); + it("should stream logs from a running container after restart", async () => { const containerLogTraceSpy = vi.spyOn(containerLog, "trace"); await using container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14") diff --git a/packages/testcontainers/src/generic-container/generic-container.ts b/packages/testcontainers/src/generic-container/generic-container.ts index fcb038bac..08c814319 100644 --- a/packages/testcontainers/src/generic-container/generic-container.ts +++ b/packages/testcontainers/src/generic-container/generic-container.ts @@ -558,12 +558,14 @@ export class GenericContainer implements TestContainer { return this; } + /** Resource limits. `memory` is gigabytes; `cpu` is CPUs. */ public withResourcesQuota({ memory, cpu }: ResourcesQuota): this { this.hostConfig.Memory = memory !== undefined ? Math.ceil(memory * 1024 ** 3) : undefined; this.hostConfig.NanoCpus = cpu !== undefined ? Math.ceil(cpu * 10 ** 9) : undefined; return this; } + /** `/dev/shm` size in bytes. */ public withSharedMemorySize(bytes: number): this { this.hostConfig.ShmSize = bytes; return this; diff --git a/packages/testcontainers/src/generic-container/started-generic-container.ts b/packages/testcontainers/src/generic-container/started-generic-container.ts index 6539caf40..18d9148b6 100644 --- a/packages/testcontainers/src/generic-container/started-generic-container.ts +++ b/packages/testcontainers/src/generic-container/started-generic-container.ts @@ -4,7 +4,7 @@ import archiver from "archiver"; import AsyncLock from "async-lock"; import type Dockerode from "dockerode"; import type { ContainerInspectInfo } from "dockerode"; -import { containerLog, log } from "../common"; +import { containerLog, log, toUnixSeconds } from "../common"; import { type ContainerRuntimeClient, getContainerRuntimeClient } from "../container-runtime"; // biome-ignore lint/suspicious/noImportCycles: Ryuk and sshd run as GenericContainers; only dereferenced at runtime import { getReaper } from "../reaper/reaper"; @@ -257,10 +257,11 @@ export class StartedGenericContainer implements StartedTestContainer { return output; } - public async logs(opts?: { since?: number; tail?: number }): Promise { + public async logs(opts?: { since?: number | Date; tail?: number }): Promise { const client = await getContainerRuntimeClient(); + const since = opts?.since !== undefined ? toUnixSeconds(opts.since) : undefined; - return client.container.logs(this.container, opts); + return client.container.logs(this.container, { ...opts, since }); } async [Symbol.asyncDispose]() { diff --git a/packages/testcontainers/src/test-container.ts b/packages/testcontainers/src/test-container.ts index 51a1b0345..1ef80602e 100644 --- a/packages/testcontainers/src/test-container.ts +++ b/packages/testcontainers/src/test-container.ts @@ -34,6 +34,7 @@ export interface TestContainer { withExposedPorts(...ports: PortWithOptionalBinding[]): this; withBindMounts(bindMounts: BindMount[]): this; withWaitStrategy(waitStrategy: WaitStrategy): this; + /** Container startup timeout in milliseconds. */ withStartupTimeout(startupTimeoutMs: number): this; withNetwork(network: StartedNetwork): this; withNetworkMode(networkMode: string): this; @@ -52,17 +53,21 @@ export interface TestContainer { withCopyArchivesToContainer(archivesToCopy: ArchiveToCopy[]): this; withCopyToContainerOptions(copyToContainerOptions: CopyToContainerOptions): this; withWorkingDir(workingDir: string): this; + /** Resource limits. `memory` is gigabytes; `cpu` is CPUs. */ withResourcesQuota(resourcesQuota: ResourcesQuota): this; + /** `/dev/shm` size in bytes. */ withSharedMemorySize(bytes: number): this; withLogConsumer(logConsumer: (stream: Readable) => unknown): this; withHostname(hostname: string): this; } export interface RestartOptions { + /** Stop timeout before restart, in milliseconds. */ timeout: number; } export interface StopOptions { + /** Stop timeout in milliseconds. */ timeout: number; remove: boolean; removeVolumes: boolean; @@ -89,7 +94,12 @@ export interface StartedTestContainer extends AsyncDisposable { copyFilesToContainer(filesToCopy: FileToCopy[]): Promise; copyContentToContainer(contentsToCopy: ContentToCopy[]): Promise; exec(command: string | string[], opts?: Partial): Promise; - logs(opts?: { since?: number; tail?: number }): Promise; + /** + * Stream container logs. + * @param opts.since Unix timestamp in **seconds**, or a `Date` (converted to seconds). Millisecond values such as `Date.now()` are far in the future for Docker. + * @param opts.tail Number of lines from the end of the logs, or omit for the full stream. + */ + logs(opts?: { since?: number | Date; tail?: number }): Promise; } export interface StoppedTestContainer { diff --git a/packages/testcontainers/src/types.ts b/packages/testcontainers/src/types.ts index 28242ebf1..99ae3080e 100644 --- a/packages/testcontainers/src/types.ts +++ b/packages/testcontainers/src/types.ts @@ -63,15 +63,21 @@ export type TmpFs = { [dir in string]: string }; export type Ulimits = { [name: string]: { hard: number | undefined; soft: number | undefined } }; export type ResourcesQuota = { - memory?: number; // Memory limit in Gigabytes - cpu?: number; // CPU quota in units of CPUs + /** Memory limit in gigabytes (e.g. `0.5` is 512 MiB). */ + memory?: number; + /** CPU quota in CPUs (e.g. `1` is one CPU). */ + cpu?: number; }; export type HealthCheck = { test: ["CMD-SHELL", string] | ["CMD", ...string[]]; + /** Interval between checks, in milliseconds. */ interval?: number; + /** Maximum time to allow one check to run, in milliseconds. */ timeout?: number; + /** Consecutive failures before the container is marked unhealthy. */ retries?: number; + /** Grace period before failed checks count, in milliseconds. */ startPeriod?: number; }; diff --git a/packages/testcontainers/src/wait-strategies/composite-wait-strategy.ts b/packages/testcontainers/src/wait-strategies/composite-wait-strategy.ts index c673607bb..c06d34f94 100644 --- a/packages/testcontainers/src/wait-strategies/composite-wait-strategy.ts +++ b/packages/testcontainers/src/wait-strategies/composite-wait-strategy.ts @@ -48,6 +48,7 @@ export class CompositeWaitStrategy extends AbstractWaitStrategy { return this; } + /** Overall deadline for all composed strategies, in milliseconds. */ public withDeadline(deadline: number): this { this.deadline = deadline; return this; diff --git a/packages/testcontainers/src/wait-strategies/http-wait-strategy.ts b/packages/testcontainers/src/wait-strategies/http-wait-strategy.ts index d0a8e5332..e883c8917 100644 --- a/packages/testcontainers/src/wait-strategies/http-wait-strategy.ts +++ b/packages/testcontainers/src/wait-strategies/http-wait-strategy.ts @@ -58,6 +58,7 @@ export class HttpWaitStrategy extends AbstractWaitStrategy { return this; } + /** HTTP read timeout in milliseconds. */ public withReadTimeout(readTimeoutMs: number): this { this.readTimeoutMs = readTimeoutMs; return this; diff --git a/packages/testcontainers/src/wait-strategies/wait-strategy.ts b/packages/testcontainers/src/wait-strategies/wait-strategy.ts index a93d8c482..2bd0b3059 100644 --- a/packages/testcontainers/src/wait-strategies/wait-strategy.ts +++ b/packages/testcontainers/src/wait-strategies/wait-strategy.ts @@ -4,6 +4,7 @@ import type { BoundPorts } from "../utils/bound-ports"; export interface WaitStrategy { waitUntilReady(container: Dockerode.Container, boundPorts: BoundPorts, startTime?: Date): Promise; + /** Wait-strategy timeout in milliseconds. */ withStartupTimeout(startupTimeoutMs: number): WaitStrategy; isStartupTimeoutSet(): boolean; @@ -21,6 +22,7 @@ export abstract class AbstractWaitStrategy implements WaitStrategy { startTime?: Date ): Promise; + /** Wait-strategy timeout in milliseconds. */ public withStartupTimeout(startupTimeoutMs: number): this { this.startupTimeoutMs = startupTimeoutMs; this.startupTimeoutSet = true;