import { describe, expect, it } from 'vitest' import { decodeMuxFrame } from 'relay-contracts' import { holdTunnel } from '../src/transport/tunnel.js' import { createHeartbeat, HEARTBEAT_INTERVAL_MS } from '../src/transport/heartbeat.js' import { FakeWs, FakeTimer } from './fixtures/fakes.js' describe('Heartbeat (T9, ยง4.1)', () => { it('replies PONG echoing the inbound PING token byte-exact', () => { const ws = new FakeWs() const tunnel = holdTunnel(ws) const hb = createHeartbeat(tunnel, { timer: new FakeTimer() }) const token = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]) hb.onPing(token) const frame = decodeMuxFrame(ws.sent.at(-1)!) expect(frame.header.type).toBe('pong') expect(Buffer.from(frame.payload).equals(Buffer.from(token))).toBe(true) }) it('fires onDead when no PONG arrives within the interval', () => { const ws = new FakeWs() const tunnel = holdTunnel(ws) const timer = new FakeTimer() const hb = createHeartbeat(tunnel, { timer, intervalMs: 1000 }) let dead = false hb.onDead(() => { dead = true }) hb.start() // sends first ping, arms deadline timer.advance(1000) // deadline fires, no pong seen expect(dead).toBe(true) }) it('does not fire onDead when PONG arrives in time', () => { const ws = new FakeWs() const tunnel = holdTunnel(ws) const timer = new FakeTimer() const hb = createHeartbeat(tunnel, { timer, intervalMs: 1000, genToken: () => new Uint8Array(8) }) let dead = false hb.onDead(() => { dead = true }) hb.start() hb.onPong(new Uint8Array(8)) // clears pending before the deadline timer.advance(1000) expect(dead).toBe(false) }) it('exposes the frozen 15s interval', () => { expect(HEARTBEAT_INTERVAL_MS).toBe(15_000) }) it('stop() cancels timers (no leak)', () => { const ws = new FakeWs() const hb = createHeartbeat(holdTunnel(ws), { timer: new FakeTimer(), intervalMs: 1000 }) hb.start() expect(() => hb.stop()).not.toThrow() }) })