AdoptVaultView.test.tsx

9.8 KB · raw

1// @vitest-environment jsdom23import 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    // The browser is the source of the QR now — the phone scans it.109    // We assert the heading + copy; the actual QR is rendered by a background110    // call that's out of scope for a jsdom test (chrome.runtime is undefined).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    // The scan button from the old flow is gone — the phone is the scanner now.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});