import { afterEach, describe, expect, mock, test } from 'bun:test'; import { encodePairingConnectionPayload, buildPairingConnectionPayload } from '@/lib/connectionPayload'; import { parseConnectionPayload, scanConnectionQr } from './mobileQrScan'; const hostEncPubJwk = { kty: 'EC', crv: 'P-256', x: 'eHhY', y: 'eVlZ' } as const; describe('parseConnectionPayload', () => { test('parses bare http(s) URLs', () => { expect(parseConnectionPayload('https://oc.example')).toEqual({ url: 'https://oc.example' }); expect(parseConnectionPayload(' http://192.168.1.10:2606 ')).toEqual({ url: 'http://192.168.1.10:2606' }); }); test('parses a v2 pairing link with direct + relay candidates', () => { const url = encodePairingConnectionPayload(buildPairingConnectionPayload({ pairingId: 'pair_abc', secret: 'one-time', label: 'My Desktop', candidates: [ { type: 'lan', url: 'http://192.168.1.20:4096', priority: 10 }, { type: 'relay', relayUrl: 'wss://relay.example/ws', serverId: 'srv_1', hostEncPubJwk, priority: 30 }, ], })); const payload = parseConnectionPayload(url); if (!payload || !('pairing' in payload)) throw new Error('expected a pairing payload'); expect(payload.pairing.pairingId).toBe('pair_abc'); expect(payload.pairing.secret).toBe('one-time'); expect(payload.pairing.candidates.map((c) => c.type)).toEqual(['lan', 'relay']); }); test('rejects non-connection and legacy/relay-offer payloads', () => { expect(parseConnectionPayload('')).toBeNull(); expect(parseConnectionPayload('hello world')).toBeNull(); expect(parseConnectionPayload('openchamber://connect')).toBeNull(); expect(parseConnectionPayload('openchamber://session/abc')).toBeNull(); // Legacy v1 direct links are no longer accepted. expect(parseConnectionPayload('openchamber://connect?v=1&server=http%3A%2F%2F192.168.1.10%3A2606&token=tok')).toBeNull(); // Legacy relay-offer format (mode=relay + fragment) is no longer accepted. expect(parseConnectionPayload('openchamber://connect?v=1&mode=relay#offer=eyJ2IjoxfQ')).toBeNull(); }); }); describe('scanConnectionQr on Android', () => { const originalWindow = globalThis.window; afterEach(() => { Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow }); }); test('uses the bundled startScan flow and cleans up after a result', async () => { const listeners = new Map }) => void>(); let removeCalls = 0; let stopCalls = 0; let scanCalls = 0; let startOptions: unknown; const remove = () => { removeCalls += 1; }; const stopScan = async () => { stopCalls += 1; }; const scan = async () => { scanCalls += 1; return { barcodes: [] }; }; const startScan = async (options?: unknown) => { startOptions = options; listeners.get('barcodesScanned')?.({ barcodes: [{ rawValue: 'https://oc.example' }] }); }; const plugin = { requestPermissions: mock(async () => ({ camera: 'granted' })), scan, startScan, stopScan, addListener: mock((event: string, callback: (info: { barcodes?: Array<{ rawValue?: string }> }) => void) => { listeners.set(event, callback); return Promise.resolve({ remove }); }), }; Object.defineProperty(globalThis, 'window', { configurable: true, value: { Capacitor: { getPlatform: () => 'android', Plugins: { BarcodeScanner: plugin } } }, }); expect(await scanConnectionQr()).toEqual({ status: 'ok', url: 'https://oc.example' }); expect(startOptions).toEqual({ formats: ['QR_CODE'] }); expect(scanCalls).toBe(0); expect(stopCalls).toBe(1); expect(removeCalls).toBe(2); }); test('stops scanning when the caller aborts', async () => { let stopCalls = 0; const stopScan = async () => { stopCalls += 1; }; const plugin = { requestPermissions: mock(async () => ({ camera: 'granted' })), startScan: mock(async () => undefined), stopScan, addListener: mock(async () => ({ remove: mock(() => undefined) })), }; Object.defineProperty(globalThis, 'window', { configurable: true, value: { Capacitor: { getPlatform: () => 'android', Plugins: { BarcodeScanner: plugin } } }, }); const controller = new AbortController(); const result = scanConnectionQr({ signal: controller.signal }); await Promise.resolve(); controller.abort(); expect(await result).toEqual({ status: 'cancelled' }); expect(stopCalls).toBe(1); }); test('waits for listener setup to finish before cleaning up an aborted scan', async () => { let finishListenerSetup: (() => void) | undefined; let removeCalls = 0; let startCalls = 0; let stopCalls = 0; const listenerSetup = new Promise((resolve) => { finishListenerSetup = resolve; }); const remove = () => { removeCalls += 1; }; const startScan = async () => { startCalls += 1; }; const plugin = { requestPermissions: mock(async () => ({ camera: 'granted' })), startScan, stopScan: async () => { stopCalls += 1; }, addListener: mock(async () => { await listenerSetup; return { remove }; }), }; Object.defineProperty(globalThis, 'window', { configurable: true, value: { Capacitor: { getPlatform: () => 'android', Plugins: { BarcodeScanner: plugin } } }, }); const controller = new AbortController(); const result = scanConnectionQr({ signal: controller.signal }); await Promise.resolve(); controller.abort(); finishListenerSetup?.(); expect(await result).toEqual({ status: 'cancelled' }); expect(startCalls).toBe(0); expect(removeCalls).toBe(2); expect(stopCalls).toBe(1); }); test('cleans up successful listener registration when the other listener fails', async () => { let removeCalls = 0; let startCalls = 0; let stopCalls = 0; const remove = () => { removeCalls += 1; }; const plugin = { requestPermissions: mock(async () => ({ camera: 'granted' })), startScan: async () => { startCalls += 1; }, stopScan: async () => { stopCalls += 1; }, addListener: mock(async (event: string) => { if (event === 'scanError') throw new Error('listener setup failed'); return { remove }; }), }; Object.defineProperty(globalThis, 'window', { configurable: true, value: { Capacitor: { getPlatform: () => 'android', Plugins: { BarcodeScanner: plugin } } }, }); expect(await scanConnectionQr()).toEqual({ status: 'failed' }); expect(startCalls).toBe(0); expect(removeCalls).toBe(1); expect(stopCalls).toBe(1); }); });