Skip to content

Commit 112214f

Browse files
committed
feat: US-192 - Create shared Docker container test utility for integration fixtures
1 parent 2b08441 commit 112214f

2 files changed

Lines changed: 303 additions & 0 deletions

File tree

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { execSync } from "node:child_process";
2+
import { describe, it, expect, afterAll } from "vitest";
3+
import { startContainer, skipUnlessDocker } from "./docker.ts";
4+
import type { Container } from "./docker.ts";
5+
6+
const skipReason = skipUnlessDocker();
7+
8+
describe.skipIf(skipReason)("Docker test utility", () => {
9+
const containers: Container[] = [];
10+
11+
afterAll(() => {
12+
for (const c of containers) c.stop();
13+
});
14+
15+
it("starts alpine, execs 'echo ok', and stops cleanly", () => {
16+
const container = startContainer("alpine:latest", {
17+
command: ["sleep", "30"],
18+
});
19+
containers.push(container);
20+
21+
expect(container.containerId).toBeTruthy();
22+
expect(container.host).toBe("127.0.0.1");
23+
24+
// Exec inside the running container
25+
const output = execSync(`docker exec ${container.containerId} echo ok`, {
26+
encoding: "utf-8",
27+
timeout: 10_000,
28+
}).trim();
29+
expect(output).toBe("ok");
30+
31+
// Stop and verify removal
32+
container.stop();
33+
34+
// Second stop should be safe (idempotent)
35+
container.stop();
36+
37+
// Container should be gone
38+
const ps = execSync(
39+
`docker ps -a --filter id=${container.containerId} --format "{{.ID}}"`,
40+
{ encoding: "utf-8", timeout: 5_000 },
41+
).trim();
42+
expect(ps).toBe("");
43+
});
44+
45+
it("starts container with port mapping and resolves host port", () => {
46+
// Use a simple nginx to test port mapping
47+
const container = startContainer("alpine:latest", {
48+
ports: { 80: 0 },
49+
command: ["sh", "-c", "while true; do echo -e 'HTTP/1.1 200 OK\\r\\n\\r\\nok' | nc -l -p 80; done"],
50+
});
51+
containers.push(container);
52+
53+
expect(container.port).toBeGreaterThan(0);
54+
expect(container.ports[80]).toBeGreaterThan(0);
55+
expect(container.ports[80]).toBe(container.port);
56+
57+
container.stop();
58+
});
59+
60+
it("passes health check before returning", () => {
61+
const container = startContainer("alpine:latest", {
62+
command: ["sh", "-c", "sleep 1 && touch /tmp/ready && sleep 30"],
63+
healthCheck: ["test", "-f", "/tmp/ready"],
64+
healthCheckTimeout: 10_000,
65+
healthCheckInterval: 200,
66+
});
67+
containers.push(container);
68+
69+
// If we get here, health check passed
70+
expect(container.containerId).toBeTruthy();
71+
72+
container.stop();
73+
});
74+
75+
it("sets environment variables in the container", () => {
76+
const container = startContainer("alpine:latest", {
77+
env: { TEST_VAR: "hello_world" },
78+
command: ["sleep", "30"],
79+
});
80+
containers.push(container);
81+
82+
const output = execSync(
83+
`docker exec ${container.containerId} sh -c 'echo $TEST_VAR'`,
84+
{ encoding: "utf-8", timeout: 10_000 },
85+
).trim();
86+
expect(output).toBe("hello_world");
87+
88+
container.stop();
89+
});
90+
});
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
/**
2+
* Shared Docker container utility for integration tests.
3+
*
4+
* Spins up containers via the docker CLI, waits for health checks,
5+
* and tears them down after tests. Automatically skips the enclosing
6+
* test suite when Docker is not available on the host.
7+
*/
8+
9+
import { execFileSync, execSync } from "node:child_process";
10+
import { randomBytes } from "node:crypto";
11+
12+
/* ------------------------------------------------------------------ */
13+
/* Docker availability check */
14+
/* ------------------------------------------------------------------ */
15+
16+
let dockerAvailable: boolean | undefined;
17+
18+
function isDockerAvailable(): boolean {
19+
if (dockerAvailable !== undefined) return dockerAvailable;
20+
try {
21+
execSync("docker info", { stdio: "ignore", timeout: 5_000 });
22+
dockerAvailable = true;
23+
} catch {
24+
dockerAvailable = false;
25+
}
26+
return dockerAvailable;
27+
}
28+
29+
/**
30+
* Skip helper matching the project convention (`describe.skipIf(reason)`).
31+
* Returns a reason string when Docker is unavailable, or `false` when ready.
32+
*/
33+
export function skipUnlessDocker(): string | false {
34+
return isDockerAvailable()
35+
? false
36+
: "Docker is not available on this host";
37+
}
38+
39+
/* ------------------------------------------------------------------ */
40+
/* Types */
41+
/* ------------------------------------------------------------------ */
42+
43+
export interface StartContainerOptions {
44+
/** Port mappings — keys are container ports, values are host ports (0 = auto-assign). */
45+
ports?: Record<number, number>;
46+
/** Environment variables passed to the container. */
47+
env?: Record<string, string>;
48+
/** Command + args to run inside the container for the health check (via `docker exec`). */
49+
healthCheck?: string[];
50+
/** Maximum time (ms) to wait for the health check to pass. Default 30 000. */
51+
healthCheckTimeout?: number;
52+
/** Interval (ms) between health check retries. Default 500. */
53+
healthCheckInterval?: number;
54+
/** Extra arguments appended to `docker run`. */
55+
args?: string[];
56+
/** Command override (appended after the image name). */
57+
command?: string[];
58+
}
59+
60+
export interface Container {
61+
/** Container ID (full SHA). */
62+
containerId: string;
63+
/** Host address — always "127.0.0.1" for local Docker. */
64+
host: string;
65+
/** Host port mapped to the *first* entry in `opts.ports`. */
66+
port: number;
67+
/** All resolved host→container port mappings. */
68+
ports: Record<number, number>;
69+
/** Idempotent stop + remove. Safe to call multiple times. */
70+
stop: () => void;
71+
}
72+
73+
/* ------------------------------------------------------------------ */
74+
/* Core implementation */
75+
/* ------------------------------------------------------------------ */
76+
77+
/**
78+
* Pull an image if it is not already present locally.
79+
*/
80+
function ensureImage(image: string): void {
81+
try {
82+
execFileSync("docker", ["image", "inspect", image], {
83+
stdio: "ignore",
84+
timeout: 10_000,
85+
});
86+
} catch {
87+
// Image not present — pull it
88+
execFileSync("docker", ["pull", image], {
89+
stdio: "ignore",
90+
timeout: 120_000,
91+
});
92+
}
93+
}
94+
95+
/**
96+
* Start a Docker container and optionally wait for a health check.
97+
*
98+
* @throws if Docker is unavailable, the image cannot be pulled, or the
99+
* health check does not pass within the configured timeout.
100+
*/
101+
export function startContainer(
102+
image: string,
103+
opts: StartContainerOptions = {},
104+
): Container {
105+
if (!isDockerAvailable()) {
106+
throw new Error("Docker is not available on this host");
107+
}
108+
109+
ensureImage(image);
110+
111+
const label = `secure-exec-test-${randomBytes(6).toString("hex")}`;
112+
const args: string[] = ["run", "-d", "--label", label];
113+
114+
// Port mappings
115+
const requestedPorts = opts.ports ?? {};
116+
for (const [containerPort, hostPort] of Object.entries(requestedPorts)) {
117+
args.push("-p", `${hostPort}:${containerPort}`);
118+
}
119+
120+
// Environment variables
121+
for (const [k, v] of Object.entries(opts.env ?? {})) {
122+
args.push("-e", `${k}=${v}`);
123+
}
124+
125+
// Extra args
126+
if (opts.args) args.push(...opts.args);
127+
128+
// Image + optional command
129+
args.push(image);
130+
if (opts.command) args.push(...opts.command);
131+
132+
const containerId = execFileSync("docker", args, {
133+
encoding: "utf-8",
134+
timeout: 30_000,
135+
}).trim();
136+
137+
// Resolve actual host ports (handles 0 = auto-assign)
138+
const resolvedPorts: Record<number, number> = {};
139+
for (const containerPort of Object.keys(requestedPorts)) {
140+
const mapped = execFileSync(
141+
"docker",
142+
["port", containerId, String(containerPort)],
143+
{ encoding: "utf-8", timeout: 5_000 },
144+
).trim();
145+
// Output format: "0.0.0.0:12345" or "[::]:12345" — grab the port
146+
const match = mapped.match(/:(\d+)$/m);
147+
resolvedPorts[Number(containerPort)] = match
148+
? Number(match[1])
149+
: Number(containerPort);
150+
}
151+
152+
// Build stop() — idempotent
153+
let stopped = false;
154+
const stop = (): void => {
155+
if (stopped) return;
156+
stopped = true;
157+
try {
158+
execFileSync("docker", ["rm", "-f", containerId], {
159+
stdio: "ignore",
160+
timeout: 15_000,
161+
});
162+
} catch {
163+
// Container may already be gone — ignore
164+
}
165+
};
166+
167+
// First port value (convenience)
168+
const firstPort =
169+
Object.values(resolvedPorts)[0] ??
170+
Number(Object.keys(requestedPorts)[0]) ??
171+
0;
172+
173+
const container: Container = {
174+
containerId,
175+
host: "127.0.0.1",
176+
port: firstPort,
177+
ports: resolvedPorts,
178+
stop,
179+
};
180+
181+
// Health check loop
182+
if (opts.healthCheck && opts.healthCheck.length > 0) {
183+
const timeout = opts.healthCheckTimeout ?? 30_000;
184+
const interval = opts.healthCheckInterval ?? 500;
185+
const deadline = Date.now() + timeout;
186+
let lastError: unknown;
187+
188+
while (Date.now() < deadline) {
189+
try {
190+
execFileSync(
191+
"docker",
192+
["exec", containerId, ...opts.healthCheck],
193+
{ stdio: "ignore", timeout: 10_000 },
194+
);
195+
// Health check passed
196+
return container;
197+
} catch (err) {
198+
lastError = err;
199+
// Sleep before retry (use Atomics.wait for precise ms sleep without shell)
200+
const buf = new SharedArrayBuffer(4);
201+
Atomics.wait(new Int32Array(buf), 0, 0, interval);
202+
}
203+
}
204+
205+
// Timed out — clean up and throw
206+
stop();
207+
throw new Error(
208+
`Health check for ${image} did not pass within ${timeout}ms: ${lastError}`,
209+
);
210+
}
211+
212+
return container;
213+
}

0 commit comments

Comments
 (0)