Files
samiandClaude Sonnet 5 90e2580b05 ext: S1 native-messaging spike (ADR 0003) + WebSocket transport
Spike S1 — run on the target machine through real snap confinement
(apparmor snap.firefox.firefox enforced; web-ext's direct-exec of the inner
binary bypasses it, so runs were forced through `snap run firefox`):

  - manifest in ~/.mozilla/native-messaging-hosts/  -> WORKS; host launched
    unconfined with real $HOME and real $XDG_RUNTIME_DIR, bound a socket in
    the real /run/user/<uid>. Corroborated by the machine's 1Password host.
  - ~/snap/firefox/common/.mozilla/native-messaging-hosts/  -> not read
  - /usr/lib/mozilla/native-messaging-hosts/               -> not read
  - flatpak path                                           -> N/A (snap Firefox)

Decision: WebSocket stays the default; native messaging is an opportunistic
upgrade taken only when its handshake succeeds. docs/05 §4 corrected in this
commit to point the snap manifest at ~/.mozilla and mark /usr/lib as
deb/tarball-only. ADR carries a self-contained reproduction; the scratch
harness has been removed.

transport/ (build order item 1):
  - types.ts        VeloxTransport interface + error taxonomy
  - rpc.ts          JSON-RPC id correlation, per-call deadline, AbortSignal
  - backoff.ts      exponential backoff with jitter
  - discovery.ts    52000-52016 scan ordering (last-good port first)
  - websocket.ts    scan -> session.hello -> auto-pair (token in
                    storage.local) -> reconnect; -32001 fatal, refused/
                    rate-limited pairing latches needsPairing (no retry storm);
                    a mid-handshake drop aborts hello immediately
  - native.ts       connectNative(); distinguishes "not installed" (fatal,
                    lets the picker fall through) from a crash (reconnect)
  - index.ts        createTransport() runtime picker + persisted Options override

Toolchain: package.json / tsconfig (strict) / vitest; webextension-polyfill
mocked. 38 tests, incl. the WS suite against a real loopback ws server.
No manifest.json yet, so CI's extension-lint guard stays a no-op.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Y9RU58hD1BuwP82DySUHk
2026-09-10 01:17:43 +04:00

94 lines
3.8 KiB
TypeScript

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);
});
});