123import type { JSX } from "solid-js";4import { afterEach, describe, expect, it, vi } from "vitest";5import { Onboarding } from "./Onboarding";6import { AdoptVaultView } from "./AdoptVaultView";7import { flushView, inputText, renderView } from "./view-test-utils";89vi.mock("@zxcvbn-ts/core", () => ({10 zxcvbn: (value: string) => ({ score: value.length >= 16 ? 4 : 1 }),11 zxcvbnOptions: { setOptions: vi.fn() },12}));13vi.mock("@zxcvbn-ts/language-common", () => ({14 adjacencyGraphs: {},15 dictionary: {},16}));17vi.mock("@zxcvbn-ts/language-en", () => ({18 translations: {},19 dictionary: {},20}));2122function button(container: HTMLElement, text: string): HTMLButtonElement {23 const found = Array.from(container.querySelectorAll("button")).find((b) =>24 (b.textContent ?? "").includes(text),25 );26 if (!found) throw new Error(`button not found: ${text}`);27 return found as HTMLButtonElement;28}2930async function enterStrongPassword(container: HTMLElement) {31 inputText(container.querySelector("#adopt-password") as HTMLInputElement, "9xQ!vZ2pLm74Tr#Ke8Wd5Bn");32 inputText(container.querySelector("#adopt-confirm-password") as HTMLInputElement, "9xQ!vZ2pLm74Tr#Ke8Wd5Bn");33 await flushView();34}3536describe("vault adoption onboarding", () => {37 const cleanups: Array<() => void> = [];3839 afterEach(() => {40 vi.useRealTimers();41 while (cleanups.length) cleanups.pop()!();42 });4344 function mounted(view: () => JSX.Element) {45 const rendered = renderView(view);46 cleanups.push(rendered.dispose);47 return rendered;48 }4950 it("T-A21: Onboarding separates pairing from backup restore", () => {51 vi.useFakeTimers();52 const onComplete = vi.fn();53 const onRestore = vi.fn();54 const onAdopt = vi.fn();55 const view = mounted(() => <Onboarding onComplete={onComplete} onRestore={onRestore} onAdopt={onAdopt} />);5657 button(view.container, "next").click();58 vi.advanceTimersByTime(220);59 button(view.container, "next").click();60 vi.advanceTimersByTime(220);6162 expect(view.container.textContent).toContain("Create vault");63 expect(view.container.textContent).toContain("Already using Peach?");64 expect(view.container.textContent).toContain("Pair with another device");65 expect(view.container.textContent).toContain("Scan a QR and confirm one 8-digit code.");66 expect(view.container.textContent).toContain("Restore a backup");67 button(view.container, "Pair with another device").click();68 expect(onAdopt).toHaveBeenCalledTimes(1);69 });7071 it("T-A22: password screen uses the same minimum-length gate as create-vault", () => {72 const view = mounted(() => <AdoptVaultView onCancel={() => {}} onDone={() => {}} />);73 const continueButton = button(view.container, "Continue");74 expect(continueButton.disabled).toBe(true);75 inputText(view.container.querySelector("#adopt-password") as HTMLInputElement, "short");76 inputText(view.container.querySelector("#adopt-confirm-password") as HTMLInputElement, "short");77 expect(view.container.textContent).toContain("need 7 more characters");78 expect(continueButton.disabled).toBe(true);79 });8081 it("T-A28: pair phase shows the pairing-request QR after a strong password", async () => {82 const view = mounted(() => <AdoptVaultView onCancel={() => {}} onDone={() => {}} />);83 await enterStrongPassword(view.container);84 button(view.container, "Continue").click();85 await flushView();8687 88 89 90 expect(view.container.textContent).toContain("Pair a device");91 expect(view.container.textContent).toContain("Scan this QR in Peach");92 expect(view.container.querySelector(".peach-adopt-qr-wrap")).toBeTruthy();93 expect(view.container.querySelectorAll(".peach-adopt-bird-stage .peach-pair-bird")).toHaveLength(2);94 95 expect(view.container.textContent).not.toContain("Enter invitation manually");96 });9798 it("shows the receiver SAS without adding a second confirmation button", async () => {99 type RuntimeListener = (msg: { type?: string; payload?: unknown }) => void;100 const listeners: RuntimeListener[] = [];101 Object.defineProperty(globalThis, "chrome", {102 configurable: true,103 value: {104 runtime: {105 lastError: undefined,106 sendMessage(107 message: { type?: string },108 callback: (response: unknown) => void,109 ) {110 if (message.type === "START_ADOPTION_RECEIVER") {111 callback({ ok: true, data: { invitation: "peach-pair-v1:test" } });112 } else {113 callback({ ok: true });114 }115 },116 onMessage: {117 addListener(fn: RuntimeListener) { listeners.push(fn); },118 removeListener(fn: RuntimeListener) {119 const index = listeners.indexOf(fn);120 if (index >= 0) listeners.splice(index, 1);121 },122 },123 },124 },125 });126127 const view = mounted(() => <AdoptVaultView onCancel={() => {}} onDone={() => {}} />);128 await enterStrongPassword(view.container);129 button(view.container, "Continue").click();130 await flushView();131132 for (const listener of listeners) {133 listener({134 type: "ADOPTION_SOURCE_SAS",135 payload: { sas: "48219371", displayOnly: true },136 });137 }138 await flushView();139140 expect(view.container.textContent).toContain("48219371");141 expect(view.container.textContent).toContain("Confirm this code on your phone");142 expect(view.container.querySelector(".peach-adoption-sas")).toBeTruthy();143 expect(view.container.querySelector(".peach-pair-qr")).toBeNull();144 expect(view.container.textContent).not.toContain("They match");145 expect(view.container.textContent).not.toContain("They don't match");146147 for (const listener of listeners) {148 listener({149 type: "ADOPTION_SOURCE_RESULT",150 payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },151 });152 }153 await flushView();154155 expect(view.container.textContent).toContain("Pair freely");156 expect(view.container.textContent).toContain("at least one of these two devices");157 expect(view.container.textContent).toContain("$39 once");158 expect(view.container.querySelector('a[href*="buy.polar.sh"]')).toBeTruthy();159 });160161 it("continues straight to initial sync when receiver pairing completes", async () => {162 type RuntimeListener = (msg: { type?: string; payload?: unknown }) => void;163 const listeners: RuntimeListener[] = [];164 Object.defineProperty(globalThis, "chrome", {165 configurable: true,166 value: {167 runtime: {168 lastError: undefined,169 sendMessage(170 message: { type?: string },171 callback: (response: unknown) => void,172 ) {173 if (message.type === "START_ADOPTION_RECEIVER") {174 callback({ ok: true, data: { invitation: "peach-pair-v1:test" } });175 } else {176 callback({ ok: true });177 }178 },179 onMessage: {180 addListener(fn: RuntimeListener) { listeners.push(fn); },181 removeListener(fn: RuntimeListener) {182 const index = listeners.indexOf(fn);183 if (index >= 0) listeners.splice(index, 1);184 },185 },186 },187 },188 });189 const onDone = vi.fn();190 const view = mounted(() => (191 <AdoptVaultView onCancel={() => {}} onDone={onDone} />192 ));193 await enterStrongPassword(view.container);194 button(view.container, "Continue").click();195 await flushView();196 vi.useFakeTimers();197198 for (const listener of listeners) {199 listener({200 type: "ADOPTION_SOURCE_RESULT",201 payload: { status: "complete" },202 });203 }204205 expect(onDone).not.toHaveBeenCalled();206 expect(view.container.textContent).toContain("Paired");207 expect(view.container.textContent).toContain("Secure connection established");208 expect(view.container.querySelector(".peach-adopt-bird-stage.act-paired")).toBeTruthy();209 expect(view.container.querySelectorAll(".peach-pair-bird-pose-happy")).toHaveLength(2);210 expect(view.container.querySelector(".peach-adopt-connection")).toBeTruthy();211 expect(view.container.textContent).not.toContain("Joined your vault");212 expect(view.container.textContent).not.toContain("Open Peach");213214 vi.advanceTimersByTime(4_999);215 expect(onDone).not.toHaveBeenCalled();216 vi.advanceTimersByTime(1);217 expect(onDone).toHaveBeenCalledOnce();218 });219});