chirp-pairing.test.ts

7.8 KB · raw

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  // Synthesize a ggwave that round-trips: encode → push raw bytes into the14  // waveform, decode → return any waveform whose first 64 samples decode as15  // a payload. This keeps the chirp-pairing logic in scope without needing16  // a real FSK codec.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() { /* no-op */ },27    encode(_instance: number, data: Uint8Array | string, _proto: number, _vol: number) {28      const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data;29      // Pack bytes as float samples in the first N positions; the rest30      // padded with sentinel value 99 so a partial read doesn't match.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      // Read first 64 samples back as bytes if they look like our encoding.39      // Sentinel 99 means "no chirp here".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    // Convert the 64 bytes into the encoded waveform shape our fake produces.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    // Inject our own chirp — should be ignored124    io.injectIncoming(ourWave);125126    // After 200ms inject a real peer chirp127    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    // Mint a chirp with a future protocol version, then re-stamp the CRC152    const tweaked = { ...theirs.outgoingPayload, version: 0x02 };153    const bytes = encodeChirpPayload({154      ...tweaked,155      // encodeChirpPayload writes a fresh CRC32 over the new bytes156    });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    // Push pure noise — fake decoder returns null because samples have value 99171    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});