chirp-pair.test.ts

7.2 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 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 { /* expected timeout */ }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() { /* unreached */ },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() { /* unreached */ },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() { /* unreached */ },145        async getLocalSubnet() { return "192.168.1"; },146        chirpTimeoutMs: 200,147        openAudio: fakeAudio(null),148        async openPairingTransport() { throw new Error("unreached"); },149        onProgress() { /* noop */ },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() { /* unreached */ },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() { /* noop */ },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() { /* unreached */ },178        async getLocalSubnet() { return null; },179        chirpTimeoutMs: 200,180        openAudio: fakeAudio(null),181        async openPairingTransport() { throw new Error("unreached"); },182        onProgress() { /* noop */ },183      }, "initiator"),184    ).rejects.toThrow(/openPairingServer/);185  });186});