test(smoke): exercise the real teardown path, not just removeSafe

Copilot was right that the previous test proved only that `removeSafe` can
delete a directory — it never drove `stop()`, so deleting the cleanup call
would have left the unit suite AND all three smokes green. That is exactly the
regression these tests exist to catch, and they did not catch it.

Extract the teardown into an exported `teardownWebServer({ child, catalogDir,
label })`, which `stop()` now delegates to, and drive it against a stand-in
child process (a real `node -e setInterval` that stays alive until signalled)
rather than a mock. Three cases:

  - it removes the catalog dir it was given (asserted on the dir itself, not a
    spy, so the assertion cannot drift from the behavior);
  - it awaits the child's exit before removing — the #1801 race in one
    assertion, since losing the await is otherwise invisible;
  - an already-dead child is not an error, which is the failure path a smoke
    takes when it calls fail() after the launcher has crashed.

Mutation-checked: with the `removeSafe` call deleted, 2 of the 3 now fail
(previously: 0). AGENTS.md updated to match.

Signed-off-by is now on every commit (#1978 DCO); the requirement itself is
undocumented and tracked in #1979.

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:
cliffhall 2026-08-11 14:51:12 -04:00
commit 3d541388db
2 changed files with 89 additions and 20 deletions

View file

@ -78,6 +78,36 @@ export function buildWebServerEnv({
};
}
/**
* Terminate a spawned web server, then remove its catalog dir.
*
* The two halves are the documented pair in `child-cleanup.mjs`, and both are
* needed. `stopChild` closes the #1801 race on the normal path (a bare `kill()`
* only *delivers* the signal, so a synchronous remove can hit ENOTEMPTY when the
* server writes the catalog on its way out); `removeSafe` then makes the
* residual case harmless, warning instead of throwing so a leftover temp dir can
* never turn a passing smoke red.
*
* Exported separately from `startProdWebServer` so the teardown *contract* is
* testable against a stand-in child, without booting a real launcher. Testing it
* matters more than it looks: deleting the `removeSafe` call would leave both the
* unit suite and all three smokes green (the smokes exit immediately after
* teardown), which is precisely the regression the tests exist to catch.
*
* @param {object} opts
* @param {import("node:child_process").ChildProcess} opts.child
* @param {string} opts.catalogDir
* @param {string} [opts.label]
*/
export async function teardownWebServer({
child,
catalogDir,
label = "smoke:web",
}) {
await stopChild(child, { label, what: "prod web server" });
removeSafe(catalogDir, { label });
}
/**
* Spawn `mcp-inspector --web` (prod, no `--dev`) against the built
* `clients/web/dist` and return handles for readiness + teardown.
@ -194,18 +224,7 @@ export function startProdWebServer({ host, port, token, label = "smoke:web" }) {
catalogPath,
waitForReady,
whenChildExits,
/**
* Terminate the server, then remove its catalog dir. **Await this** — the
* two halves are the documented pair in `child-cleanup.mjs`, and both are
* needed. `stopChild` closes the #1801 race on the normal path (a bare
* `kill()` only *delivers* the signal, so a synchronous remove can hit
* ENOTEMPTY when the server writes the catalog on its way out); `removeSafe`
* then makes the residual case harmless, warning instead of throwing so a
* leftover temp dir can never turn a passing smoke red.
*/
stop: async () => {
await stopChild(child, { label, what: "prod web server" });
removeSafe(catalogDir, { label });
},
/** Terminate the server, then remove its catalog dir. **Await this.** */
stop: () => teardownWebServer({ child, catalogDir, label }),
};
}

View file

@ -15,11 +15,17 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { spawn } from "node:child_process";
import { once } from "node:events";
import { existsSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
import { buildWebServerEnv, createTempCatalog } from "./prod-web-server.mjs";
import { removeSafe } from "./child-cleanup.mjs";
import {
buildWebServerEnv,
createTempCatalog,
teardownWebServer,
} from "./prod-web-server.mjs";
import { hasExited, removeSafe } from "./child-cleanup.mjs";
const BASE = {
host: "127.0.0.1",
@ -53,11 +59,55 @@ test("createTempCatalog: does not create the catalog file itself", () => {
}
});
test("createTempCatalog: its dir is removable by removeSafe", () => {
/**
* A stand-in for the web server: a real child process that ignores nothing and
* simply stays alive until signalled, so `teardownWebServer` exercises its true
* SIGTERM→exit path rather than a mock's idea of one.
*/
function spawnIdleChild() {
return spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], {
stdio: "ignore",
});
}
test("teardownWebServer: removes the catalog dir it was given", async () => {
// The regression this guards: dropping the `removeSafe` call from the teardown
// would leave every smoke green, since they exit right after teardown and so
// never observe the leak. Assert on the dir itself, not on a spy.
const child = spawnIdleChild();
const { dir, path } = createTempCatalog();
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, "{}");
assert.equal(removeSafe(dir), true);
assert.ok(existsSync(dir));
await teardownWebServer({ child, catalogDir: dir, label: "test" });
assert.equal(existsSync(dir), false, "teardown must remove the catalog dir");
});
test("teardownWebServer: waits for the child to exit before removing", async () => {
// The #1801 race in one assertion: a bare kill() only *delivers* SIGTERM, so a
// teardown that removed synchronously could unlink the dir while the server was
// still writing to it. If teardown resolves with the child still alive, the
// await on stopChild has been lost.
const child = spawnIdleChild();
const { dir } = createTempCatalog();
assert.equal(hasExited(child), false, "child should start alive");
await teardownWebServer({ child, catalogDir: dir, label: "test" });
assert.ok(hasExited(child), "teardown must await the child's exit");
});
test("teardownWebServer: an already-dead child is not an error", async () => {
// The failure path: a smoke calls fail() after the launcher already crashed.
// Teardown still has to clean up rather than hang on an exit that never comes.
const child = spawnIdleChild();
child.kill("SIGKILL");
await once(child, "exit");
const { dir } = createTempCatalog();
await teardownWebServer({ child, catalogDir: dir, label: "test" });
assert.equal(existsSync(dir), false);
});