Files
rea/tests/boundary/cli/environmentServeRemediation.test.ts
T
morluto 0c440bfc07 fix(cli): match listener recovery guidance to system failure
Classify listener errno and lifecycle phase at the transport owner. Preserve native cause and unknown codes while distinguishing host lookup, unavailable address, occupied port and permission recovery.

Refs #5455.
2026-10-20 19:54:56 +08:00

180 lines
5.5 KiB
TypeScript

import { createServer } from "node:http";
import { once } from "node:events";
import { randomUUID } from "node:crypto";
import { expect, it } from "vitest";
import { transportError } from "../../../src/cli/environmentCommands.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import { StreamableHttpListenError } from "../../../src/server/http/streamableHttpListenError.js";
import {
writeEnvironmentServeFiles,
type EnvironmentServeFiles,
} from "../../fixtures/environmentServe.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest, type TestCli } from "../../support/cli/cliFixture.js";
const files = async (): Promise<EnvironmentServeFiles> => {
const directory = await createTestTempDirectory("rea-serve-remediation-");
return writeEnvironmentServeFiles(directory);
};
const serve = async (
cli: TestCli,
input: EnvironmentServeFiles,
host: string,
port: number,
) => {
const result = await cli.run({
arguments: [
"environment-serve",
input.manifestPath,
"--evidence",
input.capturePath,
"--host",
host,
"--port",
String(port),
"--json",
],
timeoutMs: 30_000,
});
return {
exitCode: result.exitCode,
output: JSON.parse(result.stdout) as Record<string, unknown>,
};
};
cliTest(
"reports host lookup failure with host-specific recovery and no listener startup",
async ({ cli }) => {
const input = await files();
const host = `rea-${randomUUID()}.invalid`;
const result = await serve(cli, input, host, 0);
expect(result.exitCode).toBe(1);
expect(result.output).toMatchObject({
code: "resource_constraint",
details: {
operation: "environment-serve",
resource: "transport",
reported_limits: { host, port: 0 },
diagnostics: {
listener_failure: {
kind: "host_lookup",
system_code: "ENOTFOUND",
},
},
},
});
expect(result.output.remediation).toMatchObject({
action: expect.stringContaining("--host or MCP_HOST"),
});
expect(JSON.stringify(result.output)).toContain("ENOTFOUND");
expect(JSON.stringify(result.output)).not.toContain(
"Choose another --port",
);
},
);
cliTest(
"retains port advice without closing the loopback listener it does not own",
async ({ cli }) => {
const input = await files();
const owner = createServer();
try {
owner.listen({ host: "127.0.0.1", port: 0 });
await once(owner, "listening");
const address = owner.address();
if (address === null || typeof address === "string")
throw new Error("Owned loopback listener has no TCP address");
const result = await serve(cli, input, "127.0.0.1", address.port);
expect(result.exitCode).toBe(1);
expect(result.output).toMatchObject({
code: "resource_constraint",
details: {
reported_limits: { host: "127.0.0.1", port: address.port },
diagnostics: {
listener_failure: {
kind: "port_in_use",
system_code: "EADDRINUSE",
},
},
},
remediation: {
action: expect.stringContaining("Choose another --port or MCP_PORT"),
},
});
expect(owner.listening).toBe(true);
} finally {
await new Promise<void>((resolve, reject) =>
owner.close((cause) =>
cause === undefined ? resolve() : reject(cause),
),
);
}
},
);
it.each([
["EACCES", "permission_denied", "permitted to bind"],
["EPERM", "permission_denied", "permitted to bind"],
["EADDRNOTAVAIL", "host_unavailable", "address assigned to this machine"],
["EIO", "unclassified", "Check the selected listener host, port"],
] as const)(
"projects typed listener failure %s without parsing the error display text",
(systemCode, kind, remediationText) => {
const cause = Object.assign(
new Error(
systemCode === "EIO"
? "display mentions EADDRINUSE, but errno is EIO"
: "controlled listener failure",
),
{ code: systemCode },
);
const listenError = new StreamableHttpListenError(cause);
const transportFailure = transportError(
{ host: "127.0.0.1", port: 80 },
listenError,
);
const projected = projectAnalysisError(transportFailure);
expect(listenError.cause).toBe(cause);
expect(transportFailure.cause).toBe(listenError);
expect(projected.details).toMatchObject({
diagnostics: {
listener_failure: {
kind,
system_code: systemCode,
},
listener_phase: "startup",
},
});
expect(projected.remediation.action).toContain(remediationText);
expect(projected.remediation.action).not.toContain(
"stop the process using it",
);
},
);
it("retains an after-bind listener failure as a serving-phase error", () => {
const cause = Object.assign(new Error("socket failed after bind"), {
code: "EIO",
});
const listenError = new StreamableHttpListenError(cause, "serving");
const projected = projectAnalysisError(
transportError({ host: "127.0.0.1", port: 80 }, listenError),
);
expect(listenError.cause).toBe(cause);
expect(listenError.message).toContain("failed after binding");
expect(listenError.message).not.toContain("could not bind");
expect(projected.details).toMatchObject({
diagnostics: {
listener_failure: { kind: "unclassified", system_code: "EIO" },
listener_phase: "serving",
},
});
});