123import type { JSX } from "solid-js";4import { readFileSync } from "node:fs";5import { afterEach, describe, expect, it, vi } from "vitest";6import { ONBOARDING_CARD_STORAGE_KEY, ONBOARDING_LAST_CARD, 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-A21c: resumes on the final card when re-entered from a leave flow", async () => {73 const onComplete = vi.fn();74 const view = mounted(() => (75 <Onboarding onComplete={onComplete} onRestore={() => {}} onAdopt={() => {}} initialCard={ONBOARDING_LAST_CARD} />76 ));7778 79 expect(view.container.textContent).toContain("Create vault");80 expect(view.container.textContent).toContain("Already using Peach?");81 expect(view.container.textContent).not.toContain("Two keys.");8283 84 85 (view.container.querySelector(".peach-onboarding-back") as HTMLButtonElement).click();86 expect(view.container.querySelector(".peach-onboarding-card")?.classList.contains("is-exiting")).toBe(true);87 await new Promise((r) => setTimeout(r, 300));88 expect(view.container.textContent).toContain("Two keys.");89 expect(view.container.textContent).not.toContain("Create vault");90 });9192 it("T-A21d: persists the card on every step so a closed popup resumes mid-onboarding", async () => {93 const original = Object.getOwnPropertyDescriptor(globalThis, "chrome");94 const sets: Array<Record<string, string>> = [];95 try {96 Object.defineProperty(globalThis, "chrome", {97 configurable: true,98 value: {99 storage: {100 local: {101 set: vi.fn(async (obj: Record<string, string>) => {102 sets.push(obj);103 }),104 },105 },106 },107 });108109 const view = mounted(() => (110 <Onboarding onComplete={() => {}} onRestore={() => {}} onAdopt={() => {}} />111 ));112 button(view.container, "next").click();113 await new Promise((r) => setTimeout(r, 300));114 (view.container.querySelector(".peach-onboarding-back") as HTMLButtonElement).click();115 await new Promise((r) => setTimeout(r, 300));116117 const saved = sets.flatMap((s) => Object.entries(s));118 expect(saved).toContainEqual([ONBOARDING_CARD_STORAGE_KEY, "1"]);119 expect(saved[saved.length - 1]).toEqual([ONBOARDING_CARD_STORAGE_KEY, "0"]);120 } finally {121 if (original) Object.defineProperty(globalThis, "chrome", original);122 else delete (globalThis as { chrome?: unknown }).chrome;123 }124 });125126 it("T-A21b: create-vault is the nav call-to-action; a final-card tap does not silently create", () => {127 vi.useFakeTimers();128 const onComplete = vi.fn();129 const view = mounted(() => <Onboarding onComplete={onComplete} onRestore={() => {}} onAdopt={() => {}} />);130131 button(view.container, "next").click();132 vi.advanceTimersByTime(220);133 button(view.container, "next").click();134 vi.advanceTimersByTime(220);135136 expect(view.container.textContent).toContain("Create vault");137138 (view.container.querySelector(".peach-onboarding-card") as HTMLElement).click();139 expect(onComplete).not.toHaveBeenCalled();140141 button(view.container, "Create vault").click();142 vi.advanceTimersByTime(250);143 expect(onComplete).toHaveBeenCalledTimes(1);144 });145146 it("T-A22: password screen uses the same minimum-length gate as create-vault", () => {147 const view = mounted(() => <AdoptVaultView onCancel={() => {}} onDone={() => {}} />);148 const continueButton = button(view.container, "Continue");149 expect(continueButton.disabled).toBe(true);150 inputText(view.container.querySelector("#adopt-password") as HTMLInputElement, "short");151 inputText(view.container.querySelector("#adopt-confirm-password") as HTMLInputElement, "short");152 expect(view.container.textContent).toContain("need 7 more characters");153 expect(continueButton.disabled).toBe(true);154 });155156 it("T-A28: pair phase shows the pairing-request QR after a strong password", async () => {157 const view = mounted(() => <AdoptVaultView onCancel={() => {}} onDone={() => {}} />);158 await enterStrongPassword(view.container);159 button(view.container, "Continue").click();160 await flushView();161162 163 164 165 expect(view.container.textContent).toContain("Pair a device");166 expect(view.container.textContent).toContain("Scan this QR in Peach");167 expect(view.container.querySelector(".peach-adopt-qr-wrap")).toBeTruthy();168 expect(view.container.querySelectorAll(".peach-adopt-bird-stage .peach-pair-bird")).toHaveLength(2);169 170 expect(view.container.textContent).not.toContain("Enter invitation manually");171 });172173 it("pins the compact receiver stage above the shared pairing-stage cascade", () => {174 const css = readFileSync("src/popup/styles/popup.css", "utf8");175 const rule = css.match(176 /\.peach-pair-stage\.peach-adopt-bird-stage\s*\{([^}]*)\}/,177 )?.[1];178179 expect(rule).toContain("height: 102px");180 expect(rule).toContain("margin: -6px auto 2px");181 expect(css).toMatch(182 /\.peach-adopt-card--pair\s*\{[^}]*border:\s*0;/s,183 );184 });185186 it("shows the receiver SAS and requires explicit confirmation", async () => {187 type RuntimeListener = (msg: { type?: string; payload?: unknown }) => void;188 const listeners: RuntimeListener[] = [];189 Object.defineProperty(globalThis, "chrome", {190 configurable: true,191 value: {192 runtime: {193 lastError: undefined,194 sendMessage(195 message: { type?: string },196 callback: (response: unknown) => void,197 ) {198 if (message.type === "START_ADOPTION_RECEIVER") {199 callback({ ok: true, data: { invitation: "peach-pair-v2:test" } });200 } else {201 callback({ ok: true });202 }203 },204 onMessage: {205 addListener(fn: RuntimeListener) { listeners.push(fn); },206 removeListener(fn: RuntimeListener) {207 const index = listeners.indexOf(fn);208 if (index >= 0) listeners.splice(index, 1);209 },210 },211 },212 },213 });214215 const view = mounted(() => <AdoptVaultView onCancel={() => {}} onDone={() => {}} />);216 await enterStrongPassword(view.container);217 button(view.container, "Continue").click();218 await flushView();219220 for (const listener of listeners) {221 listener({222 type: "ADOPTION_SOURCE_SAS",223 payload: {224 id: "receiver-sas-id",225 sas: "48219371",226 displayOnly: false,227 },228 });229 }230 await flushView();231232 expect(view.container.textContent).toContain("48219371");233 expect(view.container.textContent).toContain("Match this code with your other device.");234 expect(view.container.textContent).toContain("This device will receive your vault and recovery access.");235 expect(view.container.querySelector(".peach-adopt-privacy")).toBeNull();236 expect(view.container.textContent).not.toContain("session ID");237 expect(button(view.container, "Confirm code")).toBeTruthy();238 expect(view.container.querySelector(".peach-adoption-sas")).toBeTruthy();239 expect(view.container.querySelector(".peach-pair-qr")).toBeNull();240 expect(view.container.textContent).not.toContain("They match");241 expect(view.container.textContent).not.toContain("They don't match");242243 for (const listener of listeners) {244 listener({245 type: "ADOPTION_SOURCE_RESULT",246 payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },247 });248 }249 await flushView();250251 expect(view.container.textContent).toContain("Pair freely");252 expect(view.container.textContent).toContain("at least one of these two devices");253 expect(view.container.textContent).toContain("$39 once");254 expect(view.container.querySelector('a[href*="buy.polar.sh"]')).toBeTruthy();255 });256257 it("PB-26-607: never renders a raw background error string", async () => {258 type RuntimeListener = (msg: { type?: string; payload?: unknown }) => void;259 const listeners: RuntimeListener[] = [];260 const RAW = "TypeError: cannot read property \"salt\" of undefined at deriveSlotKey (vault.ts:2841)";261 Object.defineProperty(globalThis, "chrome", {262 configurable: true,263 value: {264 runtime: {265 lastError: undefined,266 sendMessage(267 message: { type?: string },268 callback: (response: unknown) => void,269 ) {270 if (message.type === "START_ADOPTION_RECEIVER") {271 callback({ ok: false, error: RAW });272 } else {273 callback({ ok: true });274 }275 },276 onMessage: {277 addListener(fn: RuntimeListener) { listeners.push(fn); },278 removeListener(fn: RuntimeListener) {279 const index = listeners.indexOf(fn);280 if (index >= 0) listeners.splice(index, 1);281 },282 },283 },284 },285 });286287 const view = mounted(() => <AdoptVaultView onCancel={() => {}} onDone={() => {}} />);288 await enterStrongPassword(view.container);289 button(view.container, "Continue").click();290 await flushView();291292 expect(view.container.textContent).toContain("could not start joining this vault");293 expect(view.container.textContent).not.toContain(RAW);294 expect(view.container.textContent).not.toContain("vault.ts");295296 297 for (const listener of listeners) {298 listener({ type: "ADOPTION_SOURCE_RESULT", payload: { status: "error", error: RAW } });299 }300 await flushView();301302 expect(view.container.textContent).toContain("Could not join the vault.");303 expect(view.container.textContent).toContain("the other device could not finish the transfer");304 expect(view.container.textContent).not.toContain(RAW);305 expect(view.container.textContent).not.toContain("vault.ts");306 });307308 it("continues straight to initial sync when receiver pairing completes", async () => {309 type RuntimeListener = (msg: { type?: string; payload?: unknown }) => void;310 const listeners: RuntimeListener[] = [];311 Object.defineProperty(globalThis, "chrome", {312 configurable: true,313 value: {314 runtime: {315 lastError: undefined,316 sendMessage(317 message: { type?: string },318 callback: (response: unknown) => void,319 ) {320 if (message.type === "START_ADOPTION_RECEIVER") {321 callback({ ok: true, data: { invitation: "peach-pair-v2:test" } });322 } else {323 callback({ ok: true });324 }325 },326 onMessage: {327 addListener(fn: RuntimeListener) { listeners.push(fn); },328 removeListener(fn: RuntimeListener) {329 const index = listeners.indexOf(fn);330 if (index >= 0) listeners.splice(index, 1);331 },332 },333 },334 },335 });336 const onDone = vi.fn();337 const view = mounted(() => (338 <AdoptVaultView onCancel={() => {}} onDone={onDone} />339 ));340 await enterStrongPassword(view.container);341 button(view.container, "Continue").click();342 await flushView();343 vi.useFakeTimers();344345 for (const listener of listeners) {346 listener({347 type: "ADOPTION_SOURCE_RESULT",348 payload: { status: "complete" },349 });350 }351352 expect(onDone).not.toHaveBeenCalled();353 expect(view.container.textContent).toContain("Paired");354 expect(view.container.textContent).toContain("Secure connection established");355 expect(view.container.querySelector(".peach-adopt-bird-stage.act-paired")).toBeTruthy();356 expect(view.container.querySelectorAll(".peach-pair-bird-pose-happy")).toHaveLength(2);357 expect(view.container.querySelector(".peach-adopt-connection")).toBeTruthy();358 expect(view.container.textContent).not.toContain("Joined your vault");359 expect(view.container.textContent).not.toContain("Open Peach");360361 vi.advanceTimersByTime(4_999);362 expect(onDone).not.toHaveBeenCalled();363 vi.advanceTimersByTime(1);364 expect(onDone).toHaveBeenCalledOnce();365 });366});