Skip to content
2 changes: 2 additions & 0 deletions .agents/skills/add-module/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,15 @@ For the container class, tests and docs page, start from the module closest in s
## Container class

- The constructor sets exposed ports, the wait strategy and `withStartupTimeout(120_000)`. Setting the wait strategy there lets users override it.
- That timeout is one deadline for the whole startup. `containerStarting` and `containerStarted` are passed an `AbortSignal` for it: pass it to `waitForContainer`, `poll` and `exec` so a wait there ends with the startup timeout instead of starting a new one.
- Prefer listening-port, health-check or HTTP waits over log regexes. Log output changes between image versions. Shell-less images need a health-check or HTTP wait.
- Zero config must work: `new XContainer(IMAGE).start()` gives a container a client can connect to. Prefer defaults to getters that can return `undefined`.
- For readiness that a wait strategy can't express, such as calling an API in `containerStarted`, use `poll` from `testcontainers` (docs: `docs/features/wait-strategies.md#polling`). Don't hand-roll a loop or use the deprecated `IntervalRetry`.
- Validate `with*` inputs, and fail fast on half-set config (for example a username without a password) instead of waiting out the startup timeout.
- Started-container getters call `getMappedPort()` when they're invoked. A restarted container can get different host ports.
- Incompatible major versions (different ports, auth or startup) get separate classes, not image-tag parsing.
- Keep it small: no getters that exist only for tests, and no speculative options.
- Write the class following `write-code`.

## Tests

