import test from "node:test"; import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { loadDevServerConfig } from "../src/config.js"; import { DevServerService } from "../src/dev-server-service.js"; function fixture(config) { const root = fs.mkdtempSync(path.join(os.tmpdir(), "dev-server-mcp-")); const configPath = path.join(root, "services.json"); fs.writeFileSync(configPath, JSON.stringify(config)); return { root, configPath, cleanup: () => fs.rmSync(root, { recursive: true, force: true }) }; } test("configuration rejects commands outside the operator allowlist", () => { const item = fixture({ services: { web: { command: "sh", args: [], cwd: "." } } }); try { assert.throws(() => loadDevServerConfig({ configPath: item.configPath, workspaceRoot: item.root, allowedCommands: ["node"] }), /not allowlisted/); } finally { item.cleanup(); } }); test("configuration rejects workspace traversal", () => { // Refused on the shape of the path, before anything is resolved, so it holds for a // per-execution service too - whose directory does not exist yet and so cannot be resolved. const item = fixture({ services: { web: { command: "node", args: [], cwd: ".." } } }); try { assert.throws(() => loadDevServerConfig({ configPath: item.configPath, workspaceRoot: item.root, allowedCommands: ["node"] }), /Invalid cwd/); } finally { item.cleanup(); } }); test("configuration cannot replace the safe process environment", () => { const item = fixture({ services: { web: { command: "node", args: [], cwd: ".", env: { PATH: "/workspace/bin" } } } }); try { assert.throws(() => loadDevServerConfig({ configPath: item.configPath, workspaceRoot: item.root, allowedCommands: ["node"] }), /Invalid environment/); } finally { item.cleanup(); } }); test("list exposes an operator-written setup contract without exposing caller-controlled commands", () => { const item = fixture({ services: { web: { command: "node", args: ["server.js", "${port}"], cwd: ".", workspaceMode: "execution", agentInstructions: "Create server.js, bind 0.0.0.0 and use process.env.PORT." } } }); try { const config = loadDevServerConfig({ configPath: item.configPath, workspaceRoot: item.root, allowedCommands: ["node"] }); const devServer = new DevServerService({ services: config.services }); const listed = devServer.list({ key: "execution-1" }).services[0]; assert.equal(listed.agentInstructions, "Create server.js, bind 0.0.0.0 and use process.env.PORT."); assert.equal(Object.hasOwn(listed, "command"), false); } finally { item.cleanup(); } }); test("dev server starts only configured arguments, captures logs, and stops the process group", async () => { const item = fixture({ services: { web: { command: process.execPath, args: ["-e", "console.log('ready'); setInterval(() => {}, 1000)"], cwd: ".", shutdownTimeoutMs: 500 } } }); try { const config = loadDevServerConfig({ configPath: item.configPath, workspaceRoot: item.root, allowedCommands: [process.execPath] }); const devServer = new DevServerService({ services: config.services }); const started = await devServer.start({ service: "web" }); assert.equal(started.status, "running"); await new Promise((resolve) => setTimeout(resolve, 50)); assert.match(devServer.logs({ service: "web" }).logs, /ready/); const stopped = await devServer.stop({ service: "web" }); assert.equal(stopped.status, "stopped"); await devServer.close(); } finally { item.cleanup(); } }); test("unknown services and oversized log requests fail closed", () => { const devServer = new DevServerService({ services: new Map([["web", { id: "web", clientCwd: ".", publicUrl: null }]]) }); assert.throws(() => devServer.status({ service: "missing" }), /Unknown service/); assert.throws(() => devServer.logs({ service: "web", tailBytes: 999999 }), /tailBytes/); });