123456789101112131415import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";161718const { mockSendMessage } = vi.hoisted(() => ({ mockSendMessage: vi.fn() }));19vi.mock("../../shared/bridge", () => ({ sendMessage: mockSendMessage }));202122let startResponse: { ok: boolean; error?: string } = { ok: true };2324import { PairDeviceView } from "./PairDevice";25import type { PairedDevice } from "../../shared/sync-types";26import { renderView, inputText } from "./view-test-utils";272829type RuntimeListener = (msg: { type?: string; payload?: unknown }) => void;30let listeners: RuntimeListener[] = [];3132function pushFromBackground(type: string, payload: unknown): void {33 for (const l of [...listeners]) l({ type, payload });34}3536beforeEach(() => {37 listeners = [];38 startResponse = { ok: true };39 mockSendMessage.mockReset();40 mockSendMessage.mockImplementation(async (msg: { type?: string }) => {41 if (msg?.type === "PAIR_WITH_CODE") return startResponse;42 return { ok: true };43 });44 (globalThis as unknown as { chrome: unknown }).chrome = {45 runtime: {46 onMessage: {47 addListener: (fn: RuntimeListener) => { listeners.push(fn); },48 removeListener: (fn: RuntimeListener) => { listeners = listeners.filter((l) => l !== fn); },49 },50 },51 };52});5354describe("PairDevice — code pairing", () => {55 const VALID_CODE = "A74H-48J3-KI32-OEP4";56 const cleanups: Array<() => void> = [];57 afterEach(() => {58 while (cleanups.length) cleanups.pop()!();59 });6061 62 63 function mount(onBack: () => void = () => {}) {64 const r = renderView(65 <PairDeviceView isPro={true} initialPair="code" onBack={onBack} onLearnPro={() => {}} />,66 );67 cleanups.push(r.dispose);68 return r;69 }7071 function buttonByText(container: HTMLElement, text: string): HTMLButtonElement | undefined {72 return Array.from(container.querySelectorAll("button")).find(73 (b) => (b.textContent ?? "").trim() === text,74 ) as HTMLButtonElement | undefined;75 }76 function codeInput(container: HTMLElement): HTMLInputElement {77 return container.querySelector("#pair-code") as HTMLInputElement;78 }7980 81 function gotoCodeEntry(_container: HTMLElement): void {82 83 }8485 it("opens straight into the code-entry flow (no method-select screen)", () => {86 const { container } = mount();87 expect(container.textContent).not.toContain("How do you want to pair?");88 89 expect(codeInput(container)).toBeTruthy();90 expect(buttonByText(container, "Connect")).toBeTruthy();91 });9293 it("renders a code input sized for a full pairing code, with birds present", () => {94 const { container } = mount();95 gotoCodeEntry(container);96 const input = codeInput(container);97 expect(input).toBeTruthy();98 99 expect(input.getAttribute("inputmode")).toBeNull();100 expect(input.getAttribute("maxlength")).toBe("32");101 102 expect(container.querySelector(".peach-pair-bird-desktop")).toBeTruthy();103 });104105 it("uppercases and regroups a pairing code as it is typed", () => {106 const { container } = mount();107 gotoCodeEntry(container);108 const input = codeInput(container);109 inputText(input, "a74h48j3ki32oep4");110 expect(input.value).toBe("A74H-48J3-KI32-OEP4");111 expect(buttonByText(container, "Connect")!.disabled).toBe(false);112 });113114 it("groups partial input and strips unusable characters", () => {115 const { container } = mount();116 gotoCodeEntry(container);117 const input = codeInput(container);118 inputText(input, "12_34/56");119 expect(input.value).toBe("1234-56");120 expect(buttonByText(container, "Connect")!.disabled).toBe(true);121 });122123 it("keeps Connect disabled for input that is neither code form", () => {124 const { container } = mount();125 gotoCodeEntry(container);126 127 inputText(codeInput(container), "A74H48J3");128 expect(buttonByText(container, "Connect")!.disabled).toBe(true);129 });130131 it("Connect sends PAIR_WITH_CODE with the typed code, then searches", async () => {132 const { container } = mount();133 gotoCodeEntry(container);134 inputText(codeInput(container), VALID_CODE);135 const connect = buttonByText(container, "Connect")!;136 expect(connect.disabled).toBe(false);137 connect.click();138 await Promise.resolve();139 expect(mockSendMessage).toHaveBeenCalledWith({140 type: "PAIR_WITH_CODE",141 payload: { code: VALID_CODE },142 });143 expect(container.textContent).toContain("Connecting…");144 });145146 it("Connect stays disabled until the full pairing code is entered", () => {147 const { container } = mount();148 gotoCodeEntry(container);149 inputText(codeInput(container), "A74H48J3");150 expect(buttonByText(container, "Connect")!.disabled).toBe(true);151 inputText(codeInput(container), VALID_CODE);152 expect(buttonByText(container, "Connect")!.disabled).toBe(false);153 });154155 it("shows the SAS on a CODE_PAIR_SAS push, with Confirm/Cancel", async () => {156 const { container } = mount();157 gotoCodeEntry(container);158 inputText(codeInput(container), VALID_CODE);159 buttonByText(container, "Connect")!.click();160 await Promise.resolve();161162 pushFromBackground("CODE_PAIR_SAS", { sas: "895245" });163164 expect(container.textContent).toContain("Confirm this code matches on your phone:");165 expect(container.textContent).toContain("895245");166 expect(buttonByText(container, "Confirm")).toBeTruthy();167 expect(buttonByText(container, "Cancel")).toBeTruthy();168 });169170 it("Confirm sends CODE_PAIR_CONFIRM { confirmed: true }", async () => {171 const { container } = mount();172 gotoCodeEntry(container);173 inputText(codeInput(container), VALID_CODE);174 buttonByText(container, "Connect")!.click();175 await Promise.resolve();176 pushFromBackground("CODE_PAIR_SAS", { sas: "895245" });177178 buttonByText(container, "Confirm")!.click();179 await Promise.resolve();180 expect(mockSendMessage).toHaveBeenCalledWith({181 type: "CODE_PAIR_CONFIRM",182 payload: { confirmed: true },183 });184 });185186 it("Cancel on the SAS gate sends { confirmed: false } and shows mismatch copy", async () => {187 const { container } = mount();188 gotoCodeEntry(container);189 inputText(codeInput(container), VALID_CODE);190 buttonByText(container, "Connect")!.click();191 await Promise.resolve();192 pushFromBackground("CODE_PAIR_SAS", { sas: "895245" });193194 buttonByText(container, "Cancel")!.click();195 await Promise.resolve();196 expect(mockSendMessage).toHaveBeenCalledWith({197 type: "CODE_PAIR_CONFIRM",198 payload: { confirmed: false },199 });200 expect(container.textContent).toContain("Codes don't match");201 });202203 it("shows the combined 'not found or expired' copy on invalid_code", async () => {204 const { container } = mount();205 gotoCodeEntry(container);206 inputText(codeInput(container), VALID_CODE);207 buttonByText(container, "Connect")!.click();208 await Promise.resolve();209210 211 212 pushFromBackground("CODE_PAIR_RESULT", { status: "error", error: "invalid_code" });213214 expect(container.textContent).toContain("Code not found or expired");215 expect(buttonByText(container, "Try again")).toBeTruthy();216 });217218 it("shows the 'couldn't reach your phone' copy on no_host_found", async () => {219 const { container } = mount();220 gotoCodeEntry(container);221 inputText(codeInput(container), VALID_CODE);222 buttonByText(container, "Connect")!.click();223 await Promise.resolve();224 pushFromBackground("CODE_PAIR_RESULT", { status: "error", error: "no_host_found" });225 expect(container.textContent).toContain("Couldn't reach your phone");226 });227228 it("renders Paired! and the device name on a complete result", async () => {229 const device: PairedDevice = { deviceId: "d1", deviceName: "June's Pixel", deviceType: "android", pairedAt: 1700000000000 };230 const { container } = mount();231 gotoCodeEntry(container);232 inputText(codeInput(container), VALID_CODE);233 buttonByText(container, "Connect")!.click();234 await Promise.resolve();235 pushFromBackground("CODE_PAIR_SAS", { sas: "895245" });236 buttonByText(container, "Confirm")!.click();237 await Promise.resolve();238239 pushFromBackground("CODE_PAIR_RESULT", { status: "complete", device });240 expect(container.textContent).toContain("Paired!");241 expect(container.textContent).toContain("Connected to June's Pixel");242 });243244 it("auto-dismisses back to settings after the paired state settles", async () => {245 vi.useFakeTimers();246 try {247 const device: PairedDevice = { deviceId: "d1", deviceName: "Pixel", deviceType: "android", pairedAt: 0 };248 const onBack = vi.fn();249 const { container } = mount(onBack);250 gotoCodeEntry(container);251 inputText(codeInput(container), VALID_CODE);252 buttonByText(container, "Connect")!.click();253 await Promise.resolve();254 pushFromBackground("CODE_PAIR_SAS", { sas: "895245" });255 buttonByText(container, "Confirm")!.click();256 await Promise.resolve();257 pushFromBackground("CODE_PAIR_RESULT", { status: "complete", device });258 expect(container.textContent).toContain("Paired!");259 vi.advanceTimersByTime(2000);260 261 expect(onBack).toHaveBeenCalled();262 } finally {263 vi.useRealTimers();264 }265 });266267 it("surfaces a synchronous start failure (vault_locked) as an error", async () => {268 startResponse = { ok: false, error: "vault_locked" };269 const { container } = mount();270 gotoCodeEntry(container);271 inputText(codeInput(container), VALID_CODE);272 buttonByText(container, "Connect")!.click();273 await Promise.resolve();274 await Promise.resolve();275 expect(container.textContent).toContain("Unlock Peach first");276 });277});