Files
scrabble-game/ui/src/lib/streamwatchdog.test.ts
T
Ilia Denisov 25381f70a3
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m14s
CI / conformance (pull_request) Successful in 12s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m59s
fix(net): detect a silently-dead live stream via a heartbeat watchdog
An idle live stream whose connection dies without an error (airplane mode
cutting the radio) left the net-state machine stuck "online": the streaming
fetch neither errored nor delivered, no unary call was made, and the OS
`navigator` offline hint is unreliable in the Telegram/VK WebViews — so the
"Connecting…"/offline state only surfaced on the next foreground resync, not
while idle.

The gateway already pushes a keep-alive `heartbeat` every 10s, so add a
client-side watchdog (streamwatchdog.ts) that resets on every delivered event
and, after ~25s of silence, treats the stream as dropped (reportOffline +
reconnect). It is background-aware (paused while suspended) so a throttled
foreground return does not false-trip. The mock client mirrors the heartbeat
so an idle e2e session stays online.
2026-07-14 08:27:38 +02:00

83 lines
2.7 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createStreamWatchdog } from './streamwatchdog';
describe('createStreamWatchdog', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('fires onDead after the timeout with no activity', () => {
const onDead = vi.fn();
const w = createStreamWatchdog({ timeoutMs: 25000, onDead });
w.reset();
vi.advanceTimersByTime(24999);
expect(onDead).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(onDead).toHaveBeenCalledTimes(1);
});
it('reset before the timeout re-arms and prevents the fire', () => {
const onDead = vi.fn();
const w = createStreamWatchdog({ timeoutMs: 25000, onDead });
w.reset();
vi.advanceTimersByTime(20000);
w.reset(); // a fresh event arrived; the deadline moves out
vi.advanceTimersByTime(20000);
expect(onDead).not.toHaveBeenCalled();
vi.advanceTimersByTime(5000);
expect(onDead).toHaveBeenCalledTimes(1);
});
it('a steady heartbeat keeps it from ever firing', () => {
const onDead = vi.fn();
const w = createStreamWatchdog({ timeoutMs: 25000, onDead });
w.reset();
// A 10 s server heartbeat resets it well inside the 25 s window, indefinitely.
for (let i = 0; i < 20; i++) {
vi.advanceTimersByTime(10000);
w.reset();
}
expect(onDead).not.toHaveBeenCalled();
});
it('pause clears the pending timer so it cannot fire', () => {
const onDead = vi.fn();
const w = createStreamWatchdog({ timeoutMs: 25000, onDead });
w.reset();
w.pause();
vi.advanceTimersByTime(60000);
expect(onDead).not.toHaveBeenCalled();
});
it('resume re-arms after a pause and then fires on continued silence', () => {
const onDead = vi.fn();
const w = createStreamWatchdog({ timeoutMs: 25000, onDead });
w.reset();
w.pause();
vi.advanceTimersByTime(60000); // stayed silent while paused: no fire
expect(onDead).not.toHaveBeenCalled();
w.resume();
vi.advanceTimersByTime(24999);
expect(onDead).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(onDead).toHaveBeenCalledTimes(1);
});
it('clear stops a pending fire and does not re-arm', () => {
const onDead = vi.fn();
const w = createStreamWatchdog({ timeoutMs: 25000, onDead });
w.reset();
w.clear();
vi.advanceTimersByTime(60000);
expect(onDead).not.toHaveBeenCalled();
});
it('reset after a pause resumes watching (unpauses)', () => {
const onDead = vi.fn();
const w = createStreamWatchdog({ timeoutMs: 25000, onDead });
w.pause();
w.reset(); // a reopen after a suspend must start watching again
vi.advanceTimersByTime(25000);
expect(onDead).toHaveBeenCalledTimes(1);
});
});