Skip to content

Commit 8c37b2c

Browse files
committed
🤖 test: add DockerRuntime to runtime integration matrix
Adds a third runtime type (docker) to tests/runtime/runtime.test.ts by spinning up a dedicated docker container with bash+git+timeout and running the existing runtime contract suite against DockerRuntime. Also aligns DockerRuntime writeFile/resolvePath behavior with the contract tests (symlink-preserving atomic writes and ~ expansion).
1 parent cbc0bc4 commit 8c37b2c

File tree

5 files changed

+184
-16
lines changed

5 files changed

+184
-16
lines changed

src/node/runtime/DockerRuntime.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -190,11 +190,27 @@ export class DockerRuntime extends RemoteRuntime {
190190
return { process };
191191
}
192192

193+
/**
194+
* Override buildWriteCommand to preserve symlinks and file permissions.
195+
*
196+
* This matches SSHRuntime behavior: write through the symlink to the final target,
197+
* while keeping the symlink itself intact.
198+
*/
199+
protected buildWriteCommand(quotedPath: string, quotedTempPath: string): string {
200+
return `RESOLVED=$(readlink -f ${quotedPath} 2>/dev/null || echo ${quotedPath}) && PERMS=$(stat -c '%a' "$RESOLVED" 2>/dev/null || echo 600) && mkdir -p $(dirname "$RESOLVED") && cat > ${quotedTempPath} && chmod "$PERMS" ${quotedTempPath} && mv ${quotedTempPath} "$RESOLVED"`;
201+
}
193202
// ===== Runtime interface implementations =====
194203

