Files
vdm/extension/tests/transport/index.test.ts
T
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

110 lines
3.5 KiB
TypeScript

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