1import { afterEach, beforeEach, describe, expect, it } from "vitest";2import { CHIRP_PAYLOAD_LENGTH, DEVICE_TYPE_ANDROID, DEVICE_TYPE_EXTENSION } from "./sync-types";3import { encodeChirpPayload } from "./chirp-payload";4import {5 ChirpExchangeError,6 createPairingAttempt,7 runChirpExchange,8 type AudioIO,9} from "./chirp-pairing";10import { __setGgwaveModuleForTest } from "./ggwave";1112function fakeGgwaveThatPassesThroughBytes() {13 14 15 16 17 return {18 ProtocolId: { GGWAVE_PROTOCOL_ULTRASOUND_FASTEST: 5 },19 GGWAVE_OPERATING_MODE_RX: 1,20 GGWAVE_OPERATING_MODE_TX: 2,21 GGWAVE_OPERATING_MODE_RX_AND_TX: 3,22 getDefaultParameters() {23 return { sampleRateInp: 48000, sampleRateOut: 48000, samplesPerFrame: 1024, soundMarkerThreshold: 3, payloadLength: -1, operatingMode: 0 };24 },25 init() { return 1; },26 free() { },27 encode(_instance: number, data: Uint8Array | string, _proto: number, _vol: number) {28 const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data;29 30 31 const out = new Float32Array(Math.max(bytes.length, 128));32 for (let i = 0; i < bytes.length; i++) out[i] = bytes[i] / 255;33 for (let i = bytes.length; i < out.length; i++) out[i] = 99;34 return out;35 },36 decode(_instance: number, samples: Float32Array | Int16Array) {37 if (samples.length < CHIRP_PAYLOAD_LENGTH) return null;38 39 40 const buf = new Uint8Array(CHIRP_PAYLOAD_LENGTH);41 for (let i = 0; i < CHIRP_PAYLOAD_LENGTH; i++) {42 const v = (samples as Float32Array)[i];43 if (v === 99) return null;44 buf[i] = Math.round(v * 255);45 }46 return buf;47 },48 };49}5051function fakeIO(): AudioIO & {52 injectIncoming(samples: Float32Array): void;53 playedCount(): number;54 closed(): boolean;55} {56 let pending = new Float32Array(0);57 let playCount = 0;58 let isClosed = false;59 return {60 sampleRate: 48000,61 async play(_waveform: Float32Array) {62 playCount++;63 },64 async pullSamples() {65 const out = pending;66 pending = new Float32Array(0);67 return out;68 },69 async close() {70 isClosed = true;71 },72 injectIncoming(samples: Float32Array) {73 const next = new Float32Array(pending.length + samples.length);74 next.set(pending, 0);75 next.set(samples, pending.length);76 pending = next;77 },78 playedCount: () => playCount,79 closed: () => isClosed,80 };81}8283describe("createPairingAttempt", () => {84 it("rolls fresh ephemerals and a 16-byte nonce each call", () => {85 const a = createPairingAttempt(DEVICE_TYPE_EXTENSION);86 const b = createPairingAttempt(DEVICE_TYPE_EXTENSION);87 expect(a.ephemeralPublicKey).not.toEqual(b.ephemeralPublicKey);88 expect(a.sessionNonce.length).toBe(16);89 expect(a.outgoingPayload.ephemeralPublicKey).toEqual(a.ephemeralPublicKey);90 expect(a.outgoingPayload.deviceType).toBe(DEVICE_TYPE_EXTENSION);91 });92});9394describe("runChirpExchange", () => {95 beforeEach(() => __setGgwaveModuleForTest(fakeGgwaveThatPassesThroughBytes()));96 afterEach(() => __setGgwaveModuleForTest(null));9798 it("returns the remote payload when a valid chirp is received", async () => {99 const io = fakeIO();100 const ours = createPairingAttempt(DEVICE_TYPE_EXTENSION);101 const theirs = createPairingAttempt(DEVICE_TYPE_ANDROID);102 const theirBytes = encodeChirpPayload(theirs.outgoingPayload);103 104 const theirWave = new Float32Array(128);105 for (let i = 0; i < theirBytes.length; i++) theirWave[i] = theirBytes[i] / 255;106 for (let i = theirBytes.length; i < theirWave.length; i++) theirWave[i] = 99;107 io.injectIncoming(theirWave);108109 const result = await runChirpExchange(io, { attempt: ours, timeoutMs: 2000 });110 expect(result.remoteChirp.ephemeralPublicKey).toEqual(theirs.ephemeralPublicKey);111 expect(result.remoteChirp.sessionNonce).toEqual(theirs.sessionNonce);112 expect(result.remoteChirp.deviceType).toBe(DEVICE_TYPE_ANDROID);113 expect(io.closed()).toBe(true);114 });115116 it("ignores echo of our own chirp", async () => {117 const io = fakeIO();118 const ours = createPairingAttempt(DEVICE_TYPE_EXTENSION);119 const ourBytes = encodeChirpPayload(ours.outgoingPayload);120 const ourWave = new Float32Array(128);121 for (let i = 0; i < ourBytes.length; i++) ourWave[i] = ourBytes[i] / 255;122 for (let i = ourBytes.length; i < ourWave.length; i++) ourWave[i] = 99;123 124 io.injectIncoming(ourWave);125126 127 const theirs = createPairingAttempt(DEVICE_TYPE_ANDROID);128 const theirBytes = encodeChirpPayload(theirs.outgoingPayload);129 const theirWave = new Float32Array(128);130 for (let i = 0; i < theirBytes.length; i++) theirWave[i] = theirBytes[i] / 255;131 for (let i = theirBytes.length; i < theirWave.length; i++) theirWave[i] = 99;132 setTimeout(() => io.injectIncoming(theirWave), 150);133134 const result = await runChirpExchange(io, { attempt: ours, timeoutMs: 3000 });135 expect(result.remoteChirp.ephemeralPublicKey).toEqual(theirs.ephemeralPublicKey);136 });137138 it("times out after the configured deadline when no chirp arrives", async () => {139 const io = fakeIO();140 const ours = createPairingAttempt(DEVICE_TYPE_EXTENSION);141 await expect(runChirpExchange(io, { attempt: ours, timeoutMs: 200 })).rejects.toMatchObject({142 kind: "timeout",143 });144 expect(io.closed()).toBe(true);145 });146147 it("raises version_mismatch on a peer with a different protocol version", async () => {148 const io = fakeIO();149 const ours = createPairingAttempt(DEVICE_TYPE_EXTENSION);150 const theirs = createPairingAttempt(DEVICE_TYPE_ANDROID);151 152 const tweaked = { ...theirs.outgoingPayload, version: 0x02 };153 const bytes = encodeChirpPayload({154 ...tweaked,155 156 });157 const wave = new Float32Array(128);158 for (let i = 0; i < bytes.length; i++) wave[i] = bytes[i] / 255;159 for (let i = bytes.length; i < wave.length; i++) wave[i] = 99;160 io.injectIncoming(wave);161162 await expect(runChirpExchange(io, { attempt: ours, timeoutMs: 2000 })).rejects.toMatchObject({163 kind: "version_mismatch",164 });165 });166167 it("ignores noise chunks (no chirp detected) and keeps listening", async () => {168 const io = fakeIO();169 const ours = createPairingAttempt(DEVICE_TYPE_EXTENSION);170 171 io.injectIncoming(new Float32Array(256).fill(99));172173 const theirs = createPairingAttempt(DEVICE_TYPE_ANDROID);174 const theirBytes = encodeChirpPayload(theirs.outgoingPayload);175 const theirWave = new Float32Array(128);176 for (let i = 0; i < theirBytes.length; i++) theirWave[i] = theirBytes[i] / 255;177 for (let i = theirBytes.length; i < theirWave.length; i++) theirWave[i] = 99;178 setTimeout(() => io.injectIncoming(theirWave), 100);179180 const result = await runChirpExchange(io, { attempt: ours, timeoutMs: 3000 });181 expect(result.remoteChirp.ephemeralPublicKey).toEqual(theirs.ephemeralPublicKey);182 });183});184185describe("ChirpExchangeError", () => {186 it("carries a kind tag", () => {187 const err = new ChirpExchangeError("timeout");188 expect(err.kind).toBe("timeout");189 expect(err).toBeInstanceOf(Error);190 });191});