// @vitest-environment jsdom import type { JSX } from "solid-js"; import { afterEach, describe, expect, it, vi } from "vitest"; import { Onboarding } from "./Onboarding"; import { AdoptVaultView } from "./AdoptVaultView"; import { flushView, inputText, renderView } from "./view-test-utils"; vi.mock("@zxcvbn-ts/core", () => ({ zxcvbn: (value: string) => ({ score: value.length >= 16 ? 4 : 1 }), zxcvbnOptions: { setOptions: vi.fn() }, })); vi.mock("@zxcvbn-ts/language-common", () => ({ adjacencyGraphs: {}, dictionary: {}, })); vi.mock("@zxcvbn-ts/language-en", () => ({ translations: {}, dictionary: {}, })); function button(container: HTMLElement, text: string): HTMLButtonElement { const found = Array.from(container.querySelectorAll("button")).find((b) => (b.textContent ?? "").includes(text), ); if (!found) throw new Error(`button not found: ${text}`); return found as HTMLButtonElement; } async function enterStrongPassword(container: HTMLElement) { inputText(container.querySelector("#adopt-password") as HTMLInputElement, "9xQ!vZ2pLm74Tr#Ke8Wd5Bn"); inputText(container.querySelector("#adopt-confirm-password") as HTMLInputElement, "9xQ!vZ2pLm74Tr#Ke8Wd5Bn"); await flushView(); } describe("vault adoption onboarding", () => { const cleanups: Array<() => void> = []; afterEach(() => { vi.useRealTimers(); while (cleanups.length) cleanups.pop()!(); }); function mounted(view: () => JSX.Element) { const rendered = renderView(view); cleanups.push(rendered.dispose); return rendered; } it("T-A21: Onboarding separates pairing from backup restore", () => { vi.useFakeTimers(); const onComplete = vi.fn(); const onRestore = vi.fn(); const onAdopt = vi.fn(); const view = mounted(() => ); button(view.container, "next").click(); vi.advanceTimersByTime(220); button(view.container, "next").click(); vi.advanceTimersByTime(220); expect(view.container.textContent).toContain("Create vault"); expect(view.container.textContent).toContain("Already using Peach?"); expect(view.container.textContent).toContain("Pair with another device"); expect(view.container.textContent).toContain("Scan a QR and confirm one 8-digit code."); expect(view.container.textContent).toContain("Restore a backup"); button(view.container, "Pair with another device").click(); expect(onAdopt).toHaveBeenCalledTimes(1); }); it("T-A22: password screen uses the same minimum-length gate as create-vault", () => { const view = mounted(() => {}} onDone={() => {}} />); const continueButton = button(view.container, "Continue"); expect(continueButton.disabled).toBe(true); inputText(view.container.querySelector("#adopt-password") as HTMLInputElement, "short"); inputText(view.container.querySelector("#adopt-confirm-password") as HTMLInputElement, "short"); expect(view.container.textContent).toContain("need 7 more characters"); expect(continueButton.disabled).toBe(true); }); it("T-A28: pair phase shows the pairing-request QR after a strong password", async () => { const view = mounted(() => {}} onDone={() => {}} />); await enterStrongPassword(view.container); button(view.container, "Continue").click(); await flushView(); // The browser is the source of the QR now — the phone scans it. // We assert the heading + copy; the actual QR is rendered by a background // call that's out of scope for a jsdom test (chrome.runtime is undefined). expect(view.container.textContent).toContain("Pair with your other device"); expect(view.container.textContent).toContain("Scan on the device that holds your vault"); // The scan button from the old flow is gone — the phone is the scanner now. expect(view.container.textContent).not.toContain("Enter invitation manually"); }); it("shows the receiver SAS without adding a second confirmation button", async () => { type RuntimeListener = (msg: { type?: string; payload?: unknown }) => void; const listeners: RuntimeListener[] = []; Object.defineProperty(globalThis, "chrome", { configurable: true, value: { runtime: { lastError: undefined, sendMessage( message: { type?: string }, callback: (response: unknown) => void, ) { if (message.type === "START_ADOPTION_RECEIVER") { callback({ ok: true, data: { invitation: "peach-pair-v1:test" } }); } else { callback({ ok: true }); } }, onMessage: { addListener(fn: RuntimeListener) { listeners.push(fn); }, removeListener(fn: RuntimeListener) { const index = listeners.indexOf(fn); if (index >= 0) listeners.splice(index, 1); }, }, }, }, }); const view = mounted(() => {}} onDone={() => {}} />); await enterStrongPassword(view.container); button(view.container, "Continue").click(); await flushView(); for (const listener of listeners) { listener({ type: "ADOPTION_SOURCE_SAS", payload: { sas: "48219371", displayOnly: true }, }); } await flushView(); expect(view.container.textContent).toContain("48219371"); expect(view.container.textContent).toContain("Pairing continues when you confirm there"); expect(view.container.textContent).not.toContain("They match"); expect(view.container.textContent).not.toContain("They don't match"); for (const listener of listeners) { listener({ type: "ADOPTION_SOURCE_RESULT", payload: { status: "error", error: "ADOPT_PRO_REQUIRED" }, }); } await flushView(); expect(view.container.textContent).toContain("Pair freely"); expect(view.container.textContent).toContain("at least one of these two devices"); expect(view.container.textContent).toContain("$39 once"); expect(view.container.querySelector('a[href*="buy.polar.sh"]')).toBeTruthy(); }); });