195204
resolvePath(filePath: string): Promise<string> {
196-
// Inside container, paths are already absolute
197-
// Just return as-is since we use fixed /src path
205+
// DockerRuntime uses a fixed workspace base (/src), but we still want reasonable shell-style
206+
// behavior for callers that pass "~" or "~/...".
207+
if (filePath === "~") {
208+
return Promise.resolve("/root");
209+
}
210+
if (filePath.startsWith("~/")) {
211+
return Promise.resolve(path.posix.join("/root", filePath.slice(2)));
212+
}
213+
198214
return Promise.resolve(
199215
filePath.startsWith("/") ? filePath : path.posix.join(CONTAINER_SRC_DIR, filePath)
200216
);

tests/runtime/runtime.test.ts

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ import {
2424
stopSSHServer,
2525
type SSHServerConfig,
2626
} from "./test-fixtures/ssh-fixture";
27+
import {
28+
startDockerRuntimeContainer,
29+
stopDockerRuntimeContainer,
30+
type DockerRuntimeTestConfig,
31+
} from "./test-fixtures/docker-fixture";
2732
import { createTestRuntime, TestWorkspace, type RuntimeType } from "./test-fixtures/test-helpers";
2833
import { execBuffered, readFileString, writeFileString } from "@/node/utils/runtime/helpers";
2934
import type { Runtime } from "@/node/runtime/Runtime";
@@ -35,6 +40,9 @@ const describeIntegration = shouldRunIntegrationTests() ? describe : describe.sk
3540
// SSH server config (shared across all tests)
3641
let sshConfig: SSHServerConfig | undefined;
3742

43+
// DockerRuntime container config (shared across all tests)
44+
let dockerConfig: DockerRuntimeTestConfig | undefined;
45+
3846
describeIntegration("Runtime integration tests", () => {
3947
beforeAll(async () => {
4048
// Check if Docker is available (required for SSH tests)
@@ -48,24 +56,42 @@ describeIntegration("Runtime integration tests", () => {
4856
console.log("Starting SSH server container...");
4957
sshConfig = await startSSHServer();
5058
console.log(`SSH server ready on port ${sshConfig.port}`);
51-
}, 60000); // 60s timeout for Docker operations
59+
60+
console.log("Starting DockerRuntime test container...");
61+
dockerConfig = await startDockerRuntimeContainer();
62+
console.log(`DockerRuntime container ready: ${dockerConfig.containerName}`);
63+
}, 120000); // 120s timeout for Docker build/start operations
5264

5365
afterAll(async () => {
66+
if (dockerConfig) {
67+
console.log("Stopping DockerRuntime test container...");
68+
await stopDockerRuntimeContainer(dockerConfig);
69+
}
70+
5471
if (sshConfig) {
5572
console.log("Stopping SSH server container...");
5673
await stopSSHServer(sshConfig);
5774
}
5875
}, 30000);
5976

60-
// Test matrix: Run all tests for both local and SSH runtimes
61-
describe.each<{ type: RuntimeType }>([{ type: "local" }, { type: "ssh" }])(
77+
// Test matrix: Run all tests for local, SSH, and Docker runtimes
78+
describe.each<{ type: RuntimeType }>([{ type: "local" }, { type: "ssh" }, { type: "docker" }])(
6279
"Runtime: $type",
6380
({ type }) => {
6481
// Helper to create runtime for this test type
6582
// Use a base working directory - TestWorkspace will create subdirectories as needed
6683
// For local runtime, use os.tmpdir() which matches where TestWorkspace creates directories
67-
const getBaseWorkdir = () => (type === "ssh" ? sshConfig!.workdir : os.tmpdir());
68-
const createRuntime = (): Runtime => createTestRuntime(type, getBaseWorkdir(), sshConfig);
84+
const getBaseWorkdir = () => {
85+
if (type === "ssh") {
86+
return sshConfig!.workdir;
87+
}
88+
if (type === "docker") {
89+
return "/src";
90+
}
91+
return os.tmpdir();
92+
};
93+
const createRuntime = (): Runtime =>
94+
createTestRuntime(type, getBaseWorkdir(), sshConfig, dockerConfig);
6995

7096
describe("exec() - Command execution", () => {
7197
test.concurrent("captures stdout and stderr separately", async () => {
@@ -201,6 +227,8 @@ describeIntegration("Runtime integration tests", () => {
201227

202228
if (type === "ssh") {
203229
expect(resolved).toBe("/home/testuser");
230+
} else if (type === "docker") {
231+
expect(resolved).toBe("/root");
204232
} else {
205233
expect(resolved).toBe(os.homedir());
206234
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/**
2+
* Docker container fixture for DockerRuntime integration tests.
3+
*
4+
* Runs a minimal container with bash + git + timeout available.
5+
*/
6+
7+
import * as crypto from "crypto";
8+
import { spawn } from "child_process";
9+
10+
export interface DockerRuntimeTestConfig {
11+
image: string;
12+
containerName: string;
13+
}
14+
15+
export async function startDockerRuntimeContainer(): Promise<DockerRuntimeTestConfig> {
16+
// Build image used for DockerRuntime tests
17+
const dockerfilePath = `${__dirname}/docker-runtime`;
18+
const image = "mux-docker-runtime-test";
19+
20+
await execCommand("docker", ["build", "-t", image, dockerfilePath], { timeout: 60000 });
21+
22+
const containerName = `mux-docker-runtime-test-${crypto.randomBytes(8).toString("hex")}`;
23+
24+
// Start container in the background. Image CMD keeps it alive.
25+
const runResult = await execCommand(
26+
"docker",
27+
["run", "-d", "--name", containerName, "--rm", image],
28+
{ timeout: 60000 }
29+
);
30+
31+
const containerId = runResult.stdout.trim();
32+
if (!containerId) {
33+
throw new Error("Failed to start docker runtime test container");
34+
}
35+
36+
// Create /src directory (DockerRuntime expects it)
37+
await execCommand("docker", ["exec", containerName, "mkdir", "-p", "/src"], {
38+
timeout: 10000,
39+
});
40+
41+
return { image, containerName };
42+
}
43+
44+
export async function stopDockerRuntimeContainer(config: DockerRuntimeTestConfig): Promise<void> {
45+
try {
46+
await execCommand("docker", ["stop", config.containerName], { timeout: 30000 });
47+
} catch (error) {
48+
console.error("Error stopping docker runtime test container:", error);
49+
}
50+
}
51+
52+
function execCommand(
53+
command: string,
54+
args: string[],
55+
options?: { timeout?: number }
56+
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
57+
return new Promise((resolve, reject) => {
58+
let stdout = "";
59+
let stderr = "";
60+
let timedOut = false;
61+
62+
const child = spawn(command, args);
63+
64+
const timeout = options?.timeout
65+
? setTimeout(() => {
66+
timedOut = true;
67+
child.kill();
68+
reject(new Error(`Command timed out: ${command} ${args.join(" ")}`));
69+
}, options.timeout)
70+
: undefined;
71+
72+
child.stdout.on("data", (data: Buffer) => {
73+
stdout += data.toString();
74+
});
75+
76+
child.stderr.on("data", (data: Buffer) => {
77+
stderr += data.toString();
78+
});
79+
80+
child.on("close", (code) => {
81+
if (timeout) {
82+
clearTimeout(timeout);
83+
}
84+
if (timedOut) {
85+
return;
86+
}
87+
resolve({ stdout, stderr, exitCode: code ?? -1 });
88+
});
89+
90+
child.on("error", (error) => {
91+
if (timeout) {
92+
clearTimeout(timeout);
93+
}
94+
if (timedOut) {
95+
return;
96+
}
97+
reject(error);
98+
});
99+
});
100+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
FROM alpine:latest
2+
3+
# bash is required for mux remote command execution (forces bash)
4+
# git is needed for runtime contract tests (git init/commit/branch)
5+
# coreutils provides GNU timeout with -s support
6+
RUN apk add --no-cache bash git coreutils
7+
8+
# Keep the container alive for the duration of the test suite
9+
CMD ["sleep", "infinity"]

tests/runtime/test-fixtures/test-helpers.ts

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,22 +8,25 @@ import * as os from "os";
88
import * as path from "path";
99
import type { Runtime } from "@/node/runtime/Runtime";
1010
import { WorktreeRuntime } from "@/node/runtime/WorktreeRuntime";
11+
import { DockerRuntime } from "@/node/runtime/DockerRuntime";
12+
import type { DockerRuntimeTestConfig } from "./docker-fixture";
1113
import { SSHRuntime } from "@/node/runtime/SSHRuntime";
1214
import type { SSHServerConfig } from "./ssh-fixture";
1315

1416
/**
1517
* Runtime type for test matrix
1618
* Note: "local" here means worktree runtime (isolated git worktrees), not project-dir runtime
1719
*/
18-
export type RuntimeType = "local" | "ssh";
20+
export type RuntimeType = "local" | "ssh" | "docker";
1921

2022
/**
2123
* Create runtime instance based on type
2224
*/
2325
export function createTestRuntime(
2426
type: RuntimeType,
2527
workdir: string,
26-
sshConfig?: SSHServerConfig
28+
sshConfig?: SSHServerConfig,
29+
dockerConfig?: DockerRuntimeTestConfig
2730
): Runtime {
2831
switch (type) {
2932
case "local": {
@@ -43,6 +46,15 @@ export function createTestRuntime(
4346
port: sshConfig.port,
4447
});
4548
}
49+
case "docker": {
50+
if (!dockerConfig) {
51+
throw new Error("Docker config required for Docker runtime");
52+
}
53+
return new DockerRuntime({
54+
image: dockerConfig.image,
55+
containerName: dockerConfig.containerName,
56+
});
57+
}
4658
}
4759
}
4860

@@ -64,18 +76,20 @@ export class TestWorkspace {
6476
* Create a test workspace with isolated directory
6577
*/
6678
static async create(runtime: Runtime, type: RuntimeType): Promise<TestWorkspace> {
67-
const isRemote = type === "ssh";
79+
const isRemote = type !== "local";
6880

6981
if (isRemote) {
70-
// For SSH, create subdirectory in remote workdir
71-
// The path is already set in SSHRuntime config
72-
// Create a unique subdirectory
82+
// For SSH/Docker, create a unique subdirectory in the runtime's filesystem.
7383
const testId = `test-${Date.now()}-${Math.random().toString(36).substring(7)}`;
74-
const workspacePath = `/home/testuser/workspace/${testId}`;
84+
85+
const workspacePath =
86+
type === "ssh" ? `/home/testuser/workspace/${testId}` : `/src/${testId}`;
87+
88+
const cwd = type === "ssh" ? "/home/testuser" : "/";
7589

7690
// Create directory on remote
7791
const stream = await runtime.exec(`mkdir -p ${workspacePath}`, {
78-
cwd: "/home/testuser",
92+
cwd,
7993
timeout: 30,
8094
});
8195
await stream.stdin.close();
@@ -102,8 +116,9 @@ export class TestWorkspace {
102116
if (this.isRemote) {
103117
// Remove remote directory
104118
try {
119+
const cwd = this.path.startsWith("/home/testuser") ? "/home/testuser" : "/";
105120
const stream = await this.runtime.exec(`rm -rf ${this.path}`, {
106-
cwd: "/home/testuser",
121+
cwd,
107122
timeout: 60,
108123
});
109124
await stream.stdin.close();

0 commit comments

Comments
 (0)