fix(web): stop the remaining consumers calling a landed write a failure

Copilot review r3. Three findings, all the same root: a
ServerListReloadError rejects after the write succeeded, so any consumer
still reading "rejection" as "the write failed" now says something false.

- `useImportClientConfig.applyEntry` gave a successfully imported server a
  red "Failed" badge. It now reports the intended status and puts the
  reload message in `detail`, which renders in red beneath it either way —
  so the failure stays visible without claiming the row wasn't created.
- The settings-draft toast said "Failed to save settings". It now branches
  its title; this toast is the only signal there, since the modal has
  usually closed by the time a flush rejects.
- An edit modal left open by a failed reload kept targeting the old id.
  When the row landed under the new one, `configModalTarget` went
  undefined and ServerConfigModal's own reset blanked the open form and
  cleared the error it was showing. An edit/clone modal whose target has
  left the list now closes — adjusted during render via `useValueChange`,
  so no frame paints the blanked form. That also covers the ordinary case
  of an external mcp.json edit removing the server being edited.

The reachability here is not remote: the write is what triggers the
backend's change broadcast, so the SSE-driven refresh that lands the
renamed row is the likely next event.

Also adds the regression test r3 asked for on the add path — the
highlight batch has to be marked from the catch for the same reason the
rename is. Both new App tests and the import one fail without their fix.

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>
This commit is contained in:
cliffhall 2026-08-23 10:14:22 -04:00
commit b49d4f20f5

View file

