49f87c4da3
Whole tree from parallel sessions, plus the version bump: - Clone: a queue with live progress (Add workspace and extra folders), clones run under nice/ionice so the machine stays responsive. - Settings → AI tools → Session defaults: a new session's default permission mode / bypass per account. - Chat lists the background subagents that have not reported back yet. - A file pasted or dropped into a claude pane goes in as an @ mention. - Changes panel: say why amend/undo is refused on a pushed commit. - openapi.yaml regenerated (Artifacts, Host, resumeById).
259 lines
10 KiB
TypeScript
259 lines
10 KiB
TypeScript
/**
|
|
* @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 wizard never offers bypass (a Settings default may hold it); 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ĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
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<Record<string, unknown>>();
|
|
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: () => <div data-testid="pet-settings" />,
|
|
}));
|
|
|
|
import { __resetUserPrefs, hydrate, useOnboarding } from "../../../lib/userPrefs";
|
|
import { readDefaultMode, SAFE_MODES, 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("keeps a bypass default from Settings, but the pet chat's safe read and the wizard never offer it", () => {
|
|
localStorage.setItem("agenthub.permissionMode", "bypassPermissions");
|
|
expect(readDefaultMode()).toBe("bypassPermissions");
|
|
expect(readDefaultMode({ safe: true })).toBe("auto");
|
|
expect(SAFE_MODES).not.toContain("bypassPermissions");
|
|
writeDefaultMode("nonsense" as never);
|
|
expect(localStorage.getItem("agenthub.permissionMode")).toBe("bypassPermissions");
|
|
writeDefaultMode("plan");
|
|
expect(readDefaultMode()).toBe("plan");
|
|
});
|
|
});
|
|
|
|
function Probe() {
|
|
const s = useOnboarding();
|
|
return <span data-testid="onboarded">{s.onboardedAt ?? "no"}</span>;
|
|
}
|
|
const renderWizard = () =>
|
|
render(
|
|
<MemoryRouter>
|
|
<OnboardingWizard />
|
|
<Probe />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
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('<button data-testid="workspace-tree-add">+</button>');
|
|
try {
|
|
startTour();
|
|
render(
|
|
<MemoryRouter initialEntries={["/workspace"]}>
|
|
<OnboardingTour />
|
|
</MemoryRouter>
|
|
);
|
|
expect(await screen.findByText("Create your first workspace")).toBeInTheDocument();
|
|
// The dialog opened: the tour moves on by itself.
|
|
const dialog = mount(
|
|
'<div data-tour="add-lane"><div data-tour="add-lane-folder"></div><button data-tour="add-lane-create"></button></div>'
|
|
);
|
|
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 = '<div data-testid="workspace-home-composer"></div>';
|
|
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('<button data-testid="workspace-tree-add">+</button>');
|
|
try {
|
|
localStorage.setItem("agenthub.tour", "create");
|
|
render(
|
|
<MemoryRouter initialEntries={["/workspace"]}>
|
|
<OnboardingTour />
|
|
</MemoryRouter>
|
|
);
|
|
expect(await screen.findByText("Create your first workspace")).toBeInTheDocument();
|
|
} finally {
|
|
spy.mockRestore();
|
|
page.remove();
|
|
}
|
|
});
|
|
});
|