inspector/clients/cli/__tests__/skills-verify-cli.test.ts
cliffhall 89438d1a4b fix: address Copilot review round 15 on #2248
**Verification is a tri-state now, because two states could not be right.**
Round 12 was correct that `ok: true` for a manifest whose unread 513th file
may be tampered with is a false pass. Round 15 is correct that forcing `ok:
false` calls a server nonconformant for exceeding limits SEP-2640 states as
SHOULD NOT, with hosts free to support more — contradicting this module's own
rule that a warning never fails a report. Both hold, so:

  verified   -> exit 0   everything checked, everything passed
  failed     -> exit 7   a MUST was broken
  incomplete -> exit 8   nothing checked was wrong; the walk was cut short

`ok` keeps its narrow meaning, `allSkillsVerified` is stricter than
`every(r => r.ok)` since it selects the exit code, and `anySkillFailed`
separates 7 from 8. A job that tolerates oversized catalogs can allow 8 and
still fail on 7.

**`manifestKey` still used `JSON.stringify` on the entry** — the same crash I
fixed in the TUI in round 12, one file over.

**Directory children were navigable outside the skill root.** A server could
return `skill://other-skill/...` and clicking it left the tree, with "Up"
only comparing equality against the root. Containment is checked on the
normalized URI now, and an outside child renders as text rather than a link.
Worth recording from the tests: `..` cannot escape the authority, so
`skill://a/nested/../x.md` resolves back inside and must stay navigable.

**The TUI matched report rows by raw URI**, which missed a row recorded under
an equivalent spelling while `extraReportFiles` suppressed it as covered.
Normalized, like the membership test beside it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ND46Ci64DC3tzBy9aG81Nc
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-07 22:47:32 -04:00

177 lines
5.3 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { runCli } from "../src/cli.js";
import { consumeMethodOutcome } from "../src/handlers/consume-outcome.js";
import { EXIT_CODES } from "../src/error-handler.js";
/**
* `--verify`'s argument validation and its NDJSON consumption path (#2248).
*
* The validation sits with `--strict`'s, ahead of every short-circuit return in
* `parseArgs`, for the same reason: the returns below it never reach
* `runMethod`, so a check placed further down would let the flag be accepted
* and then silently ignored.
*/
describe("--verify argument validation", () => {
it("is rejected with a method other than skills/list or skills/get", async () => {
await expect(
runCli([
"node",
"cli",
"--cli",
"--method",
"tools/list",
"--verify",
"--server-url",
"http://127.0.0.1:1/mcp",
]),
).rejects.toThrow(
"--verify requires --method skills/list or --method skills/get.",
);
});
it.each([
["servers/list", ["--method", "servers/list"]],
["--list-stored-auth", ["--method", "servers/list", "--list-stored-auth"]],
])(
"is rejected on the %s short-circuit path, which never reaches the report",
async (_label, extra) => {
await expect(
runCli(["node", "cli", "--cli", "--verify", ...extra]),
).rejects.toThrow(
"--verify requires --method skills/list or --method skills/get.",
);
},
);
it("is accepted with skills/get", async () => {
// Reaches the connect and fails there — which is the point: the flag
// itself was not what was rejected.
await expect(
runCli([
"node",
"cli",
"--cli",
"--method",
"skills/get",
"--uri",
"skill://demo/SKILL.md",
"--verify",
"--server-url",
"http://127.0.0.1:1/mcp",
]),
).rejects.not.toThrow(/--verify requires/);
});
});
describe("consumeMethodOutcome NDJSON summary and exit code (#2248)", () => {
function captureStreams() {
let stdout = "";
let stderr = "";
const write = (sink: (s: string) => void) =>
((chunk: unknown, ...rest: unknown[]) => {
sink(typeof chunk === "string" ? chunk : String(chunk));
const cb = rest.find((r) => typeof r === "function") as
| (() => void)
| undefined;
cb?.();
return true;
}) as typeof process.stdout.write;
const originalOut = process.stdout.write;
const originalErr = process.stderr.write;
process.stdout.write = write((s) => (stdout += s));
process.stderr.write = write((s) => (stderr += s));
return {
get stdout() {
return stdout;
},
get stderr() {
return stderr;
},
restore() {
process.stdout.write = originalOut;
process.stderr.write = originalErr;
},
};
}
it("writes the summary to stderr so it cannot contaminate the NDJSON", async () => {
const streams = captureStreams();
try {
await consumeMethodOutcome(
{ kind: "ndjson", lines: [{ ok: true }], summary: "all good" },
{},
);
} finally {
streams.restore();
}
expect(JSON.parse(streams.stdout.trim())).toEqual({ ok: true });
expect(streams.stderr).toBe("all good\n");
});
it("throws the exit code AFTER writing the report", async () => {
// The report is the output a CI job reads; failing before writing it would
// give the reader an exit code and nothing to act on.
const streams = captureStreams();
let thrown: unknown;
try {
await consumeMethodOutcome(
{
kind: "ndjson",
lines: [{ ok: false }],
summary: "one failed",
exitCode: EXIT_CODES.SKILL_NONCONFORMANT,
},
{},
);
} catch (err) {
thrown = err;
} finally {
streams.restore();
}
expect(streams.stdout.trim()).toBe('{"ok":false}');
expect(thrown).toMatchObject({
exitCode: EXIT_CODES.SKILL_NONCONFORMANT,
envelope: { code: "skills_nonconformant" },
});
});
it("labels the envelope for an INCOMPLETE run, not a nonconformant one", async () => {
// The envelope's `code` follows the exit code, so a caller reading one
// never has to reconcile it against the other — and exit 8 means the
// server broke no MUST.
const streams = captureStreams();
let thrown: unknown;
try {
await consumeMethodOutcome(
{
kind: "ndjson",
lines: [{ outcome: "incomplete" }],
summary: "not fully checked",
exitCode: EXIT_CODES.SKILL_INCOMPLETE,
},
{},
);
} catch (err) {
thrown = err;
} finally {
streams.restore();
}
expect(thrown).toMatchObject({
exitCode: EXIT_CODES.SKILL_INCOMPLETE,
envelope: { code: "skills_incomplete" },
});
});
it("leaves an --app-info NDJSON outcome unchanged", async () => {
// No summary, no exit code — the field is additive and the older caller
// must behave exactly as before.
const streams = captureStreams();
try {
await consumeMethodOutcome({ kind: "ndjson", lines: [{ a: 1 }] }, {});
} finally {
streams.restore();
}
expect(streams.stderr).toBe("");
expect(streams.stdout.trim()).toBe('{"a":1}');
});
});