// @vitest-environment jsdom
import type { JSX } from "solid-js";
import { afterEach, describe, expect, it, vi } from "vitest";
import { Onboarding } from "./Onboarding";
import {
AdoptVaultView,
type AdoptVaultViewProps,
} from "./AdoptVaultView";
import { flushView, inputText, renderView } from "./view-test-utils";
import { createPairingInvitation } from "../../shared/pairing-invitation";
const INVITATION = createPairingInvitation(
"wss://relay.peachpasswords.com",
"AAECAwQFBgcICQoLDA0ODw",
);
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 enterInvitation(container: HTMLElement) {
button(container, "Enter invitation manually").click();
await flushView();
inputText(
container.querySelector("#adopt-relay-invitation") as HTMLTextAreaElement,
INVITATION,
);
await flushView();
}
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 offers Create vault, Restore from backup, and I already have Peach", () => {
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("Restore from backup");
expect(view.container.textContent).toContain("I already have Peach");
button(view.container, "I already have Peach").click();
expect(onAdopt).toHaveBeenCalledTimes(1);
});
it("T-A22: password screen uses the same minimum-length gate as create-vault", () => {
const view = mounted(() => {}} onDone={() => {}} startAdoption={vi.fn()} />);
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-A26: relay adoption cannot start until a valid invitation is entered", async () => {
const startAdoption = vi.fn(
async (_args: Parameters[0]) => {},
);
const view = mounted(() => {}} onDone={() => {}} startAdoption={startAdoption} />);
await enterStrongPassword(view.container);
button(view.container, "Continue").click();
await flushView();
// No relay selector here: the code carries the relay, so this screen states
// it rather than offering a choice it would overwrite
// (docs/sync-relay-surfacing.md).
expect(view.container.textContent).toContain("relay carried in the code you scan");
expect(view.container.querySelector(".peach-sync-mode")).toBeNull();
expect(view.container.querySelector(".peach-sync-mode-summary")).toBeNull();
button(view.container, "Enter invitation manually").click();
await flushView();
expect(button(view.container, "Connect securely").disabled).toBe(true);
inputText(
view.container.querySelector("#adopt-relay-invitation") as HTMLTextAreaElement,
"peach-pair-v1:not-valid",
);
await flushView();
expect(button(view.container, "Connect securely").disabled).toBe(true);
inputText(
view.container.querySelector("#adopt-relay-invitation") as HTMLTextAreaElement,
INVITATION,
);
await flushView();
expect(button(view.container, "Connect securely").disabled).toBe(false);
expect(startAdoption).not.toHaveBeenCalled();
});
it("T-A27: the relay invitation is handed to the adoption transport", async () => {
const startAdoption = vi.fn(
async (_args: Parameters[0]) => {},
);
const view = mounted(() => {}} onDone={() => {}} startAdoption={startAdoption} />);
await enterStrongPassword(view.container);
button(view.container, "Continue").click();
await enterInvitation(view.container);
button(view.container, "Connect securely").click();
await flushView();
expect(startAdoption).toHaveBeenCalledTimes(1);
expect(startAdoption.mock.calls[0][0]).toMatchObject({
connection: { invitation: INVITATION },
});
});
it("T-A23: adoption flow reaches the SAS screen", async () => {
const startAdoption = vi.fn(async ({ confirmSAS }: { confirmSAS(sas: string): Promise }) => {
await confirmSAS("895245");
});
const view = mounted(() => {}} onDone={() => {}} startAdoption={startAdoption} />);
await enterStrongPassword(view.container);
button(view.container, "Continue").click();
await enterInvitation(view.container);
button(view.container, "Connect securely").click();
await flushView();
expect(view.container.textContent).toContain("Confirm this code matches");
expect(view.container.textContent).toContain("8 9 5 2 4 5");
});
it("T-A24: success leaves a vault and opens Peach", async () => {
const onDone = vi.fn();
const view = mounted(
() => (
{}}
onDone={onDone}
startAdoption={vi.fn(async () => {})}
/>
),
);
await enterStrongPassword(view.container);
button(view.container, "Continue").click();
await enterInvitation(view.container);
button(view.container, "Connect securely").click();
await flushView();
expect(view.container.textContent).toContain("Joined your vault.");
button(view.container, "Open Peach").click();
expect(onDone).toHaveBeenCalledTimes(1);
});
it("T-A25: SAS cancel keeps receiver vault-less and returns to the password step", async () => {
const createVault = vi.fn();
const view = mounted(
() => (
{}}
onDone={() => {}}
startAdoption={vi.fn(async ({ confirmSAS }) => {
const ok = await confirmSAS("895245");
if (ok) await createVault();
else throw new Error("sas_rejected");
})}
/>
),
);
await enterStrongPassword(view.container);
button(view.container, "Continue").click();
await enterInvitation(view.container);
button(view.container, "Connect securely").click();
await flushView();
button(view.container, "Cancel").click();
await flushView();
expect(createVault).not.toHaveBeenCalled();
expect(view.container.textContent).toContain("Set a password for this device.");
});
});