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-A22: password screen uses the same minimum-length gate as create-vault", () => {73 const view = mounted(() => <AdoptVaultView onCancel={() => {}} onDone={() => {}} />);74 const continueButton = button(view.container, "Continue");75 expect(continueButton.disabled).toBe(true);76 inputText(view.container.querySelector("#adopt-password") as HTMLInputElement, "short");77 inputText(view.container.querySelector("#adopt-confirm-password") as HTMLInputElement, "short");78 expect(view.container.textContent).toContain("need 7 more characters");79 expect(continueButton.disabled).toBe(true);80 });8182 it("T-A28: pair phase shows the pairing-request QR after a strong password", async () => {83 const view = mounted(() => <AdoptVaultView onCancel={() => {}} onDone={() => {}} />);84 await enterStrongPassword(view.container);85 button(view.container, "Continue").click();86 await flushView();8788 89 90 91 expect(view.container.textContent).toContain("Pair a device");92 expect(view.container.textContent).toContain("Scan this QR in Peach");93 expect(view.container.querySelector(".peach-adopt-qr-wrap")).toBeTruthy();94 expect(view.container.querySelectorAll(".peach-adopt-bird-stage .peach-pair-bird")).toHaveLength(2);95 96 expect(view.container.textContent).not.toContain("Enter invitation manually");97 });9899 it("pins the compact receiver stage above the shared pairing-stage cascade", () => {100 const css = readFileSync("src/popup/styles/popup.css", "utf8");101 const rule = css.match(102 /\.peach-pair-stage\.peach-adopt-bird-stage\s*\{([^}]*)\}/,103 )?.[1];104105 expect(rule).toContain("height: 102px");106 expect(rule).toContain("margin: -6px auto 2px");107 expect(css).toMatch(108 /\.peach-adopt-card--pair\s*\{[^}]*border:\s*0;/s,109 );110 });111112 it("shows the receiver SAS without adding a second confirmation button", async () => {113 type RuntimeListener = (msg: { type?: string; payload?: unknown }) => void;114 const listeners: RuntimeListener[] = [];115 Object.defineProperty(globalThis, "chrome", {116 configurable: true,117 value: {118 runtime: {119 lastError: undefined,120 sendMessage(121 message: { type?: string },122 callback: (response: unknown) => void,123 ) {124 if (message.type === "START_ADOPTION_RECEIVER") {125 callback({ ok: true, data: { invitation: "peach-pair-v1:test" } });126 } else {127 callback({ ok: true });128 }129 },130 onMessage: {131 addListener(fn: RuntimeListener) { listeners.push(fn); },132 removeListener(fn: RuntimeListener) {133 const index = listeners.indexOf(fn);134 if (index >= 0) listeners.splice(index, 1);135 },136 },137 },138 },139 });140141 const view = mounted(() => <AdoptVaultView onCancel={() => {}} onDone={() => {}} />);142 await enterStrongPassword(view.container);143 button(view.container, "Continue").click();144 await flushView();145146 for (const listener of listeners) {147 listener({148 type: "ADOPTION_SOURCE_SAS",149 payload: { sas: "48219371", displayOnly: true },150 });151 }152 await flushView();153154 expect(view.container.textContent).toContain("48219371");155 expect(view.container.textContent).toContain("Confirm this code on your phone");156 expect(view.container.querySelector(".peach-adoption-sas")).toBeTruthy();157 expect(view.container.querySelector(".peach-pair-qr")).toBeNull();158 expect(view.container.textContent).not.toContain("They match");159 expect(view.container.textContent).not.toContain("They don't match");160161 for (const listener of listeners) {162 listener({163 type: "ADOPTION_SOURCE_RESULT",164 payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },165 });166 }167 await flushView();168169 expect(view.container.textContent).toContain("Pair freely");170 expect(view.container.textContent).toContain("at least one of these two devices");171 expect(view.container.textContent).toContain("$39 once");172 expect(view.container.querySelector('a[href*="buy.polar.sh"]')).toBeTruthy();173 });174175 it("continues straight to initial sync when receiver pairing completes", async () => {176 type RuntimeListener = (msg: { type?: string; payload?: unknown }) => void;177 const listeners: RuntimeListener[] = [];178 Object.defineProperty(globalThis, "chrome", {179 configurable: true,180 value: {181 runtime: {182 lastError: undefined,183 sendMessage(184 message: { type?: string },185 callback: (response: unknown) => void,186 ) {187 if (message.type === "START_ADOPTION_RECEIVER") {188 callback({ ok: true, data: { invitation: "peach-pair-v1:test" } });189 } else {190 callback({ ok: true });191 }192 },193 onMessage: {194 addListener(fn: RuntimeListener) { listeners.push(fn); },195 removeListener(fn: RuntimeListener) {196 const index = listeners.indexOf(fn);197 if (index >= 0) listeners.splice(index, 1);198 },199 },200 },201 },202 });203 const onDone = vi.fn();204 const view = mounted(() => (205 <AdoptVaultView onCancel={() => {}} onDone={onDone} />206 ));207 await enterStrongPassword(view.container);208 button(view.container, "Continue").click();209 await flushView();210 vi.useFakeTimers();211212 for (const listener of listeners) {213 listener({214 type: "ADOPTION_SOURCE_RESULT",215 payload: { status: "complete" },216 });217 }218219 expect(onDone).not.toHaveBeenCalled();220 expect(view.container.textContent).toContain("Paired");221 expect(view.container.textContent).toContain("Secure connection established");222 expect(view.container.querySelector(".peach-adopt-bird-stage.act-paired")).toBeTruthy();223 expect(view.container.querySelectorAll(".peach-pair-bird-pose-happy")).toHaveLength(2);224 expect(view.container.querySelector(".peach-adopt-connection")).toBeTruthy();225 expect(view.container.textContent).not.toContain("Joined your vault");226 expect(view.container.textContent).not.toContain("Open Peach");227228 vi.advanceTimersByTime(4_999);229 expect(onDone).not.toHaveBeenCalled();230 vi.advanceTimersByTime(1);231 expect(onDone).toHaveBeenCalledOnce();232 });233});