diff --git a/packages/testcontainers/src/container-runtime/clients/image/docker-image-client.test.ts b/packages/testcontainers/src/container-runtime/clients/image/docker-image-client.test.ts new file mode 100644 index 000000000..bf910e36e --- /dev/null +++ b/packages/testcontainers/src/container-runtime/clients/image/docker-image-client.test.ts @@ -0,0 +1,76 @@ +import Dockerode from "dockerode"; +import { Readable } from "stream"; +import { DockerImageClient } from "./docker-image-client"; + +describe("DockerImageClient", () => { + const createDockerode = (buildStream: Readable): Dockerode => + ({ + buildImage: vi.fn((stream: Readable) => { + stream.destroy(); + return Promise.resolve(buildStream); + }), + modem: { + followProgress: vi.fn( + ( + stream: Readable, + onFinished: (error: Error | null, output: unknown[]) => void, + onProgress: (event: unknown) => void + ) => { + const output: unknown[] = []; + stream.on("data", (line) => { + const event = JSON.parse(line.toString()); + output.push(event); + onProgress(event); + }); + stream.on("error", (error) => onFinished(error, output)); + stream.on("end", () => onFinished(null, output)); + } + ), + }, + }) as unknown as Dockerode; + + it("rejects when the Docker image build cannot start", async () => { + const dockerode = { + buildImage: vi.fn((stream: Readable) => { + stream.destroy(); + return Promise.reject(new Error("build failed")); + }), + } as unknown as Dockerode; + const imageClient = new DockerImageClient(dockerode, ""); + + const result = await Promise.race([ + imageClient.build(__dirname, { t: "image" }).then( + () => "resolved", + () => "rejected" + ), + new Promise((resolve) => setTimeout(() => resolve("timeout"), 100)), + ]); + + expect(result).toBe("rejected"); + }); + + it("rejects when the Docker image build stream errors", async () => { + const buildStream = new Readable({ read() {} }); + const dockerode = createDockerode(buildStream); + const imageClient = new DockerImageClient(dockerode, ""); + setTimeout(() => buildStream.destroy(new Error("build failed")), 0); + + const result = await Promise.race([ + imageClient.build(__dirname, { t: "image" }).then( + () => "resolved", + () => "rejected" + ), + new Promise((resolve) => setTimeout(() => resolve("timeout"), 100)), + ]); + + expect(result).toBe("rejected"); + }); + + it("rejects when the Docker image build progress reports an error", async () => { + const buildStream = Readable.from([`${JSON.stringify({ errorDetail: { message: "build failed" } })}\n`]); + const dockerode = createDockerode(buildStream); + const imageClient = new DockerImageClient(dockerode, ""); + + await expect(imageClient.build(__dirname, { t: "image" })).rejects.toThrow("build failed"); + }); +}); diff --git a/packages/testcontainers/src/container-runtime/clients/image/docker-image-client.ts b/packages/testcontainers/src/container-runtime/clients/image/docker-image-client.ts index 4c6ab884c..0987e72bf 100644 --- a/packages/testcontainers/src/container-runtime/clients/image/docker-image-client.ts +++ b/packages/testcontainers/src/container-runtime/clients/image/docker-image-client.ts @@ -10,6 +10,15 @@ import { getAuthConfig } from "../../auth/get-auth-config"; import { ImageName } from "../../image-name"; import { ImageClient } from "./image-client"; +type DockerBuildEvent = { + error?: string; + errorDetail?: { + message?: string; + }; +}; + +const getBuildError = (event: DockerBuildEvent): string | undefined => event.errorDetail?.message ?? event.error; + export class DockerImageClient implements ImageClient { private readonly existingImages = new Set(); private readonly imageExistsLock = new AsyncLock(); @@ -24,20 +33,22 @@ export class DockerImageClient implements ImageClient { log.debug(`Building image "${opts.t}" with context "${context}"...`); const tarPackOptions = await this.createTarPackOptions(context, opts.dockerfile ?? "Dockerfile"); const tarStream = tar.pack(context, tarPackOptions); - await new Promise((resolve) => { - this.dockerode - .buildImage(tarStream, opts) - .then((stream) => byline(stream)) - .then((stream) => { - stream.setEncoding("utf-8"); - stream.on("data", (line) => { - if (buildLog.enabled()) { - buildLog.trace(line, { imageName: opts.t }); - } - }); - stream.on("end", () => resolve()); - }); + const buildStream = await this.dockerode.buildImage(tarStream, opts); + const buildEvents = await new Promise((resolve, reject) => { + this.dockerode.modem.followProgress( + buildStream, + (err, output) => (err ? reject(err) : resolve(output)), + (event) => { + if (buildLog.enabled()) { + buildLog.trace(JSON.stringify(event), { imageName: opts.t }); + } + } + ); }); + const error = buildEvents.map(getBuildError).find((error) => error !== undefined); + if (error !== undefined) { + throw new Error(error); + } log.debug(`Built image "${opts.t}" with context "${context}"`); } catch (err) { log.error(`Failed to build image: ${err}`); diff --git a/packages/testcontainers/src/generic-container/generic-container.test.ts b/packages/testcontainers/src/generic-container/generic-container.test.ts index 2e7f999a0..89227390f 100644 --- a/packages/testcontainers/src/generic-container/generic-container.test.ts +++ b/packages/testcontainers/src/generic-container/generic-container.test.ts @@ -665,10 +665,8 @@ describe("GenericContainer", { timeout: 180_000 }, () => { expect(container.getHostname()).toEqual("hostname"); }); - // failing to build an image hangs within the DockerImageClient.build method, - // that change might be larger so leave it out of this commit but skip the failing test - it.skip("should throw an error for a target stage that does not exist", async () => { + it("should throw an error for a target stage that does not exist", async () => { const context = path.resolve(fixtures, "docker-multi-stage"); - await GenericContainer.fromDockerfile(context).withTarget("invalid").build(); + await expect(GenericContainer.fromDockerfile(context).withTarget("invalid").build()).rejects.toThrow(); }); });