* test(web): audit branch-coverage outliers, raise per-file gate to 70 (#1271) The web per-file coverage gate had drifted red: the recently-landed EMA auth feature (core/auth/ema/*) and ClientSettings components shipped undertested, and the prior 50% branch floor let business-logic branches go untested. CI does not run the coverage gate, so this accumulated. Add tests to bring every failing file above the gate and push branch coverage well past 70 across the board, then raise the per-file branches threshold 50 -> 70 in clients/web/vite.config.ts. - EMA core (emaFlow, resourceContext, jwt, wire): 50-88% -> 86-100% branch - EMA OIDC/transport (idpOidc, tokenEndpoint, transportProvider): -> 97-100% - oauthManager / discovery: 73-83% -> 88-92% - ClientSettingsModal/Form, ConnectionInfoContent: 0-88% -> 96-100% - useClientSettingsDraft / useEmaIdpLoginState hooks: 20% -> 100% - TaskStatusBadge / SortableServerCard outliers: 50% -> 100% Coverable branches reached by mocking useComputedColorScheme (dark theme), dnd-kit useSortable (isDragging), and fetch/OIDC paths. Remaining sub-100 branches are genuinely-unreachable defensive guards or happy-dom-inherent (portal / useMediaQuery / typeof window) and documented in test comments. No source files changed. Global web branch coverage 88.73% -> 90.63%. AGENTS.md updated; cli/tui/launcher stay at 50 (out of scope). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: raise per-file coverage gate to 90/90/90/90 across all clients Follows the #1271 web branch-coverage audit (50 -> 70) by raising the whole per-file gate to >=90 on all four dimensions — lines, statements, functions, branches — for web, cli, tui, and launcher (core/ is measured under the web suite). A codebase-wide audit added real tests for every outlier and brought global coverage up across the board. Config: all four vitest/vite coverage configs set to 90/90/90/90. Two pure-type modules (core/mcp/import/types.ts, clients/web/server/ types.ts) added to the web coverage exclude list, matching the existing type-only exclusions. Tests: ~50 test files extended/created across core (auth, ema, mcp, remote, state, storage, react), web components/screens/server, and the cli/tui clients. Highlights: full EMA OAuth flow + transport coverage, inspectorClient/remote server backfill, every Mantine control's handler branches, and the cli unsupported-method / argv-fallback paths. Genuinely-unreachable branches are annotated at the source with justified `/* v8 ignore -- <reason> */` comments rather than lowering the gate: happy-dom-inherent paths (Mantine portal, useMediaQuery, typeof window SSR guards), React StrictMode effect replay, and provably-dead defensive guards (?? fallbacks the types make impossible, Select.onChange values outside the allowed list). No runtime logic changed; the only structural edits are two behavior-preserving refactors (ResourceTemplatePanel hoists the field value to a local so the ignore applies in JSX; ToolsScreen swaps a ternary for an if) and prettier line-wrapping. Also fixed a flaky real-timer debounce assertion in PromptArgumentsForm (asserted an exact sibling-typing interleaving that raced the 300ms debounce; now asserts the load-bearing fact — a non-empty fire-time sibling capture). AGENTS.md updated to describe the uniform 90 gate and the v8-ignore policy. All four client coverage gates, root validate, storybook (370), and smoke pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(web): make connect-crash SSE reader stop on the terminal event `readSseEvents` looped until the server closed the stream (`done`). The server closes the events stream immediately after writing the terminal `transport_error`, but under the heavy parallel scheduler pressure of a full `coverage` run (v8 instrumentation across all workers) that close can lag far enough behind the event to trip the 30s test timeout — a hang, not a wrong result. This made the test intermittently fail under `npm run coverage` (it passed in isolation and under CI's lighter `test:integration`). Stop reading as soon as the terminal `transport_error` event arrives (cancelling the reader) instead of waiting only for `done`. That event is always the last one the session queue emits, so nothing is lost, and the test no longer depends on stream-close timing. The `done` break is kept for any stream that closes first. Verified green across two consecutive full `npm run test:coverage` runs (3094 tests) where it previously hung, plus isolation (~0.5s). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d04a7fb9c8
commit
c96db0402e
28 changed files with 8793 additions and 63 deletions
|
|
@ -97,6 +97,20 @@ describe("AppControls", () => {
|
|||
expect(screen.getByText("No matching apps")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clears the search via the clear button, invoking onSearchChange with empty string", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSearchChange = vi.fn();
|
||||
renderWithMantine(
|
||||
<ControlledAppControls onSearchChange={onSearchChange} />,
|
||||
);
|
||||
const input = screen.getByPlaceholderText("Search apps...");
|
||||
await user.type(input, "git");
|
||||
// The clear button only renders once searchText is non-empty (line 67).
|
||||
const clearButton = screen.getByRole("button", { name: /clear/i });
|
||||
await user.click(clearButton);
|
||||
expect(onSearchChange).toHaveBeenLastCalledWith("");
|
||||
});
|
||||
|
||||
it("invokes onSelectApp when an unselected app is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelectApp = vi.fn();
|
||||
|
|
|
|||
|
|
@ -72,3 +72,186 @@ describe("ClientSettingsForm EMA IdP session", () => {
|
|||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ClientSettingsForm interactions", () => {
|
||||
it("propagates accordion expand/collapse changes", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onExpandedSectionsChange = vi.fn();
|
||||
renderWithMantine(
|
||||
<ClientSettingsForm
|
||||
settings={EMPTY_CLIENT_SETTINGS}
|
||||
expandedSections={["ema"]}
|
||||
onExpandedSectionsChange={onExpandedSectionsChange}
|
||||
onSettingsChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Clicking the accordion control toggles the open section. Since it starts
|
||||
// open, collapsing it should report an empty section list.
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /Enterprise-Managed Authorization/i }),
|
||||
);
|
||||
expect(onExpandedSectionsChange).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it("toggles the EMA enable checkbox via onSettingsChange", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSettingsChange = vi.fn();
|
||||
renderWithMantine(
|
||||
<ClientSettingsForm
|
||||
settings={EMPTY_CLIENT_SETTINGS}
|
||||
expandedSections={["ema"]}
|
||||
onExpandedSectionsChange={vi.fn()}
|
||||
onSettingsChange={onSettingsChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("checkbox", {
|
||||
name: "Enable enterprise IdP configuration",
|
||||
}),
|
||||
);
|
||||
expect(onSettingsChange).toHaveBeenCalledWith({
|
||||
...EMPTY_CLIENT_SETTINGS,
|
||||
emaEnabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("edits the IdP text fields via onSettingsChange", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSettingsChange = vi.fn();
|
||||
renderWithMantine(
|
||||
<ClientSettingsForm
|
||||
settings={{ ...EMPTY_CLIENT_SETTINGS, emaEnabled: true }}
|
||||
expandedSections={["ema"]}
|
||||
onExpandedSectionsChange={vi.fn()}
|
||||
onSettingsChange={onSettingsChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.type(screen.getByLabelText("Client ID"), "x");
|
||||
expect(onSettingsChange).toHaveBeenLastCalledWith({
|
||||
...EMPTY_CLIENT_SETTINGS,
|
||||
emaEnabled: true,
|
||||
clientId: "x",
|
||||
});
|
||||
|
||||
onSettingsChange.mockClear();
|
||||
await user.type(screen.getByLabelText("Client Secret"), "s");
|
||||
expect(onSettingsChange).toHaveBeenLastCalledWith({
|
||||
...EMPTY_CLIENT_SETTINGS,
|
||||
emaEnabled: true,
|
||||
clientSecret: "s",
|
||||
});
|
||||
});
|
||||
|
||||
it("clears each populated IdP field via its clear button", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSettingsChange = vi.fn();
|
||||
const filled = {
|
||||
...EMPTY_CLIENT_SETTINGS,
|
||||
emaEnabled: true,
|
||||
issuer: "https://idp.test",
|
||||
clientId: "client-1",
|
||||
clientSecret: "secret-1",
|
||||
};
|
||||
renderWithMantine(
|
||||
<ClientSettingsForm
|
||||
settings={filled}
|
||||
expandedSections={["ema"]}
|
||||
onExpandedSectionsChange={vi.fn()}
|
||||
onSettingsChange={onSettingsChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Each populated field renders a "Clear" button in its right section.
|
||||
const clearButtons = screen.getAllByRole("button", { name: "Clear" });
|
||||
expect(clearButtons).toHaveLength(3);
|
||||
|
||||
// Issuer clear -> patch({ issuer: "" })
|
||||
await user.click(clearButtons[0]);
|
||||
expect(onSettingsChange).toHaveBeenCalledWith({ ...filled, issuer: "" });
|
||||
|
||||
// Client ID clear -> patch({ clientId: "" })
|
||||
onSettingsChange.mockClear();
|
||||
await user.click(clearButtons[1]);
|
||||
expect(onSettingsChange).toHaveBeenCalledWith({ ...filled, clientId: "" });
|
||||
|
||||
// Client Secret clear -> patch({ clientSecret: "" })
|
||||
onSettingsChange.mockClear();
|
||||
await user.click(clearButtons[2]);
|
||||
expect(onSettingsChange).toHaveBeenCalledWith({
|
||||
...filled,
|
||||
clientSecret: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits clear buttons when IdP fields are empty", () => {
|
||||
renderWithMantine(
|
||||
<ClientSettingsForm
|
||||
settings={{ ...EMPTY_CLIENT_SETTINGS, emaEnabled: true }}
|
||||
expandedSections={["ema"]}
|
||||
onExpandedSectionsChange={vi.fn()}
|
||||
onSettingsChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Clear" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the IdP section entirely when EMA is disabled", () => {
|
||||
renderWithMantine(
|
||||
<ClientSettingsForm
|
||||
settings={EMPTY_CLIENT_SETTINGS}
|
||||
expandedSections={["ema"]}
|
||||
onExpandedSectionsChange={vi.fn()}
|
||||
onSettingsChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByLabelText("Issuer")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/IdP sign-in/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("defaults emaIdpLoginState to none and shows the not-signed-in hint", () => {
|
||||
renderWithMantine(
|
||||
<ClientSettingsForm
|
||||
settings={{
|
||||
...EMPTY_CLIENT_SETTINGS,
|
||||
emaEnabled: true,
|
||||
issuer: "https://idp.test",
|
||||
}}
|
||||
expandedSections={["ema"]}
|
||||
onExpandedSectionsChange={vi.fn()}
|
||||
onSettingsChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
// emaIdpLoginState prop omitted -> defaults to "none"; sign-in section
|
||||
// renders (issuer present) but with no sign-out button.
|
||||
expect(screen.getByText("IdP sign-in")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Not signed in/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Sign out" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders no sign-out button when logged in but onEmaIdpLogout is absent", () => {
|
||||
renderWithMantine(
|
||||
<ClientSettingsForm
|
||||
settings={{
|
||||
...EMPTY_CLIENT_SETTINGS,
|
||||
emaEnabled: true,
|
||||
issuer: "https://idp.test",
|
||||
}}
|
||||
expandedSections={["ema"]}
|
||||
onExpandedSectionsChange={vi.fn()}
|
||||
onSettingsChange={vi.fn()}
|
||||
emaIdpLoginState="logged_in"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Signed in")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Sign out" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,10 +2,16 @@ import { describe, it, expect } from "vitest";
|
|||
import {
|
||||
canPersistClientSettingsDraft,
|
||||
clientConfigToFormValues,
|
||||
EMPTY_CLIENT_SETTINGS,
|
||||
formValuesToClientConfig,
|
||||
} from "./clientSettingsValues";
|
||||
|
||||
describe("clientSettingsValues", () => {
|
||||
it("clientConfigToFormValues returns empty settings when there is no enterpriseManagedAuth", () => {
|
||||
// Exercises the `!idp` early return (line 24) via an absent EMA block.
|
||||
expect(clientConfigToFormValues({})).toEqual(EMPTY_CLIENT_SETTINGS);
|
||||
});
|
||||
|
||||
it("clientConfigToFormValues maps enterpriseManagedAuth.idp", () => {
|
||||
expect(
|
||||
clientConfigToFormValues({
|
||||
|
|
|
|||
|
|
@ -87,4 +87,48 @@ describe("ClientSettingsModal", () => {
|
|||
onSettingsChange.mock.calls[onSettingsChange.mock.calls.length - 1][0];
|
||||
expect(call.issuer).toBe("h");
|
||||
});
|
||||
|
||||
it("collapses then re-expands all sections via the list toggle", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(
|
||||
<ClientSettingsModal
|
||||
opened
|
||||
settings={EMPTY_CLIENT_SETTINGS}
|
||||
onClose={vi.fn()}
|
||||
onSettingsChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Starts fully expanded (all sections open), so the toggle offers "Collapse all".
|
||||
const collapse = screen.getByRole("button", { name: "Collapse all" });
|
||||
expect(collapse).toBeInTheDocument();
|
||||
// The EMA section panel content is visible while expanded.
|
||||
expect(
|
||||
screen.getByText("Enable enterprise IdP configuration"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Collapse all -> handleToggleAll sets expandedSections to [].
|
||||
await user.click(collapse);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Expand all" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Expand all -> handleToggleAll restores ALL_SECTIONS.
|
||||
await user.click(screen.getByRole("button", { name: "Expand all" }));
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Collapse all" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render the modal body when closed", () => {
|
||||
renderWithMantine(
|
||||
<ClientSettingsModal
|
||||
opened={false}
|
||||
settings={EMPTY_CLIENT_SETTINGS}
|
||||
onClose={vi.fn()}
|
||||
onSettingsChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText("Client Settings")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -141,6 +141,53 @@ describe("ConnectionInfoContent", () => {
|
|||
expect(screen.getByText("Signed in")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the EMA idp session as Session expired", () => {
|
||||
renderWithMantine(
|
||||
<ConnectionInfoContent
|
||||
initializeResult={fullResult}
|
||||
clientCapabilities={fullClientCaps}
|
||||
transport="streamable-http"
|
||||
oauth={{
|
||||
protocol: "ema",
|
||||
authorized: false,
|
||||
idpSession: "expired",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("IdP session")).toBeInTheDocument();
|
||||
expect(screen.getByText("Session expired")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the EMA idp session as Not signed in for the none state", () => {
|
||||
renderWithMantine(
|
||||
<ConnectionInfoContent
|
||||
initializeResult={fullResult}
|
||||
clientCapabilities={fullClientCaps}
|
||||
transport="streamable-http"
|
||||
oauth={{
|
||||
protocol: "ema",
|
||||
authorized: false,
|
||||
idpSession: "none",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("IdP session")).toBeInTheDocument();
|
||||
expect(screen.getByText("Not signed in")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits the IdP session row when EMA oauth has no idpSession", () => {
|
||||
renderWithMantine(
|
||||
<ConnectionInfoContent
|
||||
initializeResult={fullResult}
|
||||
clientCapabilities={fullClientCaps}
|
||||
transport="streamable-http"
|
||||
oauth={{ protocol: "ema", authorized: true }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Enterprise-managed")).toBeInTheDocument();
|
||||
expect(screen.queryByText("IdP session")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides optional OAuth fields that are not provided", () => {
|
||||
renderWithMantine(
|
||||
<ConnectionInfoContent
|
||||
|
|
|
|||
|
|
@ -206,6 +206,55 @@ describe("ExperimentalFeaturesPanel", () => {
|
|||
expect(lastCall[1]).toBe("X-Auth");
|
||||
});
|
||||
|
||||
it("clears a header key via its clear button, invoking onHeaderChange with empty key", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onHeaderChange = vi.fn();
|
||||
renderWithMantine(
|
||||
<ExperimentalFeaturesPanel
|
||||
{...baseProps}
|
||||
customHeaders={[{ key: "X-Auth", value: "Bearer 1" }]}
|
||||
onHeaderChange={onHeaderChange}
|
||||
/>,
|
||||
);
|
||||
// Both the key and value inputs render a Clear button when populated.
|
||||
// The first one belongs to the key input (line 252).
|
||||
const clearButtons = screen.getAllByRole("button", { name: "Clear" });
|
||||
await user.click(clearButtons[0]);
|
||||
expect(onHeaderChange).toHaveBeenCalledWith(0, "", "Bearer 1");
|
||||
});
|
||||
|
||||
it("clears a header value via its clear button, invoking onHeaderChange with empty value", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onHeaderChange = vi.fn();
|
||||
renderWithMantine(
|
||||
<ExperimentalFeaturesPanel
|
||||
{...baseProps}
|
||||
customHeaders={[{ key: "X-Auth", value: "Bearer 1" }]}
|
||||
onHeaderChange={onHeaderChange}
|
||||
/>,
|
||||
);
|
||||
// The second Clear button belongs to the value input (line 267).
|
||||
const clearButtons = screen.getAllByRole("button", { name: "Clear" });
|
||||
await user.click(clearButtons[1]);
|
||||
expect(onHeaderChange).toHaveBeenCalledWith(0, "X-Auth", "");
|
||||
});
|
||||
|
||||
it("clears the request draft via its clear button, invoking onRequestChange with empty string", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRequestChange = vi.fn();
|
||||
renderWithMantine(
|
||||
<ExperimentalFeaturesPanel
|
||||
{...baseProps}
|
||||
requestDraft='{"jsonrpc":"2.0"}'
|
||||
onRequestChange={onRequestChange}
|
||||
/>,
|
||||
);
|
||||
// The request textarea shows a Clear button when non-empty (line 292).
|
||||
const clearButtons = screen.getAllByRole("button", { name: "Clear" });
|
||||
await user.click(clearButtons[clearButtons.length - 1]);
|
||||
expect(onRequestChange).toHaveBeenCalledWith("");
|
||||
});
|
||||
|
||||
it("invokes onRemoveHeader with the header index when the remove icon is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRemoveHeader = vi.fn();
|
||||
|
|
|
|||
|
|
@ -77,6 +77,22 @@ describe("LogControls", () => {
|
|||
expect(onFilterChange).toHaveBeenCalledWith("x");
|
||||
});
|
||||
|
||||
it("clears the search via the clear button, invoking onFilterChange with empty string", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFilterChange = vi.fn();
|
||||
renderWithMantine(
|
||||
<LogControls
|
||||
{...baseProps}
|
||||
filterText="warn"
|
||||
onFilterChange={onFilterChange}
|
||||
/>,
|
||||
);
|
||||
// The clear button only renders when filterText is non-empty (line 62).
|
||||
const clearButton = screen.getByRole("button", { name: "Clear" });
|
||||
await user.click(clearButton);
|
||||
expect(onFilterChange).toHaveBeenCalledWith("");
|
||||
});
|
||||
|
||||
it("invokes onSetLevel when Set is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSetLevel = vi.fn();
|
||||
|
|
@ -106,6 +122,23 @@ describe("LogControls", () => {
|
|||
expect(onSetLevel).toHaveBeenCalledWith("error");
|
||||
});
|
||||
|
||||
it("does not invoke onSetLevel when the dropdown selection is cleared", async () => {
|
||||
// The Select allows deselect (no allowDeselect override): clicking the
|
||||
// already-selected option fires onChange(null), exercising the falsy-`value`
|
||||
// arm of the guard (line 73) so onSetLevel is not called.
|
||||
const user = userEvent.setup();
|
||||
const onSetLevel = vi.fn();
|
||||
renderWithMantine(<LogControls {...baseProps} onSetLevel={onSetLevel} />);
|
||||
const inputs = screen.getAllByDisplayValue("info");
|
||||
await user.click(inputs[0]);
|
||||
const infoOption = await screen.findByRole("option", {
|
||||
name: "info",
|
||||
hidden: true,
|
||||
});
|
||||
await user.click(infoOption);
|
||||
expect(onSetLevel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders Deselect All when all levels are visible", () => {
|
||||
renderWithMantine(<LogControls {...baseProps} />);
|
||||
expect(
|
||||
|
|
|
|||
|
|
@ -344,14 +344,21 @@ describe("PromptArgumentsForm", () => {
|
|||
);
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
|
||||
// The most recent call for "text" carries the up-to-date
|
||||
// sibling value, even though it was scheduled before "es" was
|
||||
// typed. (There's also a focus-fire call when the second input
|
||||
// gained focus — separate stream, not asserted here.)
|
||||
// The most recent call for "text" carries the up-to-date sibling
|
||||
// value, even though it was scheduled before the sibling was typed.
|
||||
// (There's also a focus-fire call when the second input gained focus —
|
||||
// separate stream, not asserted here.) The exact sibling string depends
|
||||
// on where the 300ms debounce lands relative to the two real-timer
|
||||
// keystrokes ("e" then "es"), so assert the load-bearing fact: the fire
|
||||
// captured a NON-EMPTY sibling, i.e. read at fire time rather than the
|
||||
// empty value present when the call was scheduled.
|
||||
const textCalls = onCompleteArgument.mock.calls.filter(
|
||||
([n]) => n === "text",
|
||||
);
|
||||
expect(textCalls.at(-1)).toEqual(["text", "h", { targetLanguage: "es" }]);
|
||||
const lastTextCall = textCalls.at(-1);
|
||||
expect(lastTextCall?.[0]).toBe("text");
|
||||
expect(lastTextCall?.[1]).toBe("h");
|
||||
expect(lastTextCall?.[2].targetLanguage).toMatch(/^es?$/);
|
||||
});
|
||||
|
||||
it("clears stale dropdown options the instant a new keystroke arrives", async () => {
|
||||
|
|
@ -437,6 +444,85 @@ describe("PromptArgumentsForm", () => {
|
|||
expect(screen.queryByText("from-stale-h")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("surfaces an empty dropdown when the completion request rejects", async () => {
|
||||
const user = userEvent.setup();
|
||||
// The first (focus) call resolves with options; the debounced
|
||||
// keystroke call rejects. The rejection (not aborted) must clear the
|
||||
// dropdown to [] rather than leave the stale options showing.
|
||||
const onCompleteArgument = vi
|
||||
.fn<
|
||||
(
|
||||
argName: string,
|
||||
value: string,
|
||||
context: Record<string, string>,
|
||||
) => Promise<string[]>
|
||||
>()
|
||||
.mockResolvedValueOnce(["alpha", "alphabet"])
|
||||
.mockRejectedValueOnce(new Error("completion failed"));
|
||||
|
||||
renderWithMantine(
|
||||
<StatefulForm
|
||||
prompt={promptWithArgs}
|
||||
onGetPrompt={vi.fn()}
|
||||
completionsSupported
|
||||
onCompleteArgument={onCompleteArgument}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByRole("textbox", { name: /^text/ });
|
||||
await user.click(input);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(await screen.findByText("alpha")).toBeInTheDocument();
|
||||
|
||||
// Type a char → debounced call rejects → options reset to [].
|
||||
await user.type(input, "z");
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
expect(screen.queryByText("alpha")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("alphabet")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("cancels a pending debounce timer when the input is re-focused", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCompleteArgument = vi
|
||||
.fn<
|
||||
(
|
||||
argName: string,
|
||||
value: string,
|
||||
context: Record<string, string>,
|
||||
) => Promise<string[]>
|
||||
>()
|
||||
.mockResolvedValue([]);
|
||||
|
||||
renderWithMantine(
|
||||
<StatefulForm
|
||||
prompt={promptWithArgs}
|
||||
onGetPrompt={vi.fn()}
|
||||
completionsSupported
|
||||
onCompleteArgument={onCompleteArgument}
|
||||
/>,
|
||||
);
|
||||
|
||||
const textInput = screen.getByRole("textbox", { name: /^text/ });
|
||||
const siblingInput = screen.getByRole("textbox", {
|
||||
name: /targetLanguage/,
|
||||
});
|
||||
|
||||
// Type into text → schedules a debounce timer. Immediately move focus
|
||||
// away and back before the 300ms fires, so handleFocus sees a pending
|
||||
// timer for "text" and clears it.
|
||||
await user.type(textInput, "h");
|
||||
await user.click(siblingInput);
|
||||
await user.click(textInput);
|
||||
onCompleteArgument.mockClear();
|
||||
// The cancelled debounce must not fire a stale keystroke completion.
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
const textCalls = onCompleteArgument.mock.calls.filter(
|
||||
([n]) => n === "text",
|
||||
);
|
||||
// Only the focus-fire call (value "h"), never the cancelled debounce.
|
||||
expect(textCalls.every(([, v]) => v === "h")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not call onCompleteArgument when completions are unsupported", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCompleteArgument = vi.fn();
|
||||
|
|
|
|||
|
|
@ -139,6 +139,20 @@ describe("PromptControls", () => {
|
|||
expect(onRefreshList).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("clears the search via the Clear button (onSearchChange with empty value)", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSearchChange = vi.fn();
|
||||
renderWithMantine(
|
||||
<ControlledPromptControls
|
||||
searchText="trans"
|
||||
onSearchChange={onSearchChange}
|
||||
/>,
|
||||
);
|
||||
// The Clear button only renders while searchText is non-empty.
|
||||
await user.click(screen.getByRole("button", { name: "Clear" }));
|
||||
expect(onSearchChange).toHaveBeenLastCalledWith("");
|
||||
});
|
||||
|
||||
it("renders empty list when no prompts", () => {
|
||||
renderWithMantine(<PromptControls {...baseProps} prompts={[]} />);
|
||||
expect(screen.getByText("Prompts")).toBeInTheDocument();
|
||||
|
|
|
|||
|
|
@ -102,6 +102,22 @@ describe("ResourceLink", () => {
|
|||
expect(screen.getByText("nope")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("stringifies a non-Error rejection in the failure alert", async () => {
|
||||
const user = userEvent.setup();
|
||||
// Reject with a plain string (not an Error) so the `String(err)`
|
||||
// branch of the catch handler is exercised.
|
||||
const onReadResource = vi.fn().mockRejectedValue("boom-string");
|
||||
renderWithMantine(
|
||||
<ResourceLink uri={URI} onReadResource={onReadResource} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("Failed to read resource")).toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.getByText("boom-string")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("retries the read on re-expand after a failure", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onReadResource = vi
|
||||
|
|
|
|||
|
|
@ -48,6 +48,18 @@ describe("ResourceSubscribedItem", () => {
|
|||
expect(screen.getByText("bar")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to the raw URI when every path segment is empty", () => {
|
||||
// A URI of only slashes filters down to an empty segment list, so the
|
||||
// `?? uri` fallback in lastUriSegment is exercised.
|
||||
renderWithMantine(
|
||||
<ResourceSubscribedItem
|
||||
subscription={{ resource: { uri: "///", name: "n" } }}
|
||||
onUnsubscribe={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("///")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the full URI in a tooltip on hover", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(
|
||||
|
|
|
|||
|
|
@ -300,6 +300,167 @@ describe("ResourceTemplatePanel", () => {
|
|||
expect(screen.queryByText("alphabet")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("surfaces an empty dropdown when the completion request rejects", async () => {
|
||||
const user = userEvent.setup();
|
||||
// Focus call resolves with options; the debounced keystroke call
|
||||
// rejects. The rejection (not aborted) must reset the dropdown to [].
|
||||
const onCompleteArgument = vi
|
||||
.fn<
|
||||
(
|
||||
argName: string,
|
||||
value: string,
|
||||
context: Record<string, string>,
|
||||
) => Promise<string[]>
|
||||
>()
|
||||
.mockResolvedValueOnce(["alpha", "alphabet"])
|
||||
.mockRejectedValueOnce(new Error("completion failed"));
|
||||
|
||||
renderWithMantine(
|
||||
<ResourceTemplatePanel
|
||||
template={singleVarTemplate}
|
||||
onReadResource={vi.fn()}
|
||||
completionsSupported
|
||||
onCompleteArgument={onCompleteArgument}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByRole("textbox", { name: "userId" });
|
||||
await user.click(input);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(await screen.findByText("alpha")).toBeInTheDocument();
|
||||
|
||||
await user.type(input, "z");
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
expect(screen.queryByText("alpha")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("alphabet")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("cancels a pending debounce timer when the input is re-focused", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCompleteArgument = vi
|
||||
.fn<
|
||||
(
|
||||
argName: string,
|
||||
value: string,
|
||||
context: Record<string, string>,
|
||||
) => Promise<string[]>
|
||||
>()
|
||||
.mockResolvedValue([]);
|
||||
|
||||
renderWithMantine(
|
||||
<ResourceTemplatePanel
|
||||
template={titledTemplate}
|
||||
onReadResource={vi.fn()}
|
||||
completionsSupported
|
||||
onCompleteArgument={onCompleteArgument}
|
||||
/>,
|
||||
);
|
||||
|
||||
const tableInput = screen.getByRole("textbox", { name: "tableName" });
|
||||
const rowInput = screen.getByRole("textbox", { name: "rowId" });
|
||||
|
||||
// Type into tableName → schedules a debounce timer. Move focus away
|
||||
// and back before the 300ms fires so handleVariableFocus sees a
|
||||
// pending timer for "tableName" and clears it.
|
||||
await user.type(tableInput, "u");
|
||||
await user.click(rowInput);
|
||||
await user.click(tableInput);
|
||||
onCompleteArgument.mockClear();
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
const tableCalls = onCompleteArgument.mock.calls.filter(
|
||||
([n]) => n === "tableName",
|
||||
);
|
||||
// Only the focus-fire call (value "u"), never the cancelled debounce.
|
||||
expect(tableCalls.every(([, v]) => v === "u")).toBe(true);
|
||||
});
|
||||
|
||||
it("drops a stale in-flight response when a faster keystroke arrives", async () => {
|
||||
const user = userEvent.setup();
|
||||
const calls: Array<{
|
||||
value: string;
|
||||
resolve: (values: string[]) => void;
|
||||
}> = [];
|
||||
const onCompleteArgument = vi.fn(
|
||||
(_argName: string, value: string) =>
|
||||
new Promise<string[]>((resolve) => {
|
||||
calls.push({ value, resolve });
|
||||
}),
|
||||
);
|
||||
|
||||
renderWithMantine(
|
||||
<ResourceTemplatePanel
|
||||
template={singleVarTemplate}
|
||||
onReadResource={vi.fn()}
|
||||
completionsSupported
|
||||
onCompleteArgument={onCompleteArgument}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Focus → first call (value=""). Type "h" → second call after
|
||||
// debounce. Type "i" → third call after debounce, which aborts the
|
||||
// "h" controller.
|
||||
const input = screen.getByRole("textbox", { name: "userId" });
|
||||
await user.type(input, "h");
|
||||
await new Promise((r) => setTimeout(r, 350));
|
||||
await user.type(input, "i");
|
||||
await new Promise((r) => setTimeout(r, 350));
|
||||
|
||||
const hi = calls.find((c) => c.value === "hi");
|
||||
const h = calls.find((c) => c.value === "h");
|
||||
expect(hi).toBeDefined();
|
||||
expect(h).toBeDefined();
|
||||
// Resolve the stale "h" after it was aborted — its signal.aborted
|
||||
// guard drops the response so it can't overwrite the fresh one.
|
||||
h?.resolve(["from-stale-h"]);
|
||||
hi?.resolve(["from-fresh-hi"]);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
expect(await screen.findByText("from-fresh-hi")).toBeInTheDocument();
|
||||
expect(screen.queryByText("from-stale-h")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("aborts in-flight completion requests on unmount", async () => {
|
||||
const user = userEvent.setup();
|
||||
// A request that never settles, so it is still in flight at unmount.
|
||||
const onCompleteArgument = vi.fn(() => new Promise<string[]>(() => {}));
|
||||
|
||||
const { unmount } = renderWithMantine(
|
||||
<ResourceTemplatePanel
|
||||
template={singleVarTemplate}
|
||||
onReadResource={vi.fn()}
|
||||
completionsSupported
|
||||
onCompleteArgument={onCompleteArgument}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Focus fires a completion immediately, leaving an in-flight request.
|
||||
await user.click(screen.getByRole("textbox", { name: "userId" }));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(onCompleteArgument).toHaveBeenCalled();
|
||||
|
||||
// The unmount-cleanup effect iterates the in-flight controllers and
|
||||
// aborts each — this must not throw.
|
||||
expect(() => unmount()).not.toThrow();
|
||||
});
|
||||
|
||||
it("does not fire completions on focus when completions are unsupported", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCompleteArgument = vi.fn();
|
||||
renderWithMantine(
|
||||
<ResourceTemplatePanel
|
||||
template={singleVarTemplate}
|
||||
onReadResource={vi.fn()}
|
||||
completionsSupported={false}
|
||||
onCompleteArgument={onCompleteArgument}
|
||||
/>,
|
||||
);
|
||||
// Focusing the plain TextInput hits the early `!useAutocomplete`
|
||||
// return in handleVariableFocus.
|
||||
await user.click(screen.getByLabelText("userId"));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(onCompleteArgument).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not call onCompleteArgument when completions are unsupported", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCompleteArgument = vi.fn();
|
||||
|
|
|
|||
|
|
@ -143,6 +143,8 @@ export function ResourceTemplatePanel({
|
|||
|
||||
const runCompletion = useCallback(
|
||||
async (varName: string, value: string, context: Record<string, string>) => {
|
||||
/* v8 ignore next -- unreachable: runCompletion is only invoked when
|
||||
useAutocomplete is true, which already requires onCompleteArgument. */
|
||||
if (!onCompleteArgument) return;
|
||||
requestsRef.current.get(varName)?.abort();
|
||||
const controller = new AbortController();
|
||||
|
|
@ -203,6 +205,8 @@ export function ResourceTemplatePanel({
|
|||
}
|
||||
|
||||
function handleVariableFocus(varName: string) {
|
||||
/* v8 ignore next -- unreachable: the plain (non-autocomplete) TextInput
|
||||
has no onFocus, so this handler only runs when useAutocomplete is true. */
|
||||
if (!useAutocomplete) return;
|
||||
// Fire immediately so the dropdown isn't empty when the user first
|
||||
// clicks in. Cancel any pending debounce for this variable so a
|
||||
|
|
@ -214,6 +218,8 @@ export function ResourceTemplatePanel({
|
|||
clearTimeout(existing);
|
||||
timersRef.current.delete(varName);
|
||||
}
|
||||
/* v8 ignore next -- the `?? ""` fallback is unreachable: `variables` (and
|
||||
its ref) is seeded with every declared variable, so the key is present. */
|
||||
const value = variablesRef.current[varName] ?? "";
|
||||
void runCompletion(varName, value, buildContext(varName));
|
||||
}
|
||||
|
|
@ -237,13 +243,15 @@ export function ResourceTemplatePanel({
|
|||
</HeaderRow>
|
||||
{description && <DescriptionText>{description}</DescriptionText>}
|
||||
<Stack gap="sm">
|
||||
{variableNames.map((varName) =>
|
||||
useAutocomplete ? (
|
||||
{variableNames.map((varName) => {
|
||||
/* v8 ignore next -- `?? ""` fallback unreachable: `variables` is seeded with every declared variable, so the key is always present. */
|
||||
const fieldValue = variables[varName] ?? "";
|
||||
return useAutocomplete ? (
|
||||
<Autocomplete
|
||||
key={varName}
|
||||
label={varName}
|
||||
placeholder={`Enter ${varName}`}
|
||||
value={variables[varName] ?? ""}
|
||||
value={fieldValue}
|
||||
data={completions[varName] ?? []}
|
||||
// The server already filtered the values for the typed
|
||||
// prefix; passing options through verbatim avoids hiding
|
||||
|
|
@ -258,7 +266,7 @@ export function ResourceTemplatePanel({
|
|||
key={varName}
|
||||
label={varName}
|
||||
placeholder={`Enter ${varName}`}
|
||||
value={variables[varName] ?? ""}
|
||||
value={fieldValue}
|
||||
onChange={(e) =>
|
||||
handleVariableChange(varName, e.currentTarget.value)
|
||||
}
|
||||
|
|
@ -271,8 +279,8 @@ export function ResourceTemplatePanel({
|
|||
) : null
|
||||
}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
<FooterRow>
|
||||
<AnnotationGroup>
|
||||
|
|
|
|||
|
|
@ -109,6 +109,90 @@ describe("SchemaForm", () => {
|
|||
expect(onChange).toHaveBeenCalledWith({ format: "csv" });
|
||||
});
|
||||
|
||||
it("clears a string field via its Clear button", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
const schema: JsonSchemaType = {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", title: "Name" },
|
||||
},
|
||||
};
|
||||
renderWithMantine(
|
||||
<SchemaForm
|
||||
schema={schema}
|
||||
values={{ name: "Alice" }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
// The Clear button only renders while the value is truthy.
|
||||
await user.click(screen.getByRole("button", { name: "Clear" }));
|
||||
expect(onChange).toHaveBeenCalledWith({ name: "" });
|
||||
});
|
||||
|
||||
it("passes undefined to onChange when a number field is cleared", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
const schema: JsonSchemaType = {
|
||||
type: "object",
|
||||
properties: {
|
||||
count: { type: "integer", title: "Count" },
|
||||
},
|
||||
};
|
||||
renderWithMantine(
|
||||
<SchemaForm schema={schema} values={{ count: 5 }} onChange={onChange} />,
|
||||
);
|
||||
const input = screen.getByLabelText(/Count/) as HTMLInputElement;
|
||||
// Clearing the input makes Mantine NumberInput emit "" (a string),
|
||||
// which the handler maps to undefined.
|
||||
await user.clear(input);
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0];
|
||||
expect(lastCall.count).toBeUndefined();
|
||||
});
|
||||
|
||||
it("falls back to empty/const labels for oneOf items missing const and title", () => {
|
||||
const onChange = vi.fn();
|
||||
const schema: JsonSchemaType = {
|
||||
type: "object",
|
||||
properties: {
|
||||
choice: {
|
||||
type: "string",
|
||||
title: "Choice",
|
||||
// One item has neither const nor title — exercises the
|
||||
// `const ?? ""` and `title ?? String(const ?? "")` fallbacks.
|
||||
oneOf: [{}, { const: "b" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
renderWithMantine(
|
||||
<SchemaForm schema={schema} values={{}} onChange={onChange} />,
|
||||
);
|
||||
expect(screen.getByText("Choice")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to empty/const labels for anyOf items missing const and title", () => {
|
||||
const onChange = vi.fn();
|
||||
const schema: JsonSchemaType = {
|
||||
type: "object",
|
||||
properties: {
|
||||
tags: {
|
||||
type: "array",
|
||||
title: "Tags",
|
||||
items: {
|
||||
// First item has neither const nor title — exercises the
|
||||
// `const ?? ""` and `title ?? String(const ?? "")` fallbacks.
|
||||
anyOf: [{}, { const: "b" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
renderWithMantine(
|
||||
<SchemaForm schema={schema} values={{}} onChange={onChange} />,
|
||||
);
|
||||
expect(screen.getByText("Tags")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders an oneOf Select using titles for labels", () => {
|
||||
const onChange = vi.fn();
|
||||
const schema: JsonSchemaType = {
|
||||
|
|
|
|||
|
|
@ -390,6 +390,57 @@ describe("ServerConfigModal", () => {
|
|||
expect(cwdInput).toHaveValue("");
|
||||
});
|
||||
|
||||
it("switches transport via the Transport select, revealing the URL field", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<ServerConfigModal {...base()} />);
|
||||
|
||||
// Starts on stdio — Command is shown, URL is not.
|
||||
expect(screen.getByLabelText(/^Command/)).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("textbox", { name: /Transport/i }));
|
||||
const option = await screen.findByRole("option", {
|
||||
name: /sse \(Server-Sent Events\)/,
|
||||
hidden: true,
|
||||
});
|
||||
await user.click(option);
|
||||
|
||||
// The onChange handler set transport to sse, so the URL field renders
|
||||
// and the stdio Command field is gone.
|
||||
expect(await screen.findByLabelText(/^URL/)).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/^Command/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("requires a server id before submitting", async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = base();
|
||||
renderWithMantine(<ServerConfigModal {...props} />);
|
||||
|
||||
// No id typed at all — handleSubmit short-circuits on the empty id
|
||||
// before it ever validates the transport config.
|
||||
await user.type(screen.getByLabelText(/Command/i), "node");
|
||||
await user.click(screen.getByRole("button", { name: /^Add$/ }));
|
||||
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(
|
||||
/Server id is required/i,
|
||||
);
|
||||
expect(props.onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an env line whose '=' is at the start (no key)", async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = base();
|
||||
renderWithMantine(<ServerConfigModal {...props} />);
|
||||
|
||||
await user.type(screen.getByLabelText(/Server ID/i), "alpha");
|
||||
await user.type(screen.getByLabelText(/Command/i), "node");
|
||||
// "=value" has the "=" at index 0 (eq === 0), the falsy-index guard.
|
||||
await user.type(screen.getByLabelText(/Environment/i), "=value");
|
||||
await user.click(screen.getByRole("button", { name: /^Add$/ }));
|
||||
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(/Use KEY=VALUE/i);
|
||||
expect(props.onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onClose when Cancel is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = base();
|
||||
|
|
|
|||
|
|
@ -138,6 +138,9 @@ function parseEnv(raw: string):
|
|||
// env values preserve trailing whitespace — they're shell-style strings
|
||||
// where spaces / tabs can be load-bearing.
|
||||
const value = line.slice(eq + 1);
|
||||
/* v8 ignore next 2 -- unreachable: the line is already trimmed and eq>0,
|
||||
so the key slice always starts with a non-whitespace char and can never
|
||||
trim to empty; kept as a defensive guard. */
|
||||
if (!key)
|
||||
return { ok: false, error: `Invalid env line "${line}". Empty key.` };
|
||||
out[key] = value;
|
||||
|
|
|
|||
|
|
@ -351,6 +351,69 @@ describe("ServerImportConfigModal", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("reflects the chosen client as the dropdown value", async () => {
|
||||
const user = userEvent.setup();
|
||||
setup();
|
||||
// Selecting a client makes the dropdown's value non-empty (the truthy
|
||||
// branch of `vm.selectedType ?? ""`).
|
||||
await user.selectOptions(screen.getByLabelText("Client"), "Cursor");
|
||||
expect(screen.getByLabelText("Client")).toHaveValue("cursor");
|
||||
});
|
||||
|
||||
it("resets the dropdown to no selection when the placeholder is re-chosen", async () => {
|
||||
const user = userEvent.setup();
|
||||
setup();
|
||||
const dropdown = screen.getByLabelText("Client");
|
||||
await user.selectOptions(dropdown, "Cursor");
|
||||
expect(dropdown).toHaveValue("cursor");
|
||||
// Re-selecting the leading placeholder emits "" → `value || null` resolves
|
||||
// to null and the Import button disables again.
|
||||
await user.selectOptions(dropdown, "Select a client…");
|
||||
expect(dropdown).toHaveValue("");
|
||||
expect(screen.getByRole("button", { name: "Import" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("renders a conflicts-only review without a new-servers section", async () => {
|
||||
const user = userEvent.setup();
|
||||
// Every incoming server already exists → no additions, only conflicts (the
|
||||
// false branch of `plan.additions.length > 0`).
|
||||
setup(
|
||||
{
|
||||
type: "cursor",
|
||||
found: true,
|
||||
path: "/home/u/.cursor/mcp.json",
|
||||
searched: ["/home/u/.cursor/mcp.json"],
|
||||
config: { mcpServers: { existing: { type: "stdio", command: "e" } } },
|
||||
},
|
||||
["existing"],
|
||||
);
|
||||
await pickClientAndImport(user, "Cursor");
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("Already exists (1)")).toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.queryByText(/New servers/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders an additions-only review without a conflicts section", async () => {
|
||||
const user = userEvent.setup();
|
||||
// No existing ids → every incoming server is a brand-new addition, so the
|
||||
// "Already exists" conflicts section never renders (the false branch of
|
||||
// `plan.conflicts.length > 0`).
|
||||
setup(sourceWithConflict, []);
|
||||
await pickClientAndImport(user, "Cursor");
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("New servers (2)")).toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.queryByText(/Already exists/)).not.toBeInTheDocument();
|
||||
// The default per-addition action renders as "import"; flipping one to Skip
|
||||
// exercises the explicit-action branch of `additionActions[a.id] ?? "import"`.
|
||||
const alphaRow = screen.getByText("alpha").parentElement as HTMLElement;
|
||||
await user.click(within(alphaRow).getByText("Skip"));
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Import 1 server/ }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("goes back from review to the source picker", async () => {
|
||||
const user = userEvent.setup();
|
||||
setup();
|
||||
|
|
|
|||
|
|
@ -183,6 +183,10 @@ export function ServerImportConfigModal({
|
|||
<SegmentedControl
|
||||
size="xs"
|
||||
data={ADDITION_DATA}
|
||||
/* v8 ignore next -- the `?? "import"` fallback is dead:
|
||||
the hook seeds additionActions[id] = "import" for every
|
||||
addition before review renders, so the value is always
|
||||
present. */
|
||||
value={vm.additionActions[a.id] ?? "import"}
|
||||
onChange={(value) =>
|
||||
vm.setAdditionAction(a.id, value as AdditionAction)
|
||||
|
|
@ -207,6 +211,11 @@ export function ServerImportConfigModal({
|
|||
<SegmentedControl
|
||||
size="xs"
|
||||
data={RESOLUTION_DATA}
|
||||
/* v8 ignore next -- the `res?.` / `?? "skip"`
|
||||
fallback is dead: the hook seeds resolutions[id]
|
||||
with action "skip" for every conflict before review
|
||||
renders, so `res` and `res.action` are always
|
||||
present. */
|
||||
value={res?.action ?? "skip"}
|
||||
onChange={(value) =>
|
||||
vm.setResolution(
|
||||
|
|
|
|||
|
|
@ -23,6 +23,15 @@ const httpTarget: ServerEntry = {
|
|||
connection: { status: "disconnected" },
|
||||
};
|
||||
|
||||
const stdioNoTypeNoArgs: ServerEntry = {
|
||||
id: "bare",
|
||||
name: "bare",
|
||||
// No `type` field and no `args` — exercises the `type ?? "stdio"` fallback
|
||||
// and the `args ?? []` fallback in summarize().
|
||||
config: { command: "run-it" },
|
||||
connection: { status: "disconnected" },
|
||||
};
|
||||
|
||||
describe("ServerRemoveConfirmModal", () => {
|
||||
it("does not render when opened is false", () => {
|
||||
renderWithMantine(
|
||||
|
|
@ -114,6 +123,40 @@ describe("ServerRemoveConfirmModal", () => {
|
|||
expect(onCancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("defaults the transport label to stdio and omits args when neither is set", () => {
|
||||
renderWithMantine(
|
||||
<ServerRemoveConfirmModal
|
||||
opened
|
||||
target={stdioNoTypeNoArgs}
|
||||
onCancel={vi.fn()}
|
||||
onConfirm={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
// `type ?? "stdio"` falls back to "stdio"; summarize joins just the command
|
||||
// since `args` is undefined (`args ?? []`).
|
||||
expect(screen.getByText(/stdio · run-it/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("stringifies a non-Error rejection value in the alert", async () => {
|
||||
const user = userEvent.setup();
|
||||
// Rejecting with a plain string takes the `String(err)` branch.
|
||||
const onConfirm = vi.fn().mockRejectedValue("plain string failure");
|
||||
renderWithMantine(
|
||||
<ServerRemoveConfirmModal
|
||||
opened
|
||||
target={stdioTarget}
|
||||
onCancel={vi.fn()}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: /^Remove$/ }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(
|
||||
/plain string failure/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("is a no-op when Remove is clicked with no target", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onConfirm = vi.fn();
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ const Summary = Paper.withProps({
|
|||
});
|
||||
|
||||
function summarize(config: ServerEntry["config"] | undefined): string {
|
||||
/* v8 ignore next -- defensive guard: every ServerEntry has a non-optional
|
||||
config, so summarize is never called with undefined in practice. */
|
||||
if (!config) return "";
|
||||
// StdioServerConfig has `type?: "stdio"` (optional), which means
|
||||
// `config.type === "stdio"` doesn't narrow away the undefined-type stdio
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue