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 manual fallback", 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 expect(111 (container.querySelector("#pairing-invitation-manual") as HTMLTextAreaElement).value,112 ).toBe(SOURCE_INVITATION);113 expect(container.textContent).toContain("single-use random token");114 });115116 it("requires the matching SAS and master password before sharing", async () => {117 const { container } = mount();118 await vi.waitFor(() => {119 expect(container.textContent).toContain("Scan this in Peach");120 });121122 pushFromBackground("ADOPTION_SOURCE_SAS", {123 sas: "482193",124 receiverDeviceName: "June's phone",125 });126 expect(container.textContent).toContain("June's phone");127 expect(container.textContent).toContain("4 8 2 1 9 3");128129 inputText(130 container.querySelector("#adoption-consent-password") as HTMLInputElement,131 "correct horse battery staple",132 );133 const approve = Array.from(container.querySelectorAll("button")).find(134 (button) => button.textContent?.includes("Share vault"),135 ) as HTMLButtonElement;136 approve.click();137 await vi.waitFor(() => {138 expect(mockSendMessage).toHaveBeenCalledWith({139 type: "ADOPTION_SOURCE_CONFIRM",140 payload: { password: "correct horse battery staple" },141 });142 });143 await vi.waitFor(() => {144 expect(container.textContent).toContain("Waiting for the new device");145 });146 });147});148149describe("PairDevice — code pairing", () => {150 const VALID_CODE = "A74H-48J3-KI32-OEP4";151 const cleanups: Array<() => void> = [];152 afterEach(() => {153 while (cleanups.length) cleanups.pop()!();154 });155156 157 158 function mount(onBack: () => void = () => {}) {159 const r = renderView(160 <PairDeviceView isPro={true} initialPair="code" onBack={onBack} onLearnPro={() => {}} />,161 );162 cleanups.push(r.dispose);163 return r;164 }165166 function buttonByText(container: HTMLElement, text: string): HTMLButtonElement | undefined {167 return Array.from(container.querySelectorAll("button")).find(168 (b) => (b.textContent ?? "").trim() === text,169 ) as HTMLButtonElement | undefined;170 }171 function codeInput(container: HTMLElement): HTMLInputElement {172 return container.querySelector("#pair-code") as HTMLInputElement;173 }174175 176 function gotoCodeEntry(_container: HTMLElement): void {177 178 }179180 it("opens straight into the code-entry flow (no method-select screen)", () => {181 const { container } = mount();182 expect(container.textContent).not.toContain("How do you want to pair?");183 184 expect(codeInput(container)).toBeTruthy();185 expect(buttonByText(container, "Connect")).toBeTruthy();186 });187188 it("renders a code input sized for a full pairing code, with birds present", () => {189 const { container } = mount();190 gotoCodeEntry(container);191 const input = codeInput(container);192 expect(input).toBeTruthy();193 194 expect(input.getAttribute("inputmode")).toBeNull();195 expect(input.getAttribute("maxlength")).toBe("32");196 197 expect(container.querySelector(".peach-pair-bird-desktop")).toBeTruthy();198 });199200 it("uppercases and regroups a pairing code as it is typed", () => {201 const { container } = mount();202 gotoCodeEntry(container);203 const input = codeInput(container);204 inputText(input, "a74h48j3ki32oep4");205 expect(input.value).toBe("A74H-48J3-KI32-OEP4");206 expect(buttonByText(container, "Connect")!.disabled).toBe(false);207 });208209 it("groups partial input and strips unusable characters", () => {210 const { container } = mount();211 gotoCodeEntry(container);212 const input = codeInput(container);213 inputText(input, "12_34/56");214 expect(input.value).toBe("1234-56");215 expect(buttonByText(container, "Connect")!.disabled).toBe(true);216 });217218 it("keeps Connect disabled for input that is neither code form", () => {219 const { container } = mount();220 gotoCodeEntry(container);221 222 inputText(codeInput(container), "A74H48J3");223 expect(buttonByText(container, "Connect")!.disabled).toBe(true);224 });225226 it("Connect sends PAIR_WITH_CODE with the typed code, then searches", async () => {227 const { container } = mount();228 gotoCodeEntry(container);229 inputText(codeInput(container), VALID_CODE);230 const connect = buttonByText(container, "Connect")!;231 expect(connect.disabled).toBe(false);232 connect.click();233 await Promise.resolve();234 expect(mockSendMessage).toHaveBeenCalledWith({235 type: "PAIR_WITH_CODE",236 payload: { code: VALID_CODE },237 });238 expect(container.textContent).toContain("Connecting…");239 });240241 it("Connect stays disabled until the full pairing code is entered", () => {242 const { container } = mount();243 gotoCodeEntry(container);244 inputText(codeInput(container), "A74H48J3");245 expect(buttonByText(container, "Connect")!.disabled).toBe(true);246 inputText(codeInput(container), VALID_CODE);247 expect(buttonByText(container, "Connect")!.disabled).toBe(false);248 });249250 it("shows the SAS on a CODE_PAIR_SAS push, with Confirm/Cancel", async () => {251 const { container } = mount();252 gotoCodeEntry(container);253 inputText(codeInput(container), VALID_CODE);254 buttonByText(container, "Connect")!.click();255 await Promise.resolve();256257 pushFromBackground("CODE_PAIR_SAS", { sas: "895245" });258259 expect(container.textContent).toContain("Confirm this code matches on your phone:");260 expect(container.textContent).toContain("895245");261 expect(buttonByText(container, "Confirm")).toBeTruthy();262 expect(buttonByText(container, "Cancel")).toBeTruthy();263 });264265 it("Confirm sends CODE_PAIR_CONFIRM { confirmed: true }", async () => {266 const { container } = mount();267 gotoCodeEntry(container);268 inputText(codeInput(container), VALID_CODE);269 buttonByText(container, "Connect")!.click();270 await Promise.resolve();271 pushFromBackground("CODE_PAIR_SAS", { sas: "895245" });272273 buttonByText(container, "Confirm")!.click();274 await Promise.resolve();275 expect(mockSendMessage).toHaveBeenCalledWith({276 type: "CODE_PAIR_CONFIRM",277 payload: { confirmed: true },278 });279 });280281 it("Cancel on the SAS gate sends { confirmed: false } and shows mismatch copy", async () => {282 const { container } = mount();283 gotoCodeEntry(container);284 inputText(codeInput(container), VALID_CODE);285 buttonByText(container, "Connect")!.click();286 await Promise.resolve();287 pushFromBackground("CODE_PAIR_SAS", { sas: "895245" });288289 buttonByText(container, "Cancel")!.click();290 await Promise.resolve();291 expect(mockSendMessage).toHaveBeenCalledWith({292 type: "CODE_PAIR_CONFIRM",293 payload: { confirmed: false },294 });295 expect(container.textContent).toContain("Codes don't match");296 });297298 it("shows the combined 'not found or expired' copy on invalid_code", async () => {299 const { container } = mount();300 gotoCodeEntry(container);301 inputText(codeInput(container), VALID_CODE);302 buttonByText(container, "Connect")!.click();303 await Promise.resolve();304305 306 307 pushFromBackground("CODE_PAIR_RESULT", { status: "error", error: "invalid_code" });308309 expect(container.textContent).toContain("Code not found or expired");310 expect(buttonByText(container, "Try again")).toBeTruthy();311 });312313 it("shows the 'couldn't reach your phone' copy on no_host_found", async () => {314 const { container } = mount();315 gotoCodeEntry(container);316 inputText(codeInput(container), VALID_CODE);317 buttonByText(container, "Connect")!.click();318 await Promise.resolve();319 pushFromBackground("CODE_PAIR_RESULT", { status: "error", error: "no_host_found" });320 expect(container.textContent).toContain("Couldn't reach your phone");321 });322323 it("renders Paired! and the device name on a complete result", async () => {324 const device: PairedDevice = { deviceId: "d1", deviceName: "June's Pixel", deviceType: "android", pairedAt: 1700000000000 };325 const { container } = mount();326 gotoCodeEntry(container);327 inputText(codeInput(container), VALID_CODE);328 buttonByText(container, "Connect")!.click();329 await Promise.resolve();330 pushFromBackground("CODE_PAIR_SAS", { sas: "895245" });331 buttonByText(container, "Confirm")!.click();332 await Promise.resolve();333334 pushFromBackground("CODE_PAIR_RESULT", { status: "complete", device });335 expect(container.textContent).toContain("Paired!");336 expect(container.textContent).toContain("Connected to June's Pixel");337 });338339 it("auto-dismisses back to settings after the paired state settles", async () => {340 vi.useFakeTimers();341 try {342 const device: PairedDevice = { deviceId: "d1", deviceName: "Pixel", deviceType: "android", pairedAt: 0 };343 const onBack = vi.fn();344 const { container } = mount(onBack);345 gotoCodeEntry(container);346 inputText(codeInput(container), VALID_CODE);347 buttonByText(container, "Connect")!.click();348 await Promise.resolve();349 pushFromBackground("CODE_PAIR_SAS", { sas: "895245" });350 buttonByText(container, "Confirm")!.click();351 await Promise.resolve();352 pushFromBackground("CODE_PAIR_RESULT", { status: "complete", device });353 expect(container.textContent).toContain("Paired!");354 vi.advanceTimersByTime(2000);355 356 expect(onBack).toHaveBeenCalled();357 } finally {358 vi.useRealTimers();359 }360 });361362 it("surfaces a synchronous start failure (vault_locked) as an error", async () => {363 startResponse = { ok: false, error: "vault_locked" };364 const { container } = mount();365 gotoCodeEntry(container);366 inputText(codeInput(container), VALID_CODE);367 buttonByText(container, "Connect")!.click();368 await Promise.resolve();369 await Promise.resolve();370 expect(container.textContent).toContain("Unlock Peach first");371 });372});