story-kit: merge 68_story_frontend_pipeline_state_stale_after_server_restart
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { api, resolveWsHost } from "./client";
|
||||
import { api, ChatWebSocket, resolveWsHost } from "./client";
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
@@ -154,3 +154,179 @@ describe("api client", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── ChatWebSocket reconnect tests ───────────────────────────────────────────
|
||||
|
||||
interface MockWsInstance {
|
||||
onopen: (() => void) | null;
|
||||
onclose: (() => void) | null;
|
||||
onmessage: ((e: { data: string }) => void) | null;
|
||||
onerror: (() => void) | null;
|
||||
readyState: number;
|
||||
send: () => void;
|
||||
close: () => void;
|
||||
simulateClose: () => void;
|
||||
simulateMessage: (data: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
function makeMockWebSocket() {
|
||||
const instances: MockWsInstance[] = [];
|
||||
|
||||
class MockWebSocket {
|
||||
static readonly CONNECTING = 0;
|
||||
static readonly OPEN = 1;
|
||||
static readonly CLOSING = 2;
|
||||
static readonly CLOSED = 3;
|
||||
|
||||
onopen: (() => void) | null = null;
|
||||
onclose: (() => void) | null = null;
|
||||
onmessage: ((e: { data: string }) => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
readyState = 0;
|
||||
|
||||
constructor(_url: string) {
|
||||
instances.push(this as unknown as MockWsInstance);
|
||||
}
|
||||
|
||||
send() {}
|
||||
|
||||
close() {
|
||||
this.readyState = 3;
|
||||
this.onclose?.();
|
||||
}
|
||||
|
||||
simulateClose() {
|
||||
this.readyState = 3;
|
||||
this.onclose?.();
|
||||
}
|
||||
|
||||
simulateMessage(data: Record<string, unknown>) {
|
||||
this.onmessage?.({ data: JSON.stringify(data) });
|
||||
}
|
||||
}
|
||||
|
||||
return { MockWebSocket, instances };
|
||||
}
|
||||
|
||||
describe("ChatWebSocket", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
const { MockWebSocket } = makeMockWebSocket();
|
||||
vi.stubGlobal("WebSocket", MockWebSocket);
|
||||
// Reset shared static state between tests
|
||||
(ChatWebSocket as unknown as { sharedSocket: null }).sharedSocket = null;
|
||||
(ChatWebSocket as unknown as { refCount: number }).refCount = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("schedules reconnect after socket closes unexpectedly", () => {
|
||||
const { MockWebSocket, instances } = makeMockWebSocket();
|
||||
vi.stubGlobal("WebSocket", MockWebSocket);
|
||||
|
||||
const ws = new ChatWebSocket();
|
||||
ws.connect({});
|
||||
|
||||
expect(instances).toHaveLength(1);
|
||||
|
||||
instances[0].simulateClose();
|
||||
|
||||
// No new socket created yet
|
||||
expect(instances).toHaveLength(1);
|
||||
|
||||
// Advance past the initial 1s reconnect delay
|
||||
vi.advanceTimersByTime(1001);
|
||||
|
||||
// A new socket should now have been created
|
||||
expect(instances).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("delivers pipeline_state after reconnect", () => {
|
||||
const { MockWebSocket, instances } = makeMockWebSocket();
|
||||
vi.stubGlobal("WebSocket", MockWebSocket);
|
||||
|
||||
const onPipelineState = vi.fn();
|
||||
const ws = new ChatWebSocket();
|
||||
ws.connect({ onPipelineState });
|
||||
|
||||
// Simulate server restart
|
||||
instances[0].simulateClose();
|
||||
vi.advanceTimersByTime(1001);
|
||||
|
||||
// Server pushes pipeline_state on fresh connection
|
||||
const freshState = {
|
||||
upcoming: [{ story_id: "1_story_test", name: "Test", error: null }],
|
||||
current: [],
|
||||
qa: [],
|
||||
merge: [],
|
||||
};
|
||||
instances[1].simulateMessage({ type: "pipeline_state", ...freshState });
|
||||
|
||||
expect(onPipelineState).toHaveBeenCalledWith(freshState);
|
||||
});
|
||||
|
||||
it("does not reconnect after explicit close()", () => {
|
||||
const { MockWebSocket, instances } = makeMockWebSocket();
|
||||
vi.stubGlobal("WebSocket", MockWebSocket);
|
||||
|
||||
const ws = new ChatWebSocket();
|
||||
ws.connect({});
|
||||
|
||||
// Explicit close disables reconnect
|
||||
ws.close();
|
||||
|
||||
// Advance through both the DEV close-defer (250ms) and reconnect window
|
||||
vi.advanceTimersByTime(2000);
|
||||
|
||||
// No new socket should be created
|
||||
expect(instances).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("uses exponential backoff on repeated failures", () => {
|
||||
const { MockWebSocket, instances } = makeMockWebSocket();
|
||||
vi.stubGlobal("WebSocket", MockWebSocket);
|
||||
|
||||
const ws = new ChatWebSocket();
|
||||
ws.connect({});
|
||||
|
||||
// First close → reconnects after 1s
|
||||
instances[0].simulateClose();
|
||||
vi.advanceTimersByTime(1001);
|
||||
expect(instances).toHaveLength(2);
|
||||
|
||||
// Second close → reconnects after 2s (doubled)
|
||||
instances[1].simulateClose();
|
||||
vi.advanceTimersByTime(1500);
|
||||
// Not yet (delay is now 2s)
|
||||
expect(instances).toHaveLength(2);
|
||||
vi.advanceTimersByTime(600);
|
||||
expect(instances).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("resets reconnect delay after successful open", () => {
|
||||
const { MockWebSocket, instances } = makeMockWebSocket();
|
||||
vi.stubGlobal("WebSocket", MockWebSocket);
|
||||
|
||||
const ws = new ChatWebSocket();
|
||||
ws.connect({});
|
||||
|
||||
// Disconnect and reconnect twice to raise the delay
|
||||
instances[0].simulateClose();
|
||||
vi.advanceTimersByTime(1001);
|
||||
|
||||
instances[1].simulateClose();
|
||||
vi.advanceTimersByTime(2001);
|
||||
|
||||
// Simulate a successful open on third socket — resets delay to 1s
|
||||
instances[2].onopen?.();
|
||||
|
||||
// Close again — should use the reset 1s delay
|
||||
instances[2].simulateClose();
|
||||
vi.advanceTimersByTime(1001);
|
||||
|
||||
expect(instances).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user