Files
openchamber/packages/ui/src/apps/mobileQrScan.test.ts
T

166 lines
6.6 KiB
TypeScript

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<string, (event: { barcodes?: Array<{ rawValue?: string }> }) => 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<void>((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);
});
});