import { describe, it, expect, vi } from "vitest"; import userEvent from "@testing-library/user-event"; import type { ResourceTemplateType as ResourceTemplate } from "@modelcontextprotocol/client"; 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", }; const queryTemplate: ResourceTemplate = { name: "Events", uriTemplate: "foobar://events{?topic}", }; 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("clears a variable via its Clear button (non-autocomplete branch)", async () => { const user = userEvent.setup(); renderWithMantine( , ); const input = screen.getByLabelText("userId"); await user.type(input, "alice"); expect(input).toHaveValue("alice"); // The Clear button only renders while the value is non-empty. await user.click(screen.getByRole("button", { name: "Clear" })); expect(input).toHaveValue(""); }); 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("RFC 6570 expansion (#1919)", () => { it("renders an input for a query expression the old regex could not see", () => { renderWithMantine( , ); expect(screen.getByLabelText("topic")).toBeInTheDocument(); }); it("percent-encodes a reserved character in a simple variable", async () => { const user = userEvent.setup(); const onReadResource = vi.fn(); renderWithMantine( , ); await user.type(screen.getByLabelText("userId"), "foo/bar"); await user.click(screen.getByRole("button", { name: "Read Resource" })); expect(onReadResource).toHaveBeenCalledWith( "file:///users/foo%2Fbar/profile", ); }); it("builds an encoded query expression for {?topic}", async () => { const user = userEvent.setup(); const onReadResource = vi.fn(); renderWithMantine( , ); await user.type(screen.getByLabelText("topic"), "foo/bar"); await user.click(screen.getByRole("button", { name: "Read Resource" })); expect(onReadResource).toHaveBeenCalledWith( "foobar://events?topic=foo%2Fbar", ); }); it("marks a query variable Optional and does not gate the read on it", async () => { const user = userEvent.setup(); const onReadResource = vi.fn(); renderWithMantine( , ); expect(screen.getByText("Optional")).toBeInTheDocument(); const button = screen.getByRole("button", { name: "Read Resource" }); expect(button).not.toBeDisabled(); // Left blank, the whole expression drops out per RFC 6570. await user.click(button); expect(onReadResource).toHaveBeenCalledWith("foobar://events"); }); it("does not mark a required simple variable Optional", () => { renderWithMantine( , ); expect(screen.queryByText("Optional")).not.toBeInTheDocument(); }); it("previews the query expression verbatim until it is filled", async () => { const user = userEvent.setup(); renderWithMantine( , ); expect(screen.getByText("foobar://events{?topic}")).toBeInTheDocument(); await user.type(screen.getByLabelText("topic"), "news"); expect( screen.getByText("foobar://events?topic=news"), ).toBeInTheDocument(); }); // A template the server advertised can be malformed. There is no URI to // send for one, so the read is withheld and the reason shown -- reading the // raw template with its braces intact would draw a confusing "not found" // from the server for a defect that is not the user's. it.each([ ["an out-of-grammar prefix modifier", "x://items/{id:abc}"], ["an expression declaring no variable", "x://items/{}"], ])("refuses to read a template with %s", (_label, uriTemplate) => { const onReadResource = vi.fn(); renderWithMantine( , ); expect( screen.getByRole("button", { name: "Read Resource" }), ).toBeDisabled(); expect(screen.getByText(/Invalid RFC 6570 varspec/)).toBeInTheDocument(); expect(onReadResource).not.toHaveBeenCalled(); }); it("shows the malformed template unexpanded in the preview", () => { renderWithMantine( , ); expect(screen.getByText("x://items/{}")).toBeInTheDocument(); }); }); 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("surfaces an empty dropdown when the completion request rejects", async () => { const user = userEvent.setup(); // Focus call resolves with options; the debounced keystroke call // rejects. The rejection (not aborted) must reset the dropdown to []. const onCompleteArgument = vi .fn< ( argName: string, value: string, context: Record, ) => Promise >() .mockResolvedValueOnce(["alpha", "alphabet"]) .mockRejectedValueOnce(new Error("completion failed")); renderWithMantine( , ); const input = screen.getByRole("textbox", { name: "userId" }); await user.click(input); await new Promise((r) => setTimeout(r, 0)); expect(await screen.findByText("alpha")).toBeInTheDocument(); await user.type(input, "z"); await new Promise((r) => setTimeout(r, 400)); expect(screen.queryByText("alpha")).not.toBeInTheDocument(); expect(screen.queryByText("alphabet")).not.toBeInTheDocument(); }); it("cancels a pending debounce timer when the input is re-focused", async () => { const user = userEvent.setup(); const onCompleteArgument = vi .fn< ( argName: string, value: string, context: Record, ) => Promise >() .mockResolvedValue([]); renderWithMantine( , ); const tableInput = screen.getByRole("textbox", { name: "tableName" }); const rowInput = screen.getByRole("textbox", { name: "rowId" }); // Type into tableName → schedules a debounce timer. Move focus away // and back before the 300ms fires so handleVariableFocus sees a // pending timer for "tableName" and clears it. await user.type(tableInput, "u"); await user.click(rowInput); await user.click(tableInput); onCompleteArgument.mockClear(); await new Promise((r) => setTimeout(r, 400)); const tableCalls = onCompleteArgument.mock.calls.filter( ([n]) => n === "tableName", ); // Only the focus-fire call (value "u"), never the cancelled debounce. expect(tableCalls.every(([, v]) => v === "u")).toBe(true); }); it("drops a stale in-flight response when a faster keystroke arrives", async () => { const user = userEvent.setup(); const calls: Array<{ value: string; resolve: (values: string[]) => void; }> = []; const onCompleteArgument = vi.fn( (_argName: string, value: string) => new Promise((resolve) => { calls.push({ value, resolve }); }), ); renderWithMantine( , ); // Focus → first call (value=""). Type "h" → second call after // debounce. Type "i" → third call after debounce, which aborts the // "h" controller. const input = screen.getByRole("textbox", { name: "userId" }); await user.type(input, "h"); await new Promise((r) => setTimeout(r, 350)); await user.type(input, "i"); await new Promise((r) => setTimeout(r, 350)); const hi = calls.find((c) => c.value === "hi"); const h = calls.find((c) => c.value === "h"); expect(hi).toBeDefined(); expect(h).toBeDefined(); // Resolve the stale "h" after it was aborted — its signal.aborted // guard drops the response so it can't overwrite the fresh one. h?.resolve(["from-stale-h"]); hi?.resolve(["from-fresh-hi"]); await new Promise((r) => setTimeout(r, 0)); expect(await screen.findByText("from-fresh-hi")).toBeInTheDocument(); expect(screen.queryByText("from-stale-h")).not.toBeInTheDocument(); }); it("aborts in-flight completion requests on unmount", async () => { const user = userEvent.setup(); // A request that never settles, so it is still in flight at unmount. const onCompleteArgument = vi.fn(() => new Promise(() => {})); const { unmount } = renderWithMantine( , ); // Focus fires a completion immediately, leaving an in-flight request. await user.click(screen.getByRole("textbox", { name: "userId" })); await new Promise((r) => setTimeout(r, 0)); expect(onCompleteArgument).toHaveBeenCalled(); // The unmount-cleanup effect iterates the in-flight controllers and // aborts each — this must not throw. expect(() => unmount()).not.toThrow(); }); it("does not fire completions on focus when completions are unsupported", async () => { const user = userEvent.setup(); const onCompleteArgument = vi.fn(); renderWithMantine( , ); // Focusing the plain TextInput hits the early `!useAutocomplete` // return in handleVariableFocus. await user.click(screen.getByLabelText("userId")); await new Promise((r) => setTimeout(r, 0)); expect(onCompleteArgument).not.toHaveBeenCalled(); }); 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(); }); }); });