12import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";3import { DEFAULT_RELAY_URL } from "../../shared/rendezvous";4import { flushView, inputText, renderView } from "../views/view-test-utils";56const sendMessage = vi.hoisted(() => vi.fn());7vi.mock("../../shared/bridge", () => ({ sendMessage }));89import { RELAY_DISCLOSURE, SyncModeSelector } from "./SyncModeSelector";1011let settings = { mode: "public" as "public" | "private" | "off", relayUrl: DEFAULT_RELAY_URL };12let disposeView: (() => void) | null = null;1314beforeEach(() => {15 settings = { mode: "public", relayUrl: DEFAULT_RELAY_URL };16 sendMessage.mockReset();17 sendMessage.mockImplementation(async (msg: { type: string; payload?: Record<string, unknown> }) => {18 if (msg.type === "GET_SYNC_SETTINGS") return { ok: true, data: settings };19 if (msg.type === "SET_SYNC_SETTINGS") {20 const mode = msg.payload?.mode as typeof settings.mode | undefined;21 if (mode) {22 settings = { mode, relayUrl: mode === "public" ? DEFAULT_RELAY_URL : settings.relayUrl };23 } else {24 const url = String(msg.payload?.relayUrl ?? "");25 if (!url.startsWith("wss://")) return { ok: false, error: "invalid_relay_url" };26 settings = { mode: url === DEFAULT_RELAY_URL ? "public" : "private", relayUrl: url };27 }28 return { ok: true, data: settings };29 }30 return { ok: false };31 });32});3334afterEach(() => {35 disposeView?.();36 disposeView = null;37});3839async function mount(compact = false) {40 const view = renderView(() => <SyncModeSelector compact={compact} />);41 disposeView = view.dispose;42 await flushView();43 return view.container;44}4546function optionNamed(container: HTMLElement, title: string): HTMLButtonElement {47 const match = [...container.querySelectorAll<HTMLButtonElement>("button.peach-sync-mode")].find(48 (b) => b.querySelector(".peach-sync-mode-title")?.textContent === title,49 );50 if (!match) throw new Error(`no option titled ${title}`);51 return match;52}5354describe("sync mode selector", () => {55 it("offers exactly the three named choices as a radio group", async () => {56 const container = await mount();57 const titles = [...container.querySelectorAll(".peach-sync-mode-title")].map((n) => n.textContent);58 expect(titles).toEqual(["Public relay", "Private relay", "No sync"]);59 expect(container.querySelector("[role='radiogroup']")).not.toBeNull();60 expect(optionNamed(container, "Public relay").getAttribute("aria-checked")).toBe("true");61 });6263 it("shows the disclosure once, whichever state is active", async () => {64 const container = await mount();65 expect(container.querySelectorAll(".peach-sync-mode-disclosure")).toHaveLength(1);66 expect(container.querySelector(".peach-sync-mode-disclosure")?.textContent).toBe(RELAY_DISCLOSURE);67 optionNamed(container, "No sync").click();68 await flushView();69 expect(container.querySelector(".peach-sync-mode-disclosure")?.textContent).toBe(RELAY_DISCLOSURE);70 });7172 it("shows no URL field under public or off", async () => {73 const container = await mount();74 expect(container.querySelector("#sync-relay-url")).toBeNull();75 optionNamed(container, "No sync").click();76 await flushView();77 expect(container.querySelector("#sync-relay-url")).toBeNull();78 });7980 it("flags private with no URL instead of silently using the public relay", async () => {81 const container = await mount();82 optionNamed(container, "Private relay").click();83 await flushView();84 const field = container.querySelector<HTMLInputElement>("#sync-relay-url");85 expect(field).not.toBeNull();86 87 expect(field!.value).toBe("wss://");88 const warn = container.querySelector(".peach-sync-mode-warn");89 expect(warn?.textContent).toContain("Sync is paused until you enter your relay URL");90 });9192 it("clears the warning once a real relay is saved", async () => {93 const container = await mount();94 optionNamed(container, "Private relay").click();95 await flushView();96 inputText(container.querySelector<HTMLInputElement>("#sync-relay-url")!, "wss://relay.example.com");97 [...container.querySelectorAll<HTMLButtonElement>("button")].find((b) => b.textContent === "save")!.click();98 await flushView();99 expect(container.querySelector(".peach-sync-mode-warn")).toBeNull();100 expect(settings).toEqual({ mode: "private", relayUrl: "wss://relay.example.com" });101 });102});103104describe("compact form, used mid-pairing", () => {105 it("is a one-line summary until asked to change", async () => {106 const container = await mount(true);107 expect(container.querySelector(".peach-sync-mode-summary")?.textContent).toContain(108 "Using Public relay",109 );110 expect(container.querySelector(".peach-sync-mode")).toBeNull();111 container.querySelector<HTMLButtonElement>(".peach-sync-mode-change")!.click();112 await flushView();113 expect(container.querySelectorAll(".peach-sync-mode")).toHaveLength(3);114 });115116 it("reports a committed change so the host can restart pairing", async () => {117 const onChange = vi.fn();118 const view = renderView(() => <SyncModeSelector compact onChange={onChange} />);119 disposeView = view.dispose;120 await flushView();121 view.container.querySelector<HTMLButtonElement>(".peach-sync-mode-change")!.click();122 await flushView();123 optionNamed(view.container, "No sync").click();124 await flushView();125 expect(onChange).toHaveBeenCalledWith({ mode: "off", relayUrl: DEFAULT_RELAY_URL });126 });127});