inspector/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx
cliffhall d60a5c713d fix(web): only write settings on a real header edit; label rows by position (#1915)
Round-3 review follow-ups on #2038.

`buildHeaderSettingsPatch` treated the mere presence of stored headers as a
change, so saving an id or URL edit re-sent the modal's settings snapshot —
taken when it opened — and could overwrite a metadata or OAuth change made
in the settings form since. It now compares the submitted headers against
the stored ones and omits `settings` when they match, which is what makes
the PUT route preserve the node. Order counts as a difference: it is what
the form round-trips and what the user sees.

Row labels carried the key alone, so two rows sharing a key — mid-edit, or a
duplicate a server persisted — gave both rows' controls identical accessible
names, the thing the labelling exists to prevent. The row number is now
always part of the label rather than a blank-key fallback.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-16 21:57:09 -04:00

856 lines
28 KiB
TypeScript

import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import type { InspectorServerSettings } from "@inspector/core/mcp/types.js";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { ServerSettingsModal } from "./ServerSettingsModal";
const initialSettings: InspectorServerSettings = {
headers: [{ key: "Authorization", value: "Bearer abc" }],
env: [],
metadata: [{ key: "userId", value: "u-1" }],
connectionTimeout: 30000,
requestTimeout: 60000,
taskTtl: 60000,
oauthClientId: "cid",
oauthClientSecret: "secret",
oauthScopes: "read",
maxFetchRequests: 1000,
roots: [{ uri: "file:///project", name: "Project" }],
};
const emptySettings: InspectorServerSettings = {
headers: [],
env: [],
metadata: [],
connectionTimeout: 30000,
requestTimeout: 60000,
taskTtl: 60000,
maxFetchRequests: 1000,
roots: [],
};
describe("ServerSettingsModal", () => {
it("does not render content when opened is false", () => {
renderWithMantine(
<ServerSettingsModal
opened={false}
settings={emptySettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={vi.fn()}
/>,
);
expect(screen.queryByText("Server Settings")).not.toBeInTheDocument();
});
it("renders the modal title and form when opened", () => {
renderWithMantine(
<ServerSettingsModal
opened
settings={emptySettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={vi.fn()}
/>,
);
expect(screen.getByText("Server Settings")).toBeInTheDocument();
expect(screen.getByText("Custom Headers")).toBeInTheDocument();
});
it("hides the OAuth Settings section for stdio servers", () => {
renderWithMantine(
<ServerSettingsModal
opened
settings={emptySettings}
serverType="stdio"
isStdio
onClose={vi.fn()}
onSettingsChange={vi.fn()}
/>,
);
expect(screen.queryByText("OAuth Settings")).not.toBeInTheDocument();
});
it("invokes onClose when the CloseButton is clicked", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={emptySettings}
serverType="streamable-http"
isStdio={false}
onClose={onClose}
onSettingsChange={vi.fn()}
/>,
);
// Modal renders into a portal on document.body
const closeBtn = document.querySelector(
"button.mantine-CloseButton-root",
) as HTMLButtonElement | null;
expect(closeBtn).not.toBeNull();
await user.click(closeBtn!);
expect(onClose).toHaveBeenCalled();
});
it("maps the OAuth insufficient-scope policy into settings (SEP-2350)", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={initialSettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(screen.getByRole("button", { name: /OAuth Settings/i }));
await user.click(
screen.getByRole("textbox", { name: /Insufficient-scope/i }),
);
await user.click(screen.getByText("Throw (surface the error)"));
expect(onSettingsChange).toHaveBeenCalledWith(
expect.objectContaining({ oauthOnInsufficientScope: "throw" }),
);
});
it("maps the selected protocol era into settings (#1626)", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={emptySettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
// The Options section (with Protocol Era) is expanded by default.
await user.click(screen.getByDisplayValue("Legacy (2025-11-25 handshake)"));
await user.click(screen.getByText("Modern (2026-07-28, sessionless)"));
expect(onSettingsChange).toHaveBeenCalledWith(
expect.objectContaining({ protocolEra: "modern" }),
);
});
it("maps the selected modern log level into settings (#1629)", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
// The modern log-level control only shows for a modern-capable era.
settings={{ ...emptySettings, protocolEra: "modern" }}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
// The Options section (with Log Level per Request) is expanded by default;
// it defaults to "debug".
await user.click(screen.getAllByDisplayValue("debug")[0]);
await user.click(screen.getByText("Off (no logs)"));
expect(onSettingsChange).toHaveBeenCalledWith(
expect.objectContaining({ modernLogLevel: "off" }),
);
});
it("folds an advertised-extension toggle into settings.advertisedExtensions (#1739)", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={emptySettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
// Advertised Extensions is its own accordion section (#1747), collapsed by
// default — open it, then toggle Tasks (checked by the registry default) off.
await user.click(
screen.getByRole("button", { name: "Advertised Extensions" }),
);
await user.click(
screen.getByRole("checkbox", {
name: /Tasks \(io\.modelcontextprotocol\/tasks\)/,
}),
);
expect(onSettingsChange).toHaveBeenCalledWith(
expect.objectContaining({
advertisedExtensions: { "io.modelcontextprotocol/tasks": false },
}),
);
});
it("reconverges to no override when an extension toggle returns to its default (#1739)", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
// Start from a disabling override so re-checking Tasks returns it to the
// registry default (advertised) and should drop the key entirely.
settings={{
...emptySettings,
advertisedExtensions: { "io.modelcontextprotocol/tasks": false },
}}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
// Open the Advertised Extensions section (#1747), then re-check Tasks.
await user.click(
screen.getByRole("button", { name: "Advertised Extensions" }),
);
await user.click(
screen.getByRole("checkbox", {
name: /Tasks \(io\.modelcontextprotocol\/tasks\)/,
}),
);
// The only override returned to its default → the whole map drops to
// undefined (no-override), keeping the on-disk round-trip minimal.
expect(onSettingsChange).toHaveBeenCalledWith(
expect.objectContaining({ advertisedExtensions: undefined }),
);
});
it("hides the modern log-level control when this server negotiated legacy under 'auto' (#1629)", () => {
renderWithMantine(
<ServerSettingsModal
opened
settings={{ ...emptySettings, protocolEra: "auto" }}
serverType="streamable-http"
isStdio={false}
negotiatedEra="legacy"
onClose={vi.fn()}
onSettingsChange={vi.fn()}
/>,
);
expect(screen.queryByText("Log Level per Request")).toBeNull();
});
it("calls onSettingsChange when adding a header after expanding the section", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={emptySettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(screen.getByRole("button", { name: "Custom Headers" }));
await user.click(screen.getByRole("button", { name: "+ Add Header" }));
expect(onSettingsChange).toHaveBeenCalledWith({
...emptySettings,
headers: [{ key: "", value: "" }],
});
});
it("calls onSettingsChange when removing a header", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={initialSettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(screen.getByRole("button", { name: "Custom Headers" }));
await user.click(
screen.getByRole("button", {
name: "Remove header, Authorization, row 1",
}),
);
expect(onSettingsChange).toHaveBeenCalledWith({
...initialSettings,
headers: [],
});
});
it("calls onSettingsChange with updated header value when typing", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={initialSettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(screen.getByRole("button", { name: "Custom Headers" }));
const valueInput = screen.getByDisplayValue("Bearer abc");
await user.type(valueInput, "1");
expect(onSettingsChange).toHaveBeenCalled();
const lastCall =
onSettingsChange.mock.calls[onSettingsChange.mock.calls.length - 1][0];
expect(lastCall.headers[0].key).toBe("Authorization");
expect(lastCall.headers[0].value).toContain("Bearer abc");
});
it("calls onSettingsChange when adding metadata after expanding the section", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={emptySettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(screen.getByRole("button", { name: "Request Metadata" }));
await user.click(screen.getByRole("button", { name: "+ Add Metadata" }));
expect(onSettingsChange).toHaveBeenCalledWith({
...emptySettings,
metadata: [{ key: "", value: "" }],
});
});
it("calls onSettingsChange when removing metadata", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={initialSettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(screen.getByRole("button", { name: "Request Metadata" }));
// Both the header and metadata rows have a remove button; each is named
// for the row it belongs to, so no positional guess is needed.
await user.click(
screen.getByRole("button", {
name: "Remove metadata entry, userId, row 1",
}),
);
expect(onSettingsChange).toHaveBeenCalledWith({
...initialSettings,
metadata: [],
});
});
it("calls onSettingsChange when typing in metadata key/value", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={initialSettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(screen.getByRole("button", { name: "Request Metadata" }));
const valueInput = screen.getByDisplayValue("u-1");
await user.type(valueInput, "9");
expect(onSettingsChange).toHaveBeenCalled();
const lastCall =
onSettingsChange.mock.calls[onSettingsChange.mock.calls.length - 1][0];
expect(lastCall.metadata[0].key).toBe("userId");
});
it("calls onSettingsChange when changing timeouts", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={emptySettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(screen.getByRole("button", { name: "Timeouts" }));
const connInput = screen.getByLabelText(/Connection Timeout/);
await user.type(connInput, "5");
expect(onSettingsChange).toHaveBeenCalled();
const call = onSettingsChange.mock.calls[0][0];
expect(typeof call.connectionTimeout).toBe("number");
});
it("calls onSettingsChange when changing OAuth fields", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={emptySettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(screen.getByRole("button", { name: "OAuth Settings" }));
const clientIdInput = screen.getByLabelText("Client ID");
await user.type(clientIdInput, "a");
expect(onSettingsChange).toHaveBeenCalled();
const call = onSettingsChange.mock.calls[0][0];
expect(call.oauthClientId).toBe("a");
});
it("calls onSettingsChange when toggling enterprise-managed authorization", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={emptySettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(screen.getByRole("button", { name: "OAuth Settings" }));
await user.click(
screen.getByRole("checkbox", {
name: "Enterprise-managed authorization",
}),
);
expect(onSettingsChange).toHaveBeenCalledWith({
...emptySettings,
oauthClientId: "",
oauthClientSecret: "",
oauthScopes: "",
oauthAuthorizationParams: [],
enterpriseManaged: true,
});
});
// #2018 — the authorization-parameter rows ride the same onOAuthChange
// callback, so the modal folds them onto the settings object.
it("persists an added authorization-parameter row onto the settings", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={emptySettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(screen.getByRole("button", { name: "OAuth Settings" }));
await user.click(screen.getByRole("button", { name: "+ Add Parameter" }));
expect(onSettingsChange).toHaveBeenCalledWith(
expect.objectContaining({
oauthAuthorizationParams: [{ key: "", value: "" }],
}),
);
});
it("calls onSettingsChange with a blank row when adding a root", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={emptySettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(screen.getByRole("button", { name: "Roots" }));
await user.click(screen.getByRole("button", { name: "+ Add Root" }));
expect(onSettingsChange).toHaveBeenCalledWith({
...emptySettings,
roots: [{ uri: "", name: "" }],
});
});
it("calls onSettingsChange when removing a root", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={initialSettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(screen.getByRole("button", { name: "Roots" }));
const removeButtons = screen.getAllByRole("button", { name: "X" });
// The roots X is the last one (headers section is also expanded).
await user.click(removeButtons[removeButtons.length - 1]);
expect(onSettingsChange).toHaveBeenCalledWith({
...initialSettings,
roots: [],
});
});
it("calls onSettingsChange when editing a root uri/name", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={initialSettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(screen.getByRole("button", { name: "Roots" }));
const uriInput = screen.getByDisplayValue("file:///project");
await user.type(uriInput, "/src");
expect(onSettingsChange).toHaveBeenCalled();
const lastCall =
onSettingsChange.mock.calls[onSettingsChange.mock.calls.length - 1][0];
expect(lastCall.roots[0].name).toBe("Project");
expect(lastCall.roots[0].uri).toContain("file:///project");
});
it("toggles all accordion sections when ListToggle button is clicked", async () => {
const user = userEvent.setup();
renderWithMantine(
<ServerSettingsModal
opened
settings={emptySettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={vi.fn()}
/>,
);
const optionsControl = screen.getByRole("button", { name: "Options" });
const headersControl = screen.getByRole("button", {
name: "Custom Headers",
});
const metadataControl = screen.getByRole("button", {
name: "Request Metadata",
});
const timeoutsControl = screen.getByRole("button", { name: "Timeouts" });
const oauthControl = screen.getByRole("button", { name: "OAuth Settings" });
const rootsControl = screen.getByRole("button", { name: "Roots" });
// Initially only "options" is expanded.
expect(optionsControl.getAttribute("aria-expanded")).toBe("true");
expect(headersControl.getAttribute("aria-expanded")).toBe("false");
// Not every section is expanded → ListToggle starts in compact mode
// and exposes "Expand all" as its aria-label.
await user.click(screen.getByRole("button", { name: "Expand all" }));
expect(headersControl.getAttribute("aria-expanded")).toBe("true");
expect(metadataControl.getAttribute("aria-expanded")).toBe("true");
expect(timeoutsControl.getAttribute("aria-expanded")).toBe("true");
expect(oauthControl.getAttribute("aria-expanded")).toBe("true");
expect(rootsControl.getAttribute("aria-expanded")).toBe("true");
// After expanding every section the toggle flips to "Collapse all".
await user.click(screen.getByRole("button", { name: "Collapse all" }));
expect(optionsControl.getAttribute("aria-expanded")).toBe("false");
expect(headersControl.getAttribute("aria-expanded")).toBe("false");
});
it("calls onSettingsChange when toggling Auto Refresh in the Options section", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={emptySettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
// The Options section is expanded on open, so the checkbox is visible.
await user.click(
screen.getByRole("checkbox", {
name: /Auto Refresh on List Changed Notifications/,
}),
);
expect(onSettingsChange).toHaveBeenCalledWith({
...emptySettings,
autoRefreshOnListChanged: true,
});
});
it("calls onSettingsChange when changing the Network Log Size", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={{ ...emptySettings, maxFetchRequests: 1000 }}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.type(screen.getByLabelText(/Network Log Size/), "2");
expect(onSettingsChange).toHaveBeenCalled();
const call = onSettingsChange.mock.calls.at(-1)?.[0];
expect(typeof call.maxFetchRequests).toBe("number");
});
it("preserves sibling rows when editing one of several headers", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
const twoHeaders: InspectorServerSettings = {
...emptySettings,
headers: [
{ key: "A", value: "1" },
{ key: "B", value: "2" },
],
};
renderWithMantine(
<ServerSettingsModal
opened
settings={twoHeaders}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(screen.getByRole("button", { name: "Custom Headers" }));
// Editing the first header's value must leave the second row untouched
// (the `i === index ? … : h` non-matching branch).
await user.type(screen.getByDisplayValue("1"), "0");
const lastCall = onSettingsChange.mock.calls.at(-1)?.[0];
expect(lastCall.headers[1]).toEqual({ key: "B", value: "2" });
});
it("preserves sibling rows when editing one of several metadata entries", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
const twoMeta: InspectorServerSettings = {
...emptySettings,
metadata: [
{ key: "m1", value: "v1" },
{ key: "m2", value: "v2" },
],
};
renderWithMantine(
<ServerSettingsModal
opened
settings={twoMeta}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(screen.getByRole("button", { name: "Request Metadata" }));
await user.type(screen.getByDisplayValue("v1"), "x");
const lastCall = onSettingsChange.mock.calls.at(-1)?.[0];
expect(lastCall.metadata[1]).toEqual({ key: "m2", value: "v2" });
});
it("preserves sibling rows when editing one of several roots", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
const twoRoots: InspectorServerSettings = {
...emptySettings,
roots: [
{ uri: "file:///a", name: "A" },
{ uri: "file:///b", name: "B" },
],
};
renderWithMantine(
<ServerSettingsModal
opened
settings={twoRoots}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(screen.getByRole("button", { name: "Roots" }));
await user.type(screen.getByDisplayValue("file:///a"), "1");
const lastCall = onSettingsChange.mock.calls.at(-1)?.[0];
expect(lastCall.roots[1]).toEqual({ uri: "file:///b", name: "B" });
});
describe("stdio env / cwd handlers", () => {
// The Options section is expanded on open, so the stdio fields are visible
// immediately when isStdio is true.
it("does not render the stdio fields when isStdio is false", () => {
renderWithMantine(
<ServerSettingsModal
opened
settings={emptySettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={vi.fn()}
/>,
);
expect(
screen.queryByLabelText(/Working Directory/),
).not.toBeInTheDocument();
});
it("calls onSettingsChange when adding an environment variable", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={emptySettings}
serverType="stdio"
isStdio
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
// The Environment Variables section is collapsed on open — expand it.
await user.click(
screen.getByRole("button", { name: "Environment Variables" }),
);
await user.click(
screen.getByRole("button", { name: "+ Add Environment Variable" }),
);
expect(onSettingsChange).toHaveBeenCalledWith({
...emptySettings,
env: [{ key: "", value: "" }],
});
});
it("calls onSettingsChange when removing an environment variable", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={{ ...emptySettings, env: [{ key: "A", value: "1" }] }}
serverType="stdio"
isStdio
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(
screen.getByRole("button", { name: "Environment Variables" }),
);
await user.click(
screen.getByRole("button", {
name: "Remove environment variable, A, row 1",
}),
);
expect(onSettingsChange).toHaveBeenCalledWith({
...emptySettings,
env: [],
});
});
it("calls onSettingsChange with the updated env value when typing", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={{ ...emptySettings, env: [{ key: "A", value: "1" }] }}
serverType="stdio"
isStdio
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(
screen.getByRole("button", { name: "Environment Variables" }),
);
await user.type(screen.getByDisplayValue("1"), "2");
const lastCall = onSettingsChange.mock.calls.at(-1)?.[0];
expect(lastCall.env[0]).toEqual({ key: "A", value: "12" });
});
it("preserves sibling rows when editing one of several env vars", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={{
...emptySettings,
env: [
{ key: "A", value: "1" },
{ key: "B", value: "2" },
],
}}
serverType="stdio"
isStdio
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(
screen.getByRole("button", { name: "Environment Variables" }),
);
await user.type(screen.getByDisplayValue("1"), "0");
const lastCall = onSettingsChange.mock.calls.at(-1)?.[0];
// The untouched second env row survives the edit (the non-matching map
// branch).
expect(lastCall.env[1]).toEqual({ key: "B", value: "2" });
});
it("calls onSettingsChange when editing the working directory", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={{ ...emptySettings, cwd: "/srv" }}
serverType="stdio"
isStdio
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
// Controlled input: the value prop stays "/srv" (the parent vi.fn does not
// feed edits back), so a single keystroke appends to that base.
await user.type(screen.getByLabelText(/Working Directory/), "X");
expect(onSettingsChange).toHaveBeenLastCalledWith({
...emptySettings,
cwd: "/srvX",
});
});
});
});