From 0e87a1932246b33d0258010c054115ed095cc30f Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 20:12:36 +0100 Subject: [PATCH] test(web): protect heartbeat recovery from retired socket callbacks Existing heartbeat coverage stops at requesting socket closure. Exercise the subsequent disconnect/reconnect signals and ensure a retired socket cannot invalidate its replacement. Also verify malformed frames cannot postpone heartbeat expiry. This strengthens failure-detection regression coverage without changing product behaviour. Change-source: pulse-maintainer --- .../__tests__/websocket-resilience.test.ts | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/frontend-modern/src/stores/__tests__/websocket-resilience.test.ts b/frontend-modern/src/stores/__tests__/websocket-resilience.test.ts index 0b3ccb2e3..f76dfdd06 100644 --- a/frontend-modern/src/stores/__tests__/websocket-resilience.test.ts +++ b/frontend-modern/src/stores/__tests__/websocket-resilience.test.ts @@ -161,6 +161,55 @@ describe('websocket store resilience', () => { } }); + it('recovers after a heartbeat close without letting retired callbacks disconnect the new socket', async () => { + vi.spyOn(Math, 'random').mockReturnValue(0.5); + apiFetchJSONMock.mockResolvedValue([]); + const { store, dispose } = await createStoreHarness(); + try { + vi.advanceTimersByTime(1); + const silentSocket = currentInstance!; + expect(store.connected()).toBe(true); + + vi.advanceTimersByTime(90_000); + expect(silentSocket.close).toHaveBeenCalledWith(4000, 'Heartbeat timeout'); + // Model the browser completing the close handshake; the mock does not + // emit close events automatically. + silentSocket.readyState = MockWebSocketClass.CLOSED; + silentSocket.onclose?.({ code: 4000, reason: 'Heartbeat timeout' } as CloseEvent); + expect(store.connected()).toBe(false); + expect(store.reconnecting()).toBe(true); + + vi.advanceTimersByTime(1_001); + expect(instances).toHaveLength(2); + expect(store.connected()).toBe(true); + expect(store.reconnecting()).toBe(false); + + silentSocket.onclose?.({ code: 1006, reason: '' } as CloseEvent); + expect(store.connected()).toBe(true); + expect(store.reconnecting()).toBe(false); + vi.advanceTimersByTime(2_000); + expect(instances).toHaveLength(2); + } finally { + dispose(); + } + }); + + it('does not let malformed messages postpone detection of a silent server', async () => { + apiFetchJSONMock.mockResolvedValue([]); + const { dispose } = await createStoreHarness(); + try { + vi.advanceTimersByTime(1); + const socket = currentInstance!; + vi.advanceTimersByTime(89_000); + socket.onmessage?.({ data: '{invalid JSON' } as MessageEvent); + socket.onmessage?.({ data: new Blob(['pong']) } as MessageEvent); + vi.advanceTimersByTime(1_000); + expect(socket.close).toHaveBeenCalledWith(4000, 'Heartbeat timeout'); + } finally { + dispose(); + } + }); + it('keeps the websocket open when server activity arrives before heartbeat timeout', async () => { const { dispose } = await createStoreHarness(); try {