Skip to content

Commit 686a8c6

Browse files
authored
Add stdout and stderr fields to container exec result (#874)
1 parent 27f858a commit 686a8c6

File tree

9 files changed

+91
-84
lines changed

9 files changed

+91
-84
lines changed

docs/features/containers.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -503,15 +503,16 @@ const container = await new GenericContainer("alpine")
503503

504504
## Running commands
505505

506-
To run a command inside an already started container use the `exec` method. The command will be run in the container's
507-
working directory, returning the command output and exit code:
506+
To run a command inside an already started container, use the exec method.
507+
The command will be run in the container's working directory,
508+
returning the combined output (`output`), standard output (`stdout`), standard error (`stderr`), and exit code (`exitCode`).
508509

509510
```javascript
510511
const container = await new GenericContainer("alpine")
511512
.withCommand(["sleep", "infinity"])
512513
.start();
513514

514-
const { output, exitCode } = await container.exec(["echo", "hello", "world"]);
515+
const { output, stdout, stderr, exitCode } = await container.exec(["echo", "hello", "world"]);
515516
```
516517

517518
The following options can be provided to modify the command execution:
@@ -528,7 +529,7 @@ const container = await new GenericContainer("alpine")
528529
.withCommand(["sleep", "infinity"])
529530
.start();
530531

531-
const { output, exitCode } = await container.exec(["echo", "hello", "world"], {
532+
const { output, stdout, stderr, exitCode } = await container.exec(["echo", "hello", "world"], {
532533
workingDir: "/app/src/",
533534
user: "1000:1000",
534535
env: {
@@ -538,6 +539,8 @@ const { output, exitCode } = await container.exec(["echo", "hello", "world"], {
538539
});
539540
```
540541

542+
543+
541544
## Streaming logs
542545

543546
Logs can be consumed either from a started container:

packages/testcontainers/src/container-runtime/clients/client.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import { ComposeInfo, ContainerRuntimeInfo, Info, NodeInfo } from "./types";
1212
import Dockerode, { DockerOptions } from "dockerode";
1313
import { getRemoteContainerRuntimeSocketPath } from "../utils/remote-container-runtime-socket-path";
1414
import { resolveHost } from "../utils/resolve-host";
15-
import { PodmanContainerClient } from "./container/podman-container-client";
1615
import { DockerContainerClient } from "./container/docker-container-client";
1716
import { DockerImageClient } from "./image/docker-image-client";
1817
import { DockerNetworkClient } from "./network/docker-network-client";
@@ -105,9 +104,7 @@ async function initStrategy(strategy: ContainerRuntimeClientStrategy): Promise<C
105104
const hostIps = await lookupHostIps(host);
106105

107106
log.trace("Initialising clients...");
108-
const containerClient = result.uri.includes("podman.sock")
109-
? new PodmanContainerClient(dockerode)
110-
: new DockerContainerClient(dockerode);
107+
const containerClient = new DockerContainerClient(dockerode);
111108
const imageClient = new DockerImageClient(dockerode, indexServerAddress);
112109
const networkClient = new DockerNetworkClient(dockerode);
113110

packages/testcontainers/src/container-runtime/clients/container/container-client.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,15 @@ import { ContainerStatus, ExecOptions, ExecResult } from "./types";
1111

1212
export interface ContainerClient {
1313
dockerode: Dockerode;
14+
1415
getById(id: string): Container;
16+
1517
fetchByLabel(
1618
labelName: string,
1719
labelValue: string,
1820
opts?: { status?: ContainerStatus[] }
1921
): Promise<Container | undefined>;
22+
2023
fetchArchive(container: Container, path: string): Promise<NodeJS.ReadableStream>;
2124
putArchive(container: Dockerode.Container, stream: Readable, path: string): Promise<void>;
2225
list(): Promise<ContainerInfo[]>;

packages/testcontainers/src/container-runtime/clients/container/docker-container-client.ts

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import Dockerode, {
1010
import { PassThrough, Readable } from "stream";
1111
import { IncomingMessage } from "http";
1212
import { ContainerStatus, ExecOptions, ExecResult } from "./types";
13-
import byline from "byline";
1413
import { ContainerClient } from "./container-client";
1514
import { execLog, log, streamToString } from "../../../common";
1615

@@ -201,34 +200,55 @@ export class DockerContainerClient implements ContainerClient {
201200
execOptions.User = opts.user;
202201
}
203202

204-
const chunks: string[] = [];
203+
const outputChunks: string[] = [];
204+
const stdoutChunks: string[] = [];
205+
const stderrChunks: string[] = [];
206+
205207
try {
206208
if (opts?.log) {
207209
log.debug(`Execing container with command "${command.join(" ")}"...`, { containerId: container.id });
208210
}
209211

210212
const exec = await container.exec(execOptions);
211-
const stream = await exec.start({ stdin: true, Detach: false, Tty: true });
212-
if (opts?.log && execLog.enabled()) {
213-
byline(stream).on("data", (line) => execLog.trace(line, { containerId: container.id }));
214-
}
213+
const stream = await exec.start({ stdin: true, Detach: false, Tty: false });
214+
215+
const stdoutStream = new PassThrough();
216+
const stderrStream = new PassThrough();
217+
218+
this.dockerode.modem.demuxStream(stream, stdoutStream, stderrStream);
219+
220+
const processStream = (stream: Readable, chunks: string[]) => {
221+
stream.on("data", (chunk) => {
222+
chunks.push(chunk.toString());
223+
outputChunks.push(chunk.toString());
224+
225+
if (opts?.log && execLog.enabled()) {
226+
execLog.trace(chunk.toString(), { containerId: container.id });
227+
}
228+
});
229+
};
230+
231+
processStream(stdoutStream, stdoutChunks);
232+
processStream(stderrStream, stderrChunks);
215233

216234
await new Promise((res, rej) => {
217-
stream.on("data", (chunk) => chunks.push(chunk));
218235
stream.on("end", res);
219236
stream.on("error", rej);
220237
});
221238
stream.destroy();
222239

223240
const inspectResult = await exec.inspect();
224241
const exitCode = inspectResult.ExitCode ?? -1;
225-
const output = chunks.join("");
242+
const output = outputChunks.join("");
243+
const stdout = stdoutChunks.join("");
244+
const stderr = stderrChunks.join("");
245+
226246
if (opts?.log) {
227247
log.debug(`Execed container with command "${command.join(" ")}"...`, { containerId: container.id });
228248
}
229-
return { output, exitCode };
249+
return { output, stdout, stderr, exitCode };
230250
} catch (err) {
231-
log.error(`Failed to exec container with command "${command.join(" ")}": ${err}: ${chunks.join("")}`, {
251+
log.error(`Failed to exec container with command "${command.join(" ")}": ${err}: ${outputChunks.join("")}`, {
232252
containerId: container.id,
233253
});
234254
throw err;

packages/testcontainers/src/container-runtime/clients/container/podman-container-client.ts

Lines changed: 0 additions & 56 deletions
This file was deleted.

packages/testcontainers/src/container-runtime/clients/container/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ export type Environment = { [key in string]: string };
22

33
export type ExecOptions = { workingDir: string; user: string; env: Environment; log: boolean };
44

5-
export type ExecResult = { output: string; exitCode: number };
5+
export type ExecResult = { output: string; stdout: string; stderr: string; exitCode: number };
66

77
export const CONTAINER_STATUSES = ["created", "restarting", "running", "removing", "paused", "exited", "dead"] as const;
88

packages/testcontainers/src/generic-container/generic-container.test.ts

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -42,32 +42,38 @@ describe("GenericContainer", () => {
4242
it("should execute a command on a running container", async () => {
4343
const container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14").withExposedPorts(8080).start();
4444

45-
const { output, exitCode } = await container.exec(["echo", "hello", "world"]);
45+
const { output, stdout, stderr, exitCode } = await container.exec(["echo", "hello", "world"]);
4646

4747
expect(exitCode).toBe(0);
48-
expect(output).toEqual(expect.stringContaining("hello world"));
48+
expect(stdout).toEqual(expect.stringContaining("hello world"));
49+
expect(stderr).toBe("");
50+
expect(output).toEqual(stdout);
4951

5052
await container.stop();
5153
});
5254

5355
it("should execute a command in a different working directory", async () => {
5456
const container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14").withExposedPorts(8080).start();
5557

56-
const { output, exitCode } = await container.exec(["pwd"], { workingDir: "/var/log" });
58+
const { output, stdout, stderr, exitCode } = await container.exec(["pwd"], { workingDir: "/var/log" });
5759

5860
expect(exitCode).toBe(0);
59-
expect(output).toEqual(expect.stringContaining("/var/log"));
61+
expect(stdout).toEqual(expect.stringContaining("/var/log"));
62+
expect(stderr).toBe("");
63+
expect(output).toEqual(stdout);
6064

6165
await container.stop();
6266
});
6367

6468
it("should execute a command with custom environment variables", async () => {
6569
const container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14").withExposedPorts(8080).start();
6670

67-
const { output, exitCode } = await container.exec(["env"], { env: { TEST_ENV: "test" } });
71+
const { output, stdout, stderr, exitCode } = await container.exec(["env"], { env: { TEST_ENV: "test" } });
6872

6973
expect(exitCode).toBe(0);
70-
expect(output).toEqual(expect.stringContaining("TEST_ENV=test"));
74+
expect(stdout).toEqual(expect.stringContaining("TEST_ENV=test"));
75+
expect(stderr).toBe("");
76+
expect(output).toEqual(stdout);
7177

7278
await container.stop();
7379
});
@@ -76,10 +82,43 @@ describe("GenericContainer", () => {
7682
// By default, node:alpine runs as root
7783
const container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14").withExposedPorts(8080).start();
7884

79-
const { output, exitCode } = await container.exec("whoami", { user: "node" });
85+
const { output, stdout, stderr, exitCode } = await container.exec(["whoami"], { user: "node" });
8086

8187
expect(exitCode).toBe(0);
82-
expect(output).toEqual(expect.stringContaining("node"));
88+
expect(stdout).toEqual(expect.stringContaining("node"));
89+
expect(stderr).toBe("");
90+
expect(output).toEqual(stdout);
91+
92+
await container.stop();
93+
});
94+
95+
it("should capture stderr when a command fails", async () => {
96+
const container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14").withExposedPorts(8080).start();
97+
98+
const { output, stdout, stderr, exitCode } = await container.exec(["ls", "/nonexistent/path"]);
99+
100+
expect(exitCode).not.toBe(0);
101+
expect(stdout).toBe("");
102+
expect(stderr).toEqual(expect.stringContaining("No such file or directory"));
103+
expect(output).toEqual(stderr);
104+
105+
await container.stop();
106+
});
107+
108+
it("should capture stdout and stderr in the correct order", async () => {
109+
const container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14").withExposedPorts(8080).start();
110+
111+
// The command first writes to stdout and then tries to access a nonexistent file (stderr)
112+
const { output, stdout, stderr, exitCode } = await container.exec([
113+
"sh",
114+
"-c",
115+
"echo 'This is stdout'; ls /nonexistent/path",
116+
]);
117+
118+
expect(exitCode).not.toBe(0); // The command should fail due to the ls error
119+
expect(stdout).toEqual(expect.stringContaining("This is stdout"));
120+
expect(stderr).toEqual(expect.stringContaining("No such file or directory"));
121+
expect(output).toMatch(/This is stdout[\s\S]*No such file or directory/);
83122

84123
await container.stop();
85124
});

packages/testcontainers/src/test-container.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,5 +81,6 @@ export interface StartedTestContainer {
8181

8282
export interface StoppedTestContainer {
8383
getId(): string;
84+
8485
copyArchiveFromContainer(path: string): Promise<NodeJS.ReadableStream>;
8586
}

packages/testcontainers/src/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ export type BuildArgs = { [key in string]: string };
8383

8484
export type ExecOptions = { workingDir: string; user: string; env: Environment };
8585

86-
export type ExecResult = { output: string; exitCode: number };
86+
export type ExecResult = { output: string; stdout: string; stderr: string; exitCode: number };
8787

8888
export type HealthCheckStatus = "none" | "starting" | "unhealthy" | "healthy";
8989

0 commit comments

Comments
 (0)