AdoptVaultView.test.tsx

8.5 KB · raw

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