123import type { JSX } from "solid-js";4import { readFileSync } from "node:fs";5import { afterEach, describe, expect, it, vi } from "vitest";6import { Onboarding } from "./Onboarding";7import { AdoptVaultView } from "./AdoptVaultView";8import { flushView, inputText, renderView } from "./view-test-utils";910vi.mock("@zxcvbn-ts/core", () => ({11 zxcvbn: (value: string) => ({ score: value.length >= 16 ? 4 : 1 }),12 zxcvbnOptions: { setOptions: vi.fn() },13}));14vi.mock("@zxcvbn-ts/language-common", () => ({15 adjacencyGraphs: {},16 dictionary: {},17}));18vi.mock("@zxcvbn-ts/language-en", () => ({19 translations: {},20 dictionary: {},21}));2223function button(container: HTMLElement, text: string): HTMLButtonElement {24 const found = Array.from(container.querySelectorAll("button")).find((b) =>25 (b.textContent ?? "").includes(text),26 );27 if (!found) throw new Error(`button not found: ${text}`);28 return found as HTMLButtonElement;29}3031async function enterStrongPassword(container: HTMLElement) {32 inputText(container.querySelector("#adopt-password") as HTMLInputElement, "9xQ!vZ2pLm74Tr#Ke8Wd5Bn");33 inputText(container.querySelector("#adopt-confirm-password") as HTMLInputElement, "9xQ!vZ2pLm74Tr#Ke8Wd5Bn");34 await flushView();35}3637describe("vault adoption onboarding", () => {38 const cleanups: Array<() => void> = [];3940 afterEach(() => {41 vi.useRealTimers();42 while (cleanups.length) cleanups.pop()!();43 });4445 function mounted(view: () => JSX.Element) {46 const rendered = renderView(view);47 cleanups.push(rendered.dispose);48 return rendered;49 }5051 it("T-A21: Onboarding separates pairing from backup restore", () => {52 vi.useFakeTimers();53 const onComplete = vi.fn();54 const onRestore = vi.fn();55 const onAdopt = vi.fn();56 const view = mounted(() => <Onboarding onComplete={onComplete} onRestore={onRestore} onAdopt={onAdopt} />);5758 button(view.container, "next").click();59 vi.advanceTimersByTime(220);60 button(view.container, "next").click();61 vi.advanceTimersByTime(220);6263 expect(view.container.textContent).toContain("Create vault");64 expect(view.container.textContent).toContain("Already using Peach?");65 expect(view.container.textContent).toContain("Pair with another device");66 expect(view.container.textContent).toContain("Scan a QR and confirm one 8-digit code.");67 expect(view.container.textContent).toContain("Restore a backup");68 button(view.container, "Pair with another device").click();69 expect(onAdopt).toHaveBeenCalledTimes(1);70 });7172 it("T-A21b: create-vault is the nav call-to-action; a final-card tap does not silently create", () => {73 vi.useFakeTimers();74 const onComplete = vi.fn();75 const view = mounted(() => <Onboarding onComplete={onComplete} onRestore={() => {}} onAdopt={() => {}} />);7677 button(view.container, "next").click();78 vi.advanceTimersByTime(220);79 button(view.container, "next").click();80 vi.advanceTimersByTime(220);8182 expect(view.container.textContent).toContain("Create vault");8384 (view.container.querySelector(".peach-onboarding-card") as HTMLElement).click();85 expect(onComplete).not.toHaveBeenCalled();8687 button(view.container, "Create vault").click();88 vi.advanceTimersByTime(250);89 expect(onComplete).toHaveBeenCalledTimes(1);90 });9192 it("T-A22: password screen uses the same minimum-length gate as create-vault", () => {93 const view = mounted(() => <AdoptVaultView onCancel={() => {}} onDone={() => {}} />);94 const continueButton = button(view.container, "Continue");95 expect(continueButton.disabled).toBe(true);96 inputText(view.container.querySelector("#adopt-password") as HTMLInputElement, "short");97 inputText(view.container.querySelector("#adopt-confirm-password") as HTMLInputElement, "short");98 expect(view.container.textContent).toContain("need 7 more characters");99 expect(continueButton.disabled).toBe(true);100 });101102 it("T-A28: pair phase shows the pairing-request QR after a strong password", async () => {103 const view = mounted(() => <AdoptVaultView onCancel={() => {}} onDone={() => {}} />);104 await enterStrongPassword(view.container);105 button(view.container, "Continue").click();106 await flushView();107108 109 110 111 expect(view.container.textContent).toContain("Pair a device");112 expect(view.container.textContent).toContain("Scan this QR in Peach");113 expect(view.container.querySelector(".peach-adopt-qr-wrap")).toBeTruthy();114 expect(view.container.querySelectorAll(".peach-adopt-bird-stage .peach-pair-bird")).toHaveLength(2);115 116 expect(view.container.textContent).not.toContain("Enter invitation manually");117 });118119 it("pins the compact receiver stage above the shared pairing-stage cascade", () => {120 const css = readFileSync("src/popup/styles/popup.css", "utf8");121 const rule = css.match(122 /\.peach-pair-stage\.peach-adopt-bird-stage\s*\{([^}]*)\}/,123 )?.[1];124125 expect(rule).toContain("height: 102px");126 expect(rule).toContain("margin: -6px auto 2px");127 expect(css).toMatch(128 /\.peach-adopt-card--pair\s*\{[^}]*border:\s*0;/s,129 );130 });131132 it("shows the receiver SAS without adding a second confirmation button", async () => {133 type RuntimeListener = (msg: { type?: string; payload?: unknown }) => void;134 const listeners: RuntimeListener[] = [];135 Object.defineProperty(globalThis, "chrome", {136 configurable: true,137 value: {138 runtime: {139 lastError: undefined,140 sendMessage(141 message: { type?: string },142 callback: (response: unknown) => void,143 ) {144 if (message.type === "START_ADOPTION_RECEIVER") {145 callback({ ok: true, data: { invitation: "peach-pair-v1:test" } });146 } else {147 callback({ ok: true });148 }149 },150 onMessage: {151 addListener(fn: RuntimeListener) { listeners.push(fn); },152 removeListener(fn: RuntimeListener) {153 const index = listeners.indexOf(fn);154 if (index >= 0) listeners.splice(index, 1);155 },156 },157 },158 },159 });160161 const view = mounted(() => <AdoptVaultView onCancel={() => {}} onDone={() => {}} />);162 await enterStrongPassword(view.container);163 button(view.container, "Continue").click();164 await flushView();165166 for (const listener of listeners) {167 listener({168 type: "ADOPTION_SOURCE_SAS",169 payload: { sas: "48219371", displayOnly: true },170 });171 }172 await flushView();173174 expect(view.container.textContent).toContain("48219371");175 expect(view.container.textContent).toContain("Confirm this code on your phone");176 expect(view.container.querySelector(".peach-adoption-sas")).toBeTruthy();177 expect(view.container.querySelector(".peach-pair-qr")).toBeNull();178 expect(view.container.textContent).not.toContain("They match");179 expect(view.container.textContent).not.toContain("They don't match");180181 for (const listener of listeners) {182 listener({183 type: "ADOPTION_SOURCE_RESULT",184 payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },185 });186 }187 await flushView();188189 expect(view.container.textContent).toContain("Pair freely");190 expect(view.container.textContent).toContain("at least one of these two devices");191 expect(view.container.textContent).toContain("$39 once");192 expect(view.container.querySelector('a[href*="buy.polar.sh"]')).toBeTruthy();193 });194195 it("continues straight to initial sync when receiver pairing completes", async () => {196 type RuntimeListener = (msg: { type?: string; payload?: unknown }) => void;197 const listeners: RuntimeListener[] = [];198 Object.defineProperty(globalThis, "chrome", {199 configurable: true,200 value: {201 runtime: {202 lastError: undefined,203 sendMessage(204 message: { type?: string },205 callback: (response: unknown) => void,206 ) {207 if (message.type === "START_ADOPTION_RECEIVER") {208 callback({ ok: true, data: { invitation: "peach-pair-v1:test" } });209 } else {210 callback({ ok: true });211 }212 },213 onMessage: {214 addListener(fn: RuntimeListener) { listeners.push(fn); },215 removeListener(fn: RuntimeListener) {216 const index = listeners.indexOf(fn);217 if (index >= 0) listeners.splice(index, 1);218 },219 },220 },221 },222 });223 const onDone = vi.fn();224 const view = mounted(() => (225 <AdoptVaultView onCancel={() => {}} onDone={onDone} />226 ));227 await enterStrongPassword(view.container);228 button(view.container, "Continue").click();229 await flushView();230 vi.useFakeTimers();231232 for (const listener of listeners) {233 listener({234 type: "ADOPTION_SOURCE_RESULT",235 payload: { status: "complete" },236 });237 }238239 expect(onDone).not.toHaveBeenCalled();240 expect(view.container.textContent).toContain("Paired");241 expect(view.container.textContent).toContain("Secure connection established");242 expect(view.container.querySelector(".peach-adopt-bird-stage.act-paired")).toBeTruthy();243 expect(view.container.querySelectorAll(".peach-pair-bird-pose-happy")).toHaveLength(2);244 expect(view.container.querySelector(".peach-adopt-connection")).toBeTruthy();245 expect(view.container.textContent).not.toContain("Joined your vault");246 expect(view.container.textContent).not.toContain("Open Peach");247248 vi.advanceTimersByTime(4_999);249 expect(onDone).not.toHaveBeenCalled();250 vi.advanceTimersByTime(1);251 expect(onDone).toHaveBeenCalledOnce();252 });253});