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-v1:eyJyZWxheSI6IndzczovL3JlbGF5LnBlYWNocGFzc3dvcmRzLmNvbSIsInRva2VuIjoiQUFFQ0F3UUZCZ2NJQ1FvTERBME9EdyJ9";2021import { PairDeviceView } from "./PairDevice";22import { renderView, inputText } 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 === "ADOPTION_SOURCE_CONFIRM") {43 return Promise.resolve({ ok: true, data: { valid: true } });44 }45 if (msg?.type === "GET_SYNC_SETTINGS" || msg?.type === "SET_SYNC_SETTINGS") {46 return Promise.resolve({47 ok: true,48 data: { mode: "public", relayUrl: "wss://relay.peachpasswords.com" },49 });50 }51 return Promise.resolve({ ok: true });52 });53 (globalThis as unknown as { chrome: unknown }).chrome = {54 runtime: {55 onMessage: {56 addListener: (fn: RuntimeListener) => { listeners.push(fn); },57 removeListener: (fn: RuntimeListener) => { listeners = listeners.filter((l) => l !== fn); },58 },59 },60 };61});6263describe("PairDevice — relay adoption source", () => {64 const cleanups: Array<() => void> = [];65 afterEach(() => {66 while (cleanups.length) cleanups.pop()!();67 });6869 function mount() {70 const rendered = renderView(71 <PairDeviceView72 isPro={true}73 onBack={() => {}}74 onLearnPro={() => {}}75 />,76 );77 cleanups.push(rendered.dispose);78 return rendered;79 }8081 it("opens the relay room before rendering its QR, and offers no manual copy", async () => {82 const { container } = mount();83 await vi.waitFor(() => {84 expect(container.textContent).toContain("Scan this in Peach");85 });8687 expect(mockSendMessage).toHaveBeenCalledWith({88 type: "START_ADOPTION_SOURCE",89 });90 expect(container.querySelector("svg.peach-pair-qr")).toBeTruthy();91 92 expect(container.querySelector("#pairing-invitation-manual")).toBeNull();93 });9495 it("offers the same invitation as text when a camera won't cooperate", async () => {96 const { container } = mount();97 await vi.waitFor(() => {98 expect(container.textContent).toContain("Scan this in Peach");99 });100101 const toggle = () =>102 [...container.querySelectorAll<HTMLButtonElement>("button")].find((b) =>103 /Show a code instead|Show the QR instead/.test(b.textContent ?? ""),104 )!;105 toggle().click();106107 const text = container.querySelector<HTMLTextAreaElement>("#pairing-invitation-manual");108 expect(text?.value).toBe(SOURCE_INVITATION);109 110 expect(container.textContent).toContain("single-use token");111 112 expect(container.querySelector("svg.peach-pair-qr")).toBeNull();113114 toggle().click();115 expect(container.querySelector("#pairing-invitation-manual")).toBeNull();116 expect(container.querySelector("svg.peach-pair-qr")).toBeTruthy();117 });118119 120 it("names the relay in force without making it a decision", async () => {121 const { container } = mount();122 await vi.waitFor(() => {123 expect(container.textContent).toContain("Scan this in Peach");124 });125126 const summary = container.querySelector(".peach-sync-mode-summary");127 expect(summary?.textContent).toContain("Using Public relay");128 129 expect(container.querySelector(".peach-sync-mode")).toBeNull();130131 container.querySelector<HTMLButtonElement>(".peach-sync-mode-change")!.click();132 await vi.waitFor(() => {133 expect(container.querySelectorAll(".peach-sync-mode")).toHaveLength(3);134 });135 136 expect(container.querySelector("svg.peach-pair-qr")).toBeNull();137 });138139 it("rebuilds the invitation when the relay changes, since the code carries it", async () => {140 const { container } = mount();141 await vi.waitFor(() => {142 expect(container.textContent).toContain("Scan this in Peach");143 });144 const opened = () =>145 mockSendMessage.mock.calls.filter(146 ([m]) => (m as { type?: string })?.type === "START_ADOPTION_SOURCE",147 ).length;148 expect(opened()).toBe(1);149150 container.querySelector<HTMLButtonElement>(".peach-sync-mode-change")!.click();151 await vi.waitFor(() => {152 expect(container.querySelectorAll(".peach-sync-mode")).toHaveLength(3);153 });154 const noSync = [...container.querySelectorAll<HTMLButtonElement>("button.peach-sync-mode")].find(155 (b) => b.textContent?.includes("No sync"),156 )!;157 noSync.click();158159 await vi.waitFor(() => {160 expect(mockSendMessage).toHaveBeenCalledWith({ type: "CANCEL_ADOPTION_SOURCE" });161 expect(opened()).toBe(2);162 });163 });164165 it("requires the matching SAS and master password before sharing", async () => {166 const { container } = mount();167 await vi.waitFor(() => {168 expect(container.textContent).toContain("Scan this in Peach");169 });170171 pushFromBackground("ADOPTION_SOURCE_SAS", {172 sas: "48219371",173 receiverDeviceName: "June's phone",174 });175 expect(container.textContent).toContain("June's phone");176 expect(container.textContent).toContain("48219371");177178 inputText(179 container.querySelector("#adoption-consent-password") as HTMLInputElement,180 "correct horse battery staple",181 );182 const approve = Array.from(container.querySelectorAll("button")).find(183 (button) => button.textContent?.includes("Share vault"),184 ) as HTMLButtonElement;185 approve.click();186 await vi.waitFor(() => {187 expect(mockSendMessage).toHaveBeenCalledWith({188 type: "ADOPTION_SOURCE_CONFIRM",189 payload: { password: "correct horse battery staple" },190 });191 });192 await vi.waitFor(() => {193 expect(container.textContent).toContain("Waiting for the new device");194 });195 });196});