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
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
// Global test setup: a mock of `webextension-polyfill` with an in-memory
|
||||
// storage.local, so storage.ts and the transport picker run without a browser.
|
||||
|
||||
import { beforeEach, vi } from 'vitest';
|
||||
|
||||
vi.mock('webextension-polyfill', () => {
|
||||
const store = new Map<string, unknown>();
|
||||
const local = {
|
||||
get: async (keys?: string | string[] | Record<string, unknown> | null) => {
|
||||
if (keys == null) return Object.fromEntries(store);
|
||||
const names =
|
||||
typeof keys === 'string' ? [keys] : Array.isArray(keys) ? keys : Object.keys(keys);
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const k of names) if (store.has(k)) out[k] = store.get(k);
|
||||
return out;
|
||||
},
|
||||
set: async (obj: Record<string, unknown>) => {
|
||||
for (const [k, v] of Object.entries(obj)) store.set(k, v);
|
||||
},
|
||||
remove: async (keys: string | string[]) => {
|
||||
for (const k of typeof keys === 'string' ? [keys] : keys) store.delete(k);
|
||||
},
|
||||
clear: async () => {
|
||||
store.clear();
|
||||
},
|
||||
};
|
||||
return {
|
||||
default: {
|
||||
storage: { local },
|
||||
runtime: {
|
||||
getURL: (path = '/') =>
|
||||
`moz-extension://11111111-2222-3333-4444-555555555555${path}`,
|
||||
connectNative: () => {
|
||||
throw new Error('browser.runtime.connectNative was not stubbed for this test');
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
const browser = (await import('webextension-polyfill')).default;
|
||||
await browser.storage.local.clear();
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { Backoff } from '../../src/background/transport/backoff.js';
|
||||
|
||||
describe('Backoff', () => {
|
||||
it('grows geometrically and caps, with no jitter', () => {
|
||||
const b = new Backoff({ baseMs: 500, factor: 2, maxMs: 8000, jitter: 0 });
|
||||
expect([b.next(), b.next(), b.next(), b.next(), b.next(), b.next()]).toEqual([
|
||||
500, 1000, 2000, 4000, 8000, 8000,
|
||||
]);
|
||||
});
|
||||
|
||||
it('reset() returns to the base delay', () => {
|
||||
const b = new Backoff({ baseMs: 100, factor: 3, jitter: 0 });
|
||||
b.next();
|
||||
b.next();
|
||||
expect(b.attempts).toBe(2);
|
||||
b.reset();
|
||||
expect(b.attempts).toBe(0);
|
||||
expect(b.next()).toBe(100);
|
||||
});
|
||||
|
||||
it('keeps jittered delays within +/- the jitter fraction', () => {
|
||||
const rand = [0, 0.5, 1];
|
||||
let i = 0;
|
||||
const b = new Backoff({ baseMs: 1000, factor: 1, maxMs: 1000, jitter: 0.2, random: () => rand[i++]! });
|
||||
expect(b.next()).toBe(800); // random 0 -> raw - spread
|
||||
expect(b.next()).toBe(1000); // random 0.5 -> raw
|
||||
expect(b.next()).toBe(1200); // random 1 -> raw + spread
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { candidatePorts, WS_PORT_RANGE } from '../../src/background/transport/discovery.js';
|
||||
|
||||
describe('candidatePorts', () => {
|
||||
it('covers 52000-52016 inclusive by default', () => {
|
||||
const ports = candidatePorts();
|
||||
expect(WS_PORT_RANGE).toEqual({ start: 52000, end: 52016 });
|
||||
expect(ports).toHaveLength(17);
|
||||
expect(ports[0]).toBe(52000);
|
||||
expect(ports.at(-1)).toBe(52016);
|
||||
});
|
||||
|
||||
it('tries the last-known-good port first, then the rest ascending, with no duplicate', () => {
|
||||
const ports = candidatePorts(WS_PORT_RANGE, 52009);
|
||||
expect(ports[0]).toBe(52009);
|
||||
expect(ports).toHaveLength(17);
|
||||
expect(ports.filter((p) => p === 52009)).toHaveLength(1);
|
||||
expect(ports.slice(1)).toEqual([...ports.slice(1)].sort((a, b) => a - b));
|
||||
});
|
||||
|
||||
it('ignores a preferred port outside the range', () => {
|
||||
expect(candidatePorts(WS_PORT_RANGE, 40000)).toEqual(candidatePorts());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
// Test doubles for the transport suite: a real loopback JSON-RPC server for the
|
||||
// WebSocket transport, and a scriptable fake port for the native transport.
|
||||
|
||||
import { WebSocketServer, type WebSocket } from 'ws';
|
||||
|
||||
import { PROTOCOL_VERSION } from '../../src/shared/protocol/index.js';
|
||||
import type { NativePort } from '../../src/background/transport/native.js';
|
||||
|
||||
// --- a loopback veloxd, just enough of one ------------------------------------------
|
||||
|
||||
export interface FakeDaemonOptions {
|
||||
port: number;
|
||||
/** Token the daemon will accept on session.hello. null = accept none (forces pairing). */
|
||||
acceptToken?: string | null;
|
||||
/** session.hello answers -32001 before anything else. */
|
||||
versionMismatch?: boolean;
|
||||
/** session.pair answers this error instead of issuing a token. */
|
||||
pairError?: { code: number; message: string; data?: unknown };
|
||||
daemonVersion?: string;
|
||||
capabilities?: string[];
|
||||
/** Don't answer these methods at all, to exercise client timeouts. */
|
||||
blackhole?: string[];
|
||||
}
|
||||
|
||||
export class FakeDaemon {
|
||||
readonly wss: WebSocketServer;
|
||||
readonly sockets = new Set<WebSocket>();
|
||||
helloCount = 0;
|
||||
pairCount = 0;
|
||||
readonly seen: Array<{ method: string; params: unknown; token?: unknown }> = [];
|
||||
private issued = new Set<string>();
|
||||
private nextToken = 'tok-issued-1';
|
||||
|
||||
constructor(private readonly opts: FakeDaemonOptions) {
|
||||
if (opts.acceptToken) this.issued.add(opts.acceptToken);
|
||||
this.wss = new WebSocketServer({ host: '127.0.0.1', port: opts.port });
|
||||
this.wss.on('connection', (ws) => {
|
||||
this.sockets.add(ws);
|
||||
ws.on('close', () => this.sockets.delete(ws));
|
||||
ws.on('message', (raw) => this.onMessage(ws, String(raw)));
|
||||
});
|
||||
}
|
||||
|
||||
static async start(opts: FakeDaemonOptions): Promise<FakeDaemon> {
|
||||
const d = new FakeDaemon(opts);
|
||||
await new Promise<void>((r) => d.wss.once('listening', () => r()));
|
||||
return d;
|
||||
}
|
||||
|
||||
private send(ws: WebSocket, obj: unknown): void {
|
||||
if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
private onMessage(ws: WebSocket, raw: string): void {
|
||||
let req: { id?: number; method?: string; params?: Record<string, unknown> };
|
||||
try {
|
||||
req = JSON.parse(raw);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const { id, method, params = {} } = req;
|
||||
if (typeof method !== 'string') return;
|
||||
this.seen.push({ method, params, token: params['token'] });
|
||||
|
||||
if (this.opts.blackhole?.includes(method)) return;
|
||||
|
||||
const ok = (result: unknown): void => this.send(ws, { jsonrpc: '2.0', id, result });
|
||||
const err = (code: number, message: string, data?: unknown): void =>
|
||||
this.send(ws, { jsonrpc: '2.0', id, error: data === undefined ? { code, message } : { code, message, data } });
|
||||
|
||||
switch (method) {
|
||||
case 'session.hello': {
|
||||
this.helloCount += 1;
|
||||
if (this.opts.versionMismatch) {
|
||||
err(-32001, 'protocol major version mismatch', { expected: PROTOCOL_VERSION, actual: '9.0.0' });
|
||||
return;
|
||||
}
|
||||
const token = params['token'];
|
||||
if (typeof token !== 'string' || !this.issued.has(token)) {
|
||||
err(-32002, 'not paired: call session.pair first');
|
||||
return;
|
||||
}
|
||||
ok({
|
||||
daemonVersion: this.opts.daemonVersion ?? '1.2.3-fake',
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
capabilities: this.opts.capabilities ?? ['media', 'grabber'],
|
||||
sessionId: 'sess-1',
|
||||
transport: 'ws',
|
||||
});
|
||||
return;
|
||||
}
|
||||
case 'session.pair': {
|
||||
this.pairCount += 1;
|
||||
if (this.opts.pairError) {
|
||||
err(this.opts.pairError.code, this.opts.pairError.message, this.opts.pairError.data);
|
||||
return;
|
||||
}
|
||||
const t = this.nextToken;
|
||||
this.issued.add(t);
|
||||
ok({ token: t, expiresAt: null });
|
||||
return;
|
||||
}
|
||||
case 'session.subscribe':
|
||||
ok({ ok: true, events: (params['events'] as string[]) ?? [] });
|
||||
return;
|
||||
case 'download.list':
|
||||
ok({ total: 0, items: [] });
|
||||
return;
|
||||
default:
|
||||
err(-32601, 'no such method');
|
||||
}
|
||||
}
|
||||
|
||||
/** Push a server-to-client notification to every open socket. */
|
||||
notify(method: string, paramsObj: unknown): void {
|
||||
for (const ws of this.sockets) this.send(ws, { jsonrpc: '2.0', method, params: paramsObj });
|
||||
}
|
||||
|
||||
/** Hard-drop every current connection, to exercise reconnect. */
|
||||
dropAll(): void {
|
||||
for (const ws of this.sockets) ws.terminate();
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
for (const ws of this.sockets) ws.terminate();
|
||||
await new Promise<void>((r) => this.wss.close(() => r()));
|
||||
}
|
||||
}
|
||||
|
||||
/** A free-ish port inside a private band; each call is distinct within a run. */
|
||||
let portCursor = 53100 + Math.floor(Math.random() * 300);
|
||||
export function nextPort(): number {
|
||||
portCursor += 1;
|
||||
return portCursor;
|
||||
}
|
||||
|
||||
// --- scriptable native port -------------------------------------------------------
|
||||
|
||||
type Listener = (arg: unknown) => void;
|
||||
|
||||
export class FakeNativePort implements NativePort {
|
||||
error: { message: string } | null = null;
|
||||
readonly sent: unknown[] = [];
|
||||
private msgListeners = new Set<Listener>();
|
||||
private discListeners = new Set<Listener>();
|
||||
disconnected = false;
|
||||
|
||||
onMessage = {
|
||||
addListener: (cb: (m: unknown) => void) => this.msgListeners.add(cb as Listener),
|
||||
removeListener: (cb: (m: unknown) => void) => this.msgListeners.delete(cb as Listener),
|
||||
};
|
||||
onDisconnect = {
|
||||
addListener: (cb: (p?: unknown) => void) => this.discListeners.add(cb as Listener),
|
||||
removeListener: (cb: (p?: unknown) => void) => this.discListeners.delete(cb as Listener),
|
||||
};
|
||||
|
||||
/** Auto-answers session.hello unless `autoHello` is false. */
|
||||
constructor(private readonly opts: { autoHello?: boolean; helloError?: { code: number; message: string }; helloResult?: unknown } = {}) {}
|
||||
|
||||
postMessage(message: unknown): void {
|
||||
this.sent.push(message);
|
||||
const req = message as { id?: number; method?: string };
|
||||
if (req.method === 'session.hello' && this.opts.autoHello !== false && !this.disconnected) {
|
||||
queueMicrotask(() => {
|
||||
if (this.disconnected) return;
|
||||
if (this.opts.helloError) {
|
||||
this.emitMessage({ jsonrpc: '2.0', id: req.id, error: this.opts.helloError });
|
||||
} else {
|
||||
this.emitMessage({
|
||||
jsonrpc: '2.0',
|
||||
id: req.id,
|
||||
result: this.opts.helloResult ?? {
|
||||
daemonVersion: '1.2.3-nm',
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
capabilities: ['media'],
|
||||
sessionId: 'nm-sess',
|
||||
transport: 'uds',
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.disconnected = true;
|
||||
}
|
||||
|
||||
emitMessage(msg: unknown): void {
|
||||
for (const cb of this.msgListeners) cb(msg);
|
||||
}
|
||||
|
||||
fireDisconnect(errorMessage?: string): void {
|
||||
this.disconnected = true;
|
||||
if (errorMessage) this.error = { message: errorMessage };
|
||||
for (const cb of this.discListeners) cb(this);
|
||||
}
|
||||
}
|
||||
|
||||
export const tick = (ms = 0): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
||||
@@ -0,0 +1,109 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { WebSocket as WsClient } from 'ws';
|
||||
import browser from 'webextension-polyfill';
|
||||
|
||||
import { createTransport } from '../../src/background/transport/index.js';
|
||||
import type { WebSocketCtor } from '../../src/background/transport/websocket.js';
|
||||
import type { VeloxTransport } from '../../src/background/transport/types.js';
|
||||
import { FakeDaemon, FakeNativePort, nextPort, tick } from './helpers.js';
|
||||
|
||||
const CTOR = WsClient as unknown as WebSocketCtor;
|
||||
|
||||
let daemon: FakeDaemon | undefined;
|
||||
let transport: VeloxTransport | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
transport?.disconnect();
|
||||
transport = undefined;
|
||||
await daemon?.stop();
|
||||
daemon = undefined;
|
||||
});
|
||||
|
||||
describe('createTransport (runtime picker)', () => {
|
||||
it('override "ws" uses the WebSocket transport and never touches native messaging', async () => {
|
||||
const port = nextPort();
|
||||
daemon = await FakeDaemon.start({ port, acceptToken: null });
|
||||
let nativeTried = false;
|
||||
|
||||
transport = await createTransport({
|
||||
override: 'ws',
|
||||
webSocketCtor: CTOR,
|
||||
portRange: { start: port, end: port },
|
||||
connectNative: () => {
|
||||
nativeTried = true;
|
||||
return new FakeNativePort();
|
||||
},
|
||||
backoff: { baseMs: 20, jitter: 0 },
|
||||
});
|
||||
|
||||
expect(transport.kind).toBe('ws');
|
||||
expect(transport.state).toBe('connected');
|
||||
expect(nativeTried).toBe(false);
|
||||
});
|
||||
|
||||
it('"auto" prefers native messaging when its handshake succeeds', async () => {
|
||||
transport = await createTransport({
|
||||
override: 'auto',
|
||||
connectNative: () => new FakeNativePort(),
|
||||
webSocketCtor: CTOR,
|
||||
portRange: { start: nextPort(), end: nextPort() },
|
||||
});
|
||||
expect(transport.kind).toBe('uds');
|
||||
expect(transport.state).toBe('connected');
|
||||
});
|
||||
|
||||
it('"auto" falls back to WebSocket when the native host is not installed', async () => {
|
||||
const port = nextPort();
|
||||
daemon = await FakeDaemon.start({ port, acceptToken: null });
|
||||
|
||||
transport = await createTransport({
|
||||
override: 'auto',
|
||||
webSocketCtor: CTOR,
|
||||
portRange: { start: port, end: port },
|
||||
connectNative: () => {
|
||||
const fp = new FakeNativePort({ autoHello: false });
|
||||
queueMicrotask(() => fp.fireDisconnect('No such native application com.velox.host'));
|
||||
return fp;
|
||||
},
|
||||
backoff: { baseMs: 20, jitter: 0 },
|
||||
});
|
||||
|
||||
expect(transport.kind).toBe('ws');
|
||||
expect(transport.state).toBe('connected');
|
||||
});
|
||||
|
||||
it('reads the override from storage when none is passed', async () => {
|
||||
await browser.storage.local.set({ 'velox.transportOverride': 'ws' });
|
||||
const port = nextPort();
|
||||
daemon = await FakeDaemon.start({ port, acceptToken: null });
|
||||
let nativeTried = false;
|
||||
|
||||
transport = await createTransport({
|
||||
webSocketCtor: CTOR,
|
||||
portRange: { start: port, end: port },
|
||||
connectNative: () => {
|
||||
nativeTried = true;
|
||||
return new FakeNativePort();
|
||||
},
|
||||
backoff: { baseMs: 20, jitter: 0 },
|
||||
});
|
||||
|
||||
expect(transport.kind).toBe('ws');
|
||||
expect(nativeTried).toBe(false);
|
||||
});
|
||||
|
||||
it('override "uds" surfaces a native-host failure to the caller', async () => {
|
||||
await expect(
|
||||
createTransport({
|
||||
override: 'uds',
|
||||
connectNative: () => {
|
||||
const fp = new FakeNativePort({ autoHello: false });
|
||||
queueMicrotask(() => fp.fireDisconnect('No such native application com.velox.host'));
|
||||
return fp;
|
||||
},
|
||||
backoff: { baseMs: 10 },
|
||||
}),
|
||||
).rejects.toThrow(/not installed/);
|
||||
await tick(30);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
RpcError,
|
||||
RpcTimeoutError,
|
||||
TransportClosedError,
|
||||
} from '../../src/background/transport/types.js';
|
||||
import { NativeTransport } from '../../src/background/transport/native.js';
|
||||
import { FakeNativePort, tick } from './helpers.js';
|
||||
|
||||
let transport: NativeTransport | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
transport?.disconnect();
|
||||
transport = undefined;
|
||||
});
|
||||
|
||||
describe('NativeTransport', () => {
|
||||
it('completes session.hello over the port and connects', async () => {
|
||||
const port = new FakeNativePort();
|
||||
transport = new NativeTransport({ connectNative: () => port });
|
||||
|
||||
await transport.connect();
|
||||
|
||||
expect(transport.state).toBe('connected');
|
||||
expect(transport.kind).toBe('uds');
|
||||
expect(transport.status.daemonVersion).toBe('1.2.3-nm');
|
||||
expect(transport.status.capabilities).toEqual(['media']);
|
||||
expect((port.sent[0] as { method: string }).method).toBe('session.hello');
|
||||
expect((port.sent[0] as { params: { token: unknown } }).params.token).toBeNull();
|
||||
});
|
||||
|
||||
it('marks the transport fatal when the host is not installed', async () => {
|
||||
const port = new FakeNativePort({ autoHello: false });
|
||||
transport = new NativeTransport({ connectNative: () => port, backoff: { baseMs: 10 } });
|
||||
|
||||
const p = transport.connect();
|
||||
queueMicrotask(() => port.fireDisconnect('No such native application com.velox.host'));
|
||||
await expect(p).rejects.toBeInstanceOf(RpcError);
|
||||
|
||||
expect(transport.status.fatal).toMatch(/not installed/);
|
||||
// fatal => no reconnect
|
||||
const calls = [] as unknown[];
|
||||
const t2 = new NativeTransport({
|
||||
connectNative: () => {
|
||||
calls.push(1);
|
||||
const fp = new FakeNativePort({ autoHello: false });
|
||||
queueMicrotask(() => fp.fireDisconnect('No such native application'));
|
||||
return fp;
|
||||
},
|
||||
backoff: { baseMs: 10 },
|
||||
});
|
||||
await t2.connect().catch(() => undefined);
|
||||
await tick(60);
|
||||
expect(calls).toHaveLength(1);
|
||||
t2.disconnect();
|
||||
});
|
||||
|
||||
it('reconnects when an installed host drops during the handshake', async () => {
|
||||
let attempts = 0;
|
||||
transport = new NativeTransport({
|
||||
connectNative: () => {
|
||||
attempts += 1;
|
||||
const fp = new FakeNativePort({ autoHello: attempts > 1 });
|
||||
if (attempts === 1) queueMicrotask(() => fp.fireDisconnect('pipe closed'));
|
||||
return fp;
|
||||
},
|
||||
backoff: { baseMs: 10, factor: 1, jitter: 0 },
|
||||
});
|
||||
|
||||
await expect(transport.connect()).rejects.toBeInstanceOf(TransportClosedError);
|
||||
for (let i = 0; i < 40 && transport.state !== 'connected'; i += 1) await tick(10);
|
||||
expect(attempts).toBeGreaterThanOrEqual(2);
|
||||
expect(transport.state).toBe('connected');
|
||||
});
|
||||
|
||||
it('treats a protocol-major mismatch as fatal', async () => {
|
||||
const port = new FakeNativePort({ helloError: { code: -32001, message: 'daemon speaks 9.x' } });
|
||||
transport = new NativeTransport({ connectNative: () => port });
|
||||
|
||||
await expect(transport.connect()).rejects.toBeInstanceOf(RpcError);
|
||||
expect(transport.status.fatal).toMatch(/protocol mismatch/);
|
||||
});
|
||||
|
||||
it('forwards calls and fails them on a drop', async () => {
|
||||
const port = new FakeNativePort();
|
||||
transport = new NativeTransport({
|
||||
connectNative: () => port,
|
||||
backoff: { baseMs: 10_000 },
|
||||
});
|
||||
await transport.connect();
|
||||
|
||||
const pending = transport.call('download.list', { filter: null }, { timeoutMs: 5000 });
|
||||
expect((port.sent.at(-1) as { method: string }).method).toBe('download.list');
|
||||
port.fireDisconnect('host exited');
|
||||
await expect(pending).rejects.toBeInstanceOf(TransportClosedError);
|
||||
expect(transport.state).toBe('disconnected');
|
||||
});
|
||||
|
||||
it('times a call out when the host goes quiet', async () => {
|
||||
const port = new FakeNativePort();
|
||||
transport = new NativeTransport({ connectNative: () => port });
|
||||
await transport.connect();
|
||||
await expect(
|
||||
transport.call('download.list', { filter: null }, { timeoutMs: 30 }),
|
||||
).rejects.toBeInstanceOf(RpcTimeoutError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { WebSocket as WsClient } from 'ws';
|
||||
|
||||
import {
|
||||
MethodNotAllowedError,
|
||||
RpcError,
|
||||
RpcTimeoutError,
|
||||
TransportClosedError,
|
||||
} from '../../src/background/transport/types.js';
|
||||
import {
|
||||
WebSocketTransport,
|
||||
type WebSocketCtor,
|
||||
type WebSocketTransportDeps,
|
||||
} from '../../src/background/transport/websocket.js';
|
||||
import { FakeDaemon, nextPort, tick } from './helpers.js';
|
||||
|
||||
const CTOR = WsClient as unknown as WebSocketCtor;
|
||||
|
||||
interface DepsHandle {
|
||||
deps: WebSocketTransportDeps;
|
||||
store: { token: string | null; port: number | null };
|
||||
}
|
||||
|
||||
function memDeps(init: Partial<{ token: string | null }> = {}): DepsHandle {
|
||||
const store = { token: init.token ?? null, port: null as number | null };
|
||||
return {
|
||||
store,
|
||||
deps: {
|
||||
getToken: async () => store.token,
|
||||
setToken: async (t) => {
|
||||
store.token = t;
|
||||
},
|
||||
getCachedPort: async () => store.port,
|
||||
setCachedPort: async (p) => {
|
||||
store.port = p;
|
||||
},
|
||||
extensionId: '11111111-2222-3333-4444-555555555555',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeTransport(port: number, h: DepsHandle, extra: Partial<WebSocketTransportDeps> = {}) {
|
||||
return new WebSocketTransport({
|
||||
...h.deps,
|
||||
...extra,
|
||||
webSocketCtor: CTOR,
|
||||
portRange: { start: port, end: port },
|
||||
openTimeoutMs: 500,
|
||||
backoff: { baseMs: 20, factor: 2, maxMs: 120, jitter: 0 },
|
||||
});
|
||||
}
|
||||
|
||||
let daemon: FakeDaemon | undefined;
|
||||
let transport: WebSocketTransport | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
transport?.disconnect();
|
||||
transport = undefined;
|
||||
await daemon?.stop();
|
||||
daemon = undefined;
|
||||
});
|
||||
|
||||
describe('WebSocketTransport', () => {
|
||||
it('discovers the port, completes session.hello, and reports daemon info', async () => {
|
||||
const port = nextPort();
|
||||
daemon = await FakeDaemon.start({ port, acceptToken: 'good-token' });
|
||||
const h = memDeps({ token: 'good-token' });
|
||||
transport = makeTransport(port, h);
|
||||
|
||||
await transport.connect();
|
||||
|
||||
expect(transport.state).toBe('connected');
|
||||
expect(transport.status.daemonVersion).toBe('1.2.3-fake');
|
||||
expect(transport.status.capabilities).toEqual(['media', 'grabber']);
|
||||
expect(transport.status.sessionId).toBe('sess-1');
|
||||
expect(h.store.port).toBe(port); // cached for next time
|
||||
expect(daemon.pairCount).toBe(0); // had a valid token, no pairing needed
|
||||
});
|
||||
|
||||
it('pairs when unpaired, stores the token, and re-hellos with it', async () => {
|
||||
const port = nextPort();
|
||||
daemon = await FakeDaemon.start({ port, acceptToken: null });
|
||||
const h = memDeps();
|
||||
transport = makeTransport(port, h);
|
||||
|
||||
await transport.connect();
|
||||
|
||||
expect(transport.state).toBe('connected');
|
||||
expect(daemon.pairCount).toBe(1);
|
||||
expect(h.store.token).toBe('tok-issued-1'); // persisted
|
||||
expect(daemon.helloCount).toBe(2); // once unpaired, once with the fresh token
|
||||
const lastHello = [...daemon.seen].reverse().find((s) => s.method === 'session.hello');
|
||||
expect(lastHello?.token).toBe('tok-issued-1');
|
||||
});
|
||||
|
||||
it('with autoPair off, a wrong token surfaces needsPairing and does NOT retry', async () => {
|
||||
const port = nextPort();
|
||||
daemon = await FakeDaemon.start({ port, acceptToken: 'the-real-one' });
|
||||
const h = memDeps({ token: 'stale-token' });
|
||||
transport = makeTransport(port, h, { autoPair: false });
|
||||
|
||||
await expect(transport.connect()).rejects.toBeInstanceOf(RpcError);
|
||||
expect(transport.state).toBe('disconnected');
|
||||
expect(transport.status.needsPairing).toBe(true);
|
||||
|
||||
const helloAfterReject = daemon.helloCount;
|
||||
await tick(150); // longer than the (tiny) backoff would be
|
||||
expect(daemon.helloCount).toBe(helloAfterReject); // no reconnect storm
|
||||
});
|
||||
|
||||
it('treats a pairing rate-limit as needsPairing with a retry hint, no retry', async () => {
|
||||
const port = nextPort();
|
||||
daemon = await FakeDaemon.start({
|
||||
port,
|
||||
acceptToken: null,
|
||||
pairError: { code: -32014, message: 'locked out', data: { retryAfterSec: 60 } },
|
||||
});
|
||||
transport = makeTransport(port, memDeps());
|
||||
|
||||
await expect(transport.connect()).rejects.toBeInstanceOf(RpcError);
|
||||
expect(transport.status.needsPairing).toBe(true);
|
||||
expect(transport.status.retryAfterSec).toBe(60);
|
||||
const n = daemon.helloCount;
|
||||
await tick(150);
|
||||
expect(daemon.helloCount).toBe(n);
|
||||
});
|
||||
|
||||
it('treats a protocol-major mismatch as fatal and stops retrying', async () => {
|
||||
const port = nextPort();
|
||||
daemon = await FakeDaemon.start({ port, versionMismatch: true });
|
||||
transport = makeTransport(port, memDeps({ token: 'x' }));
|
||||
|
||||
await expect(transport.connect()).rejects.toBeInstanceOf(RpcError);
|
||||
expect(transport.status.fatal).toMatch(/protocol mismatch/);
|
||||
const n = daemon.helloCount;
|
||||
await tick(150);
|
||||
expect(daemon.helloCount).toBe(n);
|
||||
});
|
||||
|
||||
it('forwards a call and returns the result', async () => {
|
||||
const port = nextPort();
|
||||
daemon = await FakeDaemon.start({ port, acceptToken: 't' });
|
||||
transport = makeTransport(port, memDeps({ token: 't' }));
|
||||
await transport.connect();
|
||||
|
||||
await expect(transport.call('download.list', { filter: null })).resolves.toEqual({
|
||||
total: 0,
|
||||
items: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a call with RpcTimeoutError when the daemon never answers', async () => {
|
||||
const port = nextPort();
|
||||
daemon = await FakeDaemon.start({ port, acceptToken: 't', blackhole: ['download.list'] });
|
||||
transport = makeTransport(port, memDeps({ token: 't' }));
|
||||
await transport.connect();
|
||||
|
||||
await expect(
|
||||
transport.call('download.list', { filter: null }, { timeoutMs: 40 }),
|
||||
).rejects.toBeInstanceOf(RpcTimeoutError);
|
||||
});
|
||||
|
||||
it('rejects a uds-only method locally, before it hits the wire', async () => {
|
||||
const port = nextPort();
|
||||
daemon = await FakeDaemon.start({ port, acceptToken: 't' });
|
||||
transport = makeTransport(port, memDeps({ token: 't' }));
|
||||
await transport.connect();
|
||||
|
||||
const before = daemon.seen.length;
|
||||
await expect(
|
||||
transport.call('settings.set', { values: { 'capture.enabled': false } }),
|
||||
).rejects.toBeInstanceOf(MethodNotAllowedError);
|
||||
expect(daemon.seen.length).toBe(before);
|
||||
});
|
||||
|
||||
it('delivers server notifications to on() listeners', async () => {
|
||||
const port = nextPort();
|
||||
daemon = await FakeDaemon.start({ port, acceptToken: 't' });
|
||||
transport = makeTransport(port, memDeps({ token: 't' }));
|
||||
await transport.connect();
|
||||
|
||||
const seen: unknown[] = [];
|
||||
transport.on('event.task.progress', (p) => seen.push(p));
|
||||
daemon.notify('event.task.progress', { tasks: [{ taskId: 'a', downloadedBytes: 1 }] });
|
||||
await tick(20);
|
||||
expect(seen).toEqual([{ tasks: [{ taskId: 'a', downloadedBytes: 1 }] }]);
|
||||
});
|
||||
|
||||
it('fails in-flight calls on a drop and then reconnects on its own', async () => {
|
||||
const port = nextPort();
|
||||
daemon = await FakeDaemon.start({ port, acceptToken: 't', blackhole: ['download.list'] });
|
||||
transport = makeTransport(port, memDeps({ token: 't' }));
|
||||
await transport.connect();
|
||||
|
||||
const pending = transport.call('download.list', { filter: null }, { timeoutMs: 5000 });
|
||||
daemon.dropAll();
|
||||
await expect(pending).rejects.toBeInstanceOf(TransportClosedError);
|
||||
expect(transport.state).toBe('disconnected');
|
||||
|
||||
// backoff base is 20ms; give it room to come back
|
||||
for (let i = 0; i < 40 && transport.state !== 'connected'; i += 1) await tick(15);
|
||||
expect(transport.state).toBe('connected');
|
||||
});
|
||||
|
||||
it('disconnect() stops all reconnection', async () => {
|
||||
const port = nextPort();
|
||||
daemon = await FakeDaemon.start({ port, acceptToken: 't' });
|
||||
transport = makeTransport(port, memDeps({ token: 't' }));
|
||||
await transport.connect();
|
||||
|
||||
transport.disconnect();
|
||||
daemon.dropAll();
|
||||
const n = daemon.helloCount;
|
||||
await tick(150);
|
||||
expect(daemon.helloCount).toBe(n);
|
||||
expect(transport.state).toBe('disconnected');
|
||||
});
|
||||
|
||||
it('when nothing answers, connect() rejects but keeps retrying in the background', async () => {
|
||||
const port = nextPort(); // no daemon on it
|
||||
const h = memDeps({ token: 't' });
|
||||
transport = makeTransport(port, h);
|
||||
|
||||
await expect(transport.connect()).rejects.toBeInstanceOf(TransportClosedError);
|
||||
expect(transport.state).toBe('disconnected');
|
||||
expect(transport.status.fatal).toBeNull();
|
||||
expect(transport.status.needsPairing).toBe(false);
|
||||
|
||||
// now bring a daemon up on that port; the transport should find it
|
||||
daemon = await FakeDaemon.start({ port, acceptToken: 't' });
|
||||
for (let i = 0; i < 60 && transport.state !== 'connected'; i += 1) await tick(15);
|
||||
expect(transport.state).toBe('connected');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user