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 confirmSAS() { return true; },75 async getLocalSubnet() { return "192.168.1"; },76 chirpTimeoutMs: 200,77 openAudio: fakeAudio(null),78 async openPairingTransport() { throw new Error("unreached"); },79 onProgress(p) { trail.push(p); },80 });81 } catch { }8283 expect(trail[0].phase).toBe("idle");84 expect(trail[1].phase).toBe("listening");85 });8687 it("emits handshake then handshake_failed when no peer responds on the subnet", async () => {88 const peerEphemeral = generateKeyPair();89 const peerChirp: ChirpPayload = {90 version: 0x01,91 deviceType: DEVICE_TYPE_ANDROID,92 ephemeralPublicKey: peerEphemeral.publicKey,93 sessionNonce: new Uint8Array(16).fill(3),94 };95 const trail: PairingProgress[] = [];9697 await expect(98 runChirpPairing({99 async fetchLocalIdentity() {100 return { deviceId: "ext", deviceName: "Firefox", deviceType: "extension", longTermPublicKeyHex: "0".repeat(64) };101 },102 async completePairing() { },103 async confirmSAS() { return true; },104 async getLocalSubnet() { return "192.168.1"; },105 openAudio: fakeAudio(peerChirp),106 async openPairingTransport() { throw new Error("connection refused"); },107 onProgress(p) { trail.push(p); },108 }),109 ).rejects.toBeInstanceOf(ChirpPairingFailure);110111 expect(trail.some((p) => p.phase === "handshake")).toBe(true);112 expect(trail.at(-1)?.phase).toBe("handshake_failed");113 }, 30_000);114115 it("returns handshake_failed when getLocalSubnet returns null", async () => {116 const peerEphemeral = generateKeyPair();117 const peerChirp: ChirpPayload = {118 version: 0x01,119 deviceType: DEVICE_TYPE_ANDROID,120 ephemeralPublicKey: peerEphemeral.publicKey,121 sessionNonce: new Uint8Array(16).fill(2),122 };123 const trail: PairingProgress[] = [];124125 await expect(126 runChirpPairing({127 async fetchLocalIdentity() {128 return { deviceId: "ext", deviceName: "Firefox", deviceType: "extension", longTermPublicKeyHex: "0".repeat(64) };129 },130 async completePairing() { },131 async confirmSAS() { return true; },132 async getLocalSubnet() { return null; },133 openAudio: fakeAudio(peerChirp),134 async openPairingTransport() { throw new Error("unreached"); },135 onProgress(p) { trail.push(p); },136 }),137 ).rejects.toMatchObject({ phase: "handshake_failed" });138 expect(trail.at(-1)?.errorMessage).toBe("no_subnet");139 });140141 it("rejects responder role with non-extension local identity", async () => {142 await expect(143 runChirpPairing({144 async fetchLocalIdentity() {145 return { deviceId: "android", deviceName: "Pixel", deviceType: "android", longTermPublicKeyHex: "0".repeat(64) };146 },147 async completePairing() { },148 async confirmSAS() { return true; },149 async getLocalSubnet() { return "192.168.1"; },150 chirpTimeoutMs: 200,151 openAudio: fakeAudio(null),152 async openPairingTransport() { throw new Error("unreached"); },153 onProgress() { },154 }, "responder"),155 ).rejects.toThrow(/responder requires extension/);156 });157158 it("rejects initiator role with extension local identity", async () => {159 await expect(160 runChirpPairing({161 async fetchLocalIdentity() {162 return { deviceId: "ext", deviceName: "Firefox", deviceType: "extension", longTermPublicKeyHex: "0".repeat(64) };163 },164 async completePairing() { },165 async confirmSAS() { return true; },166 async getLocalSubnet() { return null; },167 chirpTimeoutMs: 200,168 openAudio: fakeAudio(null),169 async openPairingTransport() { throw new Error("unreached"); },170 async openPairingServer() { throw new Error("unreached"); },171 onProgress() { },172 }, "initiator"),173 ).rejects.toThrow(/initiator requires non-extension/);174 });175176 it("rejects initiator role when openPairingServer dep is missing", async () => {177 await expect(178 runChirpPairing({179 async fetchLocalIdentity() {180 return { deviceId: "android", deviceName: "Pixel", deviceType: "android", longTermPublicKeyHex: "0".repeat(64) };181 },182 async completePairing() { },183 async confirmSAS() { return true; },184 async getLocalSubnet() { return null; },185 chirpTimeoutMs: 200,186 openAudio: fakeAudio(null),187 async openPairingTransport() { throw new Error("unreached"); },188 onProgress() { },189 }, "initiator"),190 ).rejects.toThrow(/openPairingServer/);191 });192});