chirp-pair.test.ts

7.5 KB · raw

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() { /* no-op */ },55    async pullSamples() { const out = pending; pending = new Float32Array(0); return out; },56    async close() { /* no-op */ },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    // Use a short-timeout chirp exchange by injecting no-chirp audio.67    // The orchestrator will time out, but we only assert on early progress.68    try {69      await runChirpPairing({70        async fetchLocalIdentity() {71          return { deviceId: "ext", deviceName: "Firefox", deviceType: "extension", longTermPublicKeyHex: "0".repeat(64) };72        },73        async completePairing() { /* unreached */ },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 { /* expected timeout */ }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() { /* unreached */ },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() { /* unreached */ },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() { /* unreached */ },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() { /* noop */ },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() { /* unreached */ },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() { /* noop */ },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() { /* unreached */ },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() { /* noop */ },189      }, "initiator"),190    ).rejects.toThrow(/openPairingServer/);191  });192});