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"; import { PortAllocator, parsePortRange } from "../src/ports.js"; /** A dev server stand-in: answers on the port it was given and reports the tree it runs in. */ const SERVER = [ "-e", "const http=require('http');const fs=require('fs');" + "const port=Number(process.argv[1]);" + "const body=fs.existsSync('app.txt')?fs.readFileSync('app.txt','utf8'):'no app';" + "console.log('serving '+body+' on '+port);" + "http.createServer((q,s)=>{s.end(body)}).listen(port,'127.0.0.1');" ]; function stack({ services, instancesRoot = true, maxInstances = 4, portRange = "5310-5319" }) { const base = fs.mkdtempSync(path.join(os.tmpdir(), "dev-server-exec-")); const workspace = path.join(base, "workspace"); const instances = path.join(base, "instances"); fs.mkdirSync(workspace); fs.mkdirSync(instances); const configPath = path.join(base, "services.json"); fs.writeFileSync(configPath, JSON.stringify({ services })); const config = loadDevServerConfig({ configPath, workspaceRoot: workspace, allowedCommands: [process.execPath] }); const service = new DevServerService({ services: config.services, workspaceRoot: workspace, instancesRoot: instancesRoot ? instances : null, portAllocator: new PortAllocator(parsePortRange(portRange)), maxInstances, instanceHome: path.join(base, "home") }); fs.mkdirSync(path.join(base, "home")); return { base, workspace, instances, service, write(key, files) { const directory = path.join(workspace, key); fs.mkdirSync(directory, { recursive: true }); for (const [name, content] of Object.entries(files)) fs.writeFileSync(path.join(directory, name), content); return directory; }, async cleanup() { await service.close().catch(() => {}); fs.rmSync(base, { recursive: true, force: true }); } }; } const WEB = { command: process.execPath, args: [...SERVER, "${port}"], workspaceMode: "execution", healthUrl: "http://127.0.0.1:${port}/", publicUrl: "http://dev-server-worker:${port}", startupTimeoutMs: 8000, shutdownTimeoutMs: 500 }; test("two executions serve their own code at the same time, on their own ports", async () => { const item = stack({ services: { web: WEB } }); try { item.write("exec-a", { "app.txt": "A" }); item.write("exec-b", { "app.txt": "B" }); const a = await item.service.start({ service: "web", key: "exec-a" }); const b = await item.service.start({ service: "web", key: "exec-b" }); assert.equal(a.status, "running"); assert.equal(b.status, "running"); assert.notEqual(a.port, b.port); assert.equal(a.publicUrl, `http://dev-server-worker:${a.port}`); assert.equal(await (await fetch(`http://127.0.0.1:${a.port}/`)).text(), "A"); assert.equal(await (await fetch(`http://127.0.0.1:${b.port}/`)).text(), "B"); } finally { await item.cleanup(); } }); test("a per-execution service refuses to start without a key", async () => { const item = stack({ services: { web: WEB } }); try { item.write("exec-a", { "app.txt": "A" }); await assert.rejects(() => item.service.start({ service: "web" }), /runs per execution and needs an execution key/); } finally { await item.cleanup(); } }); test("an execution that has written nothing yet is told so, not handed an empty tree", async () => { const item = stack({ services: { web: WEB } }); try { await assert.rejects(() => item.service.start({ service: "web", key: "never-written" }), /No workspace has been written/); } finally { await item.cleanup(); } }); test("one execution cannot reach another's instance through the key it supplies", async () => { const item = stack({ services: { web: WEB } }); try { item.write("exec-a", { "app.txt": "A" }); await item.service.start({ service: "web", key: "exec-a" }); assert.equal(item.service.status({ service: "web", key: "exec-b" }).status, "stopped"); assert.equal(item.service.logs({ service: "web", key: "exec-b" }).logs, ""); assert.equal(item.service.list({ key: "exec-b" }).services[0].status, "stopped"); } finally { await item.cleanup(); } }); test("a restart serves the code as it is now, not as it was at start", async () => { // A restart that served the old tree would be the worst defect this tool could have: the agent // would read its own fix as having changed nothing. const item = stack({ services: { web: WEB } }); try { const source = item.write("exec-a", { "app.txt": "first" }); const started = await item.service.start({ service: "web", key: "exec-a" }); assert.equal(await (await fetch(`http://127.0.0.1:${started.port}/`)).text(), "first"); fs.writeFileSync(path.join(source, "app.txt"), "second"); const restarted = await item.service.restart({ service: "web", key: "exec-a" }); assert.equal(await (await fetch(`http://127.0.0.1:${restarted.port}/`)).text(), "second"); } finally { await item.cleanup(); } }); test("the install runs once, is skipped while the lockfile holds, and lands in the logs", async () => { const withInstall = { ...WEB, install: { command: process.execPath, args: ["-e", "require('fs').mkdirSync('node_modules',{recursive:true});console.log('installed deps')"] } }; const item = stack({ services: { web: withInstall } }); try { item.write("exec-a", { "app.txt": "A", "package-lock.json": "{\"v\":1}" }); await item.service.start({ service: "web", key: "exec-a" }); assert.match(item.service.logs({ service: "web", key: "exec-a", tailBytes: 4096 }).logs, /installed deps/); await item.service.stop({ service: "web", key: "exec-a" }); await item.service.start({ service: "web", key: "exec-a" }); assert.match(item.service.logs({ service: "web", key: "exec-a", tailBytes: 4096 }).logs, /install skipped/); } finally { await item.cleanup(); } }); test("a failed install stops the start instead of serving half-installed dependencies", async () => { const broken = { ...WEB, install: { command: process.execPath, args: ["-e", "console.error('no registry');process.exit(3)"] } }; const item = stack({ services: { web: broken } }); try { item.write("exec-a", { "app.txt": "A", "package-lock.json": "{\"v\":1}" }); await assert.rejects(() => item.service.start({ service: "web", key: "exec-a" }), /install failed with exit code 3/); const status = item.service.status({ service: "web", key: "exec-a" }); assert.equal(status.status, "stopped"); assert.equal(status.port, null, "a failed start must not keep a port"); assert.match(item.service.logs({ service: "web", key: "exec-a" }).logs, /no registry/); } finally { await item.cleanup(); } }); test("the concurrency cap names itself and the setting that raises it", async () => { const item = stack({ services: { web: WEB }, maxInstances: 1 }); try { item.write("exec-a", { "app.txt": "A" }); item.write("exec-b", { "app.txt": "B" }); await item.service.start({ service: "web", key: "exec-a" }); await assert.rejects( () => item.service.start({ service: "web", key: "exec-b" }), /Already running 1 services: raise DEV_SERVER_MAX_INSTANCES/ ); } finally { await item.cleanup(); } }); test("discarding an execution frees its port and its copy", async () => { const item = stack({ services: { web: WEB } }); try { item.write("exec-a", { "app.txt": "A" }); const started = await item.service.start({ service: "web", key: "exec-a" }); await item.service.discard({ service: "web", key: "exec-a" }); assert.ok(!fs.existsSync(path.join(item.instances, "exec-a"))); assert.equal(item.service.runningCount(), 0); await assert.rejects(() => fetch(`http://127.0.0.1:${started.port}/`)); } finally { await item.cleanup(); } }); test("a worker with no instances root refuses per-execution services outright", async () => { const item = stack({ services: { web: WEB }, instancesRoot: false }); try { item.write("exec-a", { "app.txt": "A" }); await assert.rejects(() => item.service.start({ service: "web", key: "exec-a" }), /not configured for per-execution services/); } finally { await item.cleanup(); } }); test("the default log tail fits inside the client's per-result budget", () => { // The MCP client truncates at 6000 characters keeping the head, so a default tail larger than // that would hand the model the oldest lines of the newest slice - never the error at the end. const item = stack({ services: { web: WEB } }); try { const returned = item.service.logs({ service: "web", key: "exec-a" }); assert.equal(returned.logs, ""); assert.ok(4096 < 6000); } finally { fs.rmSync(item.base, { recursive: true, force: true }); } });