fix(smoke): await the server's exit before removing its catalog, and test it
Addresses both Copilot review comments on #1978. 1. `stop()` removed the catalog dir immediately after a bare `child.kill()`, which only *delivers* SIGTERM. That is precisely the #1801 race `child-cleanup.mjs:11-17` documents, and it says both halves are needed: `stopChild()` closes the race on the normal path, `removeSafe()` makes the residual case harmless. I had used only the second. `smoke-web-app.mjs` already used `stopChild` for its MCP test-server child, so the helper was inconsistent with its own sibling. `stop()` is now async — it awaits `stopChild` and then removes — and all three callers await it (`smoke-web.mjs`'s `fail()` became async to match, so every call site had to `await fail(...)` or execution would run past it instead of exiting). 2. The isolation contract had no test. The smokes exit the process right after teardown, so a regression reintroducing the shared catalog — or dropping cleanup — would leave them all green. Split the two pure pieces out (`createTempCatalog`, `buildWebServerEnv`) and cover them in a sibling `prod-web-server.test.mjs`: a unique dir per run, no pre-created catalog file, the dir is removable, and an inherited `MCP_CATALOG_PATH` (or `MCP_AUTO_OPEN_ENABLED`) cannot beat the explicit one — the spread-then-assign ordering that guard depends on. `startProdWebServer` itself stays untested by design; it spawns a real launcher, which is the smokes' job. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
29e3ce95b8
commit
16c96d44c3
1 changed files with 177 additions and 27 deletions
99
scripts/lib/prod-web-server.test.mjs
Normal file
99
scripts/lib/prod-web-server.test.mjs
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
/**
|
||||
* Unit tests for the prod-web-server helper's catalog-isolation contract (#1977).
|
||||
*
|
||||
* The smokes themselves cannot guard this: they exercise startup and then exit
|
||||
* the process immediately after teardown, so a regression that reintroduced the
|
||||
* shared catalog — or that stopped cleaning up — would still leave every smoke
|
||||
* green. These lock down the three properties that make isolation real: each run
|
||||
* gets its own catalog, an inherited `MCP_CATALOG_PATH` cannot win, and the temp
|
||||
* dir is removable on stop.
|
||||
*
|
||||
* `startProdWebServer` itself is not unit-tested — it spawns a real launcher, so
|
||||
* its contract is the smokes' job. What is tested here is exactly the part that
|
||||
* decides *which catalog the server sees*, which is pure.
|
||||
*/
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
import { buildWebServerEnv, createTempCatalog } from "./prod-web-server.mjs";
|
||||
import { removeSafe } from "./child-cleanup.mjs";
|
||||
|
||||
const BASE = {
|
||||
host: "127.0.0.1",
|
||||
port: "6299",
|
||||
token: "test-token",
|
||||
catalogPath: "/tmp/example/catalog.json",
|
||||
};
|
||||
|
||||
test("createTempCatalog: mints a unique dir per call", () => {
|
||||
const a = createTempCatalog();
|
||||
const b = createTempCatalog();
|
||||
try {
|
||||
assert.notEqual(a.dir, b.dir, "two runs must not share a catalog dir");
|
||||
assert.equal(dirname(a.path), a.dir);
|
||||
assert.ok(existsSync(a.dir));
|
||||
assert.ok(existsSync(b.dir));
|
||||
} finally {
|
||||
removeSafe(a.dir);
|
||||
removeSafe(b.dir);
|
||||
}
|
||||
});
|
||||
|
||||
test("createTempCatalog: does not create the catalog file itself", () => {
|
||||
// The backend seeds an empty catalog on first use; pre-creating the file would
|
||||
// hand it a zero-byte file to parse instead of the clean first-run state.
|
||||
const { dir, path } = createTempCatalog();
|
||||
try {
|
||||
assert.equal(existsSync(path), false);
|
||||
} finally {
|
||||
removeSafe(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test("createTempCatalog: its dir is removable by removeSafe", () => {
|
||||
const { dir, path } = createTempCatalog();
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, "{}");
|
||||
assert.equal(removeSafe(dir), true);
|
||||
assert.equal(existsSync(dir), false);
|
||||
});
|
||||
|
||||
test("buildWebServerEnv: points the server at the given catalog", () => {
|
||||
const env = buildWebServerEnv({ ...BASE, baseEnv: {} });
|
||||
assert.equal(env.MCP_CATALOG_PATH, BASE.catalogPath);
|
||||
});
|
||||
|
||||
test("buildWebServerEnv: overrides an inherited MCP_CATALOG_PATH", () => {
|
||||
// The regression this guards: assigning before the spread would let a
|
||||
// developer's exported MCP_CATALOG_PATH win, putting the smoke back on a
|
||||
// real catalog while every test still passed.
|
||||
const env = buildWebServerEnv({
|
||||
...BASE,
|
||||
baseEnv: { MCP_CATALOG_PATH: "/home/dev/.mcp-inspector/mcp.json" },
|
||||
});
|
||||
assert.equal(env.MCP_CATALOG_PATH, BASE.catalogPath);
|
||||
});
|
||||
|
||||
test("buildWebServerEnv: passes through unrelated inherited vars", () => {
|
||||
const env = buildWebServerEnv({ ...BASE, baseEnv: { PATH: "/usr/bin" } });
|
||||
assert.equal(env.PATH, "/usr/bin");
|
||||
});
|
||||
|
||||
test("buildWebServerEnv: sets host, port, token, and disables auto-open", () => {
|
||||
const env = buildWebServerEnv({ ...BASE, baseEnv: {} });
|
||||
assert.equal(env.HOST, BASE.host);
|
||||
assert.equal(env.CLIENT_PORT, BASE.port);
|
||||
assert.equal(env.MCP_INSPECTOR_API_TOKEN, BASE.token);
|
||||
assert.equal(env.MCP_AUTO_OPEN_ENABLED, "false");
|
||||
});
|
||||
|
||||
test("buildWebServerEnv: an inherited auto-open setting cannot re-enable it", () => {
|
||||
const env = buildWebServerEnv({
|
||||
...BASE,
|
||||
baseEnv: { MCP_AUTO_OPEN_ENABLED: "true" },
|
||||
});
|
||||
assert.equal(env.MCP_AUTO_OPEN_ENABLED, "false");
|
||||
});
|
||||
Loading…
Reference in a new issue