mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-16 16:01:05 +00:00
Add Docker-backed integration test rig
This commit is contained in:
parent
c74d8c9179
commit
abf9b1858f
18 changed files with 1138 additions and 368 deletions
279
sdks/typescript/tests/helpers/docker.ts
Normal file
279
sdks/typescript/tests/helpers/docker.ts
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = resolve(__dirname, "../../../..");
|
||||
const ENSURE_IMAGE = resolve(REPO_ROOT, "scripts/test-rig/ensure-image.sh");
|
||||
const CONTAINER_PORT = 3000;
|
||||
const DEFAULT_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
|
||||
const STANDARD_PATHS = new Set([
|
||||
"/usr/local/sbin",
|
||||
"/usr/local/bin",
|
||||
"/usr/sbin",
|
||||
"/usr/bin",
|
||||
"/sbin",
|
||||
"/bin",
|
||||
]);
|
||||
|
||||
let cachedImage: string | undefined;
|
||||
let containerCounter = 0;
|
||||
|
||||
export type DockerSandboxAgentHandle = {
|
||||
baseUrl: string;
|
||||
token: string;
|
||||
dispose: () => Promise<void>;
|
||||
};
|
||||
|
||||
export type DockerSandboxAgentOptions = {
|
||||
env?: Record<string, string>;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
type TestLayout = {
|
||||
rootDir: string;
|
||||
homeDir: string;
|
||||
xdgDataHome: string;
|
||||
xdgStateHome: string;
|
||||
appDataDir: string;
|
||||
localAppDataDir: string;
|
||||
installDir: string;
|
||||
};
|
||||
|
||||
export function createDockerTestLayout(): TestLayout {
|
||||
const tempRoot = join(REPO_ROOT, ".context", "docker-test-");
|
||||
mkdirSync(resolve(REPO_ROOT, ".context"), { recursive: true });
|
||||
const rootDir = mkdtempSync(tempRoot);
|
||||
const homeDir = join(rootDir, "home");
|
||||
const xdgDataHome = join(rootDir, "xdg-data");
|
||||
const xdgStateHome = join(rootDir, "xdg-state");
|
||||
const appDataDir = join(rootDir, "appdata", "Roaming");
|
||||
const localAppDataDir = join(rootDir, "appdata", "Local");
|
||||
const installDir = join(xdgDataHome, "sandbox-agent", "bin");
|
||||
|
||||
for (const dir of [homeDir, xdgDataHome, xdgStateHome, appDataDir, localAppDataDir, installDir]) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
return {
|
||||
rootDir,
|
||||
homeDir,
|
||||
xdgDataHome,
|
||||
xdgStateHome,
|
||||
appDataDir,
|
||||
localAppDataDir,
|
||||
installDir,
|
||||
};
|
||||
}
|
||||
|
||||
export function disposeDockerTestLayout(layout: TestLayout): void {
|
||||
try {
|
||||
rmSync(layout.rootDir, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
if (
|
||||
typeof process.getuid === "function" &&
|
||||
typeof process.getgid === "function"
|
||||
) {
|
||||
try {
|
||||
execFileSync(
|
||||
"docker",
|
||||
[
|
||||
"run",
|
||||
"--rm",
|
||||
"--user",
|
||||
"0:0",
|
||||
"--entrypoint",
|
||||
"sh",
|
||||
"-v",
|
||||
`${layout.rootDir}:${layout.rootDir}`,
|
||||
ensureImage(),
|
||||
"-c",
|
||||
`chown -R ${process.getuid()}:${process.getgid()} '${layout.rootDir}'`,
|
||||
],
|
||||
{ stdio: "pipe" },
|
||||
);
|
||||
rmSync(layout.rootDir, { recursive: true, force: true });
|
||||
return;
|
||||
} catch {}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function startDockerSandboxAgent(
|
||||
layout: TestLayout,
|
||||
options: DockerSandboxAgentOptions = {},
|
||||
): Promise<DockerSandboxAgentHandle> {
|
||||
const image = ensureImage();
|
||||
const containerId = uniqueContainerId();
|
||||
const env = buildEnv(layout, options.env ?? {});
|
||||
const mounts = buildMounts(layout.rootDir, env);
|
||||
|
||||
const args = [
|
||||
"run",
|
||||
"-d",
|
||||
"--rm",
|
||||
"--name",
|
||||
containerId,
|
||||
"-p",
|
||||
`127.0.0.1::${CONTAINER_PORT}`,
|
||||
];
|
||||
|
||||
if (typeof process.getuid === "function" && typeof process.getgid === "function") {
|
||||
args.push("--user", `${process.getuid()}:${process.getgid()}`);
|
||||
}
|
||||
|
||||
if (process.platform === "linux") {
|
||||
args.push("--add-host", "host.docker.internal:host-gateway");
|
||||
}
|
||||
|
||||
for (const mount of mounts) {
|
||||
args.push("-v", `${mount}:${mount}`);
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
args.push("-e", `${key}=${value}`);
|
||||
}
|
||||
|
||||
args.push(
|
||||
image,
|
||||
"server",
|
||||
"--host",
|
||||
"0.0.0.0",
|
||||
"--port",
|
||||
String(CONTAINER_PORT),
|
||||
"--no-token",
|
||||
);
|
||||
|
||||
execFileSync("docker", args, { stdio: "pipe" });
|
||||
|
||||
try {
|
||||
const mapping = execFileSync("docker", ["port", containerId, `${CONTAINER_PORT}/tcp`], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
}).trim();
|
||||
const hostPort = mapping.split(":").at(-1)?.trim();
|
||||
if (!hostPort) {
|
||||
throw new Error(`missing mapped host port in ${mapping}`);
|
||||
}
|
||||
const baseUrl = `http://127.0.0.1:${hostPort}`;
|
||||
await waitForHealth(baseUrl, options.timeoutMs ?? 30_000);
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
token: "",
|
||||
dispose: async () => {
|
||||
try {
|
||||
execFileSync("docker", ["rm", "-f", containerId], { stdio: "pipe" });
|
||||
} catch {}
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
try {
|
||||
execFileSync("docker", ["rm", "-f", containerId], { stdio: "pipe" });
|
||||
} catch {}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureImage(): string {
|
||||
if (cachedImage) {
|
||||
return cachedImage;
|
||||
}
|
||||
|
||||
cachedImage = execFileSync("bash", [ENSURE_IMAGE], {
|
||||
cwd: REPO_ROOT,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
}).trim();
|
||||
return cachedImage;
|
||||
}
|
||||
|
||||
function buildEnv(layout: TestLayout, extraEnv: Record<string, string>): Record<string, string> {
|
||||
const env: Record<string, string> = {
|
||||
HOME: layout.homeDir,
|
||||
USERPROFILE: layout.homeDir,
|
||||
XDG_DATA_HOME: layout.xdgDataHome,
|
||||
XDG_STATE_HOME: layout.xdgStateHome,
|
||||
APPDATA: layout.appDataDir,
|
||||
LOCALAPPDATA: layout.localAppDataDir,
|
||||
PATH: DEFAULT_PATH,
|
||||
};
|
||||
|
||||
const customPathEntries = new Set<string>();
|
||||
for (const entry of (extraEnv.PATH ?? "").split(":")) {
|
||||
if (!entry || entry === DEFAULT_PATH || !entry.startsWith("/")) continue;
|
||||
if (entry.startsWith(layout.rootDir)) {
|
||||
customPathEntries.add(entry);
|
||||
}
|
||||
}
|
||||
if (customPathEntries.size > 0) {
|
||||
env.PATH = `${Array.from(customPathEntries).join(":")}:${DEFAULT_PATH}`;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(extraEnv)) {
|
||||
if (key === "PATH") {
|
||||
continue;
|
||||
}
|
||||
env[key] = rewriteLocalhostUrl(key, value);
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
function buildMounts(rootDir: string, env: Record<string, string>): string[] {
|
||||
const mounts = new Set<string>([rootDir]);
|
||||
|
||||
for (const key of [
|
||||
"HOME",
|
||||
"USERPROFILE",
|
||||
"XDG_DATA_HOME",
|
||||
"XDG_STATE_HOME",
|
||||
"APPDATA",
|
||||
"LOCALAPPDATA",
|
||||
"SANDBOX_AGENT_DESKTOP_FAKE_STATE_DIR",
|
||||
]) {
|
||||
const value = env[key];
|
||||
if (value?.startsWith("/")) {
|
||||
mounts.add(value);
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of (env.PATH ?? "").split(":")) {
|
||||
if (entry.startsWith("/") && !STANDARD_PATHS.has(entry)) {
|
||||
mounts.add(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(mounts);
|
||||
}
|
||||
|
||||
async function waitForHealth(baseUrl: string, timeoutMs: number): Promise<void> {
|
||||
const started = Date.now();
|
||||
while (Date.now() - started < timeoutMs) {
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/v1/health`);
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
|
||||
throw new Error(`timed out waiting for sandbox-agent health at ${baseUrl}`);
|
||||
}
|
||||
|
||||
function uniqueContainerId(): string {
|
||||
containerCounter += 1;
|
||||
return `sandbox-agent-ts-${process.pid}-${Date.now().toString(36)}-${containerCounter.toString(36)}`;
|
||||
}
|
||||
|
||||
function rewriteLocalhostUrl(key: string, value: string): string {
|
||||
if (key.endsWith("_URL") || key.endsWith("_URI")) {
|
||||
return value
|
||||
.replace("http://127.0.0.1", "http://host.docker.internal")
|
||||
.replace("http://localhost", "http://host.docker.internal");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
|
@ -1,9 +1,6 @@
|
|||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { existsSync } from "node:fs";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { tmpdir } from "node:os";
|
||||
import {
|
||||
InMemorySessionPersistDriver,
|
||||
|
|
@ -14,36 +11,16 @@ import {
|
|||
type SessionPersistDriver,
|
||||
type SessionRecord,
|
||||
} from "../src/index.ts";
|
||||
import { spawnSandboxAgent, isNodeRuntime, type SandboxAgentSpawnHandle } from "../src/spawn.ts";
|
||||
import { isNodeRuntime } from "../src/spawn.ts";
|
||||
import {
|
||||
createDockerTestLayout,
|
||||
disposeDockerTestLayout,
|
||||
startDockerSandboxAgent,
|
||||
type DockerSandboxAgentHandle,
|
||||
} from "./helpers/docker.ts";
|
||||
import { prepareMockAgentDataHome } from "./helpers/mock-agent.ts";
|
||||
import WebSocket from "ws";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function findBinary(): string | null {
|
||||
if (process.env.SANDBOX_AGENT_BIN) {
|
||||
return process.env.SANDBOX_AGENT_BIN;
|
||||
}
|
||||
|
||||
const cargoPaths = [resolve(__dirname, "../../../target/debug/sandbox-agent"), resolve(__dirname, "../../../target/release/sandbox-agent")];
|
||||
|
||||
for (const p of cargoPaths) {
|
||||
if (existsSync(p)) {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const BINARY_PATH = findBinary();
|
||||
if (!BINARY_PATH) {
|
||||
throw new Error("sandbox-agent binary not found. Build it (cargo build -p sandbox-agent) or set SANDBOX_AGENT_BIN.");
|
||||
}
|
||||
if (!process.env.SANDBOX_AGENT_BIN) {
|
||||
process.env.SANDBOX_AGENT_BIN = BINARY_PATH;
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
|
@ -174,7 +151,7 @@ function decodeProcessLogData(data: string, encoding: string): string {
|
|||
|
||||
function nodeCommand(source: string): { command: string; args: string[] } {
|
||||
return {
|
||||
command: process.execPath,
|
||||
command: "node",
|
||||
args: ["-e", source],
|
||||
};
|
||||
}
|
||||
|
|
@ -322,32 +299,29 @@ esac
|
|||
}
|
||||
|
||||
describe("Integration: TypeScript SDK flat session API", () => {
|
||||
let handle: SandboxAgentSpawnHandle;
|
||||
let handle: DockerSandboxAgentHandle;
|
||||
let baseUrl: string;
|
||||
let token: string;
|
||||
let dataHome: string;
|
||||
let desktopHome: string;
|
||||
let layout: ReturnType<typeof createDockerTestLayout>;
|
||||
|
||||
beforeAll(async () => {
|
||||
dataHome = mkdtempSync(join(tmpdir(), "sdk-integration-"));
|
||||
desktopHome = mkdtempSync(join(tmpdir(), "sdk-desktop-"));
|
||||
const agentEnv = prepareMockAgentDataHome(dataHome);
|
||||
const desktopEnv = prepareFakeDesktopEnv(desktopHome);
|
||||
beforeEach(async () => {
|
||||
layout = createDockerTestLayout();
|
||||
prepareMockAgentDataHome(layout.xdgDataHome);
|
||||
const desktopEnv = prepareFakeDesktopEnv(layout.rootDir);
|
||||
|
||||
handle = await spawnSandboxAgent({
|
||||
enabled: true,
|
||||
log: "silent",
|
||||
handle = await startDockerSandboxAgent(layout, {
|
||||
timeoutMs: 30000,
|
||||
env: { ...agentEnv, ...desktopEnv },
|
||||
env: desktopEnv,
|
||||
});
|
||||
baseUrl = handle.baseUrl;
|
||||
token = handle.token;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await handle.dispose();
|
||||
rmSync(dataHome, { recursive: true, force: true });
|
||||
rmSync(desktopHome, { recursive: true, force: true });
|
||||
afterEach(async () => {
|
||||
await handle?.dispose?.();
|
||||
if (layout) {
|
||||
disposeDockerTestLayout(layout);
|
||||
}
|
||||
});
|
||||
|
||||
it("detects Node.js runtime", () => {
|
||||
|
|
@ -426,11 +400,12 @@ describe("Integration: TypeScript SDK flat session API", () => {
|
|||
token,
|
||||
});
|
||||
|
||||
const directory = mkdtempSync(join(tmpdir(), "sdk-fs-"));
|
||||
const directory = join(layout.rootDir, "fs-test");
|
||||
const nestedDir = join(directory, "nested");
|
||||
const filePath = join(directory, "notes.txt");
|
||||
const movedPath = join(directory, "notes-moved.txt");
|
||||
const uploadDir = join(directory, "uploaded");
|
||||
mkdirSync(directory, { recursive: true });
|
||||
|
||||
try {
|
||||
const listedAgents = await sdk.listAgents({ config: true, noCache: true });
|
||||
|
|
@ -856,7 +831,9 @@ describe("Integration: TypeScript SDK flat session API", () => {
|
|||
token,
|
||||
});
|
||||
|
||||
const directory = mkdtempSync(join(tmpdir(), "sdk-config-"));
|
||||
const directory = join(layout.rootDir, "config-test");
|
||||
|
||||
mkdirSync(directory, { recursive: true });
|
||||
|
||||
const mcpConfig = {
|
||||
type: "local" as const,
|
||||
|
|
|
|||
|
|
@ -4,5 +4,6 @@ export default defineConfig({
|
|||
test: {
|
||||
include: ["tests/**/*.test.ts"],
|
||||
testTimeout: 30000,
|
||||
hookTimeout: 120000,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue