From 38d2cfd1e950f05579ae9c791ae562d0f0719a9d Mon Sep 17 00:00:00 2001 From: Cristian Greco Date: Thu, 8 Oct 2026 20:13:02 +0100 Subject: [PATCH 1/7] Handle floating promises and enable noFloatingPromises Enable Biome's nursery/noFloatingPromises rule at error level and fix everything it reports: - Await archiver's finalize() together with putArchive() so a finalize rejection reaches the caller instead of becoming an unhandled rejection. Apply the same fix to the three copy*ToContainer methods on StartedGenericContainer, which the rule cannot see because it cannot infer archiver()'s return type. - Make the NATS pub/sub test assert on the messages it received after draining, so a wrong or missing message fails the test. - Make smoke-test.js exit non-zero explicitly when it fails. - Mark the Dockerfile pull-event observer as intentionally not awaited. --- biome.json | 5 ++++- packages/modules/nats/src/nats-container.test.ts | 10 ++++++---- packages/testcontainers/smoke-test.js | 5 ++++- .../generic-container-dockerfile.test.ts | 3 ++- .../src/generic-container/generic-container.ts | 6 ++++-- .../src/generic-container/started-generic-container.ts | 9 +++------ 6 files changed, 23 insertions(+), 15 deletions(-) diff --git a/biome.json b/biome.json index f54aac69e..5aca760c2 100644 --- a/biome.json +++ b/biome.json @@ -22,7 +22,10 @@ "linter": { "enabled": true, "rules": { - "preset": "recommended" + "preset": "recommended", + "nursery": { + "noFloatingPromises": "error" + } } }, "assist": { diff --git a/packages/modules/nats/src/nats-container.test.ts b/packages/modules/nats/src/nats-container.test.ts index 5714572e1..39800661c 100644 --- a/packages/modules/nats/src/nats-container.test.ts +++ b/packages/modules/nats/src/nats-container.test.ts @@ -18,16 +18,18 @@ describe("NatsContainer", { timeout: 180_000 }, () => { const sub = nc.subscribe(SUBJECT); - (async () => { + const received = (async () => { + const messages: string[] = []; for await (const m of sub) { - const actual = TD.decode(m.data); - expect(actual).toEqual(PAYLOAD); + messages.push(TD.decode(m.data)); } - })().then(); + return messages; + })(); nc.publish(SUBJECT, TE.encode(PAYLOAD)); await nc.drain(); + expect(await received).toEqual([PAYLOAD]); await nc.close(); // } }); diff --git a/packages/testcontainers/smoke-test.js b/packages/testcontainers/smoke-test.js index 0da0d1d0f..aed362c7f 100644 --- a/packages/testcontainers/smoke-test.js +++ b/packages/testcontainers/smoke-test.js @@ -4,4 +4,7 @@ const { GenericContainer } = require("./build/index"); const container = await new GenericContainer("alpine:3.12").withCommand(["sleep", "infinity"]).start(); await container.stop(); -})(); +})().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/packages/testcontainers/src/generic-container/generic-container-dockerfile.test.ts b/packages/testcontainers/src/generic-container/generic-container-dockerfile.test.ts index a747bf0c1..90019f136 100644 --- a/packages/testcontainers/src/generic-container/generic-container-dockerfile.test.ts +++ b/packages/testcontainers/src/generic-container/generic-container-dockerfile.test.ts @@ -86,7 +86,8 @@ describe("GenericContainer Dockerfile", { timeout: 180_000 }, () => { await using dockerEventStream = await getDockerEventStream(); const dockerPullEventPromise = waitForDockerEvent(dockerEventStream.events, "pull"); let hasResolved = false; - dockerPullEventPromise.then(() => (hasResolved = true)); + // waitForDockerEvent never rejects; only record whether a pull event arrived. + void dockerPullEventPromise.then(() => (hasResolved = true)); await containerSpec.build(); expect(hasResolved).toBeFalsy(); diff --git a/packages/testcontainers/src/generic-container/generic-container.ts b/packages/testcontainers/src/generic-container/generic-container.ts index c44793ff7..7d74006c1 100644 --- a/packages/testcontainers/src/generic-container/generic-container.ts +++ b/packages/testcontainers/src/generic-container/generic-container.ts @@ -206,8 +206,10 @@ export class GenericContainer implements TestContainer { if (this.filesToCopy.length > 0 || this.directoriesToCopy.length > 0 || this.contentsToCopy.length > 0) { const archive = await this.createArchiveToCopyToContainer(); - archive.finalize(); - await client.container.putArchive(container, archive, "/", this.copyToContainerOptions); + await Promise.all([ + archive.finalize(), + client.container.putArchive(container, archive, "/", this.copyToContainerOptions), + ]); } for (const archive of this.archivesToCopy) { diff --git a/packages/testcontainers/src/generic-container/started-generic-container.ts b/packages/testcontainers/src/generic-container/started-generic-container.ts index e68e7018b..3abceb934 100644 --- a/packages/testcontainers/src/generic-container/started-generic-container.ts +++ b/packages/testcontainers/src/generic-container/started-generic-container.ts @@ -204,8 +204,7 @@ export class StartedGenericContainer implements StartedTestContainer { filesToCopyWithStats.forEach(({ source, target, mode, stats }) => { tar.file(source, { name: target, mode, stats }); }); - tar.finalize(); - await client.container.putArchive(this.container, tar, "/"); + await Promise.all([tar.finalize(), client.container.putArchive(this.container, tar, "/")]); log.debug(`Copied files to container`, { containerId: this.container.id }); } @@ -216,8 +215,7 @@ export class StartedGenericContainer implements StartedTestContainer { directoriesToCopy.forEach(({ source, target }) => { tar.directory(source, target); }); - tar.finalize(); - await client.container.putArchive(this.container, tar, "/"); + await Promise.all([tar.finalize(), client.container.putArchive(this.container, tar, "/")]); log.debug(`Copied directories to container`, { containerId: this.container.id }); } @@ -228,8 +226,7 @@ export class StartedGenericContainer implements StartedTestContainer { contentsToCopy.forEach(({ content, target, mode }) => { tar.append(content, { name: target, mode: mode }); }); - tar.finalize(); - await client.container.putArchive(this.container, tar, "/"); + await Promise.all([tar.finalize(), client.container.putArchive(this.container, tar, "/")]); log.debug(`Copied content to container`, { containerId: this.container.id }); } From 5355e1d257bc2db0fb8d8831998a09d0df550c07 Mon Sep 17 00:00:00 2001 From: Cristian Greco Date: Sat, 10 Oct 2026 19:45:00 +0100 Subject: [PATCH 2/7] Use try/catch in the CommonJS smoke test A try/catch reads better than a trailing .catch(). The async function call itself is still an unawaited promise, so it is marked with void. --- packages/testcontainers/smoke-test.js | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/testcontainers/smoke-test.js b/packages/testcontainers/smoke-test.js index aed362c7f..52b47cf04 100644 --- a/packages/testcontainers/smoke-test.js +++ b/packages/testcontainers/smoke-test.js @@ -1,10 +1,13 @@ const { GenericContainer } = require("./build/index"); -(async () => { - const container = await new GenericContainer("alpine:3.12").withCommand(["sleep", "infinity"]).start(); +// CommonJS has no top-level await, so the async function is invoked without being awaited. +void (async () => { + try { + const container = await new GenericContainer("alpine:3.12").withCommand(["sleep", "infinity"]).start(); - await container.stop(); -})().catch((err) => { - console.error(err); - process.exit(1); -}); + await container.stop(); + } catch (err) { + console.error(err); + process.exit(1); + } +})(); From 6041b043b4735fe3b45d735b029a6689cb74322d Mon Sep 17 00:00:00 2001 From: Cristian Greco Date: Sat, 10 Oct 2026 20:15:50 +0100 Subject: [PATCH 3/7] Wait for late pull events before asserting no pull Docker events arrive on a separate stream from the build response, so a pull event could be delivered after the build finished and after the test had already asserted that none arrived. Keep observing for a short window after the build before asserting. Run the test apart from the file's other tests: the event stream is daemon-wide and a neighbouring test pulls the same image. --- .../generic-container-dockerfile.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/testcontainers/src/generic-container/generic-container-dockerfile.test.ts b/packages/testcontainers/src/generic-container/generic-container-dockerfile.test.ts index 90019f136..6dcac6512 100644 --- a/packages/testcontainers/src/generic-container/generic-container-dockerfile.test.ts +++ b/packages/testcontainers/src/generic-container/generic-container-dockerfile.test.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; import { RandomUuid } from "../common"; import { getContainerRuntimeClient, ImageName } from "../container-runtime"; import { getReaper } from "../reaper/reaper"; @@ -75,7 +76,8 @@ describe("GenericContainer Dockerfile", { timeout: 180_000 }, () => { await dockerPullEventPromise; }); - it("should not pull existing image without pull policy", async () => { + // Docker events are daemon-wide, so run apart from this file's other tests, which pull the same image. + it("should not pull existing image without pull policy", { concurrent: false }, async () => { const client = await getContainerRuntimeClient(); await client.image.pull(new ImageName("docker.io", "node", "10-alpine")); @@ -84,13 +86,11 @@ describe("GenericContainer Dockerfile", { timeout: 180_000 }, () => { await containerSpec.build(); await using dockerEventStream = await getDockerEventStream(); - const dockerPullEventPromise = waitForDockerEvent(dockerEventStream.events, "pull"); - let hasResolved = false; - // waitForDockerEvent never rejects; only record whether a pull event arrived. - void dockerPullEventPromise.then(() => (hasResolved = true)); + const pulled = waitForDockerEvent(dockerEventStream.events, "pull").then(() => true); await containerSpec.build(); - expect(hasResolved).toBeFalsy(); + // Events arrive on a separate stream, so give a late pull event time to be delivered before asserting. + expect(await Promise.race([pulled, delay(500, false)])).toBe(false); }); } From 0c851c0b3f8b41430fe3f09d2268fc6527368b5f Mon Sep 17 00:00:00 2001 From: Cristian Greco Date: Sat, 10 Oct 2026 20:25:09 +0100 Subject: [PATCH 4/7] Make the no-pull Dockerfile test independent of Docker events Watching the Docker event stream for the absence of a pull event cannot be made deterministic: events arrive on a separate stream with no delivery deadline, and the stream is daemon-wide. Build from a tag that only exists locally instead. The build succeeds when the image is not pulled and fails when a pull is attempted. This replaces the post-build observation window and lets the test run concurrently again. --- .../generic-container-dockerfile.test.ts | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/packages/testcontainers/src/generic-container/generic-container-dockerfile.test.ts b/packages/testcontainers/src/generic-container/generic-container-dockerfile.test.ts index 6dcac6512..9988c2514 100644 --- a/packages/testcontainers/src/generic-container/generic-container-dockerfile.test.ts +++ b/packages/testcontainers/src/generic-container/generic-container-dockerfile.test.ts @@ -1,5 +1,4 @@ import path from "node:path"; -import { setTimeout as delay } from "node:timers/promises"; import { RandomUuid } from "../common"; import { getContainerRuntimeClient, ImageName } from "../container-runtime"; import { getReaper } from "../reaper/reaper"; @@ -76,21 +75,21 @@ describe("GenericContainer Dockerfile", { timeout: 180_000 }, () => { await dockerPullEventPromise; }); - // Docker events are daemon-wide, so run apart from this file's other tests, which pull the same image. - it("should not pull existing image without pull policy", { concurrent: false }, async () => { + it("should not pull existing image without pull policy", async () => { const client = await getContainerRuntimeClient(); await client.image.pull(new ImageName("docker.io", "node", "10-alpine")); - - const dockerfile = path.resolve(fixtures, "docker"); - const containerSpec = GenericContainer.fromDockerfile(dockerfile); - - await containerSpec.build(); - await using dockerEventStream = await getDockerEventStream(); - const pulled = waitForDockerEvent(dockerEventStream.events, "pull").then(() => true); - await containerSpec.build(); - - // Events arrive on a separate stream, so give a late pull event time to be delivered before asserting. - expect(await Promise.race([pulled, delay(500, false)])).toBe(false); + // Base the build on a tag that only exists locally, so the build fails if it pulls. + const localTag = `local-${uuidGen.nextUuid()}`; + await client.container.dockerode.getImage("node:10-alpine").tag({ repo: "node", tag: localTag }); + + const context = path.resolve(fixtures, "docker-with-buildargs"); + const containerSpec = GenericContainer.fromDockerfile(context).withBuildArgs({ VERSION: localTag }); + + try { + await expect(containerSpec.build()).resolves.toBeDefined(); + } finally { + await deleteImageByName(`node:${localTag}`); + } }); } From 3b8c86e218a00db41063baaef9242efe03c9e653 Mon Sep 17 00:00:00 2001 From: Cristian Greco Date: Sun, 11 Oct 2026 18:36:50 +0100 Subject: [PATCH 5/7] Remove comment from the CommonJS smoke test --- packages/testcontainers/smoke-test.js | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/testcontainers/smoke-test.js b/packages/testcontainers/smoke-test.js index 52b47cf04..021310b93 100644 --- a/packages/testcontainers/smoke-test.js +++ b/packages/testcontainers/smoke-test.js @@ -1,6 +1,5 @@ const { GenericContainer } = require("./build/index"); -// CommonJS has no top-level await, so the async function is invoked without being awaited. void (async () => { try { const container = await new GenericContainer("alpine:3.12").withCommand(["sleep", "infinity"]).start(); From 506674c0ed911e386cfcc72b91c8c72662276e77 Mon Sep 17 00:00:00 2001 From: Cristian Greco Date: Sun, 11 Oct 2026 18:38:28 +0100 Subject: [PATCH 6/7] Restore the no-pull Dockerfile test Keep only the void needed for noFloatingPromises. The rewrite that re-tagged the base image is out of scope for this change. --- .../generic-container-dockerfile.test.ts | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/testcontainers/src/generic-container/generic-container-dockerfile.test.ts b/packages/testcontainers/src/generic-container/generic-container-dockerfile.test.ts index 9988c2514..37ae3e42f 100644 --- a/packages/testcontainers/src/generic-container/generic-container-dockerfile.test.ts +++ b/packages/testcontainers/src/generic-container/generic-container-dockerfile.test.ts @@ -78,18 +78,18 @@ describe("GenericContainer Dockerfile", { timeout: 180_000 }, () => { it("should not pull existing image without pull policy", async () => { const client = await getContainerRuntimeClient(); await client.image.pull(new ImageName("docker.io", "node", "10-alpine")); - // Base the build on a tag that only exists locally, so the build fails if it pulls. - const localTag = `local-${uuidGen.nextUuid()}`; - await client.container.dockerode.getImage("node:10-alpine").tag({ repo: "node", tag: localTag }); - - const context = path.resolve(fixtures, "docker-with-buildargs"); - const containerSpec = GenericContainer.fromDockerfile(context).withBuildArgs({ VERSION: localTag }); - - try { - await expect(containerSpec.build()).resolves.toBeDefined(); - } finally { - await deleteImageByName(`node:${localTag}`); - } + + const dockerfile = path.resolve(fixtures, "docker"); + const containerSpec = GenericContainer.fromDockerfile(dockerfile); + + await containerSpec.build(); + await using dockerEventStream = await getDockerEventStream(); + const dockerPullEventPromise = waitForDockerEvent(dockerEventStream.events, "pull"); + let hasResolved = false; + void dockerPullEventPromise.then(() => (hasResolved = true)); + await containerSpec.build(); + + expect(hasResolved).toBeFalsy(); }); } From 7f3c6a5cc0350426abd6545ee399b99eebca66d5 Mon Sep 17 00:00:00 2001 From: Cristian Greco Date: Sun, 11 Oct 2026 18:48:36 +0100 Subject: [PATCH 7/7] Add AGENTS.md note on not pulling new images in tests --- AGENTS.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 043db2c2d..52b8c6fa3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,6 +28,9 @@ It captures practical rules that prevent avoidable CI and PR churn. - In docs Markdown, keep `` blocks tight with no blank lines between the markers and the include line, or the rendered snippet will contain blank lines between code lines. - Tests should verify observable behavior changes, not only internal/config state. - Example: for a security option, assert a real secure/insecure behavior difference. +- Tests should not pull container images that the test suite does not already pull. + - Every extra image increases how long the tests take to run and how much storage they need. + - Reuse an image the tests already use, for example `cristianrgreco/testcontainer:1.1.14` in `packages/testcontainers`, or the module's own image. - When adding a regression test for a bug fix, follow a red-green-refactor workflow. - Run the focused test against the pre-fix implementation and confirm it fails for the expected reason. - Apply the implementation change, rerun the same test, and confirm it passes.