203 lines
8.8 KiB
JavaScript
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 }); }
|
|
});
|