/** * @file Onboarding.test.tsx * @description The first-run setup (docs/plans/onboarding): the account's * preferences are loaded into localStorage on sign-in and every later change * is sent back; the starting choices never hold bypass; the wizard shows only * for an account that is new to it, writes each pick like Settings does and * ends in the tour; the tour follows the real UI step by step. * @author Nguyễn Ngọc Trí Vĩ */ import { describe, it, expect, beforeEach, vi } from "vitest"; import { act, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router-dom"; const prefsMock = vi.fn(); const saveMock = vi.fn(); const onboardedMock = vi.fn(); const lanesMock = vi.fn(); vi.mock("../../../lib/api", async (importOriginal) => { const actual = await importOriginal>(); return { ...actual, api: { me: { prefs: () => prefsMock(), savePrefs: (p: unknown) => saveMock(p), onboarded: () => onboardedMock(), }, lanes: { list: () => lanesMock() }, aiTools: { iconUrl: (id: string) => `/api/ai-tools/${id}/icon`, list: async () => ({ default: "claude", catalog: [], tools: [ { id: "claude", label: "Claude Code", command: "claude", installed: true, enabled: true, }, { id: "codex", label: "Codex", command: "codex", installed: true, enabled: true }, { id: "kimi", label: "Kimi Code", command: "kimi", installed: false, enabled: true }, ], }), }, }, }; }); vi.mock("../../settings/PetSettings", () => ({ PetSettings: () =>
, })); import { __resetUserPrefs, hydrate, useOnboarding } from "../../../lib/userPrefs"; import { readDefaultMode, writeDefaultMode } from "../../../lib/sessionDefaults"; import { OnboardingWizard } from "../OnboardingWizard"; import { OnboardingTour, startTour } from "../OnboardingTour"; import { __resetAiTools } from "../../../hooks/useAiTools"; beforeEach(() => { localStorage.clear(); __resetUserPrefs(); __resetAiTools(); prefsMock.mockReset(); saveMock.mockReset().mockResolvedValue({ prefs: {}, onboardedAt: null }); onboardedMock.mockReset().mockResolvedValue({ prefs: {}, onboardedAt: "2026-10-08T00:00:00Z" }); lanesMock.mockReset().mockResolvedValue({ lanes: [], counts: {} }); }); describe("account preferences", () => { it("loads the account's values, sends up this browser's, then every change", async () => { vi.useFakeTimers(); try { localStorage.setItem("agenthub.terminal.font", "maple"); localStorage.setItem("theme", "dark"); prefsMock.mockResolvedValue({ prefs: { theme: "light" }, onboardedAt: null }); await hydrate(); expect(localStorage.getItem("theme")).toBe("light"); await vi.advanceTimersByTimeAsync(10); expect(saveMock).toHaveBeenCalledWith( expect.objectContaining({ "agenthub.terminal.font": "maple" }) ); saveMock.mockClear(); localStorage.setItem("agenthub.theme.dark", "darcula"); localStorage.setItem("unrelated.key", "x"); localStorage.removeItem("agenthub.pet"); await vi.advanceTimersByTimeAsync(500); expect(saveMock).toHaveBeenCalledTimes(1); expect(saveMock).toHaveBeenCalledWith({ "agenthub.theme.dark": "darcula", "agenthub.pet": null, }); } finally { vi.useRealTimers(); } }); it("never keeps bypass as the starting permission mode", () => { localStorage.setItem("agenthub.permissionMode", "bypassPermissions"); expect(readDefaultMode()).toBe("auto"); writeDefaultMode("bypassPermissions" as never); expect(localStorage.getItem("agenthub.permissionMode")).toBe("bypassPermissions"); writeDefaultMode("plan"); expect(readDefaultMode()).toBe("plan"); }); }); function Probe() { const s = useOnboarding(); return {s.onboardedAt ?? "no"}; } const renderWizard = () => render( ); describe("the setup wizard", () => { it("does not show before the account's preferences load, nor once done", async () => { renderWizard(); expect(screen.queryByTestId("onboarding-wizard")).toBeNull(); prefsMock.mockResolvedValue({ prefs: {}, onboardedAt: "2026-01-01T00:00:00Z" }); await act(() => hydrate()); expect(screen.queryByTestId("onboarding-wizard")).toBeNull(); }); it("skips itself for an account that already has a workspace", async () => { lanesMock.mockResolvedValue({ lanes: [{ id: 1 }], counts: {} }); prefsMock.mockResolvedValue({ prefs: {}, onboardedAt: null }); renderWizard(); await act(() => hydrate()); await waitFor(() => expect(onboardedMock).toHaveBeenCalled()); expect(screen.queryByTestId("onboarding-wizard")).toBeNull(); }); it("walks four steps, saving each pick, and ends in the tour", async () => { const user = userEvent.setup(); prefsMock.mockResolvedValue({ prefs: {}, onboardedAt: null }); renderWizard(); await act(() => hydrate()); await screen.findByTestId("onboarding-wizard"); expect(screen.getByText("Pick your default agent")).toBeInTheDocument(); await user.click(await screen.findByTestId("onboarding-agent-codex")); expect(localStorage.getItem("agenthub.defaultTool")).toBe("codex"); // A tool that is not installed is not offered until asked for, and never pickable. expect(screen.queryByText("Kimi Code")).toBeNull(); await user.click(screen.getByText(/Show 1 more agents/)); expect(screen.getByText("Kimi Code").closest("button")).toBeNull(); await user.click(screen.getByRole("radio", { name: "Plan" })); expect(localStorage.getItem("agenthub.permissionMode")).toBe("plan"); await user.click(screen.getByTestId("onboarding-next")); expect(screen.getByText("Make it feel like home")).toBeInTheDocument(); await user.click(screen.getByTestId("onboarding-mode-light")); expect(localStorage.getItem("theme")).toBe("light"); await user.click(screen.getByRole("button", { name: "Light theme: Canvas" })); expect(localStorage.getItem("agenthub.theme.light")).toBe("canvas"); await user.click(screen.getByTestId("onboarding-next")); expect(screen.getByTestId("pet-settings")).toBeInTheDocument(); await user.click(screen.getByTestId("onboarding-next")); expect(screen.getByText("Set up notifications")).toBeInTheDocument(); await user.click(screen.getByRole("button", { name: /Add your first workspace/ })); await waitFor(() => expect(onboardedMock).toHaveBeenCalled()); expect(screen.queryByTestId("onboarding-wizard")).toBeNull(); expect(localStorage.getItem("agenthub.tour")).toBe("add"); }); it("is skipped for good with Skip setup", async () => { const user = userEvent.setup(); prefsMock.mockResolvedValue({ prefs: {}, onboardedAt: null }); renderWizard(); await act(() => hydrate()); await user.click(await screen.findByRole("button", { name: "Skip setup" })); await waitFor(() => expect(screen.getByTestId("onboarded").textContent).not.toBe("no")); expect(onboardedMock).toHaveBeenCalled(); expect(localStorage.getItem("agenthub.tour")).toBeNull(); }); }); describe("the tour", () => { const mount = (html: string) => { const host = document.createElement("div"); host.innerHTML = html; document.body.appendChild(host); return host; }; // jsdom has no layout: give the targets a box. const boxed = () => vi.spyOn(Element.prototype, "getBoundingClientRect").mockReturnValue({ x: 10, y: 10, left: 10, top: 10, width: 100, height: 30, right: 110, bottom: 40, toJSON: () => ({}), } as DOMRect); it("follows the real UI: add → folder → create → prompt", async () => { const spy = boxed(); const user = userEvent.setup(); const page = mount(''); try { startTour(); render( ); expect(await screen.findByText("Create your first workspace")).toBeInTheDocument(); // The dialog opened: the tour moves on by itself. const dialog = mount( '
' ); expect(await screen.findByText("Choose the project folder")).toBeInTheDocument(); await user.click(screen.getByTestId("onboarding-tour-next")); expect(screen.getByText("Create it")).toBeInTheDocument(); // Created: the dialog is gone and the workspace's prompt box is there. dialog.remove(); page.innerHTML = '
'; expect(await screen.findByText("Start your first session")).toBeInTheDocument(); expect(localStorage.getItem("agenthub.tour")).toBe("prompt"); await user.click(screen.getByRole("button", { name: "Skip tour" })); expect(screen.queryByTestId("onboarding-tour")).toBeNull(); expect(localStorage.getItem("agenthub.tour")).toBe("done"); } finally { spy.mockRestore(); page.remove(); } }); it("goes back to Add workspace when the dialog closes with nothing made", async () => { const spy = boxed(); const page = mount(''); try { localStorage.setItem("agenthub.tour", "create"); render( ); expect(await screen.findByText("Create your first workspace")).toBeInTheDocument(); } finally { spy.mockRestore(); page.remove(); } }); });