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();
});
});
});