| 1 | import { describe, expect, it } from "vitest"; |
| 2 | import { |
| 3 | INITIAL_CONNECTION_STATE, |
| 4 | RETRY_BASE_MS, |
| 5 | RETRY_MAX_MS, |
| 6 | backoffDelayMs, |
| 7 | nextConnectionState, |
| 8 | shouldProbe, |
| 9 | type ConnectionState, |
| 10 | } from "./connection-state"; |
| 11 | |
| 12 | function run(events: Parameters<typeof nextConnectionState>[1][]): ConnectionState { |
| 13 | return events.reduce(nextConnectionState, INITIAL_CONNECTION_STATE); |
| 14 | } |
| 15 | |
| 16 | describe("connection state", () => { |
| 17 | it("starts online and stays online on successful probes", () => { |
| 18 | const state = run([{ type: "probe-start" }, { type: "probe-ok", at: 10 }]); |
| 19 | expect(state).toEqual({ status: "online", attempt: 0, lastCheckedAt: 10, restored: false }); |
| 20 | expect(shouldProbe(state)).toBe(false); |
| 21 | }); |
| 22 | |
| 23 | it("goes offline on the browser event and does not probe until the browser returns", () => { |
| 24 | const offline = run([{ type: "browser-offline" }]); |
| 25 | expect(offline.status).toBe("offline"); |
| 26 | expect(shouldProbe(offline)).toBe(false); |
| 27 | // A probe that happens to fail while offline records the time but does |
| 28 | // not count as a reconnect attempt. |
| 29 | const still = nextConnectionState(offline, { type: "probe-failed", at: 5 }); |
| 30 | expect(still.status).toBe("offline"); |
| 31 | expect(still.attempt).toBe(0); |
| 32 | expect(still.lastCheckedAt).toBe(5); |
| 33 | // The same for a probe that was already in flight when the browser went |
| 34 | // offline and lands successfully afterward: the browser may emit no |
| 35 | // second event, so a late success must not hide the banner. |
| 36 | const staleOk = nextConnectionState(offline, { type: "probe-ok", at: 6 }); |
| 37 | expect(staleOk.status).toBe("offline"); |
| 38 | expect(staleOk.attempt).toBe(0); |
| 39 | expect(staleOk.lastCheckedAt).toBe(6); |
| 40 | }); |
| 41 | |
| 42 | it("reconnects through the server, not on the browser's word alone", () => { |
| 43 | const back = run([{ type: "browser-offline" }, { type: "browser-online" }]); |
| 44 | expect(back.status).toBe("reconnecting"); |
| 45 | expect(shouldProbe(back)).toBe(true); |
| 46 | const failed = nextConnectionState(back, { type: "probe-failed", at: 20 }); |
| 47 | expect(failed.status).toBe("degraded"); |
| 48 | expect(failed.attempt).toBe(1); |
| 49 | const again = nextConnectionState(failed, { type: "probe-start" }); |
| 50 | expect(again.status).toBe("reconnecting"); |
| 51 | const ok = nextConnectionState(again, { type: "probe-ok", at: 30 }); |
| 52 | expect(ok).toEqual({ status: "online", attempt: 0, lastCheckedAt: 30, restored: true }); |
| 53 | expect(nextConnectionState(ok, { type: "restored-seen" }).restored).toBe(false); |
| 54 | }); |
| 55 | |
| 56 | it("marks a failed probe while apparently online as degraded", () => { |
| 57 | const state = run([{ type: "probe-failed", at: 1 }]); |
| 58 | expect(state.status).toBe("degraded"); |
| 59 | expect(state.attempt).toBe(1); |
| 60 | expect(shouldProbe(state)).toBe(true); |
| 61 | }); |
| 62 | |
| 63 | it("schedules retries on a capped exponential backoff", () => { |
| 64 | expect(backoffDelayMs(0)).toBe(RETRY_BASE_MS); |
| 65 | expect(backoffDelayMs(1)).toBe(RETRY_BASE_MS * 2); |
| 66 | expect(backoffDelayMs(2)).toBe(RETRY_BASE_MS * 4); |
| 67 | expect(backoffDelayMs(3)).toBe(RETRY_BASE_MS * 8); |
| 68 | expect(backoffDelayMs(4)).toBe(RETRY_MAX_MS); |
| 69 | expect(backoffDelayMs(50)).toBe(RETRY_MAX_MS); |
| 70 | }); |
| 71 | }); |
| 72 |