Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions clients/web/src/hooks/useServerCommands.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -862,6 +862,118 @@ describe("subscriptions and completion", () => {
expect(h.api().onSubscribeResource).toBeTypeOf("function");
});

// #2174. Both used to discard the promise with a bare `void`, so a failure
// was invisible in the UI and surfaced only as an unhandled rejection.
it("toasts a failed subscribe rather than swallowing it", async () => {
const h = harness({
client: client({
subscribeToResource: vi
.fn()
.mockRejectedValue(
new Error("Server does not support resource subscriptions"),
),
}),
});
await act(async () => h.api().onSubscribeResource("u"));
await waitFor(() =>
expect(notificationsMock.show).toHaveBeenCalledWith(
expect.objectContaining({
title: "Failed to subscribe to resource",
message: "Server does not support resource subscriptions",
}),
),
);
});

it("toasts a failed unsubscribe rather than swallowing it", async () => {
const h = harness({
client: client({
unsubscribeFromResource: vi
.fn()
.mockRejectedValue(new Error("Client is not connected")),
}),
});
await act(async () => h.api().onUnsubscribeResource("u"));
await waitFor(() =>
expect(notificationsMock.show).toHaveBeenCalledWith(
expect.objectContaining({
title: "Failed to unsubscribe from resource",
message: "Client is not connected",
}),
),
);
});

it("recovers a lapsed authorization on subscribe, and retries", async () => {
const recover = vi.fn().mockResolvedValue(true);
const subscribeToResource = vi
.fn()
.mockRejectedValueOnce(authError())
.mockResolvedValue(undefined);
const h = harness({
client: client({ subscribeToResource }),
activeServerId: "a",
recovery: { handleCommandScopedAuthRecovery: recover },
});
await act(async () => h.api().onSubscribeResource("u"));
await waitFor(() => expect(subscribeToResource).toHaveBeenCalledTimes(2));
// `ambient`, not `resource`: a `resource` step-up failure is routed into
// the read-preview panel, which this command has nothing to do with.
expect(recover).toHaveBeenCalledWith(
expect.any(AuthRecoveryRequiredError),
{
serverId: "a",
source: "ambient",
retryOperation: expect.any(Function),
},
);
// The recovery owns the prompt, so the command itself stays quiet.
expect(notificationsMock.show).not.toHaveBeenCalled();
});

it("recovers a lapsed authorization on unsubscribe, and retries", async () => {
const recover = vi.fn().mockResolvedValue(true);
const unsubscribeFromResource = vi
.fn()
.mockRejectedValueOnce(authError())
.mockResolvedValue(undefined);
const h = harness({
client: client({ unsubscribeFromResource }),
activeServerId: "a",
recovery: { handleCommandScopedAuthRecovery: recover },
});
await act(async () => h.api().onUnsubscribeResource("u"));
await waitFor(() =>
expect(unsubscribeFromResource).toHaveBeenCalledTimes(2),
);
expect(recover).toHaveBeenCalledWith(
expect.any(AuthRecoveryRequiredError),
{
serverId: "a",
source: "ambient",
retryOperation: expect.any(Function),
},
);
expect(notificationsMock.show).not.toHaveBeenCalled();
});

it("does not toast a subscribe whose recovery was left unsatisfied", async () => {
// The recovery has taken over — a redirect is pending or a step-up prompt
// is open — so the wrapper resolves `undefined` rather than rejecting, and
// a toast here would talk over the prompt the user is looking at.
const recover = vi.fn().mockResolvedValue(false);
const h = harness({
client: client({
subscribeToResource: vi.fn().mockRejectedValue(authError()),
}),
activeServerId: "a",
recovery: { handleCommandScopedAuthRecovery: recover },
});
await act(async () => h.api().onSubscribeResource("u"));
await waitFor(() => expect(recover).toHaveBeenCalled());
expect(notificationsMock.show).not.toHaveBeenCalled();
});

it("returns the completion values", async () => {
const c = client();
const h = harness({ client: c });
Expand Down
33 changes: 29 additions & 4 deletions clients/web/src/hooks/useServerCommands.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -564,20 +564,45 @@ export function useServerCommands({
[inspectorClient, activeServerId, handleCommandScopedAuthRecovery],
);

// Both route through the shared recovery like every other command (#2174).
// They used to discard the promise with a bare `void`, which neither the
// callee nor anything else owned: `subscribeToResource` throws when the
// client is disconnected, when the server declares no subscription support,
// and (wrapped) on a failed request, so a subscribe that failed did nothing
// visible and surfaced only as an unhandled rejection.
//
// The source is `ambient`, not `resource`. A `resource` step-up failure is
// routed into `readResourceState` — the *preview* panel — which describes a
// read of whatever resource is selected, not this subscribe; marking it
// errored would contradict a read that succeeded. Subscribing has no panel
// of its own (the tile only flips its button label), which is the same
// position the refreshes and the pagination toggle are in, and they are
// `ambient` for the same reason.
//
// Both pass an `errorTitle`: nothing else records these failures, so without
// one the button would go on silently doing nothing.
const onSubscribeResource = useCallback(
(uri: string) => {
if (!inspectorClient) return;
void inspectorClient.subscribeToResource(uri);
runCommandInBackground(
() => inspectorClient.subscribeToResource(uri),
"ambient",
"Failed to subscribe to resource",
Comment thread
cliffhall marked this conversation as resolved.
);
},
[inspectorClient],
[inspectorClient, runCommandInBackground],
);

const onUnsubscribeResource = useCallback(
(uri: string) => {
if (!inspectorClient) return;
void inspectorClient.unsubscribeFromResource(uri);
runCommandInBackground(
() => inspectorClient.unsubscribeFromResource(uri),
"ambient",
"Failed to unsubscribe from resource",
);
},
[inspectorClient],
[inspectorClient, runCommandInBackground],
);

const onCompleteArgument = useCallback(
Expand Down