first commit
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { loadConfig } from "../src/config.js";
|
||||
|
||||
describe("loadConfig", () => {
|
||||
it("requires authentication unless explicitly disabled", () => {
|
||||
expect(() => loadConfig({}, "/tmp")).toThrow("MCP_AUTH_TOKEN is required");
|
||||
expect(loadConfig({ MCP_ALLOW_NO_AUTH: "true" }, "/tmp").allowNoAuth).toBe(true);
|
||||
});
|
||||
|
||||
it("loads full-access host settings", () => {
|
||||
const config = loadConfig(
|
||||
{
|
||||
MCP_AUTH_TOKEN: "secret",
|
||||
MCP_PORT: "4321",
|
||||
MCP_DEFAULT_CWD: "/",
|
||||
MCP_ALLOWED_HOSTS: "mcp.example.com,localhost",
|
||||
},
|
||||
"/tmp",
|
||||
);
|
||||
|
||||
expect(config).toMatchObject({
|
||||
port: 4321,
|
||||
defaultCwd: "/",
|
||||
authToken: "secret",
|
||||
allowedHosts: ["mcp.example.com", "localhost"],
|
||||
});
|
||||
});
|
||||
|
||||
it("requires public HTTPS metadata when OAuth is enabled", () => {
|
||||
expect(() =>
|
||||
loadConfig({ MCP_AUTH_TOKEN: "secret", MCP_OAUTH_ENABLED: "true" }, "/tmp"),
|
||||
).toThrow("MCP_OAUTH_ISSUER is required");
|
||||
|
||||
const config = loadConfig(
|
||||
{
|
||||
MCP_AUTH_TOKEN: "secret",
|
||||
MCP_OAUTH_ENABLED: "true",
|
||||
MCP_PUBLIC_URL: "https://mcp.example.com",
|
||||
MCP_OAUTH_STATE_FILE: "/tmp/oauth-state.json",
|
||||
},
|
||||
"/tmp",
|
||||
);
|
||||
expect(config).toMatchObject({
|
||||
oauthEnabled: true,
|
||||
oauthIssuerUrl: "https://mcp.example.com/",
|
||||
oauthResourceUrl: "https://mcp.example.com/mcp",
|
||||
oauthStateFile: "/tmp/oauth-state.json",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { FileService } from "../src/file-service.js";
|
||||
|
||||
describe("FileService", () => {
|
||||
let temporaryDirectory: string;
|
||||
let files: FileService;
|
||||
|
||||
beforeEach(async () => {
|
||||
temporaryDirectory = await mkdtemp(path.join(os.tmpdir(), "remote-dev-mcp-test-"));
|
||||
files = new FileService({
|
||||
defaultCwd: temporaryDirectory,
|
||||
maxChunkBytes: 1024 * 1024,
|
||||
maxEditFileBytes: 1024 * 1024,
|
||||
maxOutputBytes: 1024 * 1024,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(temporaryDirectory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("writes, reads, lists, and replaces text", async () => {
|
||||
await files.writeFileContent(
|
||||
"src/example.txt",
|
||||
undefined,
|
||||
"alpha beta\n",
|
||||
"utf8",
|
||||
"overwrite",
|
||||
true,
|
||||
);
|
||||
await files.replaceInFile(
|
||||
"src/example.txt",
|
||||
undefined,
|
||||
"beta",
|
||||
"gamma",
|
||||
false,
|
||||
1,
|
||||
);
|
||||
|
||||
const read = await files.readFileChunk(
|
||||
"src/example.txt",
|
||||
undefined,
|
||||
0,
|
||||
1024,
|
||||
"utf8",
|
||||
);
|
||||
const listed = await files.listDirectory(".", undefined, {
|
||||
recursive: true,
|
||||
includeMetadata: true,
|
||||
});
|
||||
|
||||
expect(read.content).toBe("alpha gamma\n");
|
||||
expect(read.eof).toBe(true);
|
||||
expect(listed.entries).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ relativePath: path.join("src", "example.txt") }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("uploads and downloads binary chunks with offsets", async () => {
|
||||
const first = await files.uploadChunk(
|
||||
"artifact.bin",
|
||||
undefined,
|
||||
Buffer.from("hello").toString("base64"),
|
||||
0,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
await files.uploadChunk(
|
||||
"artifact.bin",
|
||||
undefined,
|
||||
Buffer.from(" world").toString("base64"),
|
||||
first.nextOffset as number,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
|
||||
const downloaded = await files.downloadChunk(
|
||||
"artifact.bin",
|
||||
undefined,
|
||||
0,
|
||||
1024,
|
||||
);
|
||||
const hashed = await files.hashFile("artifact.bin", undefined, "sha256");
|
||||
|
||||
expect(Buffer.from(downloaded.dataBase64 as string, "base64").toString()).toBe(
|
||||
"hello world",
|
||||
);
|
||||
expect(downloaded.eof).toBe(true);
|
||||
expect(hashed.digest).toBe(
|
||||
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9",
|
||||
);
|
||||
});
|
||||
|
||||
it("validates and applies a unified diff", async () => {
|
||||
await writeFile(path.join(temporaryDirectory, "patch.txt"), "old\n", "utf8");
|
||||
const patchText = [
|
||||
"diff --git a/patch.txt b/patch.txt",
|
||||
"--- a/patch.txt",
|
||||
"+++ b/patch.txt",
|
||||
"@@ -1 +1 @@",
|
||||
"-old",
|
||||
"+new",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
const checked = await files.applyPatch(patchText, undefined, {
|
||||
checkOnly: true,
|
||||
reverse: false,
|
||||
threeWay: false,
|
||||
});
|
||||
const applied = await files.applyPatch(patchText, undefined, {
|
||||
checkOnly: false,
|
||||
reverse: false,
|
||||
threeWay: false,
|
||||
});
|
||||
|
||||
expect(checked.applied).toBe(false);
|
||||
expect(applied.applied).toBe(true);
|
||||
expect(await readFile(path.join(temporaryDirectory, "patch.txt"), "utf8")).toBe(
|
||||
"new\n",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import { loadConfig, type AppConfig } from "../src/config.js";
|
||||
import { startHttpServer, type RunningHttpServer } from "../src/http-server.js";
|
||||
import { createServices, type McpServices } from "../src/mcp-server.js";
|
||||
|
||||
describe("remote development MCP server", () => {
|
||||
let temporaryDirectory: string;
|
||||
let config: AppConfig;
|
||||
let services: McpServices;
|
||||
let running: RunningHttpServer;
|
||||
let endpoint: URL;
|
||||
|
||||
beforeAll(async () => {
|
||||
temporaryDirectory = await mkdtemp(path.join(os.tmpdir(), "remote-dev-mcp-http-test-"));
|
||||
config = loadConfig(
|
||||
{
|
||||
MCP_AUTH_TOKEN: "integration-secret",
|
||||
MCP_HOST: "127.0.0.1",
|
||||
MCP_DEFAULT_CWD: temporaryDirectory,
|
||||
MCP_MAX_FILE_CHUNK_BYTES: "65536",
|
||||
},
|
||||
temporaryDirectory,
|
||||
);
|
||||
config.port = 0;
|
||||
services = createServices(config);
|
||||
running = await startHttpServer(config, services);
|
||||
const address = running.httpServer.address() as AddressInfo;
|
||||
endpoint = new URL(`http://127.0.0.1:${address.port}${config.endpoint}`);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await running.close();
|
||||
await rm(temporaryDirectory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("rejects unauthenticated MCP initialization", async () => {
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "initialize",
|
||||
params: {
|
||||
protocolVersion: "2025-11-25",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "test", version: "1" },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it("lists tools and executes script and file workflows", async () => {
|
||||
const client = new Client({ name: "integration-test", version: "1.0.0" });
|
||||
const transport = new StreamableHTTPClientTransport(endpoint, {
|
||||
requestInit: {
|
||||
headers: { Authorization: "Bearer integration-secret" },
|
||||
},
|
||||
});
|
||||
await client.connect(transport);
|
||||
try {
|
||||
expect(client.getServerVersion()).toMatchObject({
|
||||
name: "cokacremote",
|
||||
version: "0.1.0",
|
||||
});
|
||||
const tools = await client.listTools();
|
||||
expect(tools.tools.map((tool) => tool.name)).toEqual(
|
||||
expect.arrayContaining([
|
||||
"exec_command",
|
||||
"run_script",
|
||||
"write_stdin",
|
||||
"read_file",
|
||||
"write_file",
|
||||
"apply_patch",
|
||||
"upload_file",
|
||||
"download_file",
|
||||
]),
|
||||
);
|
||||
|
||||
const scriptResult = await client.callTool({
|
||||
name: "run_script",
|
||||
arguments: {
|
||||
runtime: "node",
|
||||
script: "console.log(6 * 7)",
|
||||
yieldTimeMs: 2000,
|
||||
},
|
||||
});
|
||||
expect(scriptResult.isError).not.toBe(true);
|
||||
expect(scriptResult.structuredContent).toMatchObject({
|
||||
completed: true,
|
||||
exitCode: 0,
|
||||
stdout: "42\n",
|
||||
});
|
||||
|
||||
const writeResult = await client.callTool({
|
||||
name: "write_file",
|
||||
arguments: { path: "hello.txt", content: "hello MCP\n" },
|
||||
});
|
||||
expect(writeResult.isError).not.toBe(true);
|
||||
|
||||
const readResult = await client.callTool({
|
||||
name: "read_file",
|
||||
arguments: { path: "hello.txt" },
|
||||
});
|
||||
expect(readResult.structuredContent).toMatchObject({
|
||||
content: "hello MCP\n",
|
||||
eof: true,
|
||||
});
|
||||
} finally {
|
||||
await transport.terminateSession();
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { createServer } from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import { loadConfig, type AppConfig } from "../src/config.js";
|
||||
import { startHttpServer, type RunningHttpServer } from "../src/http-server.js";
|
||||
import { createServices } from "../src/mcp-server.js";
|
||||
|
||||
async function reservePort(): Promise<number> {
|
||||
const server = createServer();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const port = (server.address() as AddressInfo).port;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
return port;
|
||||
}
|
||||
|
||||
function form(values: Record<string, string>): URLSearchParams {
|
||||
return new URLSearchParams(values);
|
||||
}
|
||||
|
||||
describe("OAuth 2.1 MCP authorization", () => {
|
||||
let temporaryDirectory: string;
|
||||
let stateFile: string;
|
||||
let config: AppConfig;
|
||||
let running: RunningHttpServer;
|
||||
let baseUrl: string;
|
||||
let resourceUrl: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
temporaryDirectory = await mkdtemp(path.join(os.tmpdir(), "remote-dev-mcp-oauth-test-"));
|
||||
stateFile = path.join(temporaryDirectory, "oauth", "state.json");
|
||||
const port = await reservePort();
|
||||
baseUrl = `http://127.0.0.1:${port}`;
|
||||
resourceUrl = `${baseUrl}/mcp`;
|
||||
config = loadConfig(
|
||||
{
|
||||
MCP_AUTH_TOKEN: "oauth-login-secret",
|
||||
MCP_OAUTH_ENABLED: "true",
|
||||
MCP_PUBLIC_URL: baseUrl,
|
||||
MCP_OAUTH_STATE_FILE: stateFile,
|
||||
MCP_HOST: "127.0.0.1",
|
||||
MCP_PORT: String(port),
|
||||
MCP_DEFAULT_CWD: temporaryDirectory,
|
||||
},
|
||||
temporaryDirectory,
|
||||
);
|
||||
running = await startHttpServer(config, createServices(config));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await running.close();
|
||||
await rm(temporaryDirectory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("discovers, authorizes with PKCE, refreshes, revokes, and calls MCP tools", async () => {
|
||||
const unauthenticated = await fetch(resourceUrl, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "initialize",
|
||||
params: {
|
||||
protocolVersion: "2025-11-25",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "oauth-test", version: "1" },
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(unauthenticated.status).toBe(401);
|
||||
expect(unauthenticated.headers.get("www-authenticate")).toContain(
|
||||
`${baseUrl}/.well-known/oauth-protected-resource/mcp`,
|
||||
);
|
||||
|
||||
for (const metadataPath of [
|
||||
"/.well-known/oauth-protected-resource",
|
||||
"/.well-known/oauth-protected-resource/mcp",
|
||||
]) {
|
||||
const response = await fetch(`${baseUrl}${metadataPath}`);
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toMatchObject({
|
||||
resource: resourceUrl,
|
||||
authorization_servers: [`${baseUrl}/`],
|
||||
scopes_supported: ["mcp:tools"],
|
||||
resource_name: "cokacremote",
|
||||
});
|
||||
}
|
||||
|
||||
const metadataResponse = await fetch(`${baseUrl}/.well-known/oauth-authorization-server`);
|
||||
expect(metadataResponse.status).toBe(200);
|
||||
expect(await metadataResponse.json()).toMatchObject({
|
||||
issuer: `${baseUrl}/`,
|
||||
authorization_endpoint: `${baseUrl}/authorize`,
|
||||
token_endpoint: `${baseUrl}/token`,
|
||||
registration_endpoint: `${baseUrl}/register`,
|
||||
code_challenge_methods_supported: ["S256"],
|
||||
token_endpoint_auth_methods_supported: expect.arrayContaining(["none"]),
|
||||
});
|
||||
|
||||
const redirectUri = "https://chatgpt.com/connector/oauth/test-callback";
|
||||
const registrationResponse = await fetch(`${baseUrl}/register`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
redirect_uris: [redirectUri],
|
||||
token_endpoint_auth_method: "none",
|
||||
grant_types: ["authorization_code", "refresh_token"],
|
||||
response_types: ["code"],
|
||||
client_name: "ChatGPT OAuth integration test",
|
||||
scope: "mcp:tools",
|
||||
}),
|
||||
});
|
||||
expect(registrationResponse.status).toBe(201);
|
||||
const registered = (await registrationResponse.json()) as { client_id: string };
|
||||
expect(registered.client_id).toBeTruthy();
|
||||
|
||||
const codeVerifier = randomBytes(48).toString("base64url");
|
||||
const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url");
|
||||
const authorizationValues = {
|
||||
client_id: registered.client_id,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: "code",
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: "S256",
|
||||
scope: "mcp:tools",
|
||||
state: "oauth-test-state",
|
||||
resource: resourceUrl,
|
||||
};
|
||||
|
||||
const loginPage = await fetch(`${baseUrl}/authorize?${form(authorizationValues)}`, {
|
||||
redirect: "manual",
|
||||
});
|
||||
expect(loginPage.status).toBe(200);
|
||||
expect(loginPage.headers.get("content-security-policy")).toContain(
|
||||
"form-action 'self' https://chatgpt.com",
|
||||
);
|
||||
expect(await loginPage.text()).toContain("MCP 인증키");
|
||||
|
||||
const rejectedLogin = await fetch(`${baseUrl}/authorize`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body: form({ ...authorizationValues, access_key: "wrong-key" }),
|
||||
redirect: "manual",
|
||||
});
|
||||
expect(rejectedLogin.status).toBe(401);
|
||||
|
||||
const approvedLogin = await fetch(`${baseUrl}/authorize`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body: form({ ...authorizationValues, access_key: "oauth-login-secret" }),
|
||||
redirect: "manual",
|
||||
});
|
||||
expect(approvedLogin.status).toBe(302);
|
||||
const callback = new URL(approvedLogin.headers.get("location")!);
|
||||
expect(callback.origin + callback.pathname).toBe(redirectUri);
|
||||
expect(callback.searchParams.get("state")).toBe("oauth-test-state");
|
||||
const authorizationCode = callback.searchParams.get("code");
|
||||
expect(authorizationCode).toBeTruthy();
|
||||
|
||||
const tokenResponse = await fetch(`${baseUrl}/token`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body: form({
|
||||
grant_type: "authorization_code",
|
||||
client_id: registered.client_id,
|
||||
code: authorizationCode!,
|
||||
code_verifier: codeVerifier,
|
||||
redirect_uri: redirectUri,
|
||||
resource: resourceUrl,
|
||||
}),
|
||||
});
|
||||
expect(tokenResponse.status).toBe(200);
|
||||
const tokens = (await tokenResponse.json()) as {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_in: number;
|
||||
scope: string;
|
||||
};
|
||||
expect(tokens).toMatchObject({ expires_in: 3600, scope: "mcp:tools" });
|
||||
|
||||
await running.close();
|
||||
running = await startHttpServer(config, createServices(config));
|
||||
|
||||
const client = new Client({ name: "oauth-integration-test", version: "1.0.0" });
|
||||
const transport = new StreamableHTTPClientTransport(new URL(resourceUrl), {
|
||||
requestInit: { headers: { Authorization: `Bearer ${tokens.access_token}` } },
|
||||
});
|
||||
await client.connect(transport);
|
||||
try {
|
||||
const tools = await client.listTools();
|
||||
expect(tools.tools.some((tool) => tool.name === "run_script")).toBe(true);
|
||||
} finally {
|
||||
await transport.terminateSession();
|
||||
await client.close();
|
||||
}
|
||||
|
||||
const refreshResponse = await fetch(`${baseUrl}/token`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body: form({
|
||||
grant_type: "refresh_token",
|
||||
client_id: registered.client_id,
|
||||
refresh_token: tokens.refresh_token,
|
||||
resource: resourceUrl,
|
||||
}),
|
||||
});
|
||||
expect(refreshResponse.status).toBe(200);
|
||||
const refreshed = (await refreshResponse.json()) as {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
};
|
||||
expect(refreshed.access_token).not.toBe(tokens.access_token);
|
||||
expect(refreshed.refresh_token).not.toBe(tokens.refresh_token);
|
||||
|
||||
const revokeResponse = await fetch(`${baseUrl}/revoke`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body: form({ client_id: registered.client_id, token: refreshed.access_token }),
|
||||
});
|
||||
expect(revokeResponse.status).toBe(200);
|
||||
|
||||
const revokedRequest = await fetch(resourceUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${refreshed.access_token}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ jsonrpc: "2.0", id: 2, method: "initialize", params: {} }),
|
||||
});
|
||||
expect(revokedRequest.status).toBe(401);
|
||||
|
||||
expect((await stat(stateFile)).mode & 0o777).toBe(0o600);
|
||||
const persisted = await readFile(stateFile, "utf8");
|
||||
expect(persisted).not.toContain(tokens.access_token);
|
||||
expect(persisted).not.toContain(tokens.refresh_token);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { ProcessManager } from "../src/process-manager.js";
|
||||
|
||||
function createManager(): ProcessManager {
|
||||
return new ProcessManager({
|
||||
maxRetainedOutputBytes: 1024 * 1024,
|
||||
processRetentionMs: 60_000,
|
||||
maxProcesses: 16,
|
||||
defaultMaxOutputBytes: 1024 * 1024,
|
||||
});
|
||||
}
|
||||
|
||||
describe("ProcessManager", () => {
|
||||
let manager: ProcessManager | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
await manager?.shutdown();
|
||||
});
|
||||
|
||||
it("captures stdout, stderr, and exit state", async () => {
|
||||
manager = createManager();
|
||||
const sessionId = manager.start({
|
||||
executable: "/bin/bash",
|
||||
args: ["-c", "printf stdout; printf stderr >&2"],
|
||||
commandForDisplay: "test output",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
await manager.waitForExit(sessionId, 2000);
|
||||
const result = await manager.read(sessionId);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
running: false,
|
||||
exitCode: 0,
|
||||
stdout: "stdout",
|
||||
stderr: "stderr",
|
||||
timedOut: false,
|
||||
});
|
||||
expect(result.output).toContain("stdout");
|
||||
expect(result.output).toContain("stderr");
|
||||
});
|
||||
|
||||
it("supports interactive stdin and closes cleanly", async () => {
|
||||
manager = createManager();
|
||||
const sessionId = manager.start({
|
||||
executable: "/bin/cat",
|
||||
args: [],
|
||||
commandForDisplay: "cat",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
await manager.write(sessionId, "hello\n", true);
|
||||
await manager.waitForExit(sessionId, 2000);
|
||||
const result = await manager.read(sessionId);
|
||||
|
||||
expect(result.running).toBe(false);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toBe("hello\n");
|
||||
});
|
||||
|
||||
it("terminates a command when its timeout expires", async () => {
|
||||
manager = createManager();
|
||||
const sessionId = manager.start({
|
||||
executable: "/bin/bash",
|
||||
args: ["-c", "sleep 10"],
|
||||
commandForDisplay: "sleep 10",
|
||||
cwd: process.cwd(),
|
||||
timeoutMs: 50,
|
||||
});
|
||||
|
||||
await manager.waitForExit(sessionId, 3000);
|
||||
const result = await manager.read(sessionId);
|
||||
|
||||
expect(result.running).toBe(false);
|
||||
expect(result.timedOut).toBe(true);
|
||||
expect(result.error).toContain("timeout");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user