123456789101112import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";131415const { mockSendMessage } = vi.hoisted(() => ({ mockSendMessage: vi.fn() }));16vi.mock("../../shared/bridge", () => ({ sendMessage: mockSendMessage }));1718const SOURCE_INVITATION =19 "peach-pair-v2:eyJyZWxheSI6IndzczovL3JlbGF5LnBlYWNocGFzc3dvcmRzLmNvbSIsInRva2VuIjoiQUFFQ0F3UUZCZ2NJQ1FvTERBME9EdyJ9";2021import { PairDeviceView } from "./PairDevice";22import { renderView } from "./view-test-utils";232425type RuntimeListener = (msg: { type?: string; payload?: unknown }) => void;26let listeners: RuntimeListener[] = [];2728function pushFromBackground(type: string, payload: unknown): void {29 for (const l of [...listeners]) l({ type, payload });30}3132beforeEach(() => {33 listeners = [];34 mockSendMessage.mockReset();35 mockSendMessage.mockImplementation((msg: { type?: string }) => {36 if (msg?.type === "START_ADOPTION_SOURCE") {37 return Promise.resolve({38 ok: true,39 data: { invitation: SOURCE_INVITATION },40 });41 }42 if (msg?.type === "GET_SYNC_SETTINGS" || msg?.type === "SET_SYNC_SETTINGS") {43 return Promise.resolve({44 ok: true,45 data: { mode: "public", relayUrl: "wss://relay.peachpasswords.com" },46 });47 }48 return Promise.resolve({ ok: true });49 });50 (globalThis as unknown as { chrome: unknown }).chrome = {51 runtime: {52 onMessage: {53 addListener: (fn: RuntimeListener) => { listeners.push(fn); },54 removeListener: (fn: RuntimeListener) => { listeners = listeners.filter((l) => l !== fn); },55 },56 },57 };58});5960describe("PairDevice — relay adoption source", () => {61 const cleanups: Array<() => void> = [];62 afterEach(() => {63 while (cleanups.length) cleanups.pop()!();64 });6566 function mount(onLearnPro: () => void = () => {}) {67 const rendered = renderView(68 <PairDeviceView69 isPro={true}70 onBack={() => {}}71 onLearnPro={onLearnPro}72 />,73 );74 cleanups.push(rendered.dispose);75 return rendered;76 }7778 it("opens the relay room before rendering one source QR", async () => {79 const { container } = mount();80 await vi.waitFor(() => {81 expect(container.textContent).toContain("This extension has the vault");82 });8384 expect(mockSendMessage).toHaveBeenCalledWith({85 type: "START_ADOPTION_SOURCE",86 });87 expect(container.querySelector("svg.peach-pair-qr")).toBeTruthy();88 expect(container.querySelector("svg.peach-pair-qr")?.getAttribute("width")).toBe("224");89 expect(container.querySelector("#pairing-invitation-manual")).toBeNull();90 expect(container.textContent).not.toContain("Show a code instead");91 expect(container.textContent).not.toContain("peach-pair-v2:");92 });9394 95 it("names the relay in force without making it a decision", async () => {96 const { container } = mount();97 await vi.waitFor(() => {98 expect(container.textContent).toContain("This extension has the vault");99 });100101 const summary = container.querySelector(".peach-sync-mode-summary");102 expect(summary?.textContent).toContain("Using Public relay");103 104 expect(container.querySelector(".peach-sync-mode")).toBeNull();105106 container.querySelector<HTMLButtonElement>(".peach-sync-mode-change")!.click();107 await vi.waitFor(() => {108 expect(container.querySelectorAll(".peach-sync-mode")).toHaveLength(3);109 });110 111 expect(container.querySelector("svg.peach-pair-qr")).toBeNull();112 });113114 it("rebuilds the invitation when the relay changes, since the code carries it", async () => {115 const { container } = mount();116 await vi.waitFor(() => {117 expect(container.textContent).toContain("This extension has the vault");118 });119 const opened = () =>120 mockSendMessage.mock.calls.filter(121 ([m]) => (m as { type?: string })?.type === "START_ADOPTION_SOURCE",122 ).length;123 expect(opened()).toBe(1);124125 container.querySelector<HTMLButtonElement>(".peach-sync-mode-change")!.click();126 await vi.waitFor(() => {127 expect(container.querySelectorAll(".peach-sync-mode")).toHaveLength(3);128 });129 const noSync = [...container.querySelectorAll<HTMLButtonElement>("button.peach-sync-mode")].find(130 (b) => b.textContent?.includes("No sync"),131 )!;132 noSync.click();133134 await vi.waitFor(() => {135 expect(mockSendMessage).toHaveBeenCalledWith({ type: "CANCEL_ADOPTION_SOURCE" });136 expect(opened()).toBe(2);137 });138 });139140 it("shows the SAS and waits for the popup's single confirmation", async () => {141 const { container } = mount();142 await vi.waitFor(() => {143 expect(container.textContent).toContain("This extension has the vault");144 });145146 pushFromBackground("ADOPTION_SOURCE_SAS", {147 id: "test-sas-id",148 sas: "48219371",149 receiverDeviceName: "June's phone",150 });151 expect(container.textContent).toContain("June's phone");152 expect(container.textContent).toContain("48219371");153 expect(container.textContent).toContain("Compare the 8-digit safety code");154 expect(container.textContent).toContain("complete copy of this vault");155 expect(container.textContent).not.toContain("Session ID");156 expect(container.querySelector("#adoption-consent-password")).toBeNull();157 158 expect(mockSendMessage).not.toHaveBeenCalledWith(159 expect.objectContaining({ type: "SOURCE_SAS_DECISION" }),160 );161 const matchButton = [...container.querySelectorAll<HTMLButtonElement>("button")]162 .find((b) => b.textContent?.trim() === "They match");163 expect(matchButton).toBeTruthy();164 const rejectButton = [...container.querySelectorAll<HTMLButtonElement>("button")]165 .find((b) => b.textContent?.trim() === "They don't match");166 expect(rejectButton).toBeTruthy();167168 matchButton!.click();169 await vi.waitFor(() => {170 expect(mockSendMessage).toHaveBeenCalledWith({171 type: "SOURCE_SAS_DECISION",172 payload: { id: "test-sas-id", ok: true },173 });174 });175176 177 178 pushFromBackground("ADOPTION_SOURCE_RESULT", { status: "complete" });179 expect(container.textContent).toContain("Device added");180 expect(container.querySelectorAll(".peach-pair-bird-pose-happy")).toHaveLength(2);181 });182183 it("shows the Peach Pro upgrade path when neither pairing peer is Pro", async () => {184 const onLearnPro = vi.fn();185 const { container } = mount(onLearnPro);186 await vi.waitFor(() => {187 expect(container.textContent).toContain("This extension has the vault");188 });189190 pushFromBackground("ADOPTION_SOURCE_RESULT", {191 status: "error",192 error: "ADOPT_PRO_REQUIRED",193 });194195 expect(container.textContent).toContain("Pair freely");196 expect(container.textContent).toContain("at least one of these two devices");197 expect(container.textContent).toContain("$39 once");198 const openPro = [...container.querySelectorAll<HTMLButtonElement>("button")]199 .find((button) => button.textContent?.trim() === "Open Peach Pro");200 expect(openPro).toBeTruthy();201 openPro!.click();202 expect(onLearnPro).toHaveBeenCalledOnce();203 });204});