@ -223,8 +223,9 @@ const { updateServerSettingsSpy } = vi.hoisted(() => ({
// Same idea for the edit-modal rename path (#1914): the test needs to make the
// PUT's *reload* fail, which only a spy it controls can do.
const { updateServerSpy } = vi.hoisted(() => ({
const { updateServerSpy, addServerSpy } = vi.hoisted(() => ({
updateServerSpy: vi.fn(() => Promise.resolve()),
addServerSpy: vi.fn(() => Promise.resolve()),
}));
// Stable spy for the tools list-changed acknowledgement, so a test can assert
// the paginated Refresh clears the indicator (#1721).
@ -240,7 +241,7 @@ vi.mock("@inspector/core/react/useServers.js", async (importOriginal) => ({
>()),
useServers: vi.fn(() => ({
servers: [SERVER_A],
addServer: vi.fn(),
addServer: addServerSpy,
updateServer: updateServerSpy,
updateServerSettings: updateServerSettingsSpy,
removeServer: vi.fn(),
@ -425,6 +426,8 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
onRefreshTasks: () => void;
onServerSettings: (id: string) => void;
onServerEdit: (id: string) => void;
onServerAdd: () => void;
highlightedServerIds?: string[];
onClearProtocol: () => void;
onReplayProtocol: (id: string) => void;
onTogglePinProtocol: (id: string) => void;
@ -550,9 +553,14 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
</button>
<button onClick={() => props.onSetLogLevel("debug")}>set-level</button>
<button onClick={() => props.onServerSettings("A")}>open-settings</button>
{/* The real server grid (and its Edit button) lives inside this mocked
view, so the edit modal is only reachable through its callback. */}
{/* The real server grid (and its Add / Edit controls) lives inside this
mocked view, so the config modal is only reachable through these
callbacks — and the highlight batch only observable through this prop. */}
<button onClick={() => props.onServerEdit("A")}>edit-server</button>
<button onClick={() => props.onServerAdd()}>add-server</button>
<span data-testid="highlighted-servers">
{(props.highlightedServerIds ?? []).join(",") || "none"}
</span>
<span data-testid="pinned-history">
{Array.from(props.pinnedProtocolIds ?? []).join(",")}
</span>
@ -2481,12 +2489,11 @@ describe("App paginated list pagination toggle (#1721)", () => {
});
});
// `onConfigSubmit` moves `activeServerId` to the new id only after
// `updateServer` *resolves*. Once a failed list reload rejects, the rename is
// still on disk — so without the catch-side `followRename` the active id stays
// on the old one, and the next successful refresh leaves it pointing at a row
// that no longer exists (#1914 review r2).
describe("App edit rename with a failed list reload (#1914)", () => {
// A post-write reload failure rejects *after* the write landed, so every piece
// of state `onConfigSubmit` used to apply on the resolve path has to be applied
// on the reject path too — the rename target and the highlight batch both
// describe a row that really is on disk (#1914 review r2 / r3).
describe("App config submit with a failed list reload (#1914)", () => {
// Earlier describes install their own `useServers` return value, which
// outlives them — so this block installs its own and puts the previous
// implementation back, rather than trusting whatever leaked in.
@ -2497,7 +2504,7 @@ describe("App edit rename with a failed list reload (#1914)", () => {
loading: false,
error: undefined,
refresh: vi.fn().mockResolvedValue(undefined),
addServer: vi.fn().mockResolvedValue(undefined),
addServer: addServerSpy,
updateServer: updateServerSpy,
updateServerSettings: updateServerSettingsSpy,
removeServer: vi.fn().mockResolvedValue(undefined),
@ -2512,6 +2519,7 @@ describe("App edit rename with a failed list reload (#1914)", () => {
? () => vi.mocked(useServers).mockImplementation(previous)
: undefined;
updateServerSpy.mockClear();
addServerSpy.mockClear();
updateServerSettingsSpy.mockClear();
vi.mocked(useServers).mockReturnValue(serversResult(["A"]));
});
@ -2530,7 +2538,7 @@ describe("App edit rename with a failed list reload (#1914)", () => {
"The server was saved, but the server list could not be reloaded: disk full",
),
);
renderWithMantine(<App />);
const { rerender } = renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
@ -2552,12 +2560,19 @@ describe("App edit rename with a failed list reload (#1914)", () => {
await screen.findByText(/could not be reloaded: disk full/),
).toBeInTheDocument();
// Now let the row land, as a later successful refresh would, and read the
// active id back off the toggle.
// Now let the row land, as the SSE refresh the write itself triggers
// would. The modal's target ("A") is gone, so it closes rather than
// blanking its own form — `initialId`/`initialConfig` would go undefined
// and ServerConfigModal's reset would wipe the open form and the error
// it is showing (#1914 r3).
vi.mocked(useServers).mockReturnValue(serversResult(["A-renamed"]));
await user.click(screen.getByRole("button", { name: "Cancel" }));
await user.click(screen.getByText("paginated-on"));
rerender(<App />);
await waitFor(() =>
expect(screen.queryByLabelText(/Server ID/)).not.toBeInTheDocument(),
);
// With the modal gone, read the active id back off the toggle.
await user.click(screen.getByText("paginated-on"));
await waitFor(() =>
expect(updateServerSettingsSpy).toHaveBeenCalledWith(
"A-renamed",
@ -2565,6 +2580,38 @@ describe("App edit rename with a failed list reload (#1914)", () => {
),
);
});
it("keeps a successful add in the highlight batch when the reload failed (#1914)", async () => {
// `addServerHighlighted` marked the new id only after `addServer`
// resolved. The row is on disk either way, so on a reload failure it has
// to be marked from the catch — the next successful refresh is what
// renders it, and it would otherwise arrive unhighlighted.
const user = userEvent.setup();
addServerSpy.mockRejectedValueOnce(
new ServerListReloadError(
"The server was added, but the server list could not be reloaded: disk full",
),
);
renderWithMantine(<App />);
expect(screen.getByTestId("highlighted-servers")).toHaveTextContent("none");
await user.click(screen.getByText("add-server"));
await user.type(await screen.findByLabelText(/Server ID/), "brand-new");
await user.type(screen.getByLabelText(/^Command/), "node");
await user.click(screen.getByRole("button", { name: "Add" }));
await waitFor(() =>
expect(addServerSpy).toHaveBeenCalledWith("brand-new", expect.anything()),
);
expect(
await screen.findByText(/could not be reloaded: disk full/),
).toBeInTheDocument();
await waitFor(() =>
expect(screen.getByTestId("highlighted-servers")).toHaveTextContent(
"brand-new",
),
);
});
});
// A background command runs through `runCommandInBackground`, which owns the