import { describe, expect, it, vi } from 'vitest'; import { RpcConnection } from '../../src/background/transport/rpc.js'; import { RpcError, RpcTimeoutError } from '../../src/background/transport/types.js'; function makeConn() { const sent: Array<{ id: number; method: string; params: unknown }> = []; const conn = new RpcConnection((frame) => sent.push(frame)); return { conn, sent }; } describe('RpcConnection', () => { it('resolves a call when a matching id comes back', async () => { const { conn, sent } = makeConn(); const p = conn.request('download.list', { filter: null }, 1000); expect(sent[0]).toMatchObject({ id: 1, method: 'download.list' }); conn.handleInbound({ jsonrpc: '2.0', id: 1, result: { total: 0, items: [] } }); await expect(p).resolves.toEqual({ total: 0, items: [] }); expect(conn.inFlight).toBe(0); }); it('rejects with RpcError on an error frame, carrying code and data', async () => { const { conn } = makeConn(); const p = conn.request('download.get', { taskId: 'x' }, 1000); conn.handleInbound({ jsonrpc: '2.0', id: 1, error: { code: -32010, message: 'no such task', data: { taskId: 'x' } } }); await expect(p).rejects.toBeInstanceOf(RpcError); await p.catch((e: RpcError) => { expect(e.code).toBe(-32010); expect(e.data).toEqual({ taskId: 'x' }); }); }); it('matches replies to the right call when they arrive out of order', async () => { const { conn } = makeConn(); const a = conn.request('download.list', {}, 1000); const b = conn.request('queue.list', {}, 1000); conn.handleInbound({ jsonrpc: '2.0', id: 2, result: 'B' }); conn.handleInbound({ jsonrpc: '2.0', id: 1, result: 'A' }); await expect(a).resolves.toBe('A'); await expect(b).resolves.toBe('B'); }); it('ignores a reply for an unknown id and returns it as non-notification', () => { const { conn } = makeConn(); expect(conn.handleInbound({ jsonrpc: '2.0', id: 999, result: 1 })).toBeNull(); }); it('returns notifications (no id) for the caller to route', () => { const { conn } = makeConn(); const note = conn.handleInbound({ jsonrpc: '2.0', method: 'event.task.progress', params: { tasks: [] } }); expect(note).toEqual({ jsonrpc: '2.0', method: 'event.task.progress', params: { tasks: [] } }); }); it('times out and then ignores the late reply', async () => { vi.useFakeTimers(); const { conn } = makeConn(); const p = conn.request('capture.offer', {}, 750); const assertion = expect(p).rejects.toBeInstanceOf(RpcTimeoutError); await vi.advanceTimersByTimeAsync(751); await assertion; // a reply that shows up after the deadline must not throw or resolve anything expect(conn.handleInbound({ jsonrpc: '2.0', id: 1, result: 'too late' })).toBeNull(); vi.useRealTimers(); }); it('failAll rejects every pending call with the given reason', async () => { const { conn } = makeConn(); const a = conn.request('download.list', {}, 1000); const b = conn.request('queue.list', {}, 1000); const reason = new Error('socket dropped'); conn.failAll(reason); await expect(a).rejects.toBe(reason); await expect(b).rejects.toBe(reason); expect(conn.inFlight).toBe(0); }); it('rejects when the AbortSignal fires and drops the pending entry', async () => { const { conn } = makeConn(); const ac = new AbortController(); const p = conn.request('download.probe', {}, 30_000, ac.signal); ac.abort(new Error('cancelled')); await expect(p).rejects.toThrow('cancelled'); expect(conn.inFlight).toBe(0); }); it('rejects synchronously-ish when the send callback throws', async () => { const conn = new RpcConnection(() => { throw new Error('not open'); }); await expect(conn.request('download.list', {}, 1000)).rejects.toThrow('not open'); expect(conn.inFlight).toBe(0); }); });