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
265 changes: 254 additions & 11 deletions web/src/components/ChatSidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,23 @@ const apiMocks = vi.hoisted(() => ({
})),
}));

const gatewayMocks = vi.hoisted(() => ({
close: vi.fn(),
connect: vi.fn(async () => undefined),
on: vi.fn(() => () => undefined),
onState: vi.fn((handler: (state: string) => void) => {
handler("open");
return () => undefined;
}),
request: vi.fn(async () => ({ session_id: "sidecar-1" })),
}));
const gatewayMocks = vi.hoisted(() => {
const handlers = new Map<string, (event: unknown) => void>();
return {
close: vi.fn(),
connect: vi.fn(async () => undefined),
handlers,
on: vi.fn((event: string, handler: (event: unknown) => void) => {
handlers.set(event, handler);
return () => handlers.delete(event);
}),
onState: vi.fn((handler: (state: string) => void) => {
handler("open");
return () => undefined;
}),
request: vi.fn(async () => ({ session_id: "sidecar-1" })),
};
});

const reloadMocks = vi.hoisted(() => ({
maybeReloadForLoopbackWsAuthFailure: vi.fn(() => true),
Expand Down Expand Up @@ -69,6 +76,7 @@ class FakeWebSocket {

private listeners = new Map<string, Array<(event: EventLike) => void>>();
readonly url: string;
closed = false;

constructor(url: string) {
this.url = url;
Expand All @@ -81,7 +89,9 @@ class FakeWebSocket {
this.listeners.set(type, listeners);
}

close() {}
close() {
this.closed = true;
}

emit(type: string, event: EventLike) {
for (const listener of this.listeners.get(type) ?? []) {
Expand Down Expand Up @@ -131,3 +141,236 @@ describe("ChatSidebar event socket", () => {
).toHaveBeenCalledWith(4401);
});
});

describe("ChatSidebar event socket reconnect", () => {
beforeEach(() => {
// Not loopback: exercise the gated-mode path so closes fall through to
// the reconnect logic instead of triggering a page reload.
reloadMocks.maybeReloadForLoopbackWsAuthFailure.mockReturnValue(false);
vi.useFakeTimers();
});

afterEach(() => {
vi.useRealTimers();
});

async function renderSidebar() {
const { ChatSidebar } = await import("./ChatSidebar");
await render(<ChatSidebar channel="chat-1" />);
await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1));
}

/** Advance timers and flush the async `connect()` that fires on the tick. */
async function advance(ms: number) {
await act(async () => {
await vi.advanceTimersByTimeAsync(ms);
});
}

it("reconnects after a transient close", async () => {
await renderSidebar();

await act(async () => {
FakeWebSocket.instances[0].emit("close", { code: 1006 });
});
expect(FakeWebSocket.instances).toHaveLength(1);

await advance(1_000);
expect(FakeWebSocket.instances).toHaveLength(2);
expect(apiMocks.buildWsUrl).toHaveBeenCalledTimes(2);
});

it("backs off exponentially across repeated failures", async () => {
await renderSidebar();

// 1s, then 2s, then 4s — a socket that never opens keeps backing off.
for (const [index, delay] of [1_000, 2_000, 4_000].entries()) {
await act(async () => {
FakeWebSocket.instances[index].emit("close", { code: 1006 });
});

// The previous (shorter) delay must not be enough to fire this one.
if (index > 0) {
await advance(delay - 1);
expect(FakeWebSocket.instances).toHaveLength(index + 1);
}

await advance(delay);
expect(FakeWebSocket.instances).toHaveLength(index + 2);
}
});

it("schedules only one retry when error and close both fire", async () => {
await renderSidebar();

// A failed socket emits `error` then `close`. Scheduling from both
// paths would queue two timers and leak the untracked one.
await act(async () => {
FakeWebSocket.instances[0].emit("error", {});
FakeWebSocket.instances[0].emit("close", { code: 1006 });
});

await advance(30_000);
expect(FakeWebSocket.instances).toHaveLength(2);
});

it("resets the backoff after a successful reconnect", async () => {
await renderSidebar();

await act(async () => {
FakeWebSocket.instances[0].emit("close", { code: 1006 });
});
await advance(1_000);
expect(FakeWebSocket.instances).toHaveLength(2);

// Reconnected — the next drop should start from 1s again, not 2s.
await act(async () => {
FakeWebSocket.instances[1].emit("open", {});
FakeWebSocket.instances[1].emit("close", { code: 1006 });
});
await advance(1_000);
expect(FakeWebSocket.instances).toHaveLength(3);
});

it("does not retry auth rejections", async () => {
await renderSidebar();

await act(async () => {
FakeWebSocket.instances[0].emit("close", { code: 4403 });
});

await advance(60_000);
expect(FakeWebSocket.instances).toHaveLength(1);
});

it("does not retry a normal closure", async () => {
await renderSidebar();

await act(async () => {
FakeWebSocket.instances[0].emit("close", { code: 1000 });
});

await advance(60_000);
expect(FakeWebSocket.instances).toHaveLength(1);
});

it("gives up after the attempt cap instead of retrying forever", async () => {
await renderSidebar();

for (let i = 0; i < 40; i++) {
const socket =
FakeWebSocket.instances[FakeWebSocket.instances.length - 1];
await act(async () => {
socket.emit("close", { code: 1006 });
});
await advance(30_000);
}

// 15 retries + the initial connection.
expect(FakeWebSocket.instances.length).toBeLessThanOrEqual(16);
});

it("clears its own banner on a successful reconnect", async () => {
await renderSidebar();

await act(async () => {
FakeWebSocket.instances[0].emit("close", { code: 1006 });
});
expect(container.textContent).toContain("events feed disconnected");

await advance(1_000);
await act(async () => {
FakeWebSocket.instances[1].emit("open", {});
});

// Banner gone entirely — including the "reconnect events feed" button,
// which only renders while `error` is set.
expect(container.textContent).not.toContain("events feed");
});

it("does not clear a credential warning when the feed recovers", async () => {
await renderSidebar();

await act(async () => {
FakeWebSocket.instances[0].emit("close", { code: 1006 });
});
await advance(1_000);

// A sidecar error lands while the events socket is still reconnecting.
// The banner is shared, so a blind `setError(null)` on reconnect would
// hide a real problem the user needs to see.
await act(async () => {
gatewayMocks.handlers.get("error")?.({
payload: { message: "ANTHROPIC_API_KEY is not set" },
});
});

await act(async () => {
FakeWebSocket.instances[1].emit("open", {});
});

expect(container.textContent).toContain("ANTHROPIC_API_KEY is not set");
});

it("does not overwrite a sidecar error when the feed drops", async () => {
await renderSidebar();

// A sidecar error is already on the banner...
await act(async () => {
gatewayMocks.handlers.get("error")?.({
payload: { message: "ANTHROPIC_API_KEY is not set" },
});
});

// ...when the events feed drops. `error` is that message's only home,
// so overwriting it loses the warning permanently — the feed's own
// banner would later clear itself to null and the sidecar never
// re-emits.
await act(async () => {
FakeWebSocket.instances[0].emit("error", {});
FakeWebSocket.instances[0].emit("close", { code: 1006 });
});

expect(container.textContent).toContain("ANTHROPIC_API_KEY is not set");
// The disconnect message must not have replaced it. (Matching the
// banner text specifically — "reconnect events feed" is the button
// label, which is expected to be present whenever a banner shows.)
expect(container.textContent).not.toContain("events feed disconnected");
});

it("still reconnects while a foreign banner suppresses its message", async () => {
await renderSidebar();

await act(async () => {
gatewayMocks.handlers.get("error")?.({
payload: { message: "ANTHROPIC_API_KEY is not set" },
});
});
await act(async () => {
FakeWebSocket.instances[0].emit("close", { code: 1006 });
});

// Declining to write the banner must not disable the retry itself.
await advance(1_000);
expect(FakeWebSocket.instances).toHaveLength(2);
});

it("clears the reconnect timer and closes the socket on unmount", async () => {
await renderSidebar();

await act(async () => {
FakeWebSocket.instances[0].emit("close", { code: 1006 });
});
expect(vi.getTimerCount()).toBeGreaterThan(0);

await act(async () => root.unmount());

expect(FakeWebSocket.instances[0].closed).toBe(true);
// The pending retry timer must be cleared, not merely neutered by the
// `unmounting` flag — a live timer keeps the effect closure alive.
expect(vi.getTimerCount()).toBe(0);

await advance(60_000);
expect(FakeWebSocket.instances).toHaveLength(1);
});
});
Loading
Loading