Expand Down
1 change: 1 addition & 0 deletions .agents/skills/review-pr/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ gh pr checks <N>
- Zero-config defaults work, and invalid or half-set config fails fast.
- The wait strategy is set in the constructor, and waits are robust (listening ports, health check or HTTP rather than log regexes).
- Images are pinned in the module `Dockerfile`.
- **Style:** new code follows `write-code`.
- **Scope:**
- Flag new files for a few lines of logic, dead fallbacks, duplicated constants, getters that exist only for tests, and tests of third-party behaviour.
- Flag unrelated dependency bumps; leave those to Dependabot.
Expand Down
25 changes: 25 additions & 0 deletions .agents/skills/write-code/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
name: write-code
description: Writes and refactors TypeScript in testcontainers-node by reusing what the project already has (existing helpers, shared functions, core's building blocks) instead of adding another copy. Use when writing, changing or refactoring code under packages/, e.g. "add an option to the Kafka module", "fix this bug", "add a wait for X", and when review feedback says code is duplicated or should be refactored.
---

# Write code

Biome enforces the lint and formatting rules (`biome.json`). This skill covers what a linter can't see. The most common review finding is new code that repeats something the project already has.

## Before adding code

- Search for code that already does it: in the file, in the package, and in what `testcontainers` exports (`packages/testcontainers/src/index.ts`). If it exists, use it.
- If something close exists, change it so it serves both callers. Don't copy it and adapt the copy.

## When the same code is needed twice

- In one file, extract a helper: a private method, or a function the file doesn't export. Define it below the first function that calls it.
- Across files in a package, move it into a function in its own file.
- Across modules, it belongs in core, exported from `testcontainers`, because modules can't import each other. That adds public API, so propose it instead of copying the code.

## Use what core provides

- Configure containers through the `with*` methods. Don't assign to `this.createOpts` or `this.hostConfig`.
- Wait with a `WaitStrategy` run through `waitForContainer`. Don't call `waitUntilReady` on a strategy or write a polling loop.
- A wait added after start is passed the startup signal its callback receives, so it ends with the user's startup timeout instead of starting a new one.
1 change: 1 addition & 0 deletions .claude/skills/write-code
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
This is a working guide for contributors and coding agents in this repository.
It captures practical rules that prevent avoidable CI and PR churn.

This file holds the rules for every task. Workflows live in skills under `.agents/skills/`, which Claude Code reads through symlinks in `.claude/skills/`. They cover opening and reviewing PRs, adding modules, issue triage, CI and dependency maintenance, and release review.
This file holds the rules for every task. Workflows live in skills under `.agents/skills/`, which Claude Code reads through symlinks in `.claude/skills/`. They cover writing code, opening and reviewing PRs, adding modules, issue triage, CI and dependency maintenance, and release review.
If a skill or this file turns out to be wrong or incomplete, update it in the same PR.

## Repository Layout
Expand All @@ -30,6 +30,7 @@ If a skill or this file turns out to be wrong or incomplete, update it in the sa

### Code

- Follow the `write-code` skill when writing or changing code under `packages/`.
- Keep code comments to a minimum; the codebase has very few. Add one only when it tells the reader something they can't get from the code, the PR or the git history, such as why a non-obvious approach was needed, or a link to an upstream bug.
- Don't narrate what the code does, describe the change you made, or reference the task or review that prompted it.
- Fix the root cause. A change that only makes a failure less likely is not a fix.
Expand Down
26 changes: 23 additions & 3 deletions docs/features/containers.md
Original file line number Diff line number Diff line change
Expand Up @@ -568,15 +568,17 @@ class CustomContainer extends GenericContainer {

protected override async containerStarting(
inspectResult: InspectResult,
reused: boolean
reused: boolean,
startupSignal?: AbortSignal
): Promise<void> {
// ...
}

protected override async containerStarted(
container: StartedTestContainer,
inspectResult: InspectResult,
reused: boolean
reused: boolean,
startupSignal?: AbortSignal
): Promise<void> {
// ...
}
Expand All @@ -597,7 +599,25 @@ class CustomStartedContainer extends AbstractStartedContainer {
}
```

To wait for a condition in a callback, such as an API becoming available after the container has started, use [`poll`](wait-strategies.md#polling).
`containerStarting` and `containerStarted` are passed an `AbortSignal` that aborts when the container's [startup timeout](wait-strategies.md) is reached. It is `undefined` when no startup timeout is set. Pass it to whatever waits in the callback, so that the wait ends with the startup timeout instead of running to a timeout of its own.

To wait for a condition in a callback, such as an API becoming available after the container has started, use [`poll`](wait-strategies.md#polling):

```ts
protected override async containerStarted(
container: StartedTestContainer,
inspectResult: InspectResult,
reused: boolean,
startupSignal?: AbortSignal
): Promise<void> {
await poll(({ signal }) => container.exec(["stat", "/tmp/app.lock"], { signal }), {
until: ({ exitCode }) => exitCode === 0,
intervalMs: 100,
timeoutMs: 60_000,
signal: startupSignal,
});
}
```

## Exposing container ports

Expand Down
6 changes: 6 additions & 0 deletions docs/features/wait-strategies.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ const container = await new GenericContainer("alpine")
.start();
```

A startup timeout set on the container is one deadline for the whole startup. It is counted from when the container is started. The wait strategy stops when it is reached, and the [lifecycle callbacks](containers.md#lifecycle-callbacks) are passed an `AbortSignal` for it. Pulling the image is not included.

The deadline starts again when the container is restarted. The startup timeout of a Compose environment is one deadline for all of its containers, counted from when Compose has started them.

## 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.
Expand Down Expand Up @@ -304,3 +308,5 @@ const { exitCode } = await poll(({ signal }) => container.exec(["stat", "/tmp/ap
```

The timeout error message defaults to `Timed out after ${timeoutMs}ms` and can be set with `timeoutMessage`.

To stop polling before the timeout, pass an `AbortSignal` as `signal`, for example the one a lifecycle callback is given. When it aborts, `poll` rejects with the timeout error.
54 changes: 34 additions & 20 deletions packages/modules/couchbase/src/couchbase-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,14 +175,17 @@ export class CouchbaseContainer extends GenericContainer {
client: ContainerRuntimeClient,
inspectResult: InspectResult,
startedTestContainer: StartedTestContainer,
timeoutMs: number
timeoutMs: number,
startupSignal?: AbortSignal
) {
return Wait.forHttp("/pools", PORTS.MGMT_PORT)
.forStatusCode(200)
.withStartupTimeout(timeoutMs)
.waitUntilReady(
client.container.getById(startedTestContainer.getId()),
BoundPorts.fromInspectResult(client.info.containerRuntime.hostIps, inspectResult)
BoundPorts.fromInspectResult(client.info.containerRuntime.hostIps, inspectResult),
undefined,
startupSignal
);
}

Expand Down Expand Up @@ -381,7 +384,8 @@ export class CouchbaseContainer extends GenericContainer {
client: ContainerRuntimeClient,
inspectResult: InspectResult,
startedTestContainer: StartedTestContainer,
remainingStartupMs: () => number
timeoutMs: number,
startupSignal?: AbortSignal
) {
log.debug(`Creating ${this.buckets.size} buckets (and corresponding indexes).`);
for (const bucket of this.buckets) {
Expand All @@ -407,7 +411,7 @@ export class CouchbaseContainer extends GenericContainer {
await Wait.forHttp(`/pools/default/b/${bucket.getName()}`, PORTS.MGMT_PORT)
.withBasicCredentials(this.username, this.password)
.forStatusCode(200)
.withStartupTimeout(remainingStartupMs())
.withStartupTimeout(timeoutMs)
.forResponsePredicate((response) => {
try {
const jsonResponse = JSON.parse(response);
Expand All @@ -432,13 +436,15 @@ export class CouchbaseContainer extends GenericContainer {
})
.waitUntilReady(
client.container.getById(startedTestContainer.getId()),
BoundPorts.fromInspectResult(client.info.containerRuntime.hostIps, inspectResult)
BoundPorts.fromInspectResult(client.info.containerRuntime.hostIps, inspectResult),
undefined,
startupSignal
);

await Wait.forHttp(`/pools/default/buckets/${bucket.getName()}`, PORTS.MGMT_PORT)
.withBasicCredentials(this.username, this.password)
.forStatusCode(200)
.withStartupTimeout(remainingStartupMs())
.withStartupTimeout(timeoutMs)
.forResponsePredicate((response) => {
try {
const jsonResponse = JSON.parse(response);
Expand All @@ -450,12 +456,12 @@ export class CouchbaseContainer extends GenericContainer {
})
.waitUntilReady(
client.container.getById(startedTestContainer.getId()),
BoundPorts.fromInspectResult(client.info.containerRuntime.hostIps, inspectResult)
BoundPorts.fromInspectResult(client.info.containerRuntime.hostIps, inspectResult),
undefined,
startupSignal
);

if (this.enabledServices.has(CouchbaseService.QUERY)) {
const timeoutMs = remainingStartupMs();

await poll(
async () => {
try {
Expand Down Expand Up @@ -492,6 +498,7 @@ export class CouchbaseContainer extends GenericContainer {
intervalMs: 1000,
timeoutMs,
timeoutMessage: `URL /query/service not accessible after ${timeoutMs}ms`,
signal: startupSignal,
}
);
}
Expand All @@ -512,8 +519,6 @@ export class CouchbaseContainer extends GenericContainer {

await this.checkResponse(response, `Could not create primary index for bucket ${bucket.getName()}`);

const timeoutMs = remainingStartupMs();

await poll(
async () => {
try {
Expand Down Expand Up @@ -553,6 +558,7 @@ export class CouchbaseContainer extends GenericContainer {
intervalMs: 1000,
timeoutMs,
timeoutMessage: `URL /query/service not accessible after ${timeoutMs}ms`,
signal: startupSignal,
}
);
} else {
Expand All @@ -566,13 +572,14 @@ export class CouchbaseContainer extends GenericContainer {

protected override async containerStarted(
startedTestContainer: StartedTestContainer,
inspectResult: InspectResult
inspectResult: InspectResult,
_reused?: boolean,
startupSignal?: AbortSignal
): Promise<void> {
const deadline = this.startupTimeoutMs === undefined ? undefined : Date.now() + this.startupTimeoutMs;
const remainingStartupMs = () => (deadline === undefined ? 60_000 : Math.max(deadline - Date.now(), 0));
const timeoutMs = this.startupTimeoutMs ?? 60_000;
const client = await getContainerRuntimeClient();

await this.waitUntilNodeIsOnline(client, inspectResult, startedTestContainer, remainingStartupMs());
await this.waitUntilNodeIsOnline(client, inspectResult, startedTestContainer, timeoutMs, startupSignal);
await this.initializeIsEnterprise(startedTestContainer);
await this.renameNode(startedTestContainer, inspectResult);
await this.initializeServices(startedTestContainer);
Expand All @@ -586,19 +593,21 @@ export class CouchbaseContainer extends GenericContainer {
await Promise.all(
this.constructWaitStrategies().map((s) =>
s
.withStartupTimeout(remainingStartupMs())
.withStartupTimeout(timeoutMs)
.waitUntilReady(
client.container.getById(startedTestContainer.getId()),
BoundPorts.fromInspectResult(client.info.containerRuntime.hostIps, inspectResult)
BoundPorts.fromInspectResult(client.info.containerRuntime.hostIps, inspectResult),
undefined,
startupSignal
)
)
);

if (this.enabledServices.has(CouchbaseService.KV)) {
await this.waitUntilKvAcceptsCredentials(startedTestContainer, remainingStartupMs());
await this.waitUntilKvAcceptsCredentials(startedTestContainer, timeoutMs, startupSignal);
}

await this.createBuckets(client, inspectResult, startedTestContainer, remainingStartupMs);
await this.createBuckets(client, inspectResult, startedTestContainer, timeoutMs, startupSignal);

log.info(
`Couchbase container is ready! UI available at http://${startedTestContainer.getHost()}:${startedTestContainer.getMappedPort(
Expand All @@ -607,7 +616,11 @@ export class CouchbaseContainer extends GenericContainer {
);
}

private async waitUntilKvAcceptsCredentials(container: StartedTestContainer, timeoutMs: number) {
private async waitUntilKvAcceptsCredentials(
container: StartedTestContainer,
timeoutMs: number,
startupSignal?: AbortSignal
) {
await poll(
async ({ signal }) => {
const { exitCode } = await container.exec(
Expand All @@ -622,6 +635,7 @@ export class CouchbaseContainer extends GenericContainer {
timeoutMs,
timeoutMessage: `KV service did not accept credentials after ${timeoutMs}ms`,
retryOnError: false,
signal: startupSignal,
}
);
}
Expand Down
6 changes: 4 additions & 2 deletions packages/modules/kafka/src/kafka-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,9 @@ export class KafkaContainer extends GenericContainer {

protected override async containerStarted(
container: StartedTestContainer,
inspectResult: InspectResult
inspectResult: InspectResult,
_reused?: boolean,
startupSignal?: AbortSignal
): Promise<void> {
let command = "#!/bin/bash\n";
const advertisedListeners = this.updateAdvertisedListeners(container, inspectResult);
Expand Down Expand Up @@ -197,7 +199,7 @@ export class KafkaContainer extends GenericContainer {
if (this.startupTimeoutMs !== undefined) {
waitStrategy.withStartupTimeout(this.startupTimeoutMs);
}
await waitForContainer(client, dockerContainer, waitStrategy, boundPorts);
await waitForContainer(client, dockerContainer, waitStrategy, boundPorts, undefined, startupSignal);

if (this.saslSslConfig && this.mode !== KafkaMode.KRAFT) {
await this.createUser(container, this.saslSslConfig.sasl);
Expand Down
6 changes: 4 additions & 2 deletions packages/modules/redpanda/src/redpanda-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,9 @@ export class RedpandaContainer extends GenericContainer {

protected override async containerStarted(
container: StartedTestContainer,
inspectResult: InspectResult
inspectResult: InspectResult,
_reused?: boolean,
startupSignal?: AbortSignal
): Promise<void> {
// Docker writes a copied file in place, so the starter could read the marker before the rest.
// A rename on the same filesystem publishes the whole file at once.
Expand All @@ -83,7 +85,7 @@ export class RedpandaContainer extends GenericContainer {
if (this.startupTimeoutMs !== undefined) {
waitStrategy.withStartupTimeout(this.startupTimeoutMs);
}
await waitForContainer(client, dockerContainer, waitStrategy, boundPorts);
await waitForContainer(client, dockerContainer, waitStrategy, boundPorts, undefined, startupSignal);
}

private renderRedpandaFile(host: string, port: number): string {
Expand Down
1 change: 1 addition & 0 deletions packages/testcontainers/src/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export { withFileLock } from "./file-lock";
export { hash } from "./hash";
export { buildLog, composeLog, containerLog, execLog, Logger, log, pullLog } from "./logger";
export { IntervalRetry, PollOptions, poll, Retry } from "./retry";
export { createStartupSignal } from "./startup-signal";
export * from "./time";
export * from "./type-guards";
export { RandomUuid, randomUuid, Uuid } from "./uuid";
41 changes: 41 additions & 0 deletions packages/testcontainers/src/common/retry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,47 @@ describe("Retry", { concurrent: false }, () => {

expect(fnMock).toHaveBeenCalledTimes(callsAtTimeout);
});

it("should reject with the timeout message when the given signal aborts", async () => {
const controller = new AbortController();
const promise = poll(async () => false, {
until: (result) => result,
intervalMs: 1,
timeoutMs: 60_000,
timeoutMessage: "not ready after 60000ms",
signal: controller.signal,
});

controller.abort();

await expect(promise).rejects.toThrow("not ready after 60000ms");
});

it("should abort the attempt's signal when the given signal aborts", async () => {
const controller = new AbortController();
let attemptSignal: AbortSignal | undefined;
const promise = poll(
({ signal }) => {
attemptSignal = signal;
return new Promise<never>(() => {});
},
{ intervalMs: 1, timeoutMs: 60_000, signal: controller.signal }
);

controller.abort();

await expect(promise).rejects.toThrow("Timed out after 60000ms");
expect(attemptSignal?.aborted).toBe(true);
});

it("should not start an attempt when the given signal has already aborted", async () => {
const fnMock = vi.fn().mockResolvedValue("result");

await expect(
poll(() => fnMock(), { intervalMs: 1, timeoutMs: 60_000, signal: AbortSignal.abort() })
).rejects.toThrow("Timed out after 60000ms");
expect(fnMock).not.toHaveBeenCalled();
});
});

describe("IntervalRetry", () => {
Expand Down
Loading
Loading