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 };23let lanOnly = true;24const SOURCE_INVITATION =25 "peach-pair-v1:eyJyZWxheSI6IndzczovL3JlbGF5LnBlYWNocGFzc3dvcmRzLmNvbSIsInRva2VuIjoiQUFFQ0F3UUZCZ2NJQ1FvTERBME9EdyJ9";2627import { PairDeviceView } from "./PairDevice";28import type { PairedDevice } from "../../shared/sync-types";29import { renderView, inputText } from "./view-test-utils";303132type RuntimeListener = (msg: { type?: string; payload?: unknown }) => void;33let listeners: RuntimeListener[] = [];3435function pushFromBackground(type: string, payload: unknown): void {36 for (const l of [...listeners]) l({ type, payload });37}3839beforeEach(() => {40 listeners = [];41 startResponse = { ok: true };42 lanOnly = true;43 mockSendMessage.mockReset();44 mockSendMessage.mockImplementation((msg: { type?: string }) => {45 if (msg?.type === "GET_RELAY_SETTINGS") {46 47 48 49 return {50 then(resolve: (value: unknown) => unknown) {51 return Promise.resolve(resolve({52 ok: true,53 data: { lanOnly },54 }));55 },56 };57 }58 if (msg?.type === "START_ADOPTION_SOURCE") {59 return Promise.resolve({60 ok: true,61 data: { invitation: SOURCE_INVITATION },62 });63 }64 if (msg?.type === "ADOPTION_SOURCE_CONFIRM") {65 return Promise.resolve({ ok: true, data: { valid: true } });66 }67 if (msg?.type === "PAIR_WITH_CODE") return Promise.resolve(startResponse);68 return Promise.resolve({ ok: true });69 });70 (globalThis as unknown as { chrome: unknown }).chrome = {71 runtime: {72 onMessage: {73 addListener: (fn: RuntimeListener) => { listeners.push(fn); },74 removeListener: (fn: RuntimeListener) => { listeners = listeners.filter((l) => l !== fn); },75 },76 },77 };78});7980describe("PairDevice — relay adoption source", () => {81 const cleanups: Array<() => void> = [];82 afterEach(() => {83 while (cleanups.length) cleanups.pop()!();84 });8586 function mount() {87 lanOnly = false;88 const rendered = renderView(89 <PairDeviceView90 isPro={true}91 initialPair="code"92 onBack={() => {}}93 onLearnPro={() => {}}94 />,95 );96 cleanups.push(rendered.dispose);97 return rendered;98 }99100 it("opens the relay room before rendering its QR, and offers no manual copy", async () => {101 const { container } = mount();102 await vi.waitFor(() => {103 expect(container.textContent).toContain("Scan this in Peach");104 });105106 expect(mockSendMessage).toHaveBeenCalledWith({107 type: "START_ADOPTION_SOURCE",108 });109 expect(container.querySelector("svg.peach-pair-qr")).toBeTruthy();110 111 112 expect(container.querySelector("#pairing-invitation-manual")).toBeNull();113 expect(container.textContent).not.toContain("or copy the invitation");114 expect(container.textContent).toContain("single-use random token");115 });116117 it("requires the matching SAS and master password before sharing", async () => {118 const { container } = mount();119 await vi.waitFor(() => {120 expect(container.textContent).toContain("Scan this in Peach");121 });122123 pushFromBackground("ADOPTION_SOURCE_SAS", {124 sas: "48219371",125 receiverDeviceName: "June's phone",126 });127 expect(container.textContent).toContain("June's phone");128 expect(container.textContent).toContain("48219371");129130 inputText(131 container.querySelector("#adoption-consent-password") as HTMLInputElement,132 "correct horse battery staple",133 );134 const approve = Array.from(container.querySelectorAll("button")).find(135 (button) => button.textContent?.includes("Share vault"),136 ) as HTMLButtonElement;137 approve.click();138 await vi.waitFor(() => {139 expect(mockSendMessage).toHaveBeenCalledWith({140 type: "ADOPTION_SOURCE_CONFIRM",141 payload: { password: "correct horse battery staple" },142 });143 });144 await vi.waitFor(() => {145 expect(container.textContent).toContain("Waiting for the new device");146 });147 });148});149150describe("PairDevice — code pairing", () => {151 const VALID_CODE = "A74H-48J3-KI32-OEP4";152 const cleanups: Array<() => void> = [];153 afterEach(() => {154 while (cleanups.length) cleanups.pop()!();155 });156157 158 159 function mount(onBack: () => void = () => {}) {160 const r = renderView(161 <PairDeviceView isPro={true} initialPair="code" onBack={onBack} onLearnPro={() => {}} />,162 );163 cleanups.push(r.dispose);164 return r;165 }166167 function buttonByText(container: HTMLElement, text: string): HTMLButtonElement | undefined {168 return Array.from(container.querySelectorAll("button")).find(169 (b) => (b.textContent ?? "").trim() === text,170 ) as HTMLButtonElement | undefined;171 }172 function codeInput(container: HTMLElement): HTMLInputElement {173 return container.querySelector("#pair-code") as HTMLInputElement;174 }175176 177 function gotoCodeEntry(_container: HTMLElement): void {178 179 }180181 it("opens straight into the code-entry flow (no method-select screen)", () => {182 const { container } = mount();183 expect(container.textContent).not.toContain("How do you want to pair?");184 185 expect(codeInput(container)).toBeTruthy();186 expect(buttonByText(container, "Connect")).toBeTruthy();187 });188189 it("renders a code input sized for a full pairing code, with birds present", () => {190 const { container } = mount();191 gotoCodeEntry(container);192 const input = codeInput(container);193 expect(input).toBeTruthy();194 195 expect(input.getAttribute("inputmode")).toBeNull();196 expect(input.getAttribute("maxlength")).toBe("32");197 198 expect(container.querySelector(".peach-pair-bird-desktop")).toBeTruthy();199 });200201 it("uppercases and regroups a pairing code as it is typed", () => {202 const { container } = mount();203 gotoCodeEntry(container);204 const input = codeInput(container);205 inputText(input, "a74h48j3ki32oep4");206 expect(input.value).toBe("A74H-48J3-KI32-OEP4");207 expect(buttonByText(container, "Connect")!.disabled).toBe(false);208 });209210 it("groups partial input and strips unusable characters", () => {211 const { container } = mount();212 gotoCodeEntry(container);213 const input = codeInput(container);214 inputText(input, "12_34/56");215 expect(input.value).toBe("1234-56");216 expect(buttonByText(container, "Connect")!.disabled).toBe(true);217 });218219 it("keeps Connect disabled for input that is neither code form", () => {220 const { container } = mount();221 gotoCodeEntry(container);222 223 inputText(codeInput(container), "A74H48J3");224 expect(buttonByText(container, "Connect")!.disabled).toBe(true);225 });226227 it("Connect sends PAIR_WITH_CODE with the typed code, then searches", async () => {228 const { container } = mount();229 gotoCodeEntry(container);230 inputText(codeInput(container), VALID_CODE);231 const connect = buttonByText(container, "Connect")!;232 expect(connect.disabled).toBe(false);233 connect.click();234 await Promise.resolve();235 expect(mockSendMessage).toHaveBeenCalledWith({236 type: "PAIR_WITH_CODE",237 payload: { code: VALID_CODE },238 });239 expect(container.textContent).toContain("Connecting…");240 });241242 it("Connect stays disabled until the full pairing code is entered", () => {243 const { container } = mount();244 gotoCodeEntry(container);245 inputText(codeInput(container), "A74H48J3");246 expect(buttonByText(container, "Connect")!.disabled).toBe(true);247 inputText(codeInput(container), VALID_CODE);248 expect(buttonByText(container, "Connect")!.disabled).toBe(false);249 });250251 it("shows the SAS on a CODE_PAIR_SAS push, with Confirm/Cancel", async () => {252 const { container } = mount();253 gotoCodeEntry(container);254 inputText(codeInput(container), VALID_CODE);255 buttonByText(container, "Connect")!.click();256 await Promise.resolve();257258 pushFromBackground("CODE_PAIR_SAS", { sas: "89524517" });259260 expect(container.textContent).toContain("Confirm this code matches on your phone:");261 expect(container.textContent).toContain("89524517");262 expect(buttonByText(container, "Confirm")).toBeTruthy();263 expect(buttonByText(container, "Cancel")).toBeTruthy();264 });265266 it("Confirm sends CODE_PAIR_CONFIRM { confirmed: true }", async () => {267 const { container } = mount();268 gotoCodeEntry(container);269 inputText(codeInput(container), VALID_CODE);270 buttonByText(container, "Connect")!.click();271 await Promise.resolve();272 pushFromBackground("CODE_PAIR_SAS", { sas: "89524517" });273274 buttonByText(container, "Confirm")!.click();275 await Promise.resolve();276 expect(mockSendMessage).toHaveBeenCalledWith({277 type: "CODE_PAIR_CONFIRM",278 payload: { confirmed: true },279 });280 });281282 it("Cancel on the SAS gate sends { confirmed: false } and shows mismatch copy", async () => {283 const { container } = mount();284 gotoCodeEntry(container);285 inputText(codeInput(container), VALID_CODE);286 buttonByText(container, "Connect")!.click();287 await Promise.resolve();288 pushFromBackground("CODE_PAIR_SAS", { sas: "89524517" });289290 buttonByText(container, "Cancel")!.click();291 await Promise.resolve();292 expect(mockSendMessage).toHaveBeenCalledWith({293 type: "CODE_PAIR_CONFIRM",294 payload: { confirmed: false },295 });296 expect(container.textContent).toContain("Codes don't match");297 });298299 it("shows the combined 'not found or expired' copy on invalid_code", async () => {300 const { container } = mount();301 gotoCodeEntry(container);302 inputText(codeInput(container), VALID_CODE);303 buttonByText(container, "Connect")!.click();304 await Promise.resolve();305306 307 308 pushFromBackground("CODE_PAIR_RESULT", { status: "error", error: "invalid_code" });309310 expect(container.textContent).toContain("Code not found or expired");311 expect(buttonByText(container, "Try again")).toBeTruthy();312 });313314 it("shows the 'couldn't reach your phone' copy on no_host_found", async () => {315 const { container } = mount();316 gotoCodeEntry(container);317 inputText(codeInput(container), VALID_CODE);318 buttonByText(container, "Connect")!.click();319 await Promise.resolve();320 pushFromBackground("CODE_PAIR_RESULT", { status: "error", error: "no_host_found" });321 expect(container.textContent).toContain("Couldn't reach your phone");322 });323324 it("renders Paired! and the device name on a complete result", async () => {325 const device: PairedDevice = { deviceId: "d1", deviceName: "June's Pixel", deviceType: "android", pairedAt: 1700000000000 };326 const { container } = mount();327 gotoCodeEntry(container);328 inputText(codeInput(container), VALID_CODE);329 buttonByText(container, "Connect")!.click();330 await Promise.resolve();331 pushFromBackground("CODE_PAIR_SAS", { sas: "89524517" });332 buttonByText(container, "Confirm")!.click();333 await Promise.resolve();334335 pushFromBackground("CODE_PAIR_RESULT", { status: "complete", device });336 expect(container.textContent).toContain("Paired!");337 expect(container.textContent).toContain("Connected to June's Pixel");338 });339340 it("auto-dismisses back to settings after the paired state settles", async () => {341 vi.useFakeTimers();342 try {343 const device: PairedDevice = { deviceId: "d1", deviceName: "Pixel", deviceType: "android", pairedAt: 0 };344 const onBack = vi.fn();345 const { container } = mount(onBack);346 gotoCodeEntry(container);347 inputText(codeInput(container), VALID_CODE);348 buttonByText(container, "Connect")!.click();349 await Promise.resolve();350 pushFromBackground("CODE_PAIR_SAS", { sas: "89524517" });351 buttonByText(container, "Confirm")!.click();352 await Promise.resolve();353 pushFromBackground("CODE_PAIR_RESULT", { status: "complete", device });354 expect(container.textContent).toContain("Paired!");355 vi.advanceTimersByTime(2000);356 357 expect(onBack).toHaveBeenCalled();358 } finally {359 vi.useRealTimers();360 }361 });362363 it("surfaces a synchronous start failure (vault_locked) as an error", async () => {364 startResponse = { ok: false, error: "vault_locked" };365 const { container } = mount();366 gotoCodeEntry(container);367 inputText(codeInput(container), VALID_CODE);368 buttonByText(container, "Connect")!.click();369 await Promise.resolve();370 await Promise.resolve();371 expect(container.textContent).toContain("Unlock Peach first");372 });373});