import { describe, it, expect, vi } from "vitest"; import userEvent from "@testing-library/user-event"; import type { ResourceTemplate } from "@modelcontextprotocol/sdk/types.js"; import { renderWithMantine, screen } from "../../../test/renderWithMantine"; import { ResourceTemplatePanel } from "./ResourceTemplatePanel"; const singleVarTemplate: ResourceTemplate = { name: "User Profile", uriTemplate: "file:///users/{userId}/profile", description: "Fetch a user profile.", }; const titledTemplate: ResourceTemplate = { name: "Table Row", title: "Database Row", uriTemplate: "db://tables/{tableName}/rows/{rowId}", }; const annotatedTemplate: ResourceTemplate = { name: "Dynamic", uriTemplate: "resource://dynamic/{id}", annotations: { audience: ["user"], priority: 0.8 }, }; const noVarTemplate: ResourceTemplate = { name: "Static", uriTemplate: "file:///static.txt", }; describe("ResourceTemplatePanel", () => { it("renders the template title (or name) and description", () => { renderWithMantine( , ); expect(screen.getByText("User Profile Template")).toBeInTheDocument(); expect(screen.getByText("Fetch a user profile.")).toBeInTheDocument(); }); it("prefers the title over the name when present", () => { renderWithMantine( , ); expect(screen.getByText("Database Row Template")).toBeInTheDocument(); }); it("renders an input per template variable", () => { renderWithMantine( , ); expect(screen.getByLabelText("tableName")).toBeInTheDocument(); expect(screen.getByLabelText("rowId")).toBeInTheDocument(); }); it("disables Read Resource until all variables are filled", async () => { const user = userEvent.setup(); renderWithMantine( , ); const button = screen.getByRole("button", { name: "Read Resource" }); expect(button).toBeDisabled(); await user.type(screen.getByLabelText("tableName"), "users"); expect(button).toBeDisabled(); await user.type(screen.getByLabelText("rowId"), "42"); expect(button).not.toBeDisabled(); }); it("invokes onReadResource with the resolved URI when submitted", async () => { const user = userEvent.setup(); const onReadResource = vi.fn(); renderWithMantine( , ); await user.type(screen.getByLabelText("userId"), "alice"); await user.click(screen.getByRole("button", { name: "Read Resource" })); expect(onReadResource).toHaveBeenCalledWith("file:///users/alice/profile"); }); it("updates the URI preview as variables change", async () => { const user = userEvent.setup(); renderWithMantine( , ); expect( screen.getByText("file:///users/{userId}/profile"), ).toBeInTheDocument(); await user.type(screen.getByLabelText("userId"), "bob"); expect(screen.getByText("file:///users/bob/profile")).toBeInTheDocument(); }); it("renders annotation badges when present", () => { renderWithMantine( , ); expect(screen.getByText("audience: user")).toBeInTheDocument(); expect(screen.getByText("priority: high")).toBeInTheDocument(); }); it("renders without description when not provided", () => { renderWithMantine( , ); expect(screen.queryByText("Fetch a user profile.")).not.toBeInTheDocument(); }); it("enables submission immediately when there are no variables", () => { renderWithMantine( , ); expect( screen.getByRole("button", { name: "Read Resource" }), ).not.toBeDisabled(); }); describe("completions", () => { it("fires a completion immediately on focus before any keystroke", async () => { const user = userEvent.setup(); const onCompleteArgument = vi .fn< ( argName: string, value: string, context: Record, ) => Promise >() .mockResolvedValue(["alpha", "alphabet"]); renderWithMantine( , ); await user.click(screen.getByRole("textbox", { name: "tableName" })); await new Promise((r) => setTimeout(r, 0)); // Empty value, empty sibling — but the sibling key is still // present so the server sees the full argument set. expect(onCompleteArgument).toHaveBeenCalledWith("tableName", "", { rowId: "", }); expect(await screen.findByText("alpha")).toBeInTheDocument(); }); it("calls onCompleteArgument (debounced) and surfaces values when supported", async () => { const user = userEvent.setup(); const onCompleteArgument = vi .fn< ( argName: string, value: string, context: Record, ) => Promise >() .mockResolvedValue(["alpha", "alphabet"]); renderWithMantine( , ); await user.type(screen.getByRole("textbox", { name: "userId" }), "al"); // Wait past the 300ms debounce. await new Promise((r) => setTimeout(r, 400)); // user.type focuses first (firing one immediate completion) and // then types the characters (firing the debounced one). Only the // typed-prefix call is the one we care about here. expect(onCompleteArgument).toHaveBeenLastCalledWith("userId", "al", {}); // Server-returned values surface in the Autocomplete dropdown. expect(await screen.findByText("alpha")).toBeInTheDocument(); expect(screen.getByText("alphabet")).toBeInTheDocument(); }); it("passes sibling variables as completion context", async () => { const user = userEvent.setup(); const onCompleteArgument = vi .fn< ( argName: string, value: string, context: Record, ) => Promise >() .mockResolvedValue([]); renderWithMantine( , ); await user.type( screen.getByRole("textbox", { name: "tableName" }), "users", ); await new Promise((r) => setTimeout(r, 400)); // The completing arg ("tableName") is excluded from context; only // the other variables ride along. expect(onCompleteArgument).toHaveBeenLastCalledWith( "tableName", "users", { rowId: "" }, ); await user.type(screen.getByRole("textbox", { name: "rowId" }), "42"); await new Promise((r) => setTimeout(r, 400)); expect(onCompleteArgument).toHaveBeenLastCalledWith("rowId", "42", { tableName: "users", }); }); it("clears stale dropdown options the instant a new keystroke arrives", async () => { const user = userEvent.setup(); const deferred: Array<{ value: string; resolve: (values: string[]) => void; }> = []; const onCompleteArgument = vi.fn( (_argName: string, value: string) => new Promise((resolve) => { deferred.push({ value, resolve }); }), ); renderWithMantine( , ); // Focus → first call (value=""). Resolve so the dropdown has // something to show. await user.click(screen.getByRole("textbox", { name: "userId" })); await new Promise((r) => setTimeout(r, 0)); expect(deferred.length).toBe(1); deferred[0].resolve(["alpha", "alphabet"]); expect(await screen.findByText("alpha")).toBeInTheDocument(); // Type a new character — the keystroke handler must drop the // stale options immediately so the dropdown doesn't show // "alpha" / "alphabet" while the next request is in flight // (300ms debounce + network latency). await user.type(screen.getByRole("textbox", { name: "userId" }), "z"); expect(screen.queryByText("alpha")).not.toBeInTheDocument(); expect(screen.queryByText("alphabet")).not.toBeInTheDocument(); }); it("does not call onCompleteArgument when completions are unsupported", async () => { const user = userEvent.setup(); const onCompleteArgument = vi.fn(); renderWithMantine( , ); await user.type(screen.getByLabelText("userId"), "ab"); await new Promise((r) => setTimeout(r, 400)); expect(onCompleteArgument).not.toHaveBeenCalled(); }); }); });