// @vitest-environment jsdom import type { JSX } from "solid-js"; import { readFileSync } from "node:fs"; import { afterEach, describe, expect, it, vi } from "vitest"; import { ONBOARDING_CARD_STORAGE_KEY, ONBOARDING_LAST_CARD, 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-A21c: resumes on the final card when re-entered from a leave flow", async () => { const onComplete = vi.fn(); const view = mounted(() => ( {}} onAdopt={() => {}} initialCard={ONBOARDING_LAST_CARD} /> )); // Landing straight on the final card shows the exit actions, not card 0. expect(view.container.textContent).toContain("Create vault"); expect(view.container.textContent).toContain("Already using Peach?"); expect(view.container.textContent).not.toContain("Two keys."); // Back (matched by class — the "Restore a backup" label also contains the // substring "back") still responds and steps to the previous card. (view.container.querySelector(".peach-onboarding-back") as HTMLButtonElement).click(); expect(view.container.querySelector(".peach-onboarding-card")?.classList.contains("is-exiting")).toBe(true); await new Promise((r) => setTimeout(r, 300)); expect(view.container.textContent).toContain("Two keys."); expect(view.container.textContent).not.toContain("Create vault"); }); it("T-A21d: persists the card on every step so a closed popup resumes mid-onboarding", async () => { const original = Object.getOwnPropertyDescriptor(globalThis, "chrome"); const sets: Array> = []; try { Object.defineProperty(globalThis, "chrome", { configurable: true, value: { storage: { local: { set: vi.fn(async (obj: Record) => { sets.push(obj); }), }, }, }, }); const view = mounted(() => ( {}} onRestore={() => {}} onAdopt={() => {}} /> )); button(view.container, "next").click(); await new Promise((r) => setTimeout(r, 300)); (view.container.querySelector(".peach-onboarding-back") as HTMLButtonElement).click(); await new Promise((r) => setTimeout(r, 300)); const saved = sets.flatMap((s) => Object.entries(s)); expect(saved).toContainEqual([ONBOARDING_CARD_STORAGE_KEY, "1"]); expect(saved[saved.length - 1]).toEqual([ONBOARDING_CARD_STORAGE_KEY, "0"]); } finally { if (original) Object.defineProperty(globalThis, "chrome", original); else delete (globalThis as { chrome?: unknown }).chrome; } }); it("T-A21b: create-vault is the nav call-to-action; a final-card tap does not silently create", () => { vi.useFakeTimers(); const onComplete = vi.fn(); const view = mounted(() => {}} onAdopt={() => {}} />); button(view.container, "next").click(); vi.advanceTimersByTime(220); button(view.container, "next").click(); vi.advanceTimersByTime(220); expect(view.container.textContent).toContain("Create vault"); (view.container.querySelector(".peach-onboarding-card") as HTMLElement).click(); expect(onComplete).not.toHaveBeenCalled(); button(view.container, "Create vault").click(); vi.advanceTimersByTime(250); expect(onComplete).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 a device"); expect(view.container.textContent).toContain("Scan this QR in Peach"); expect(view.container.querySelector(".peach-adopt-qr-wrap")).toBeTruthy(); expect(view.container.querySelectorAll(".peach-adopt-bird-stage .peach-pair-bird")).toHaveLength(2); // 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("pins the compact receiver stage above the shared pairing-stage cascade", () => { const css = readFileSync("src/popup/styles/popup.css", "utf8"); const rule = css.match( /\.peach-pair-stage\.peach-adopt-bird-stage\s*\{([^}]*)\}/, )?.[1]; expect(rule).toContain("height: 102px"); expect(rule).toContain("margin: -6px auto 2px"); expect(css).toMatch( /\.peach-adopt-card--pair\s*\{[^}]*border:\s*0;/s, ); }); it("shows the receiver SAS and requires explicit confirmation", 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-v2: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: { id: "receiver-sas-id", sas: "48219371", displayOnly: false }, }); } await flushView(); expect(view.container.textContent).toContain("48219371"); expect(view.container.textContent).toContain("Confirm this code matches the one on your phone"); expect(button(view.container, "Confirm code")).toBeTruthy(); expect(view.container.querySelector(".peach-adoption-sas")).toBeTruthy(); expect(view.container.querySelector(".peach-pair-qr")).toBeNull(); 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(); }); it("continues straight to initial sync when receiver pairing completes", 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-v2: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 onDone = vi.fn(); const view = mounted(() => ( {}} onDone={onDone} /> )); await enterStrongPassword(view.container); button(view.container, "Continue").click(); await flushView(); vi.useFakeTimers(); for (const listener of listeners) { listener({ type: "ADOPTION_SOURCE_RESULT", payload: { status: "complete" }, }); } expect(onDone).not.toHaveBeenCalled(); expect(view.container.textContent).toContain("Paired"); expect(view.container.textContent).toContain("Secure connection established"); expect(view.container.querySelector(".peach-adopt-bird-stage.act-paired")).toBeTruthy(); expect(view.container.querySelectorAll(".peach-pair-bird-pose-happy")).toHaveLength(2); expect(view.container.querySelector(".peach-adopt-connection")).toBeTruthy(); expect(view.container.textContent).not.toContain("Joined your vault"); expect(view.container.textContent).not.toContain("Open Peach"); vi.advanceTimersByTime(4_999); expect(onDone).not.toHaveBeenCalled(); vi.advanceTimersByTime(1); expect(onDone).toHaveBeenCalledOnce(); }); });