// @vitest-environment jsdom
import type { JSX } from "solid-js";
import { readFileSync } from "node:fs";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ONBOARDING_CARD_STORAGE_KEY, ONBOARDING_LAST_CARD, Onboarding } from "./Onboarding";
import { AdoptVaultView } from "./AdoptVaultView";
import { flushView, inputText, renderView } from "./view-test-utils";
vi.mock("@zxcvbn-ts/core", () => ({
zxcvbn: (value: string) => ({ score: value.length >= 16 ? 4 : 1 }),
zxcvbnOptions: { setOptions: vi.fn() },
}));
vi.mock("@zxcvbn-ts/language-common", () => ({
adjacencyGraphs: {},
dictionary: {},
}));
vi.mock("@zxcvbn-ts/language-en", () => ({
translations: {},
dictionary: {},
}));
function button(container: HTMLElement, text: string): HTMLButtonElement {
const found = Array.from(container.querySelectorAll("button")).find((b) =>
(b.textContent ?? "").includes(text),
);
if (!found) throw new Error(`button not found: ${text}`);
return found as HTMLButtonElement;
}
async function enterStrongPassword(container: HTMLElement) {
inputText(container.querySelector("#adopt-password") as HTMLInputElement, "9xQ!vZ2pLm74Tr#Ke8Wd5Bn");
inputText(container.querySelector("#adopt-confirm-password") as HTMLInputElement, "9xQ!vZ2pLm74Tr#Ke8Wd5Bn");
await flushView();
}
describe("vault adoption onboarding", () => {
const cleanups: Array<() => void> = [];
afterEach(() => {
vi.useRealTimers();
while (cleanups.length) cleanups.pop()!();
});
function mounted(view: () => JSX.Element) {
const rendered = renderView(view);
cleanups.push(rendered.dispose);
return rendered;
}
it("T-A21: Onboarding separates pairing from backup restore", () => {
vi.useFakeTimers();
const onComplete = vi.fn();
const onRestore = vi.fn();
const onAdopt = vi.fn();
const view = mounted(() => );
button(view.container, "next").click();
vi.advanceTimersByTime(220);
button(view.container, "next").click();
vi.advanceTimersByTime(220);
expect(view.container.textContent).toContain("Create vault");
expect(view.container.textContent).toContain("Already using Peach?");
expect(view.container.textContent).toContain("Pair with another device");
expect(view.container.textContent).toContain("Scan a QR and confirm one 8-digit code.");
expect(view.container.textContent).toContain("Restore a backup");
button(view.container, "Pair with another device").click();
expect(onAdopt).toHaveBeenCalledTimes(1);
});
it("T-A21c: resumes on the final card when re-entered from a leave flow", async () => {
const onComplete = vi.fn();
const view = mounted(() => (
{}} onAdopt={() => {}} initialCard={ONBOARDING_LAST_CARD} />
));
// Landing straight on the final card shows the exit actions, not card 0.
expect(view.container.textContent).toContain("Create vault");
expect(view.container.textContent).toContain("Already using Peach?");
expect(view.container.textContent).not.toContain("Two keys.");
// Back (matched by class — the "Restore a backup" label also contains the
// substring "back") still responds and steps to the previous card.
(view.container.querySelector(".peach-onboarding-back") as HTMLButtonElement).click();
expect(view.container.querySelector(".peach-onboarding-card")?.classList.contains("is-exiting")).toBe(true);
await new Promise((r) => setTimeout(r, 300));
expect(view.container.textContent).toContain("Two keys.");
expect(view.container.textContent).not.toContain("Create vault");
});
it("T-A21d: persists the card on every step so a closed popup resumes mid-onboarding", async () => {
const original = Object.getOwnPropertyDescriptor(globalThis, "chrome");
const sets: Array> = [];
try {
Object.defineProperty(globalThis, "chrome", {
configurable: true,
value: {
storage: {
local: {
set: vi.fn(async (obj: Record) => {
sets.push(obj);
}),
},
},
},
});
const view = mounted(() => (
{}} onRestore={() => {}} onAdopt={() => {}} />
));
button(view.container, "next").click();
await new Promise((r) => setTimeout(r, 300));
(view.container.querySelector(".peach-onboarding-back") as HTMLButtonElement).click();
await new Promise((r) => setTimeout(r, 300));
const saved = sets.flatMap((s) => Object.entries(s));
expect(saved).toContainEqual([ONBOARDING_CARD_STORAGE_KEY, "1"]);
expect(saved[saved.length - 1]).toEqual([ONBOARDING_CARD_STORAGE_KEY, "0"]);
} finally {
if (original) Object.defineProperty(globalThis, "chrome", original);
else delete (globalThis as { chrome?: unknown }).chrome;
}
});
it("T-A21b: create-vault is the nav call-to-action; a final-card tap does not silently create", () => {
vi.useFakeTimers();
const onComplete = vi.fn();
const view = mounted(() => {}} onAdopt={() => {}} />);
button(view.container, "next").click();
vi.advanceTimersByTime(220);
button(view.container, "next").click();
vi.advanceTimersByTime(220);
expect(view.container.textContent).toContain("Create vault");
(view.container.querySelector(".peach-onboarding-card") as HTMLElement).click();
expect(onComplete).not.toHaveBeenCalled();
button(view.container, "Create vault").click();
vi.advanceTimersByTime(250);
expect(onComplete).toHaveBeenCalledTimes(1);
});
it("T-A22: password screen uses the same minimum-length gate as create-vault", () => {
const view = mounted(() => {}} onDone={() => {}} />);
const continueButton = button(view.container, "Continue");
expect(continueButton.disabled).toBe(true);
inputText(view.container.querySelector("#adopt-password") as HTMLInputElement, "short");
inputText(view.container.querySelector("#adopt-confirm-password") as HTMLInputElement, "short");
expect(view.container.textContent).toContain("need 7 more characters");
expect(continueButton.disabled).toBe(true);
});
it("T-A28: pair phase shows the pairing-request QR after a strong password", async () => {
const view = mounted(() => {}} onDone={() => {}} />);
await enterStrongPassword(view.container);
button(view.container, "Continue").click();
await flushView();
// The browser is the source of the QR now — the phone scans it.
// We assert the heading + copy; the actual QR is rendered by a background
// call that's out of scope for a jsdom test (chrome.runtime is undefined).
expect(view.container.textContent).toContain("Pair a device");
expect(view.container.textContent).toContain("Scan this QR in Peach");
expect(view.container.querySelector(".peach-adopt-qr-wrap")).toBeTruthy();
expect(view.container.querySelectorAll(".peach-adopt-bird-stage .peach-pair-bird")).toHaveLength(2);
// The scan button from the old flow is gone — the phone is the scanner now.
expect(view.container.textContent).not.toContain("Enter invitation manually");
});
it("pins the compact receiver stage above the shared pairing-stage cascade", () => {
const css = readFileSync("src/popup/styles/popup.css", "utf8");
const rule = css.match(
/\.peach-pair-stage\.peach-adopt-bird-stage\s*\{([^}]*)\}/,
)?.[1];
expect(rule).toContain("height: 102px");
expect(rule).toContain("margin: -6px auto 2px");
expect(css).toMatch(
/\.peach-adopt-card--pair\s*\{[^}]*border:\s*0;/s,
);
});
it("shows the receiver SAS and requires explicit confirmation", async () => {
type RuntimeListener = (msg: { type?: string; payload?: unknown }) => void;
const listeners: RuntimeListener[] = [];
Object.defineProperty(globalThis, "chrome", {
configurable: true,
value: {
runtime: {
lastError: undefined,
sendMessage(
message: { type?: string },
callback: (response: unknown) => void,
) {
if (message.type === "START_ADOPTION_RECEIVER") {
callback({ ok: true, data: { invitation: "peach-pair-v2:test" } });
} else {
callback({ ok: true });
}
},
onMessage: {
addListener(fn: RuntimeListener) { listeners.push(fn); },
removeListener(fn: RuntimeListener) {
const index = listeners.indexOf(fn);
if (index >= 0) listeners.splice(index, 1);
},
},
},
},
});
const view = mounted(() => {}} onDone={() => {}} />);
await enterStrongPassword(view.container);
button(view.container, "Continue").click();
await flushView();
for (const listener of listeners) {
listener({
type: "ADOPTION_SOURCE_SAS",
payload: {
id: "receiver-sas-id",
sas: "48219371",
displayOnly: false,
},
});
}
await flushView();
expect(view.container.textContent).toContain("48219371");
expect(view.container.textContent).toContain("Confirm this 8-digit code");
expect(view.container.textContent).toContain("complete copy of its vault");
expect(view.container.textContent).not.toContain("session ID");
expect(button(view.container, "Confirm code")).toBeTruthy();
expect(view.container.querySelector(".peach-adoption-sas")).toBeTruthy();
expect(view.container.querySelector(".peach-pair-qr")).toBeNull();
expect(view.container.textContent).not.toContain("They match");
expect(view.container.textContent).not.toContain("They don't match");
for (const listener of listeners) {
listener({
type: "ADOPTION_SOURCE_RESULT",
payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },
});
}
await flushView();
expect(view.container.textContent).toContain("Pair freely");
expect(view.container.textContent).toContain("at least one of these two devices");
expect(view.container.textContent).toContain("$39 once");
expect(view.container.querySelector('a[href*="buy.polar.sh"]')).toBeTruthy();
});
it("PB-26-607: never renders a raw background error string", async () => {
type RuntimeListener = (msg: { type?: string; payload?: unknown }) => void;
const listeners: RuntimeListener[] = [];
const RAW = "TypeError: cannot read property \"salt\" of undefined at deriveSlotKey (vault.ts:2841)";
Object.defineProperty(globalThis, "chrome", {
configurable: true,
value: {
runtime: {
lastError: undefined,
sendMessage(
message: { type?: string },
callback: (response: unknown) => void,
) {
if (message.type === "START_ADOPTION_RECEIVER") {
callback({ ok: false, error: RAW });
} else {
callback({ ok: true });
}
},
onMessage: {
addListener(fn: RuntimeListener) { listeners.push(fn); },
removeListener(fn: RuntimeListener) {
const index = listeners.indexOf(fn);
if (index >= 0) listeners.splice(index, 1);
},
},
},
},
});
const view = mounted(() => {}} onDone={() => {}} />);
await enterStrongPassword(view.container);
button(view.container, "Continue").click();
await flushView();
expect(view.container.textContent).toContain("could not start joining this vault");
expect(view.container.textContent).not.toContain(RAW);
expect(view.container.textContent).not.toContain("vault.ts");
// Same guarantee on the result-push path, which switches to the error phase.
for (const listener of listeners) {
listener({ type: "ADOPTION_SOURCE_RESULT", payload: { status: "error", error: RAW } });
}
await flushView();
expect(view.container.textContent).toContain("Could not join the vault.");
expect(view.container.textContent).toContain("the other device could not finish the transfer");
expect(view.container.textContent).not.toContain(RAW);
expect(view.container.textContent).not.toContain("vault.ts");
});
it("continues straight to initial sync when receiver pairing completes", async () => {
type RuntimeListener = (msg: { type?: string; payload?: unknown }) => void;
const listeners: RuntimeListener[] = [];
Object.defineProperty(globalThis, "chrome", {
configurable: true,
value: {
runtime: {
lastError: undefined,
sendMessage(
message: { type?: string },
callback: (response: unknown) => void,
) {
if (message.type === "START_ADOPTION_RECEIVER") {
callback({ ok: true, data: { invitation: "peach-pair-v2:test" } });
} else {
callback({ ok: true });
}
},
onMessage: {
addListener(fn: RuntimeListener) { listeners.push(fn); },
removeListener(fn: RuntimeListener) {
const index = listeners.indexOf(fn);
if (index >= 0) listeners.splice(index, 1);
},
},
},
},
});
const onDone = vi.fn();
const view = mounted(() => (
{}} onDone={onDone} />
));
await enterStrongPassword(view.container);
button(view.container, "Continue").click();
await flushView();
vi.useFakeTimers();
for (const listener of listeners) {
listener({
type: "ADOPTION_SOURCE_RESULT",
payload: { status: "complete" },
});
}
expect(onDone).not.toHaveBeenCalled();
expect(view.container.textContent).toContain("Paired");
expect(view.container.textContent).toContain("Secure connection established");
expect(view.container.querySelector(".peach-adopt-bird-stage.act-paired")).toBeTruthy();
expect(view.container.querySelectorAll(".peach-pair-bird-pose-happy")).toHaveLength(2);
expect(view.container.querySelector(".peach-adopt-connection")).toBeTruthy();
expect(view.container.textContent).not.toContain("Joined your vault");
expect(view.container.textContent).not.toContain("Open Peach");
vi.advanceTimersByTime(4_999);
expect(onDone).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(onDone).toHaveBeenCalledOnce();
});
});