Closes #1005 A tool schema can be perfectly legal JSON Schema and still be rejected by the client the server is meant to run against — Go's `jsonschema` emits a bare `true` for `interface{}`, which the SDK accepts and Claude Code refuses with an opaque "Invalid input". The Inspector passed it through silently. Adds `core/json/schemaLint.ts`, a portability lint over a tool's inputSchema/outputSchema, and surfaces one shared verdict everywhere: the CLI's `--strict` report (exit 6 on an error-severity finding), the TUI's tool list marker + detail pane, and the web Tools sidebar flag + detail section. Deliberately not a JSON Schema validator: a census of 617 public servers found zero that fail the SDK's own parse, so a conformance check would report nothing on real servers. Each rule is instead a construct that is legal JSON Schema and known to be refused or mishandled by a shipping client — a bare boolean in a schema position, an array-form `type`, a remote `$ref`, a schema with no validation keyword, a non-object root. `test-servers/configs/unportable-schemas-http.json` exercises every rule, via a new `rawToolSchemas` override — the Zod-built presets cannot emit any of these shapes, which is the same reason a real server only hits this when its schemas come from another generator. 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>
593 lines
20 KiB
TypeScript
593 lines
20 KiB
TypeScript
import { describe, it, expect, vi } from "vitest";
|
|
import userEvent from "@testing-library/user-event";
|
|
import type { Tool } from "@modelcontextprotocol/client";
|
|
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
|
import { ToolDetailPanel } from "./ToolDetailPanel";
|
|
|
|
const simpleTool: Tool = {
|
|
name: "send_message",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
message: { type: "string", description: "The message to send" },
|
|
},
|
|
required: ["message"],
|
|
},
|
|
};
|
|
|
|
const titledTool: Tool = {
|
|
name: "send_message",
|
|
title: "Send Message",
|
|
description: "Sends a message to the recipient",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
message: { type: "string" },
|
|
},
|
|
},
|
|
};
|
|
|
|
const ICON_SRC = "data:image/svg+xml,%3Csvg/%3E";
|
|
|
|
const iconedTool: Tool = {
|
|
name: "send_message",
|
|
title: "Send Message",
|
|
icons: [{ src: ICON_SRC }],
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {},
|
|
},
|
|
};
|
|
|
|
const annotatedTool: Tool = {
|
|
name: "delete_records",
|
|
description: "Deletes records",
|
|
annotations: {
|
|
readOnlyHint: true,
|
|
destructiveHint: true,
|
|
idempotentHint: true,
|
|
openWorldHint: true,
|
|
},
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
pattern: { type: "string" },
|
|
},
|
|
},
|
|
};
|
|
|
|
const baseProps = {
|
|
formValues: {},
|
|
isExecuting: false,
|
|
serverSupportsTaskToolCalls: false,
|
|
runAsTask: false,
|
|
onRunAsTaskChange: vi.fn(),
|
|
onFormChange: vi.fn(),
|
|
onExecute: vi.fn(),
|
|
onCancel: vi.fn(),
|
|
};
|
|
|
|
describe("ToolDetailPanel", () => {
|
|
it("renders the tool name when no title is provided", () => {
|
|
renderWithMantine(<ToolDetailPanel {...baseProps} tool={simpleTool} />);
|
|
expect(screen.getByText("send_message")).toBeInTheDocument();
|
|
});
|
|
|
|
it("prefers the title over the name when title is provided", () => {
|
|
renderWithMantine(<ToolDetailPanel {...baseProps} tool={titledTool} />);
|
|
expect(screen.getByText("Send Message")).toBeInTheDocument();
|
|
expect(
|
|
screen.getByText("Sends a message to the recipient"),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders the first icon when tool.icons is present", () => {
|
|
renderWithMantine(<ToolDetailPanel {...baseProps} tool={iconedTool} />);
|
|
const img = screen.getByRole("presentation");
|
|
expect(img).toHaveAttribute("src", ICON_SRC);
|
|
});
|
|
|
|
it("does not render an icon when tool.icons is missing", () => {
|
|
renderWithMantine(<ToolDetailPanel {...baseProps} tool={simpleTool} />);
|
|
expect(screen.queryByRole("presentation")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("does not render the description when none is provided", () => {
|
|
renderWithMantine(<ToolDetailPanel {...baseProps} tool={simpleTool} />);
|
|
expect(
|
|
screen.queryByText("Sends a message to the recipient"),
|
|
).not.toBeInTheDocument();
|
|
});
|
|
|
|
describe("Collapsible description", () => {
|
|
it("renders the description shown by default (Hide description toggle)", () => {
|
|
renderWithMantine(<ToolDetailPanel {...baseProps} tool={titledTool} />);
|
|
const toggle = screen.getByRole("button", { name: "Hide description" });
|
|
expect(toggle).toBeInTheDocument();
|
|
expect(
|
|
screen.queryByRole("button", { name: "Show description" }),
|
|
).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("hides the description when the toggle is clicked", async () => {
|
|
const user = userEvent.setup();
|
|
renderWithMantine(<ToolDetailPanel {...baseProps} tool={titledTool} />);
|
|
await user.click(
|
|
screen.getByRole("button", { name: "Hide description" }),
|
|
);
|
|
expect(
|
|
screen.getByRole("button", { name: "Show description" }),
|
|
).toBeInTheDocument();
|
|
expect(
|
|
screen.queryByRole("button", { name: "Hide description" }),
|
|
).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("shows the description again when the collapsed toggle is clicked", async () => {
|
|
const user = userEvent.setup();
|
|
renderWithMantine(<ToolDetailPanel {...baseProps} tool={titledTool} />);
|
|
await user.click(
|
|
screen.getByRole("button", { name: "Hide description" }),
|
|
);
|
|
await user.click(
|
|
screen.getByRole("button", { name: "Show description" }),
|
|
);
|
|
expect(
|
|
screen.getByRole("button", { name: "Hide description" }),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it("resets to shown when switching to a different tool", async () => {
|
|
const user = userEvent.setup();
|
|
const { rerender } = renderWithMantine(
|
|
<ToolDetailPanel {...baseProps} tool={titledTool} />,
|
|
);
|
|
await user.click(
|
|
screen.getByRole("button", { name: "Hide description" }),
|
|
);
|
|
expect(
|
|
screen.getByRole("button", { name: "Show description" }),
|
|
).toBeInTheDocument();
|
|
|
|
// Switching tools (different name) should reset the local hidden state.
|
|
rerender(<ToolDetailPanel {...baseProps} tool={annotatedTool} />);
|
|
expect(
|
|
screen.getByRole("button", { name: "Hide description" }),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it("wires the toggle as an expandable control (aria-expanded + aria-controls)", async () => {
|
|
const user = userEvent.setup();
|
|
renderWithMantine(<ToolDetailPanel {...baseProps} tool={titledTool} />);
|
|
const toggle = screen.getByRole("button", { name: "Hide description" });
|
|
expect(toggle).toHaveAttribute("aria-expanded", "true");
|
|
// aria-controls points at the Collapse region holding the description.
|
|
const regionId = toggle.getAttribute("aria-controls");
|
|
expect(regionId).toBeTruthy();
|
|
expect(document.getElementById(regionId as string)).toContainElement(
|
|
screen.getByText("Sends a message to the recipient"),
|
|
);
|
|
|
|
await user.click(toggle);
|
|
expect(
|
|
screen.getByRole("button", { name: "Show description" }),
|
|
).toHaveAttribute("aria-expanded", "false");
|
|
});
|
|
|
|
it("does not render a description toggle when the tool has no description", () => {
|
|
renderWithMantine(<ToolDetailPanel {...baseProps} tool={simpleTool} />);
|
|
expect(
|
|
screen.queryByRole("button", { name: "Show description" }),
|
|
).not.toBeInTheDocument();
|
|
expect(
|
|
screen.queryByRole("button", { name: "Hide description" }),
|
|
).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("renders all annotation badges when annotations are present", () => {
|
|
renderWithMantine(<ToolDetailPanel {...baseProps} tool={annotatedTool} />);
|
|
expect(screen.getByText("read-only")).toBeInTheDocument();
|
|
expect(screen.getByText("destructive")).toBeInTheDocument();
|
|
expect(screen.getByText("idempotent")).toBeInTheDocument();
|
|
expect(screen.getByText("open-world")).toBeInTheDocument();
|
|
});
|
|
|
|
it("does not render annotation row when no annotation flags are set", () => {
|
|
renderWithMantine(<ToolDetailPanel {...baseProps} tool={simpleTool} />);
|
|
expect(screen.queryByText("read-only")).not.toBeInTheDocument();
|
|
expect(screen.queryByText("destructive")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("does not render annotation row when annotations object is empty", () => {
|
|
const toolWithEmptyAnnotations: Tool = {
|
|
name: "no_hints",
|
|
annotations: { title: "Does not matter" },
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {},
|
|
},
|
|
};
|
|
renderWithMantine(
|
|
<ToolDetailPanel {...baseProps} tool={toolWithEmptyAnnotations} />,
|
|
);
|
|
expect(screen.queryByText("read-only")).not.toBeInTheDocument();
|
|
expect(screen.queryByText("destructive")).not.toBeInTheDocument();
|
|
expect(screen.queryByText("idempotent")).not.toBeInTheDocument();
|
|
expect(screen.queryByText("open-world")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("renders the Execute Tool button enabled when not executing", () => {
|
|
renderWithMantine(
|
|
<ToolDetailPanel
|
|
{...baseProps}
|
|
tool={simpleTool}
|
|
formValues={{ message: "hi" }}
|
|
/>,
|
|
);
|
|
const button = screen.getByRole("button", { name: "Execute Tool" });
|
|
expect(button).toBeInTheDocument();
|
|
expect(button).not.toBeDisabled();
|
|
});
|
|
|
|
it("invokes onExecute when Execute Tool is clicked", async () => {
|
|
const user = userEvent.setup();
|
|
const onExecute = vi.fn();
|
|
renderWithMantine(
|
|
<ToolDetailPanel
|
|
{...baseProps}
|
|
tool={simpleTool}
|
|
formValues={{ message: "hi" }}
|
|
onExecute={onExecute}
|
|
/>,
|
|
);
|
|
await user.click(screen.getByRole("button", { name: "Execute Tool" }));
|
|
expect(onExecute).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("disables the Execute Tool button while executing and renders Cancel", () => {
|
|
renderWithMantine(
|
|
<ToolDetailPanel {...baseProps} tool={simpleTool} isExecuting={true} />,
|
|
);
|
|
expect(screen.getByRole("button", { name: "Execute Tool" })).toBeDisabled();
|
|
expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
|
|
});
|
|
|
|
it("invokes onCancel when Cancel is clicked", async () => {
|
|
const user = userEvent.setup();
|
|
const onCancel = vi.fn();
|
|
renderWithMantine(
|
|
<ToolDetailPanel
|
|
{...baseProps}
|
|
tool={simpleTool}
|
|
isExecuting={true}
|
|
onCancel={onCancel}
|
|
/>,
|
|
);
|
|
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
|
expect(onCancel).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("renders progress display when progress is provided", () => {
|
|
renderWithMantine(
|
|
<ToolDetailPanel
|
|
{...baseProps}
|
|
tool={simpleTool}
|
|
progress={{ progress: 3, total: 5, message: "Step 3 of 5" }}
|
|
/>,
|
|
);
|
|
expect(screen.getByText("Step 3 of 5")).toBeInTheDocument();
|
|
expect(screen.getByText("60%")).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders the schema form using the tool's inputSchema", () => {
|
|
renderWithMantine(<ToolDetailPanel {...baseProps} tool={simpleTool} />);
|
|
// The string field 'message' should render with its description
|
|
expect(screen.getByText("The message to send")).toBeInTheDocument();
|
|
});
|
|
|
|
it("invokes onFormChange when the user types in a form field", async () => {
|
|
const user = userEvent.setup();
|
|
const onFormChange = vi.fn();
|
|
renderWithMantine(
|
|
<ToolDetailPanel
|
|
{...baseProps}
|
|
tool={simpleTool}
|
|
onFormChange={onFormChange}
|
|
/>,
|
|
);
|
|
const input = screen.getByRole("textbox");
|
|
await user.type(input, "hi");
|
|
expect(onFormChange).toHaveBeenCalled();
|
|
});
|
|
|
|
describe("Run as task toggle", () => {
|
|
const toolWithSupport = (
|
|
taskSupport: "forbidden" | "optional" | "required",
|
|
): Tool => ({
|
|
name: "run_job",
|
|
execution: { taskSupport },
|
|
inputSchema: { type: "object", properties: {} },
|
|
});
|
|
|
|
it("hides the toggle when the server doesn't support task tool calls", () => {
|
|
renderWithMantine(
|
|
<ToolDetailPanel
|
|
{...baseProps}
|
|
tool={toolWithSupport("optional")}
|
|
serverSupportsTaskToolCalls={false}
|
|
/>,
|
|
);
|
|
expect(screen.queryByLabelText("Run as task")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("hides the toggle for a task-forbidden tool even when the server supports tasks", () => {
|
|
renderWithMantine(
|
|
<ToolDetailPanel
|
|
{...baseProps}
|
|
tool={toolWithSupport("forbidden")}
|
|
serverSupportsTaskToolCalls={true}
|
|
/>,
|
|
);
|
|
expect(screen.queryByLabelText("Run as task")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("shows the toggle for a task-forbidden tool on a modern connection (#1631)", () => {
|
|
// On modern (SEP-2663) task creation is server-directed, so any tool can
|
|
// become a task — the toggle is offered regardless of per-tool taskSupport.
|
|
renderWithMantine(
|
|
<ToolDetailPanel
|
|
{...baseProps}
|
|
tool={toolWithSupport("forbidden")}
|
|
serverSupportsTaskToolCalls={true}
|
|
modernTasks={true}
|
|
runAsTask={false}
|
|
/>,
|
|
);
|
|
const toggle = screen.getByLabelText("Run as task");
|
|
expect(toggle).not.toBeChecked();
|
|
expect(toggle).not.toBeDisabled();
|
|
});
|
|
|
|
it("routes the user's runAsTask choice on a modern connection (#1631)", async () => {
|
|
const user = userEvent.setup();
|
|
const onExecute = vi.fn();
|
|
renderWithMantine(
|
|
<ToolDetailPanel
|
|
{...baseProps}
|
|
tool={toolWithSupport("forbidden")}
|
|
serverSupportsTaskToolCalls={true}
|
|
modernTasks={true}
|
|
runAsTask={true}
|
|
onExecute={onExecute}
|
|
/>,
|
|
);
|
|
// The toggle is checked (user's choice), and executing forwards true.
|
|
expect(screen.getByLabelText("Run as task")).toBeChecked();
|
|
await user.click(screen.getByRole("button", { name: "Execute Tool" }));
|
|
expect(onExecute).toHaveBeenCalledWith(true);
|
|
});
|
|
|
|
it("shows an unchecked, enabled toggle for an optional tool (off by default)", () => {
|
|
renderWithMantine(
|
|
<ToolDetailPanel
|
|
{...baseProps}
|
|
tool={toolWithSupport("optional")}
|
|
serverSupportsTaskToolCalls={true}
|
|
runAsTask={false}
|
|
/>,
|
|
);
|
|
const toggle = screen.getByLabelText("Run as task");
|
|
expect(toggle).not.toBeChecked();
|
|
expect(toggle).not.toBeDisabled();
|
|
});
|
|
|
|
it("reflects the runAsTask prop for an optional tool", () => {
|
|
renderWithMantine(
|
|
<ToolDetailPanel
|
|
{...baseProps}
|
|
tool={toolWithSupport("optional")}
|
|
serverSupportsTaskToolCalls={true}
|
|
runAsTask={true}
|
|
/>,
|
|
);
|
|
expect(screen.getByLabelText("Run as task")).toBeChecked();
|
|
});
|
|
|
|
it("forces the toggle on and disabled for a required tool", () => {
|
|
renderWithMantine(
|
|
<ToolDetailPanel
|
|
{...baseProps}
|
|
tool={toolWithSupport("required")}
|
|
serverSupportsTaskToolCalls={true}
|
|
runAsTask={false}
|
|
/>,
|
|
);
|
|
const toggle = screen.getByLabelText("Run as task");
|
|
expect(toggle).toBeChecked();
|
|
expect(toggle).toBeDisabled();
|
|
});
|
|
|
|
it("invokes onRunAsTaskChange when an optional toggle is clicked", async () => {
|
|
const user = userEvent.setup();
|
|
const onRunAsTaskChange = vi.fn();
|
|
renderWithMantine(
|
|
<ToolDetailPanel
|
|
{...baseProps}
|
|
tool={toolWithSupport("optional")}
|
|
serverSupportsTaskToolCalls={true}
|
|
runAsTask={false}
|
|
onRunAsTaskChange={onRunAsTaskChange}
|
|
/>,
|
|
);
|
|
await user.click(screen.getByLabelText("Run as task"));
|
|
expect(onRunAsTaskChange).toHaveBeenCalledWith(true);
|
|
});
|
|
|
|
it("passes the effective run-as-task decision to onExecute", async () => {
|
|
const user = userEvent.setup();
|
|
const onExecute = vi.fn();
|
|
// optional + runAsTask=true → effective true
|
|
renderWithMantine(
|
|
<ToolDetailPanel
|
|
{...baseProps}
|
|
tool={toolWithSupport("optional")}
|
|
serverSupportsTaskToolCalls={true}
|
|
runAsTask={true}
|
|
onExecute={onExecute}
|
|
/>,
|
|
);
|
|
await user.click(screen.getByRole("button", { name: "Execute Tool" }));
|
|
expect(onExecute).toHaveBeenCalledWith(true);
|
|
});
|
|
|
|
it("passes runAsTask=true to onExecute for a required tool regardless of the prop", async () => {
|
|
const user = userEvent.setup();
|
|
const onExecute = vi.fn();
|
|
renderWithMantine(
|
|
<ToolDetailPanel
|
|
{...baseProps}
|
|
tool={toolWithSupport("required")}
|
|
serverSupportsTaskToolCalls={true}
|
|
runAsTask={false}
|
|
onExecute={onExecute}
|
|
/>,
|
|
);
|
|
await user.click(screen.getByRole("button", { name: "Execute Tool" }));
|
|
expect(onExecute).toHaveBeenCalledWith(true);
|
|
});
|
|
|
|
it("gates onExecute to false when the server lacks task-tool-call support, even with a stale runAsTask", async () => {
|
|
const user = userEvent.setup();
|
|
const onExecute = vi.fn();
|
|
// The toggle is hidden (server doesn't support it), but a leftover
|
|
// runAsTask=true must not leak into the effective decision.
|
|
renderWithMantine(
|
|
<ToolDetailPanel
|
|
{...baseProps}
|
|
tool={toolWithSupport("optional")}
|
|
serverSupportsTaskToolCalls={false}
|
|
runAsTask={true}
|
|
onExecute={onExecute}
|
|
/>,
|
|
);
|
|
expect(screen.queryByLabelText("Run as task")).not.toBeInTheDocument();
|
|
await user.click(screen.getByRole("button", { name: "Execute Tool" }));
|
|
expect(onExecute).toHaveBeenCalledWith(false);
|
|
});
|
|
});
|
|
|
|
describe("mirrored request headers (SEP-2243, #1632)", () => {
|
|
const mirroredTool: Tool = {
|
|
name: "get_weather",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
city: { type: "string", "x-mcp-header": "City" },
|
|
country: { type: "string", "x-mcp-header": "Country" },
|
|
},
|
|
},
|
|
};
|
|
|
|
it("lists each mirrored arg and its Mcp-Param header", () => {
|
|
renderWithMantine(<ToolDetailPanel {...baseProps} tool={mirroredTool} />);
|
|
expect(
|
|
screen.getByText("Mirrored request headers (SEP-2243)"),
|
|
).toBeInTheDocument();
|
|
// The header names are unique to this section (the arg names `city` /
|
|
// `country` also appear as form-field labels below).
|
|
expect(screen.getByText("Mcp-Param-City")).toBeInTheDocument();
|
|
expect(screen.getByText("Mcp-Param-Country")).toBeInTheDocument();
|
|
// The arg path renders in a <code> alongside its header.
|
|
expect(screen.getAllByText("city").length).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
it("omits the section for a tool without x-mcp-header annotations", () => {
|
|
renderWithMantine(<ToolDetailPanel {...baseProps} tool={simpleTool} />);
|
|
expect(
|
|
screen.queryByText("Mirrored request headers (SEP-2243)"),
|
|
).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe("schema portability findings (#1005)", () => {
|
|
it("lists a finding with its path when a schema is unportable", () => {
|
|
renderWithMantine(
|
|
<ToolDetailPanel
|
|
{...baseProps}
|
|
tool={{
|
|
name: "info",
|
|
inputSchema: { type: "object", properties: {} },
|
|
outputSchema: { type: "object", properties: { data: true } },
|
|
}}
|
|
/>,
|
|
);
|
|
expect(screen.getByText("Schema portability (1)")).toBeInTheDocument();
|
|
expect(
|
|
screen.getByText("outputSchema.properties.data"),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it("omits the section for a portable tool", () => {
|
|
renderWithMantine(<ToolDetailPanel {...baseProps} tool={simpleTool} />);
|
|
expect(screen.queryByTestId("schema-findings")).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
// #2020: Execute used to be gated on `isExecuting` alone — this panel never
|
|
// called `hasMissingRequiredFields`, unlike the App and elicitation panels —
|
|
// so arguments that could not be sent were executed anyway and simply arrived
|
|
// absent at the server.
|
|
describe("submit gating (#2020)", () => {
|
|
const optionalJsonTool: Tool = {
|
|
name: "record_shipment",
|
|
inputSchema: {
|
|
type: "object",
|
|
// An array of no declared item type renders through the JSON editor,
|
|
// which is where a draft can fail to parse.
|
|
properties: { payload: { type: "array", title: "Payload" } },
|
|
},
|
|
};
|
|
|
|
it("disables Execute while a required argument has no value", () => {
|
|
renderWithMantine(
|
|
<ToolDetailPanel {...baseProps} tool={simpleTool} formValues={{}} />,
|
|
);
|
|
expect(
|
|
screen.getByRole("button", { name: "Execute Tool" }),
|
|
).toBeDisabled();
|
|
});
|
|
|
|
it("enables Execute once the required argument is filled", () => {
|
|
renderWithMantine(
|
|
<ToolDetailPanel
|
|
{...baseProps}
|
|
tool={simpleTool}
|
|
formValues={{ message: "hi" }}
|
|
/>,
|
|
);
|
|
expect(
|
|
screen.getByRole("button", { name: "Execute Tool" }),
|
|
).not.toBeDisabled();
|
|
});
|
|
|
|
it("disables Execute while an optional field holds text it cannot send", async () => {
|
|
const user = userEvent.setup();
|
|
renderWithMantine(
|
|
<ToolDetailPanel {...baseProps} tool={optionalJsonTool} />,
|
|
);
|
|
const execute = screen.getByRole("button", { name: "Execute Tool" });
|
|
expect(execute).not.toBeDisabled();
|
|
|
|
// The draft never reaches `formValues` — the field reports `undefined`
|
|
// for text it cannot parse — so only the form's validity channel makes
|
|
// this visible to the gate.
|
|
const jsonInput = screen.getByLabelText(/Payload/);
|
|
await user.type(jsonInput, "x");
|
|
expect(execute).toBeDisabled();
|
|
|
|
await user.clear(jsonInput);
|
|
expect(execute).not.toBeDisabled();
|
|
});
|
|
});
|
|
});
|