dev-mcps/dev-server-mcp/test/execution-instances.test.js

203 lines
8.8 KiB
JavaScript

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 }); }
});