AdoptVaultView.test.tsx

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