1import { afterEach, beforeEach, describe, expect, it } from "vitest";2import { runChirpPairing, ChirpPairingFailure } from "./chirp-pair";3import { __setGgwaveModuleForTest } from "../shared/ggwave";4import { encodeChirpPayload } from "../shared/chirp-payload";5import { generateKeyPair } from "@stablelib/x25519";6import {7 CHIRP_PAYLOAD_LENGTH,8 DEVICE_TYPE_ANDROID,9 type ChirpPayload,10 type PairingProgress,11} from "../shared/sync-types";1213function fakeGgwave() {14 return {15 ProtocolId: { GGWAVE_PROTOCOL_ULTRASOUND_FASTEST: 5 },16 GGWAVE_OPERATING_MODE_RX: 1, GGWAVE_OPERATING_MODE_TX: 2, GGWAVE_OPERATING_MODE_RX_AND_TX: 3,17 getDefaultParameters() {18 return { sampleRateInp: 48000, sampleRateOut: 48000, samplesPerFrame: 1024, soundMarkerThreshold: 3, payloadLength: -1, operatingMode: 0 };19 },20 init() { return 1; },21 free() {},22 encode(_inst: number, data: Uint8Array | string) {23 const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data;24 const out = new Float32Array(Math.max(bytes.length, 128));25 for (let i = 0; i < bytes.length; i++) out[i] = bytes[i] / 255;26 for (let i = bytes.length; i < out.length; i++) out[i] = 99;27 return out;28 },29 decode(_inst: number, samples: Float32Array | Int16Array) {30 if (samples.length < CHIRP_PAYLOAD_LENGTH) return null;31 const buf = new Uint8Array(CHIRP_PAYLOAD_LENGTH);32 for (let i = 0; i < CHIRP_PAYLOAD_LENGTH; i++) {33 const v = (samples as Float32Array)[i];34 if (v === 99) return null;35 buf[i] = Math.round(v * 255);36 }37 return buf;38 },39 };40}4142function fakeAudio(injectedChirp: ChirpPayload | null) {43 let pending: Float32Array;44 if (injectedChirp) {45 const bytes = encodeChirpPayload(injectedChirp);46 pending = new Float32Array(128);47 for (let i = 0; i < bytes.length; i++) pending[i] = bytes[i] / 255;48 for (let i = bytes.length; i < pending.length; i++) pending[i] = 99;49 } else {50 pending = new Float32Array(0);51 }52 return async () => ({53 sampleRate: 48000,54 async play() { },55 async pullSamples() { const out = pending; pending = new Float32Array(0); return out; },56 async close() { },57 });58}5960describe("runChirpPairing (popup orchestrator)", () => {61 beforeEach(() => __setGgwaveModuleForTest(fakeGgwave()));62 afterEach(() => __setGgwaveModuleForTest(null));6364 it("emits listening progress before audio I/O starts", async () => {65 const trail: PairingProgress[] = [];66 67 68 try {69 await runChirpPairing({70 async fetchLocalIdentity() {71 return { deviceId: "ext", deviceName: "Firefox", deviceType: "extension", longTermPublicKeyHex: "0".repeat(64) };72 },73 async completePairing() { },74 async getLocalSubnet() { return "192.168.1"; },75 chirpTimeoutMs: 200,76 openAudio: fakeAudio(null),77 async openPairingTransport() { throw new Error("unreached"); },78 onProgress(p) { trail.push(p); },79 });80 } catch { }8182 expect(trail[0].phase).toBe("idle");83 expect(trail[1].phase).toBe("listening");84 });8586 it("emits handshake then handshake_failed when no peer responds on the subnet", async () => {87 const peerEphemeral = generateKeyPair();88 const peerChirp: ChirpPayload = {89 version: 0x01,90 deviceType: DEVICE_TYPE_ANDROID,91 ephemeralPublicKey: peerEphemeral.publicKey,92 sessionNonce: new Uint8Array(16).fill(3),93 };94 const trail: PairingProgress[] = [];9596 await expect(97 runChirpPairing({98 async fetchLocalIdentity() {99 return { deviceId: "ext", deviceName: "Firefox", deviceType: "extension", longTermPublicKeyHex: "0".repeat(64) };100 },101 async completePairing() { },102 async getLocalSubnet() { return "192.168.1"; },103 openAudio: fakeAudio(peerChirp),104 async openPairingTransport() { throw new Error("connection refused"); },105 onProgress(p) { trail.push(p); },106 }),107 ).rejects.toBeInstanceOf(ChirpPairingFailure);108109 expect(trail.some((p) => p.phase === "handshake")).toBe(true);110 expect(trail.at(-1)?.phase).toBe("handshake_failed");111 }, 30_000);112113 it("returns handshake_failed when getLocalSubnet returns null", async () => {114 const peerEphemeral = generateKeyPair();115 const peerChirp: ChirpPayload = {116 version: 0x01,117 deviceType: DEVICE_TYPE_ANDROID,118 ephemeralPublicKey: peerEphemeral.publicKey,119 sessionNonce: new Uint8Array(16).fill(2),120 };121 const trail: PairingProgress[] = [];122123 await expect(124 runChirpPairing({125 async fetchLocalIdentity() {126 return { deviceId: "ext", deviceName: "Firefox", deviceType: "extension", longTermPublicKeyHex: "0".repeat(64) };127 },128 async completePairing() { },129 async getLocalSubnet() { return null; },130 openAudio: fakeAudio(peerChirp),131 async openPairingTransport() { throw new Error("unreached"); },132 onProgress(p) { trail.push(p); },133 }),134 ).rejects.toMatchObject({ phase: "handshake_failed" });135 expect(trail.at(-1)?.errorMessage).toBe("no_subnet");136 });137138 it("rejects responder role with non-extension local identity", async () => {139 await expect(140 runChirpPairing({141 async fetchLocalIdentity() {142 return { deviceId: "android", deviceName: "Pixel", deviceType: "android", longTermPublicKeyHex: "0".repeat(64) };143 },144 async completePairing() { },145 async getLocalSubnet() { return "192.168.1"; },146 chirpTimeoutMs: 200,147 openAudio: fakeAudio(null),148 async openPairingTransport() { throw new Error("unreached"); },149 onProgress() { },150 }, "responder"),151 ).rejects.toThrow(/responder requires extension/);152 });153154 it("rejects initiator role with extension local identity", async () => {155 await expect(156 runChirpPairing({157 async fetchLocalIdentity() {158 return { deviceId: "ext", deviceName: "Firefox", deviceType: "extension", longTermPublicKeyHex: "0".repeat(64) };159 },160 async completePairing() { },161 async getLocalSubnet() { return null; },162 chirpTimeoutMs: 200,163 openAudio: fakeAudio(null),164 async openPairingTransport() { throw new Error("unreached"); },165 async openPairingServer() { throw new Error("unreached"); },166 onProgress() { },167 }, "initiator"),168 ).rejects.toThrow(/initiator requires non-extension/);169 });170171 it("rejects initiator role when openPairingServer dep is missing", async () => {172 await expect(173 runChirpPairing({174 async fetchLocalIdentity() {175 return { deviceId: "android", deviceName: "Pixel", deviceType: "android", longTermPublicKeyHex: "0".repeat(64) };176 },177 async completePairing() { },178 async getLocalSubnet() { return null; },179 chirpTimeoutMs: 200,180 openAudio: fakeAudio(null),181 async openPairingTransport() { throw new Error("unreached"); },182 onProgress() { },183 }, "initiator"),184 ).rejects.toThrow(/openPairingServer/);185 });186});