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
94 lines
3.8 KiB
TypeScript
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);
|
|
});
|
|
});
|