import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { getAceText, setAceText } from "../../../test/aceEditor";
import type { Tool } from "@modelcontextprotocol/client";
import { EditReplayModal } from "./EditReplayModal";
const TOOLS: Tool[] = [
{
name: "echo",
inputSchema: {
type: "object",
properties: { message: { type: "string" } },
},
},
{
name: "add",
inputSchema: { type: "object", properties: { count: { type: "number" } } },
},
];
const baseProps = {
opened: true,
method: "tools/call",
onSend: vi.fn(),
onClose: vi.fn(),
};
describe("EditReplayModal", () => {
it("renders nothing while closed", () => {
renderWithMantine();
expect(screen.queryByRole("button", { name: "Send" })).toBeNull();
});
it("seeds the editor with the entry's params and names the method", () => {
renderWithMantine(
,
);
expect(getAceText()).toBe('{\n "name": "echo"\n}');
expect(screen.getByText("tools/call")).toBeInTheDocument();
});
it("opens empty for a request that carried no params", () => {
renderWithMantine();
expect(getAceText()).toBe("");
expect(screen.getByRole("button", { name: "Send" })).not.toBeDisabled();
});
it("sends the edited params and closes", async () => {
const user = userEvent.setup();
const onSend = vi.fn();
const onClose = vi.fn();
renderWithMantine(
,
);
await setAceText('{"name":"add","arguments":{"a":1}}');
await user.click(screen.getByRole("button", { name: "Send" }));
expect(onSend).toHaveBeenCalledWith({
name: "add",
arguments: { a: 1 },
});
expect(onClose).toHaveBeenCalledTimes(1);
});
// `null`, not `undefined`: the caller has to be able to tell "the user
// cleared the editor" from "the user did not edit anything" (#2151).
it("reports an emptied editor as no params at all", async () => {
const user = userEvent.setup();
const onSend = vi.fn();
renderWithMantine(
,
);
await setAceText("");
await user.click(screen.getByRole("button", { name: "Send" }));
expect(onSend).toHaveBeenCalledWith(null);
});
// Unlike the metadata editor, this modal has a commit gesture to gate — so
// invalid text must disable it rather than silently sending the last value
// that parsed.
it("disables Send while the draft does not parse", async () => {
renderWithMantine(
,
);
await setAceText('{"name":');
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
expect(screen.getByText(/Not valid JSON/)).toBeInTheDocument();
});
it("disables Send for JSON that is not an object", async () => {
renderWithMantine();
await setAceText("42");
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
expect(screen.getByText(/must be a JSON object/i)).toBeInTheDocument();
});
// `JSON.parse("1e400")` yields `Infinity`, which `JSON.stringify` writes back
// as `null` — so Send would dispatch a number the user never typed while the
// editor still showed what they wrote.
it("disables Send for a number that cannot survive being sent", async () => {
renderWithMantine();
await setAceText('{"limit":1e400}');
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
expect(screen.getByText(/Numbers must be finite/)).toBeInTheDocument();
});
// Replay dispatches through the typed client methods, so a param they have no
// room for is dropped. Saying which, rather than seeding it into the editor,
// is what keeps the modal from inviting an edit that Send discards.
it("names the params replay cannot re-send", () => {
renderWithMantine(
,
);
expect(screen.getByText(/`_meta` is not re-sent/)).toBeInTheDocument();
});
it("says nothing when every param survives", () => {
renderWithMantine(
,
);
expect(screen.queryByText(/not re-sent/)).toBeNull();
});
it("agrees with its verb for several dropped params", () => {
renderWithMantine(
,
);
expect(
screen.getByText(/`_meta`, `extra` are not re-sent/),
).toBeInTheDocument();
});
// Seeding from the projection is only half of it: a key the dispatcher does
// not read can be *added* to the draft, and Send would then discard an edit
// the user was looking at.
it("disables Send for a key this method's dispatch would not read", async () => {
renderWithMantine(
,
);
await setAceText('{"cursor":"abc","_meta":{"progressToken":1}}');
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
expect(
screen.getByText(/`_meta` is not carried by tools\/list/),
).toBeInTheDocument();
});
it("re-enables Send once the unsupported key is removed", async () => {
renderWithMantine(
,
);
await setAceText('{"cursor":"abc","_meta":{}}');
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
await setAceText('{"cursor":"abc"}');
expect(screen.getByRole("button", { name: "Send" })).not.toBeDisabled();
expect(screen.queryByText(/not carried by/)).toBeNull();
});
// The check is per method, not a fixed key list: `arguments` is read by a
// tool call and by nothing on a list request.
it("judges a key against the method it would be sent to", async () => {
const { rerender } = renderWithMantine(
,
);
await setAceText('{"name":"echo","arguments":{"a":1}}');
expect(screen.getByRole("button", { name: "Send" })).not.toBeDisabled();
rerender();
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
expect(
screen.getByText(/`name`, `arguments` are not carried by tools\/list/),
).toBeInTheDocument();
});
// Surviving the key check is not the same as being sent as written:
// `replayProtocolRequest` applies `arguments ?? {}`, so a null shown in the
// editor reaches the wire as `{}`.
it("disables Send for a null arguments, which would be sent as {}", async () => {
renderWithMantine();
await setAceText('{"name":"echo","arguments":null}');
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
expect(screen.getByText(/sent as `\{\}` when null/)).toBeInTheDocument();
});
it("disables Send for a non-object arguments", async () => {
renderWithMantine();
await setAceText('{"name":"echo","arguments":[1,2]}');
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
expect(
screen.getByText(/`arguments` must be a JSON object/),
).toBeInTheDocument();
});
// `JSON.parse` rounds a whole number past 2^53−1 to the nearest double, so
// the draft shows digits the wire will not carry.
it("disables Send for a whole number that loses digits when parsed", async () => {
renderWithMantine();
await setAceText('{"name":"echo","arguments":{"id":9007199254740993}}');
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
expect(screen.getByText(/would not arrive as written/)).toBeInTheDocument();
});
it("disables Send for a non-string prompt argument", async () => {
renderWithMantine();
await setAceText('{"name":"greet","arguments":{"count":2}}');
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
expect(
screen.getByText(/`count` would be sent as JSON text/),
).toBeInTheDocument();
});
// `callTool` coerces string arguments to the type the schema declares, so
// `"2"` against a numeric field is sent as `2`.
it("disables Send for a tool argument the schema would coerce", async () => {
renderWithMantine(
,
);
await setAceText('{"name":"add","arguments":{"count":"2"}}');
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
expect(screen.getByText(/`count` would be converted/)).toBeInTheDocument();
});
// `name` is editable, so the schema to validate against is whichever tool the
// draft *now* names — not the one the entry happened to call.
it("re-validates against the tool the draft retargets to", async () => {
renderWithMantine(
,
);
expect(screen.getByRole("button", { name: "Send" })).not.toBeDisabled();
// Retargeted at `add`, whose `count` is a number — the string would coerce.
await setAceText('{"name":"add","arguments":{"count":"2"}}');
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
// And back: `echo`'s `message` is a string, so nothing is converted.
await setAceText('{"name":"echo","arguments":{"message":"2"}}');
expect(screen.getByRole("button", { name: "Send" })).not.toBeDisabled();
});
// `JSON.parse` keeps only the last occurrence, so the draft would show two
// cursors and Send would dispatch one.
it("disables Send for a document that names the same member twice", async () => {
renderWithMantine();
await setAceText('{"cursor":"first","cursor":"second"}');
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
expect(screen.getByText(/`cursor` appears twice/)).toBeInTheDocument();
});
it("closes without sending when cancelled", async () => {
const user = userEvent.setup();
const onSend = vi.fn();
const onClose = vi.fn();
renderWithMantine(
,
);
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(onClose).toHaveBeenCalledTimes(1);
expect(onSend).not.toHaveBeenCalled();
});
// The draft belongs to the one opening of the modal it was typed in.
// Reopening must show the entry's params again, not whatever was typed and
// abandoned last time.
it("reseeds from the entry when reopened", async () => {
const { rerender } = renderWithMantine(
,
);
await setAceText('{"name":"abandoned"}');
rerender(
,
);
rerender();
expect(getAceText()).toBe('{\n "name": "echo"\n}');
});
});