inspector/clients/web/src/components/groups/EditReplayModal/EditReplayModal.test.tsx
cliffhall 8aa6f01505 fix(web): reject a negative-zero literal, which loses its sign when serialized
Copilot's twelfth pass. The suppressed comment is right and is fixed here; the
inline one is real too, but needs a core API change and is filed as #2171.

`-0` is represented exactly, so the digit comparison had nothing to say about
it — but `JSON.stringify` writes it as `0`, so the loss happens on the way
*out* rather than on the way in. The editor showed `-0` and the wire carried
`0`, and the viewer rewrote a received `-0` while pretty-printing.

`hasImpreciseIntegerLiteral` becomes `findUnsendableNumberLiteral`, checking
`Object.is(Number(literal), -0)` for every numeric spelling — `-0`, `-0.0`,
`-0e1` — before the integer-only digit check. Returning the offending literal
rather than a boolean lets the message name it, which is more use than either
of the two rules behind it and matches how the duplicate-key message reads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-27 06:11:23 -04:00

306 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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(<EditReplayModal {...baseProps} opened={false} />);
expect(screen.queryByRole("button", { name: "Send" })).toBeNull();
});
it("seeds the editor with the entry's params and names the method", () => {
renderWithMantine(
<EditReplayModal {...baseProps} params={{ name: "echo" }} />,
);
expect(getAceText()).toBe('{\n "name": "echo"\n}');
expect(screen.getByText("tools/call")).toBeInTheDocument();
});
it("opens empty for a request that carried no params", () => {
renderWithMantine(<EditReplayModal {...baseProps} method="tools/list" />);
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(
<EditReplayModal
{...baseProps}
params={{ name: "echo" }}
onSend={onSend}
onClose={onClose}
/>,
);
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(
<EditReplayModal
{...baseProps}
params={{ name: "echo" }}
onSend={onSend}
/>,
);
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(
<EditReplayModal {...baseProps} params={{ name: "echo" }} />,
);
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(<EditReplayModal {...baseProps} />);
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(<EditReplayModal {...baseProps} />);
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(
<EditReplayModal
{...baseProps}
params={{ name: "echo" }}
droppedParamKeys={["_meta"]}
/>,
);
expect(screen.getByText(/`_meta` is not re-sent/)).toBeInTheDocument();
});
it("says nothing when every param survives", () => {
renderWithMantine(
<EditReplayModal
{...baseProps}
params={{ name: "echo" }}
droppedParamKeys={[]}
/>,
);
expect(screen.queryByText(/not re-sent/)).toBeNull();
});
it("agrees with its verb for several dropped params", () => {
renderWithMantine(
<EditReplayModal
{...baseProps}
params={{ cursor: "abc" }}
droppedParamKeys={["_meta", "extra"]}
/>,
);
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(
<EditReplayModal {...baseProps} method="tools/list" params={undefined} />,
);
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(
<EditReplayModal {...baseProps} method="tools/list" params={undefined} />,
);
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(
<EditReplayModal {...baseProps} method="tools/call" />,
);
await setAceText('{"name":"echo","arguments":{"a":1}}');
expect(screen.getByRole("button", { name: "Send" })).not.toBeDisabled();
rerender(<EditReplayModal {...baseProps} method="tools/list" />);
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(<EditReplayModal {...baseProps} method="tools/call" />);
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(<EditReplayModal {...baseProps} method="tools/call" />);
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(<EditReplayModal {...baseProps} method="tools/call" />);
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(<EditReplayModal {...baseProps} method="prompts/get" />);
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(
<EditReplayModal {...baseProps} method="tools/call" tools={TOOLS} />,
);
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(
<EditReplayModal
{...baseProps}
method="tools/call"
params={{ name: "echo", arguments: { message: "hi" } }}
tools={TOOLS}
/>,
);
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(<EditReplayModal {...baseProps} method="tools/list" />);
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(
<EditReplayModal {...baseProps} onSend={onSend} onClose={onClose} />,
);
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(
<EditReplayModal {...baseProps} params={{ name: "echo" }} />,
);
await setAceText('{"name":"abandoned"}');
rerender(
<EditReplayModal
{...baseProps}
opened={false}
params={{ name: "echo" }}
/>,
);
rerender(<EditReplayModal {...baseProps} params={{ name: "echo" }} />);
expect(getAceText()).toBe('{\n "name": "echo"\n}');
});
});