inspector/clients/tui/__tests__/SkillsTab.test.tsx
cliffhall 4fa00d1cfd feat: report a skill-name collision across the listing (#2248)
SEP-2640: hosts MUST NOT assume name uniqueness, and when two entries in one
listing collide on `name` a host MUST disambiguate them rather than silently
discarding or preferring one.

`checkSkillConformance` structurally cannot see this — it takes one entry and
a collision is a property of the pair — so `checkSkillNameCollisions` walks
the listing and returns a finding per colliding entry, each naming the
others. All three clients merge it into the entry's own findings, so it
carries through the header badge, the CLI report and the TUI row marks with
no new surface.

**It is a `warning`, not an `error`,** and that is the severity split doing
its job. The obligation is on the *consumer*: a server may legitimately
publish two skills with the same name under different paths, and the SEP's
own `acme/billing/refunds` example is exactly that shape. Calling it an error
would tell a conforming author their catalog is invalid. `--verify` therefore
still exits 0 for a collision.

In the web screen it renders as a banner directly under the Conformance
header and is filtered out of the findings list, the same treatment
`dynamic-resources` gets: it changes how everything below it should be read,
and stating one fact twice reads as two findings.

Fixed while adding it: an entry whose only finding was a banner one rendered
an EMPTY findings container instead of "no structural issues", because the
list's presence was decided on the unfiltered set while its contents were
filtered. `listedIssues` is now derived once and used for both.

The fixture gains `acme/reports` + `globex/reports`, both fully conforming
and sharing the name `reports` — also the only fixture with a multi-segment
skill path, which nothing else exercised. Eight skills over four pages now,
and the integration test walks the cursor to exhaustion rather than asserting
a fixed page count, so a future fixture does not require editing 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 19:02:18 -04:00

676 lines
21 KiB
TypeScript

import React from "react";
import { describe, it, expect, vi } from "vitest";
import { render } from "./helpers/renderTui";
import type { InspectorClient } from "@inspector/core/mcp/index.js";
import type { SkillEntry } from "@inspector/core/mcp/skillsSchemas.js";
import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
import { sha256Digest, textToBytes } from "@inspector/core/mcp/skills.js";
// MUST mock ink-scroll-view: the real ScrollView renders a placeholder minimap
// in the non-TTY test env and never mounts its children.
vi.mock("ink-scroll-view", () => import("./helpers/inkScrollViewMock.js"));
import { SkillsTab } from "../src/components/SkillsTab.js";
const tick = async () => {
for (let i = 0; i < 8; i++)
await new Promise((resolve) => setTimeout(resolve, 4));
};
const ESC = String.fromCharCode(27);
const UP = `${ESC}[A`;
const DOWN = `${ESC}[B`;
const PAGE_UP = `${ESC}[5~`;
const PAGE_DOWN = `${ESC}[6~`;
const ENTER = "\r";
const SKILL_MD = "---\nname: clean\ndescription: A clean skill\n---\n\n# C\n";
// sha256 of SKILL_MD, so the clean fixture actually verifies.
const CLEAN_DIGEST =
"sha256:0000000000000000000000000000000000000000000000000000000000000000";
const clean: SkillEntry = {
uri: "skill://clean/SKILL.md",
frontmatter: { name: "clean", description: "A clean skill" },
resources: [
{ uri: "skill://clean/SKILL.md", digest: CLEAN_DIGEST, size: 51 },
],
};
// A `name-path-mismatch`: the one structural invariant SEP-2640 states
// outright, so this row must carry the error mark.
const broken: SkillEntry = {
uri: "skill://wrong-folder/SKILL.md",
frontmatter: { name: "right-name", description: "Mismatched" },
resources: [
{ uri: "skill://wrong-folder/SKILL.md", digest: CLEAN_DIGEST, size: 1 },
],
};
// Legal but unverifiable — a WARNING, which must read differently from an error.
const dynamic: SkillEntry = {
uri: "skill://gen/SKILL.md",
frontmatter: { name: "gen", description: "Generated" },
resources: "dynamic",
};
const noSize: SkillEntry = {
uri: "skill://nosize/SKILL.md",
frontmatter: { name: "nosize", description: "No declared size" },
resources: [{ uri: "skill://nosize/SKILL.md", digest: CLEAN_DIGEST }],
};
const skills = [clean, broken, dynamic, noSize];
function mockClient(
readResource: unknown = vi.fn().mockResolvedValue({
result: { contents: [{ uri: "skill://clean/SKILL.md", text: SKILL_MD }] },
}),
): InspectorClient {
return { readResource } as unknown as InspectorClient;
}
describe("SkillsTab (#2248)", () => {
it("renders the empty state when there are no skills", () => {
const { lastFrame } = render(
<SkillsTab
skills={[]}
pageCount={0}
inspectorClient={null}
width={140}
height={30}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Skills (0)");
expect(frame).toContain("No skills available");
expect(frame).toContain("Select a skill to view details");
});
it("shows the page count only when the walk took more than one page", () => {
const one = render(
<SkillsTab
skills={skills}
pageCount={1}
inspectorClient={null}
width={140}
height={30}
/>,
);
expect(one.lastFrame() ?? "").toContain("Skills (4)");
expect(one.lastFrame() ?? "").not.toContain("pages");
const many = render(
<SkillsTab
skills={skills}
pageCount={3}
inspectorClient={null}
width={140}
height={30}
/>,
);
expect(many.lastFrame() ?? "").toContain("3 pages");
});
it("renders the list error in place of the list", () => {
const { lastFrame } = render(
<SkillsTab
skills={[]}
pageCount={0}
loadError={new Error("walk failed")}
inspectorClient={null}
width={140}
height={30}
/>,
);
expect(lastFrame() ?? "").toContain("walk failed");
});
it("marks each row with its static conformance verdict", () => {
// The mark is a glyph, not only a colour: this pane is read over ssh, in
// tmux and through `script(1)`, where colour may not survive.
const { lastFrame } = render(
<SkillsTab
skills={skills}
pageCount={1}
inspectorClient={null}
width={140}
height={30}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("✓ clean");
// `skillDisplayName` prefers the declared name over the URI segment.
expect(frame).toContain("✗ right-name");
expect(frame).toContain("! gen");
});
it("shows the selected skill's URI, description, findings and manifest", () => {
const { lastFrame } = render(
<SkillsTab
skills={skills}
pageCount={1}
inspectorClient={null}
width={140}
height={30}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("skill://clean/SKILL.md");
expect(frame).toContain("A clean skill");
expect(frame).toContain("Conformance: conforms");
expect(frame).toContain("Manifest (1)");
expect(frame).toContain("SKILL.md");
expect(frame).toContain("(51 B)");
expect(frame).toContain("[Enter to verify digests and frontmatter]");
});
it("renders a dynamic skill's manifest as unadvertised rather than empty", async () => {
const { lastFrame, stdin } = render(
<SkillsTab
skills={skills}
pageCount={1}
inspectorClient={null}
width={140}
height={30}
focusedPane="list"
/>,
);
stdin.write(DOWN);
await tick();
stdin.write(DOWN);
await tick();
const frame = lastFrame() ?? "";
expect(frame).toContain('"dynamic" — no files advertised');
expect(frame).toContain("integrity cannot be verified");
});
it("omits the size caption when the manifest declares none", async () => {
const { lastFrame, stdin } = render(
<SkillsTab
skills={skills}
pageCount={1}
inspectorClient={null}
width={140}
height={30}
focusedPane="list"
/>,
);
for (let i = 0; i < 3; i++) {
stdin.write(DOWN);
await tick();
}
const frame = lastFrame() ?? "";
expect(frame).toContain("Manifest (1)");
expect(frame).not.toContain(" B)");
});
it("moves selection with the arrow keys and stops at both boundaries", async () => {
const { lastFrame, stdin } = render(
<SkillsTab
skills={skills}
pageCount={1}
inspectorClient={null}
width={140}
height={30}
focusedPane="list"
/>,
);
stdin.write(UP);
await tick();
expect(lastFrame() ?? "").toContain("▶ ✓ clean");
stdin.write(DOWN);
await tick();
expect(lastFrame() ?? "").toContain("▶ ✗ right-name");
for (let i = 0; i < 5; i++) {
stdin.write(DOWN);
await tick();
}
// `nosize` omits a required `size`, so its row carries the error mark too
// — the mark tracks the checks, not the position.
expect(lastFrame() ?? "").toContain("▶ ✗ nosize");
// …and back up from the bottom, which is the other direction of the same
// guard: the top boundary above never exercises the move itself.
stdin.write(UP);
await tick();
expect(lastFrame() ?? "").toContain("▶ ! gen");
});
it("scrolls the details pane without moving the selection", async () => {
const scrollBy = vi.fn();
const { stdin } = render(
<SkillsTab
skills={skills}
pageCount={1}
inspectorClient={null}
width={140}
height={30}
focusedPane="details"
/>,
);
stdin.write(UP);
stdin.write(DOWN);
stdin.write(PAGE_UP);
stdin.write(PAGE_DOWN);
await tick();
// Nothing to assert on the mock beyond not crashing and not moving the
// selection — the ScrollView handle is stubbed by the shared mock.
expect(scrollBy).not.toHaveBeenCalled();
});
it("ignores input entirely when a modal is open", async () => {
const { lastFrame, stdin } = render(
<SkillsTab
skills={skills}
pageCount={1}
inspectorClient={null}
width={140}
height={30}
focusedPane="list"
modalOpen
/>,
);
stdin.write(DOWN);
await tick();
expect(lastFrame() ?? "").toContain("▶ ✓ clean");
});
it("verifies the selected skill on Enter and reports the outcome", async () => {
const readResource = vi.fn().mockResolvedValue({
result: { contents: [{ uri: "skill://clean/SKILL.md", text: SKILL_MD }] },
});
const { lastFrame, stdin } = render(
<SkillsTab
skills={[clean]}
pageCount={1}
inspectorClient={mockClient(readResource)}
width={140}
height={30}
focusedPane="list"
/>,
);
stdin.write(ENTER);
await tick();
expect(readResource).toHaveBeenCalled();
const frame = lastFrame() ?? "";
// The fixture's advertised digest is all zeroes, so this is a mismatch —
// which is the outcome worth showing loudly.
expect(frame).toContain("Verification FAILED");
expect(frame).toContain("✗ SKILL.md");
});
it("surfaces the frontmatter cross-check after verifying", async () => {
const lying: SkillEntry = {
...clean,
frontmatter: { name: "clean", description: "Something else entirely" },
};
const { lastFrame, stdin } = render(
<SkillsTab
skills={[lying]}
pageCount={1}
inspectorClient={mockClient()}
width={140}
height={30}
focusedPane="list"
/>,
);
stdin.write(ENTER);
await tick();
const frame = lastFrame() ?? "";
expect(frame).toContain("Frontmatter cross-check:");
expect(frame).toContain("Something else entirely");
});
it("reports an ordinary read failure as a failed verdict, not a crash", async () => {
// `verifySkills` records a plain read failure per file rather than
// throwing, so the pane shows the verdict rather than the error banner.
const readResource = vi.fn().mockRejectedValue(new Error("network down"));
const { lastFrame, stdin } = render(
<SkillsTab
skills={[clean]}
pageCount={1}
inspectorClient={mockClient(readResource)}
width={140}
height={30}
focusedPane="list"
/>,
);
stdin.write(ENTER);
await tick();
expect(lastFrame() ?? "").toContain("Verification FAILED");
expect(lastFrame() ?? "").toContain("network down");
});
it("hands an auth-recovery error to the callback instead of rendering it", async () => {
// The one error `verifySkills` re-throws: the session's authorization
// expired, and this callback is how the TUI offers to fix it. Rendered as
// a message instead, the user would be told the file could not be read and
// given no way to recover.
const err = new AuthRecoveryRequiredError(
new URL("https://auth.example/authorize"),
{ reason: "expired" } as never,
);
const onAuthRecoveryRequired = vi.fn();
const { lastFrame, stdin } = render(
<SkillsTab
skills={[clean]}
pageCount={1}
inspectorClient={mockClient(vi.fn().mockRejectedValue(err))}
width={140}
height={30}
focusedPane="list"
onAuthRecoveryRequired={onAuthRecoveryRequired}
/>,
);
stdin.write(ENTER);
await tick();
expect(onAuthRecoveryRequired).toHaveBeenCalledWith(err);
expect(lastFrame() ?? "").not.toContain("Verification FAILED");
});
it("shows the read failure's own reason under the file it happened on", async () => {
// A client missing `readResource` entirely fails every read; the walk
// records the reason per file rather than aborting, so the diagnosis lands
// beside the file it belongs to.
const { lastFrame, stdin } = render(
<SkillsTab
skills={[clean]}
pageCount={1}
inspectorClient={{} as unknown as InspectorClient}
width={140}
height={30}
focusedPane="list"
/>,
);
stdin.write(ENTER);
await tick();
expect(lastFrame() ?? "").toContain("is not a function");
});
it("does nothing on Enter with no connected client", async () => {
const { lastFrame, stdin } = render(
<SkillsTab
skills={[clean]}
pageCount={1}
inspectorClient={null}
width={140}
height={30}
focusedPane="list"
/>,
);
stdin.write(ENTER);
await tick();
expect(lastFrame() ?? "").toContain(
"[Enter to verify digests and frontmatter]",
);
});
it("reports a verified skill and re-verifies on a second Enter", async () => {
// The digest is computed from the very bytes the fake read returns, so the
// pass is real rather than a constant that happens to match.
const digest = await sha256Digest(textToBytes(SKILL_MD));
const verifiable: SkillEntry = {
...clean,
resources: [
{
uri: "skill://clean/SKILL.md",
digest,
size: textToBytes(SKILL_MD).byteLength,
},
],
};
const readResource = vi.fn().mockResolvedValue({
result: { contents: [{ uri: "skill://clean/SKILL.md", text: SKILL_MD }] },
});
const { lastFrame, stdin } = render(
<SkillsTab
skills={[verifiable]}
pageCount={1}
inspectorClient={mockClient(readResource)}
width={140}
height={30}
focusedPane="list"
/>,
);
stdin.write(ENTER);
await tick();
expect(lastFrame() ?? "").toContain("Verified — Enter to re-verify");
expect(lastFrame() ?? "").toContain("✓ SKILL.md");
stdin.write(ENTER);
await tick();
expect(readResource).toHaveBeenCalledTimes(2);
});
it("shows a verifying state and ignores Enter while one is in flight", async () => {
// The guard is what stops a held Enter from opening a second walk over the
// same manifest on top of the first.
let release: ((value: unknown) => void) | undefined;
const readResource = vi.fn(
() =>
new Promise((resolve) => {
release = resolve;
}),
);
const { lastFrame, stdin } = render(
<SkillsTab
skills={[clean]}
pageCount={1}
inspectorClient={mockClient(readResource)}
width={140}
height={30}
focusedPane="list"
/>,
);
stdin.write(ENTER);
await tick();
expect(lastFrame() ?? "").toContain("[Verifying…]");
stdin.write(ENTER);
await tick();
expect(readResource).toHaveBeenCalledTimes(1);
release?.({
result: { contents: [{ uri: "skill://clean/SKILL.md", text: SKILL_MD }] },
});
await tick();
});
it("drops a verdict when the entry changes under the same URI", async () => {
// A refresh can replace the manifest or the frontmatter without the URI
// moving. A URI-keyed verdict would then present hashes and findings
// computed for the PREVIOUS snapshot as if they described the new one.
const digest = await sha256Digest(textToBytes(SKILL_MD));
const verifiable: SkillEntry = {
...clean,
resources: [
{
uri: "skill://clean/SKILL.md",
digest,
size: textToBytes(SKILL_MD).byteLength,
},
],
};
const { lastFrame, stdin, rerender } = render(
<SkillsTab
skills={[verifiable]}
pageCount={1}
inspectorClient={mockClient()}
width={140}
height={30}
focusedPane="list"
/>,
);
stdin.write(ENTER);
await tick();
expect(lastFrame() ?? "").toContain("Verified — Enter to re-verify");
// Same URI, different manifest — the old verdict must not carry over.
rerender(
<SkillsTab
skills={[
{
...verifiable,
resources: [
{ uri: "skill://clean/SKILL.md", digest: CLEAN_DIGEST, size: 9 },
],
},
]}
pageCount={1}
inspectorClient={mockClient()}
width={140}
height={30}
focusedPane="list"
/>,
);
await tick();
expect(lastFrame() ?? "").toContain(
"[Enter to verify digests and frontmatter]",
);
});
it("keeps a verdict across a reorder that leaves the entry unchanged", async () => {
// The reason the key is the entry rather than the list index: moving a
// skill down the list must not discard a verdict the user paid for.
const digest = await sha256Digest(textToBytes(SKILL_MD));
const verifiable: SkillEntry = {
...clean,
resources: [
{
uri: "skill://clean/SKILL.md",
digest,
size: textToBytes(SKILL_MD).byteLength,
},
],
};
const { lastFrame, stdin, rerender } = render(
<SkillsTab
skills={[verifiable]}
pageCount={1}
inspectorClient={mockClient()}
width={140}
height={30}
focusedPane="list"
/>,
);
stdin.write(ENTER);
await tick();
expect(lastFrame() ?? "").toContain("Verified — Enter to re-verify");
rerender(
<SkillsTab
skills={[verifiable, dynamic]}
pageCount={1}
inspectorClient={mockClient()}
width={140}
height={30}
focusedPane="list"
/>,
);
await tick();
expect(lastFrame() ?? "").toContain("Verified — Enter to re-verify");
});
it("falls back to the whole URI when a manifest entry has no path separator", async () => {
const odd: SkillEntry = {
uri: "skill://odd/SKILL.md",
frontmatter: { name: "odd", description: "d" },
resources: [{ uri: "urn:opaque", digest: CLEAN_DIGEST, size: 1 }],
};
const { lastFrame } = render(
<SkillsTab
skills={[odd]}
pageCount={1}
inspectorClient={null}
width={140}
height={30}
/>,
);
expect(lastFrame() ?? "").toContain("urn:opaque");
});
it("keys a row by its index when the entry carries no URI", () => {
// A URI-less entry is a `malformed-uri` finding this pane reports, so it
// must still render a addressable row rather than colliding React keys.
const nameless = {
uri: "",
frontmatter: { name: "nameless", description: "d" },
resources: [],
} as SkillEntry;
const { lastFrame } = render(
<SkillsTab
skills={[nameless]}
pageCount={1}
inspectorClient={null}
width={140}
height={30}
/>,
);
expect(lastFrame() ?? "").toContain("nameless");
});
it("reports a name collision on both entries, as a warning", async () => {
// A catalog-level fact `checkSkillConformance` structurally cannot see —
// and a warning, because the server did nothing wrong: the obligation is
// on the consumer to tell two same-named skills apart.
const acme: SkillEntry = {
uri: "skill://acme/reports/SKILL.md",
frontmatter: { name: "reports", description: "Acme ledger" },
resources: [
{ uri: "skill://acme/reports/SKILL.md", digest: CLEAN_DIGEST, size: 1 },
],
};
const globex: SkillEntry = {
uri: "skill://globex/reports/SKILL.md",
frontmatter: { name: "reports", description: "Globex ledger" },
resources: [
{
uri: "skill://globex/reports/SKILL.md",
digest: CLEAN_DIGEST,
size: 1,
},
],
};
const { lastFrame, stdin } = render(
<SkillsTab
skills={[acme, globex]}
pageCount={1}
inspectorClient={null}
width={160}
height={30}
focusedPane="list"
/>,
);
const frame = lastFrame() ?? "";
// Both rows carry the warning mark, not the error one.
expect(frame.match(/! reports/g)).toHaveLength(2);
expect(frame).not.toContain("✗ reports");
expect(frame).toContain("also declares the name");
// The detail pane names the OTHER skill, which is the disambiguation.
expect(frame).toContain("skill://globex/reports/SKILL.md");
stdin.write(DOWN);
await tick();
expect(lastFrame() ?? "").toContain("skill://acme/reports/SKILL.md");
});
it("shows the details footer only when the details pane is focused", () => {
const unfocused = render(
<SkillsTab
skills={[clean]}
pageCount={1}
inspectorClient={null}
width={140}
height={30}
focusedPane="list"
/>,
);
expect(unfocused.lastFrame() ?? "").not.toContain("Enter to verify\n");
const focused = render(
<SkillsTab
skills={[clean]}
pageCount={1}
inspectorClient={null}
width={140}
height={30}
focusedPane="details"
/>,
);
expect(focused.lastFrame() ?? "").toContain(
"↑/↓ to scroll, Enter to verify",
);
});
});