123456789101112import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";13import { readFileSync } from "node:fs";141516const { mockSendMessage } = vi.hoisted(() => ({ mockSendMessage: vi.fn() }));17vi.mock("../../shared/bridge", () => ({ sendMessage: mockSendMessage }));1819const SOURCE_INVITATION =20 "peach-pair-v2:eyJyZWxheSI6IndzczovL3JlbGF5LnBlYWNocGFzc3dvcmRzLmNvbSIsInRva2VuIjoiQUFFQ0F3UUZCZ2NJQ1FvTERBME9EdyJ9";2122import { PairDeviceView } from "./PairDevice";23import { renderView } from "./view-test-utils";242526type RuntimeListener = (msg: { type?: string; payload?: unknown }) => void;27let listeners: RuntimeListener[] = [];2829function pushFromBackground(type: string, payload: unknown): void {30 for (const l of [...listeners]) l({ type, payload });31}3233beforeEach(() => {34 listeners = [];35 mockSendMessage.mockReset();36 mockSendMessage.mockImplementation((msg: { type?: string }) => {37 if (msg?.type === "START_ADOPTION_SOURCE") {38 return Promise.resolve({39 ok: true,40 data: { invitation: SOURCE_INVITATION },41 });42 }43 if (msg?.type === "GET_SYNC_SETTINGS" || msg?.type === "SET_SYNC_SETTINGS") {44 return Promise.resolve({45 ok: true,46 data: { mode: "public", relayUrl: "wss://relay.peachpasswords.com" },47 });48 }49 return Promise.resolve({ ok: true });50 });51 (globalThis as unknown as { chrome: unknown }).chrome = {52 runtime: {53 onMessage: {54 addListener: (fn: RuntimeListener) => { listeners.push(fn); },55 removeListener: (fn: RuntimeListener) => { listeners = listeners.filter((l) => l !== fn); },56 },57 },58 };59});6061describe("PairDevice — relay adoption source", () => {62 const cleanups: Array<() => void> = [];63 afterEach(() => {64 while (cleanups.length) cleanups.pop()!();65 });6667 function mount(onLearnPro: () => void = () => {}) {68 const rendered = renderView(69 <PairDeviceView70 isPro={true}71 onBack={() => {}}72 onLearnPro={onLearnPro}73 />,74 );75 cleanups.push(rendered.dispose);76 return rendered;77 }7879 it("reserves exactly the bird height without shrinking or adding dead space", () => {80 const css = readFileSync("src/popup/styles/popup.css", "utf8");81 expect(css).toMatch(/\.peach-pair-stage\s*\{[^}]*flex:\s*0 0 108px/s);82 expect(css).toMatch(/\.peach-pair-stage\s*\{[^}]*height:\s*108px/s);83 });8485 it("opens the relay room before rendering one source QR", async () => {86 const { container } = mount();87 await vi.waitFor(() => {88 expect(container.textContent).toContain("This extension has the vault");89 });9091 expect(mockSendMessage).toHaveBeenCalledWith({92 type: "START_ADOPTION_SOURCE",93 });94 expect(container.textContent).not.toContain("Pair a device");95 expect(container.querySelector("svg.peach-pair-qr")).toBeTruthy();96 expect(container.querySelector("svg.peach-pair-qr")?.getAttribute("width")).toBe("224");97 expect(container.querySelector("#pairing-invitation-manual")).toBeNull();98 expect(container.textContent).not.toContain("Show a code instead");99 expect(container.textContent).not.toContain("peach-pair-v2:");100 });101102 103 it("names the relay in force without making it a decision", async () => {104 const { container } = mount();105 await vi.waitFor(() => {106 expect(container.textContent).toContain("This extension has the vault");107 });108109 const summary = container.querySelector(".peach-sync-mode-summary");110 expect(summary?.textContent).toContain("Using Public relay");111 112 expect(container.querySelector(".peach-sync-mode")).toBeNull();113114 container.querySelector<HTMLButtonElement>(".peach-sync-mode-change")!.click();115 await vi.waitFor(() => {116 expect(container.querySelectorAll(".peach-sync-mode")).toHaveLength(3);117 });118 119 expect(container.querySelector("svg.peach-pair-qr")).toBeNull();120 });121122 it("rebuilds the invitation when the relay changes, since the code carries it", async () => {123 const { container } = mount();124 await vi.waitFor(() => {125 expect(container.textContent).toContain("This extension has the vault");126 });127 const opened = () =>128 mockSendMessage.mock.calls.filter(129 ([m]) => (m as { type?: string })?.type === "START_ADOPTION_SOURCE",130 ).length;131 expect(opened()).toBe(1);132133 container.querySelector<HTMLButtonElement>(".peach-sync-mode-change")!.click();134 await vi.waitFor(() => {135 expect(container.querySelectorAll(".peach-sync-mode")).toHaveLength(3);136 });137 const noSync = [...container.querySelectorAll<HTMLButtonElement>("button.peach-sync-mode")].find(138 (b) => b.textContent?.includes("No sync"),139 )!;140 noSync.click();141142 await vi.waitFor(() => {143 expect(mockSendMessage).toHaveBeenCalledWith({ type: "CANCEL_ADOPTION_SOURCE" });144 expect(opened()).toBe(2);145 });146 });147148 it("shows the SAS and waits for the popup's single confirmation", async () => {149 const { container } = mount();150 await vi.waitFor(() => {151 expect(container.textContent).toContain("This extension has the vault");152 });153154 pushFromBackground("ADOPTION_SOURCE_SAS", {155 id: "test-sas-id",156 sas: "48219371",157 receiverDeviceName: "June's phone",158 });159 expect(container.textContent).toContain("June's phone");160 expect(container.textContent).toContain("48219371");161 expect(container.textContent).toContain("Compare the 8-digit safety code");162 expect(container.textContent).toContain("complete copy of this vault");163 expect(container.textContent).not.toContain("Session ID");164 expect(container.querySelector("#adoption-consent-password")).toBeNull();165 166 expect(mockSendMessage).not.toHaveBeenCalledWith(167 expect.objectContaining({ type: "SOURCE_SAS_DECISION" }),168 );169 const matchButton = [...container.querySelectorAll<HTMLButtonElement>("button")]170 .find((b) => b.textContent?.trim() === "They match");171 expect(matchButton).toBeTruthy();172 const rejectButton = [...container.querySelectorAll<HTMLButtonElement>("button")]173 .find((b) => b.textContent?.trim() === "They don't match");174 expect(rejectButton).toBeTruthy();175176 matchButton!.click();177 await vi.waitFor(() => {178 expect(mockSendMessage).toHaveBeenCalledWith({179 type: "SOURCE_SAS_DECISION",180 payload: { id: "test-sas-id", ok: true },181 });182 });183184 185 186 pushFromBackground("ADOPTION_SOURCE_RESULT", { status: "complete" });187 expect(container.textContent).toContain("Device added");188 expect(container.querySelectorAll(".peach-pair-bird-pose-happy")).toHaveLength(2);189 });190191 it("shows the Peach Pro upgrade path when neither pairing peer is Pro", async () => {192 const onLearnPro = vi.fn();193 const { container } = mount(onLearnPro);194 await vi.waitFor(() => {195 expect(container.textContent).toContain("This extension has the vault");196 });197198 pushFromBackground("ADOPTION_SOURCE_RESULT", {199 status: "error",200 error: "ADOPT_PRO_REQUIRED",201 });202203 expect(container.textContent).toContain("Pair freely");204 expect(container.textContent).toContain("at least one of these two devices");205 expect(container.textContent).toContain("$39 once");206 const openPro = [...container.querySelectorAll<HTMLButtonElement>("button")]207 .find((button) => button.textContent?.trim() === "Open Peach Pro");208 expect(openPro).toBeTruthy();209 openPro!.click();210 expect(onLearnPro).toHaveBeenCalledOnce();211 });212});