Files
fusion/packages/dashboard/app/components/__tests__/onboarding-flow.test.tsx
Fusion 17d45b8fe7 feat(FN-2980): add droid cli provider card to onboarding
Adds a new Droid CLI provider card to the onboarding flow, including a dedicated provider icon, simplified card CSS, and token fallback fixes for droid provider authentication. The changes wire the card into the ModelOnboardingModal and add test coverage for the card component, icon, and onboarding

Fusion-Task-Id: FN-2980
2026-05-01 05:15:19 -07:00

1272 lines
46 KiB
TypeScript

import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor, within, type RenderResult } from "@testing-library/react";
import { useState } from "react";
import { ModelOnboardingModal } from "../ModelOnboardingModal";
import { OnboardingResumeCard } from "../OnboardingResumeCard";
import { PostOnboardingRecommendations } from "../PostOnboardingRecommendations";
import { useAuthOnboarding } from "../../hooks/useAuthOnboarding";
import type { AuthProvider } from "../../api";
import type { Task } from "@fusion/core";
const mockFetchAuthStatus = vi.fn();
const mockFetchGlobalSettings = vi.fn();
const mockFetchModels = vi.fn();
const mockLoginProvider = vi.fn();
const mockLogoutProvider = vi.fn();
const mockSaveApiKey = vi.fn();
const mockClearApiKey = vi.fn();
const mockUpdateGlobalSettings = vi.fn();
const mockCreateTask = vi.fn();
vi.mock("../../api", () => ({
fetchAuthStatus: (...args: unknown[]) => mockFetchAuthStatus(...args),
fetchGlobalSettings: (...args: unknown[]) => mockFetchGlobalSettings(...args),
fetchModels: (...args: unknown[]) => mockFetchModels(...args),
loginProvider: (...args: unknown[]) => mockLoginProvider(...args),
logoutProvider: (...args: unknown[]) => mockLogoutProvider(...args),
saveApiKey: (...args: unknown[]) => mockSaveApiKey(...args),
clearApiKey: (...args: unknown[]) => mockClearApiKey(...args),
updateGlobalSettings: (...args: unknown[]) => mockUpdateGlobalSettings(...args),
createTask: (...args: unknown[]) => mockCreateTask(...args),
}));
const mockGetOnboardingState = vi.fn();
const mockSaveOnboardingState = vi.fn();
const mockClearOnboardingState = vi.fn();
const mockMarkOnboardingCompleted = vi.fn();
const mockMarkStepSkipped = vi.fn();
const mockGetSkippedSteps = vi.fn();
const mockGetStepData = vi.fn();
const mockGetOnboardingResumeStep = vi.fn();
const mockIsOnboardingResumable = vi.fn();
const mockIsOnboardingCompleted = vi.fn();
const mockIsPostOnboardingDismissed = vi.fn();
const mockDismissPostOnboardingRecommendations = vi.fn();
vi.mock("../model-onboarding-state", () => ({
getOnboardingState: (...args: unknown[]) => mockGetOnboardingState(...args),
saveOnboardingState: (...args: unknown[]) => mockSaveOnboardingState(...args),
clearOnboardingState: (...args: unknown[]) => mockClearOnboardingState(...args),
markOnboardingCompleted: (...args: unknown[]) => mockMarkOnboardingCompleted(...args),
markStepSkipped: (...args: unknown[]) => mockMarkStepSkipped(...args),
getSkippedSteps: (...args: unknown[]) => mockGetSkippedSteps(...args),
getStepData: (...args: unknown[]) => mockGetStepData(...args),
getOnboardingResumeStep: (...args: unknown[]) => mockGetOnboardingResumeStep(...args),
isOnboardingResumable: (...args: unknown[]) => mockIsOnboardingResumable(...args),
isOnboardingCompleted: (...args: unknown[]) => mockIsOnboardingCompleted(...args),
isPostOnboardingDismissed: (...args: unknown[]) => mockIsPostOnboardingDismissed(...args),
dismissPostOnboardingRecommendations: (...args: unknown[]) => mockDismissPostOnboardingRecommendations(...args),
ONBOARDING_FLOW_STEPS: ["ai-setup", "github", "project-setup", "first-task"],
}));
vi.mock("../CustomModelDropdown", () => ({
CustomModelDropdown: ({ value, onChange, placeholder }: { value: string; onChange: (v: string) => void; placeholder?: string }) => (
<select data-testid="mock-model-dropdown" value={value} onChange={(event) => onChange(event.target.value)}>
<option value="">{placeholder ?? "Select…"}</option>
<option value="anthropic/claude-sonnet-4-5">Claude Sonnet 4.5</option>
<option value="openai/gpt-4o">GPT-4o</option>
</select>
),
}));
vi.mock("../ProviderIcon", () => ({
ProviderIcon: ({ provider, size }: { provider: string; size?: string }) => (
<span data-testid="provider-icon" data-provider={provider} data-size={size}>
{provider} icon
</span>
),
}));
vi.mock("../ClaudeCliProviderCard", () => ({
ClaudeCliProviderCard: ({ authenticated }: { authenticated: boolean }) => (
<div data-testid="claude-cli-provider-card" data-authenticated={authenticated ? "true" : "false"}>
Anthropic via Claude CLI
</div>
),
}));
vi.mock("../DroidCliProviderCard", () => ({
DroidCliProviderCard: ({ authenticated }: { authenticated: boolean }) => (
<div data-testid="droid-cli-provider-card" data-authenticated={authenticated ? "true" : "false"}>
Factory AI via Droid CLI
</div>
),
}));
vi.mock("lucide-react", async (importOriginal) => {
const actual = await importOriginal() as Record<string, unknown>;
return {
...actual,
X: () => <span data-testid="icon-x">X</span>,
Loader2: ({ className }: { className?: string }) => (
<span data-testid="icon-loader" className={className}>
Loader2
</span>
),
CheckCircle: () => <span data-testid="icon-check-circle">CheckCircle</span>,
Key: () => <span data-testid="icon-key">Key</span>,
Zap: () => <span data-testid="icon-zap">Zap</span>,
GitPullRequest: () => <span data-testid="icon-git-pull-request">GitPullRequest</span>,
Rocket: () => <span data-testid="icon-rocket">Rocket</span>,
Plus: () => <span data-testid="icon-plus">Plus</span>,
ChevronRight: () => <span data-testid="icon-chevron-right">ChevronRight</span>,
};
});
const defaultAuthProviders: AuthProvider[] = [
{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth" },
{ id: "openai", name: "OpenAI", authenticated: false, type: "api_key" },
];
const defaultModels = [
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: false, contextWindow: 200000 },
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
];
const createdTaskMock = {
id: "FN-0001",
title: "Initial task",
description: "Implement onboarding success flow\nAdditional details that should not render",
} as unknown as Task;
let mockStore: Record<string, string> = {};
const mockLocalStorage = {
getItem: (key: string) => mockStore[key] ?? null,
setItem: (key: string, value: string) => {
mockStore[key] = value;
},
removeItem: (key: string) => {
delete mockStore[key];
},
clear: () => {
mockStore = {};
},
};
interface RenderModalOverrides {
onComplete?: () => void;
addToast?: (message: string, type?: "success" | "error" | "warning" | "info") => void;
onOpenNewTask?: () => void;
onOpenGitHubImport?: () => void;
onViewTask?: (task: Task) => void;
firstCreatedTask?: Task | null;
projectId?: string;
}
interface RenderModalResult extends RenderResult {
onComplete: () => void;
addToast: (message: string, type?: "success" | "error" | "warning" | "info") => void;
onOpenNewTask: () => void;
onOpenGitHubImport: () => void;
onViewTask: (task: Task) => void;
}
function renderModal(overrides: RenderModalOverrides = {}): RenderModalResult {
const onComplete = overrides.onComplete ?? vi.fn();
const addToast = overrides.addToast ?? vi.fn();
const onOpenNewTask = overrides.onOpenNewTask ?? vi.fn();
const onOpenGitHubImport = overrides.onOpenGitHubImport ?? vi.fn();
const onViewTask = overrides.onViewTask ?? vi.fn();
const result = render(
<ModelOnboardingModal
onComplete={onComplete}
addToast={addToast}
projectId={overrides.projectId ?? "proj_123"}
onOpenNewTask={onOpenNewTask}
onOpenGitHubImport={onOpenGitHubImport}
onViewTask={onViewTask}
firstCreatedTask={overrides.firstCreatedTask ?? null}
/>,
);
return {
...result,
onComplete,
addToast,
onOpenNewTask,
onOpenGitHubImport,
onViewTask,
};
}
async function advanceThroughSteps(_renderResult: RenderResult, actions: Array<"next" | "skip">): Promise<RenderResult> {
for (const action of actions) {
const previousTitle = screen.getByRole("heading", { level: 2 }).textContent;
if (action === "next") {
fireEvent.click(screen.getByRole("button", { name: "Next →" }));
} else {
const skipButton =
screen.queryByRole("button", { name: "Skip setup →" })
?? screen.queryByRole("button", { name: "Skip GitHub →" });
expect(skipButton).toBeTruthy();
fireEvent.click(skipButton!);
}
await waitFor(() => {
expect(screen.getByRole("heading", { level: 2 }).textContent).not.toBe(previousTitle);
});
}
return _renderResult;
}
async function dismissModal(_renderResult: RenderResult): Promise<RenderResult> {
fireEvent.click(screen.getByRole("button", { name: "Skip onboarding" }));
await waitFor(() => {
expect(screen.queryByRole("dialog")).toBeNull();
});
return _renderResult;
}
async function simulateFirstTaskCreation(
_renderResult: RenderResult,
description: string,
): Promise<RenderResult> {
const input = screen.getByTestId("onboarding-first-task-input") as HTMLTextAreaElement;
fireEvent.change(input, { target: { value: description } });
fireEvent.click(screen.getByTestId("onboarding-first-task-submit"));
return _renderResult;
}
function getResumeCard(onResume: () => void = vi.fn()): RenderResult {
return render(<OnboardingResumeCard onResume={onResume} />);
}
function hasSavedStateCall(
step: string,
matcher?: (options: {
completedSteps?: string[];
skippedSteps?: string[];
} | undefined) => boolean,
): boolean {
return mockSaveOnboardingState.mock.calls.some(([savedStep, options]) => {
if (savedStep !== step) {
return false;
}
if (!matcher) {
return true;
}
return matcher(options as { completedSteps?: string[]; skippedSteps?: string[] } | undefined);
});
}
function getProviderOrderInSection(container: HTMLElement | null): string[] {
if (!container) {
return [];
}
return Array.from(container.querySelectorAll("[data-testid^='onboarding-provider-card-'], [data-testid='claude-cli-provider-card']"))
.map((element) => {
const testId = element.getAttribute("data-testid");
if (!testId) {
return null;
}
if (testId.startsWith("onboarding-provider-card-")) {
return testId.replace("onboarding-provider-card-", "");
}
return testId === "claude-cli-provider-card" ? "claude-cli" : null;
})
.filter((providerId): providerId is string => providerId !== null);
}
interface AuthOnboardingHarnessProps {
openModelOnboardingSpy?: () => void;
openSettingsSpy?: (section?: string) => void;
}
function AuthOnboardingHarness({ openModelOnboardingSpy, openSettingsSpy }: AuthOnboardingHarnessProps) {
const [modelOnboardingOpen, setModelOnboardingOpen] = useState(false);
const openModelOnboarding = () => {
openModelOnboardingSpy?.();
setModelOnboardingOpen(true);
};
const openSettings = (section?: string) => {
openSettingsSpy?.(section);
};
useAuthOnboarding({
projectId: "project-onboarding-flow",
setupWizardOpen: false,
openModelOnboarding,
openSettings,
});
return (
<>
{modelOnboardingOpen ? (
<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} projectId="proj_123" />
) : (
<div data-testid="onboarding-modal-closed" />
)}
</>
);
}
beforeEach(() => {
vi.clearAllMocks();
mockStore = {};
vi.stubGlobal("localStorage", mockLocalStorage);
mockGetOnboardingState.mockReturnValue(null);
mockSaveOnboardingState.mockImplementation(() => {});
mockClearOnboardingState.mockImplementation(() => {});
mockMarkOnboardingCompleted.mockImplementation(() => {});
mockMarkStepSkipped.mockImplementation(() => {});
mockGetSkippedSteps.mockReturnValue([]);
mockGetStepData.mockReturnValue(null);
mockGetOnboardingResumeStep.mockReturnValue(null);
mockIsOnboardingResumable.mockReturnValue(false);
mockIsOnboardingCompleted.mockReturnValue(false);
mockIsPostOnboardingDismissed.mockReturnValue(false);
mockDismissPostOnboardingRecommendations.mockImplementation(() => {});
mockFetchAuthStatus.mockResolvedValue({ providers: defaultAuthProviders });
mockFetchModels.mockResolvedValue({ models: defaultModels, favoriteProviders: [], favoriteModels: [] });
mockFetchGlobalSettings.mockResolvedValue({});
mockLoginProvider.mockResolvedValue({ url: "https://auth.example.com/login" });
mockLogoutProvider.mockResolvedValue({ success: true });
mockSaveApiKey.mockResolvedValue({ success: true });
mockClearApiKey.mockResolvedValue({ success: true });
mockUpdateGlobalSettings.mockResolvedValue({});
mockCreateTask.mockResolvedValue(createdTaskMock);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
});
describe("onboarding flow integration", () => {
describe("first-run flow", () => {
it("first-run flow: hook triggers modal open and modal renders AI Setup step", async () => {
mockFetchAuthStatus.mockResolvedValue({
providers: defaultAuthProviders,
});
mockFetchGlobalSettings.mockResolvedValue({ modelOnboardingComplete: false });
mockIsOnboardingCompleted.mockReturnValue(false);
const openModelOnboardingSpy = vi.fn();
render(<AuthOnboardingHarness openModelOnboardingSpy={openModelOnboardingSpy} />);
await waitFor(() => {
expect(openModelOnboardingSpy).toHaveBeenCalledTimes(1);
});
expect(screen.getByRole("dialog")).toBeInTheDocument();
const aiSetupIndicator = screen.getByText("AI Setup").closest(".model-onboarding-step-indicator");
expect(aiSetupIndicator).toHaveClass("active");
expect(screen.getByText("Set Up AI")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Next →" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Skip for now" })).toBeInTheDocument();
});
it("first-run flow: local completion suppresses modal even when server says incomplete", async () => {
mockFetchGlobalSettings.mockResolvedValue({ modelOnboardingComplete: false });
mockIsOnboardingCompleted.mockReturnValue(true);
const openModelOnboardingSpy = vi.fn();
render(<AuthOnboardingHarness openModelOnboardingSpy={openModelOnboardingSpy} />);
await waitFor(() => {
expect(mockFetchGlobalSettings).toHaveBeenCalledTimes(1);
});
expect(openModelOnboardingSpy).not.toHaveBeenCalled();
expect(screen.queryByRole("dialog")).toBeNull();
expect(screen.getByTestId("onboarding-modal-closed")).toBeInTheDocument();
});
it("first-run flow: completed state in both server and local prevents auto-open", async () => {
mockFetchAuthStatus.mockResolvedValue({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" },
],
});
mockFetchGlobalSettings.mockResolvedValue({
modelOnboardingComplete: true,
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
});
mockIsOnboardingCompleted.mockReturnValue(true);
const openModelOnboardingSpy = vi.fn();
const openSettingsSpy = vi.fn();
render(
<AuthOnboardingHarness
openModelOnboardingSpy={openModelOnboardingSpy}
openSettingsSpy={openSettingsSpy}
/>,
);
await waitFor(() => {
expect(mockFetchGlobalSettings).toHaveBeenCalledTimes(1);
});
expect(openModelOnboardingSpy).not.toHaveBeenCalled();
expect(openSettingsSpy).not.toHaveBeenCalled();
expect(screen.queryByRole("dialog")).toBeNull();
expect(screen.getByTestId("onboarding-modal-closed")).toBeInTheDocument();
});
});
describe("AI setup provider layout", () => {
it("shows quick-start providers by default, keeps advanced providers collapsed, and surfaces connected advanced providers", async () => {
mockFetchAuthStatus.mockResolvedValue({
providers: [
{ id: "openai", name: "OpenAI", authenticated: false, type: "api_key" },
{ id: "moonshot", name: "Moonshot", authenticated: false, type: "oauth" },
{ id: "claude-cli", name: "Anthropic — via Claude CLI", authenticated: false, type: "cli" },
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" },
{ id: "google", name: "Google", authenticated: false, type: "oauth" },
{ id: "minimax", name: "MiniMax", authenticated: false, type: "api_key" },
{ id: "openrouter", name: "OpenRouter", authenticated: true, type: "api_key" },
{ id: "github", name: "GitHub", authenticated: true, type: "oauth" },
],
});
renderModal();
await waitFor(() => {
expect(screen.getByText("Set Up AI")).toBeInTheDocument();
});
expect(screen.getByText("You only need one provider to get started.")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Advanced provider settings/ })).toHaveAttribute("aria-expanded", "false");
const quickStartSection = screen.getByTestId("onboarding-quick-start-providers");
expect(getProviderOrderInSection(quickStartSection)).toEqual(["anthropic", "openai", "google"]);
const connectedSection = screen.getByTestId("onboarding-connected-providers");
expect(getProviderOrderInSection(connectedSection)).toEqual(["openrouter"]);
expect(screen.queryByTestId("onboarding-provider-card-minimax")).not.toBeInTheDocument();
expect(screen.queryByTestId("onboarding-provider-card-moonshot")).not.toBeInTheDocument();
expect(screen.queryByTestId("claude-cli-provider-card")).not.toBeInTheDocument();
expect(screen.queryByTestId("onboarding-advanced-provider-settings")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /Advanced provider settings/ }));
await waitFor(() => {
expect(screen.getByTestId("onboarding-advanced-provider-settings")).toBeInTheDocument();
});
expect(screen.getByTestId("onboarding-provider-card-minimax")).toBeInTheDocument();
expect(screen.getByTestId("onboarding-provider-card-moonshot")).toBeInTheDocument();
expect(screen.getByTestId("claude-cli-provider-card")).toBeInTheDocument();
expect(screen.queryByTestId("onboarding-provider-card-github")).not.toBeInTheDocument();
});
it("keeps provider controls functional across quick-start, connected, and advanced sections", async () => {
mockFetchAuthStatus.mockResolvedValue({
providers: [
{ id: "openai", name: "OpenAI", authenticated: false, type: "api_key" },
{ id: "openrouter", name: "OpenRouter", authenticated: true, type: "api_key" },
{ id: "openai-codex", name: "OpenAI Codex", authenticated: false, type: "oauth" },
{ id: "claude-cli", name: "Anthropic — via Claude CLI", authenticated: false, type: "cli" },
],
});
renderModal();
await waitFor(() => {
expect(screen.getByTestId("onboarding-apikey-input-openai")).toBeInTheDocument();
});
expect(screen.getByTestId("onboarding-apikey-save-openai")).toBeInTheDocument();
const connectedSection = screen.getByTestId("onboarding-connected-providers");
expect(within(connectedSection).getByText("OpenRouter")).toBeInTheDocument();
expect(within(connectedSection).getByRole("button", { name: "Remove Key" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /Advanced provider settings/ }));
await waitFor(() => {
expect(screen.getByTestId("onboarding-provider-card-openai-codex")).toBeInTheDocument();
});
const codexCard = screen.getByTestId("onboarding-provider-card-openai-codex");
expect(within(codexCard).getByRole("button", { name: "Login" })).toBeInTheDocument();
expect(screen.getByTestId("claude-cli-provider-card")).toHaveAttribute("data-authenticated", "false");
});
it("still progresses to later onboarding steps after interacting with the sectioned provider UI", async () => {
mockFetchAuthStatus.mockResolvedValue({
providers: [
{ id: "openai", name: "OpenAI", authenticated: false, type: "api_key" },
{ id: "minimax", name: "MiniMax", authenticated: false, type: "api_key" },
{ id: "github", name: "GitHub", authenticated: false, type: "oauth" },
],
});
renderModal();
await waitFor(() => {
expect(screen.getByTestId("onboarding-apikey-input-openai")).toBeInTheDocument();
});
fireEvent.change(screen.getByTestId("onboarding-apikey-input-openai"), {
target: { value: "sk-flow-test" },
});
fireEvent.click(screen.getByTestId("onboarding-apikey-save-openai"));
await waitFor(() => {
expect(mockSaveApiKey).toHaveBeenCalledWith("openai", "sk-flow-test");
});
fireEvent.click(screen.getByRole("button", { name: /Advanced provider settings/ }));
await waitFor(() => {
expect(screen.getByTestId("onboarding-provider-card-minimax")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: "Next →" }));
await waitFor(() => {
expect(screen.getByText("Connect GitHub")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: "Next →" }));
await waitFor(() => {
expect(screen.getByRole("button", { name: "Next →" })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: "Next →" }));
await waitFor(() => {
expect(screen.getByText("Create Your First Task")).toBeInTheDocument();
});
});
});
describe("dismissal flow", () => {
it("dismissal flow: dismissing on GitHub step saves state with currentStep=github", async () => {
const renderResult = renderModal();
await waitFor(() => {
expect(screen.getByRole("button", { name: "Next →" })).toBeInTheDocument();
});
await advanceThroughSteps(renderResult, ["next"]);
await dismissModal(renderResult);
await waitFor(() => {
expect(hasSavedStateCall("github")).toBe(true);
});
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith({ modelOnboardingComplete: true });
expect(renderResult.onComplete).toHaveBeenCalledTimes(1);
expect(mockMarkOnboardingCompleted).not.toHaveBeenCalled();
});
it("dismissal flow: dismissing preserves completedSteps array", async () => {
const renderResult = renderModal();
await advanceThroughSteps(renderResult, ["next"]);
await dismissModal(renderResult);
await waitFor(() => {
expect(
hasSavedStateCall(
"github",
(options) => options?.completedSteps?.includes("ai-setup") === true,
),
).toBe(true);
});
expect(screen.queryByRole("dialog")).toBeNull();
});
it("dismissal flow: dismissing on first-task step saves skipped GitHub state", async () => {
const renderResult = renderModal();
await advanceThroughSteps(renderResult, ["next", "skip", "next"]);
await waitFor(() => {
expect(screen.getByText("Create Your First Task")).toBeInTheDocument();
});
await dismissModal(renderResult);
expect(mockMarkStepSkipped).toHaveBeenCalledWith("github");
await waitFor(() => {
expect(
hasSavedStateCall(
"first-task",
(options) => options?.skippedSteps?.includes("github") === true,
),
).toBe(true);
});
});
it("dismissal flow: dismissing still works when updateGlobalSettings fails", async () => {
mockUpdateGlobalSettings.mockRejectedValueOnce(new Error("save failed"));
const renderResult = renderModal();
await dismissModal(renderResult);
expect(renderResult.onComplete).toHaveBeenCalledTimes(1);
expect(screen.queryByRole("dialog")).toBeNull();
});
it("dismissal flow: resume card renders after dismissal with saved progress", () => {
mockGetOnboardingResumeStep.mockReturnValue({
currentStep: "github",
label: "GitHub",
completedSteps: ["ai-setup"],
});
getResumeCard();
expect(screen.getByText("Continue Setup")).toBeInTheDocument();
expect(screen.getByText(/GitHub/)).toBeInTheDocument();
expect(screen.getByText(/1 of 4 step complete/)).toBeInTheDocument();
});
});
describe("resume flow", () => {
it("resume flow: modal reopens at the persisted step", async () => {
mockGetOnboardingState.mockReturnValue({
currentStep: "github",
completedSteps: ["ai-setup"],
skippedSteps: [],
dismissed: false,
completed: false,
stepData: {},
});
renderModal();
await waitFor(() => {
expect(screen.getByText("Connect GitHub")).toBeInTheDocument();
});
const aiSetupIndicator = screen.getByRole("button", { name: "Go back to AI Setup" });
expect(aiSetupIndicator).toHaveClass("done");
expect(screen.getByRole("button", { name: "← Back" })).toBeInTheDocument();
});
it("resume flow: resume card triggers modal open", () => {
mockGetOnboardingResumeStep.mockReturnValue({
currentStep: "github",
label: "GitHub",
completedSteps: ["ai-setup"],
});
const onResume = vi.fn();
getResumeCard(onResume);
fireEvent.click(screen.getByRole("button", { name: "Continue onboarding" }));
expect(onResume).toHaveBeenCalledTimes(1);
});
it("resume flow: resumed modal can continue forward to first-task", async () => {
mockGetOnboardingState.mockReturnValue({
currentStep: "github",
completedSteps: ["ai-setup"],
skippedSteps: [],
dismissed: false,
completed: false,
stepData: {},
});
const renderResult = renderModal();
await waitFor(() => {
expect(screen.getByText("Connect GitHub")).toBeInTheDocument();
});
await advanceThroughSteps(renderResult, ["next", "next"]);
await waitFor(() => {
expect(screen.getByText("Create Your First Task")).toBeInTheDocument();
});
const githubIndicator = screen.getByRole("button", { name: "Go back to GitHub" });
expect(githubIndicator).toHaveClass("done");
});
it("resume flow: resumed modal can go back to completed step", async () => {
mockGetOnboardingState.mockReturnValue({
currentStep: "github",
completedSteps: ["ai-setup"],
skippedSteps: [],
dismissed: false,
completed: false,
stepData: {},
});
renderModal();
await waitFor(() => {
expect(screen.getByText("Connect GitHub")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: "Go back to AI Setup" }));
await waitFor(() => {
expect(screen.getByText("Set Up AI")).toBeInTheDocument();
});
});
it("resume flow: no resume card when onboarding is completed", () => {
mockGetOnboardingResumeStep.mockReturnValue(null);
const { container } = getResumeCard();
expect(container.firstChild).toBeNull();
});
it("resume flow: resumed modal restores skipped providers from stepData", async () => {
mockGetOnboardingState.mockReturnValue({
currentStep: "github",
completedSteps: ["ai-setup"],
skippedSteps: [],
dismissed: false,
completed: false,
stepData: {
"ai-setup": {
skippedProviders: { anthropic: true },
},
},
});
renderModal();
await waitFor(() => {
expect(screen.getByText("Connect GitHub")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: "Go back to AI Setup" }));
await waitFor(() => {
expect(screen.getByText("Set Up AI")).toBeInTheDocument();
});
const anthropicCard = screen.getByText("Anthropic").closest(".onboarding-provider-card");
expect(anthropicCard).toBeTruthy();
expect(within(anthropicCard as HTMLElement).getByText("Skipped")).toBeInTheDocument();
});
});
describe("skip warnings flow", () => {
it("skip warnings: skipping AI Setup shows warning banner on GitHub step", async () => {
renderModal();
await waitFor(() => {
expect(screen.getByRole("button", { name: "Skip setup →" })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: "Skip setup →" }));
await waitFor(() => {
expect(screen.getByText("Connect GitHub")).toBeInTheDocument();
});
expect(screen.getByText("No AI provider connected")).toBeInTheDocument();
expect(mockMarkStepSkipped).toHaveBeenCalledWith("ai-setup");
});
it("skip warnings: GitHub step shows 'not connected' status and no banner when AI is set up", async () => {
mockFetchAuthStatus.mockResolvedValue({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" },
{ id: "github", name: "GitHub", authenticated: false, type: "oauth" },
],
});
const renderResult = renderModal();
await advanceThroughSteps(renderResult, ["next"]);
await waitFor(() => {
expect(screen.getByText("Connect GitHub")).toBeInTheDocument();
});
expect(screen.queryByText("No AI provider connected")).toBeNull();
expect(screen.getByTestId("github-status-badge")).toHaveTextContent("Not connected");
});
it("skip warnings: first-task readiness summary shows AI Provider as 'missing' when no provider connected", async () => {
mockFetchAuthStatus.mockResolvedValue({
providers: [
{ id: "github", name: "GitHub", authenticated: false, type: "oauth" },
],
});
const renderResult = renderModal();
await advanceThroughSteps(renderResult, ["next", "next", "next"]);
await waitFor(() => {
expect(screen.getByText("Create Your First Task")).toBeInTheDocument();
});
const readinessSummary = screen.getByTestId("readiness-summary");
const aiProviderItem = within(readinessSummary).getByText("AI Provider").closest(".onboarding-readiness-item");
expect(aiProviderItem).toHaveAttribute("data-status", "missing");
expect(screen.getByText("Connect a provider in Settings → AI Setup")).toBeInTheDocument();
});
it("skip warnings: first-task readiness summary shows AI Provider as 'skipped' when providers were explicitly skipped", async () => {
const renderResult = renderModal();
await waitFor(() => {
expect(screen.getByText("Anthropic")).toBeInTheDocument();
});
await advanceThroughSteps(renderResult, ["skip", "skip", "next"]);
await waitFor(() => {
expect(screen.getByText("Create Your First Task")).toBeInTheDocument();
});
const readinessSummary = screen.getByTestId("readiness-summary");
const aiProviderItem = within(readinessSummary).getByText("AI Provider").closest(".onboarding-readiness-item");
expect(aiProviderItem).toHaveAttribute("data-status", "skipped");
expect(screen.getByText("AI agents won't be available until you connect a provider")).toBeInTheDocument();
});
it("skip warnings: GitHub shows as 'skipped' in readiness when GitHub is not available", async () => {
mockFetchAuthStatus.mockResolvedValue({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth" },
],
});
const renderResult = renderModal();
await advanceThroughSteps(renderResult, ["next", "next", "next"]);
await waitFor(() => {
expect(screen.getByText("Create Your First Task")).toBeInTheDocument();
});
const readinessSummary = screen.getByTestId("readiness-summary");
const githubItem = within(readinessSummary).getByText("GitHub").closest(".onboarding-readiness-item");
expect(githubItem).toHaveAttribute("data-status", "skipped");
expect(screen.getByText("You can connect anytime from Settings")).toBeInTheDocument();
});
it("skip warnings: all-connected readiness summary when everything is set up", async () => {
mockFetchAuthStatus.mockResolvedValue({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" },
{ id: "github", name: "GitHub", authenticated: true, type: "oauth" },
],
});
mockFetchGlobalSettings.mockResolvedValue({
modelOnboardingComplete: true,
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
});
const renderResult = renderModal();
await advanceThroughSteps(renderResult, ["next", "next", "next"]);
await waitFor(() => {
expect(screen.getByText("Create Your First Task")).toBeInTheDocument();
});
expect(screen.getByText("✓ All integrations connected")).toBeInTheDocument();
});
it("skip warnings: Import from GitHub CTA shows connection requirement note when GitHub not connected", async () => {
const renderResult = renderModal();
await advanceThroughSteps(renderResult, ["next", "next", "next"]);
await waitFor(() => {
expect(screen.getByText("Create Your First Task")).toBeInTheDocument();
});
expect(screen.getByText("Requires GitHub connection")).toBeInTheDocument();
});
it("skip warnings: gh CLI auth marks GitHub as ready in summary and hides connection-required note", async () => {
mockFetchAuthStatus.mockResolvedValue({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth" },
],
ghCli: { available: true, authenticated: true },
});
const renderResult = renderModal();
await advanceThroughSteps(renderResult, ["next", "next", "next"]);
await waitFor(() => {
expect(screen.getByText("Create Your First Task")).toBeInTheDocument();
});
const readinessSummary = screen.getByTestId("readiness-summary");
const githubItem = within(readinessSummary).getByText("GitHub").closest(".onboarding-readiness-item");
expect(githubItem).toHaveAttribute("data-status", "connected");
expect(screen.getByText("Connected via GitHub CLI — imports and PR tracking are available")).toBeInTheDocument();
expect(screen.queryByText("Requires GitHub connection")).toBeNull();
});
});
describe("task creation flow", () => {
it("task creation flow: inline task creation shows success state and marks onboarding complete", async () => {
const renderResult = renderModal();
await advanceThroughSteps(renderResult, ["next", "next", "next"]);
await simulateFirstTaskCreation(renderResult, "Ship onboarding telemetry");
await waitFor(() => {
expect(mockCreateTask).toHaveBeenCalledWith(
{ description: "Ship onboarding telemetry", source: { sourceType: "dashboard_ui" } },
"proj_123",
);
});
expect(mockMarkOnboardingCompleted).toHaveBeenCalled();
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith({ modelOnboardingComplete: true });
await waitFor(() => {
expect(screen.getByText("Your first task is ready!")).toBeInTheDocument();
});
});
it("task creation flow: View Task button navigates and completes onboarding", async () => {
const renderResult = renderModal();
await advanceThroughSteps(renderResult, ["next", "next", "next"]);
await simulateFirstTaskCreation(renderResult, "View task flow");
await waitFor(() => {
expect(screen.getByRole("button", { name: "View Task" })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: "View Task" }));
expect(renderResult.onViewTask).toHaveBeenCalledWith(createdTaskMock);
expect(renderResult.onComplete).toHaveBeenCalled();
expect(mockMarkOnboardingCompleted).toHaveBeenCalled();
});
it("task creation flow: Go to Dashboard button completes onboarding and closes modal", async () => {
function ModalHost() {
const [isOpen, setIsOpen] = useState(true);
return isOpen ? (
<ModelOnboardingModal
onComplete={() => {
setIsOpen(false);
}}
addToast={vi.fn()}
projectId="proj_123"
/>
) : (
<div data-testid="modal-host-closed" />
);
}
render(<ModalHost />);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Next →" })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: "Next →" }));
await waitFor(() => {
expect(screen.getByRole("button", { name: "Next →" })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: "Next →" }));
await waitFor(() => {
expect(screen.getByRole("button", { name: "Next →" })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: "Next →" }));
await waitFor(() => {
expect(screen.getByText("Create Your First Task")).toBeInTheDocument();
});
const input = screen.getByTestId("onboarding-first-task-input") as HTMLTextAreaElement;
fireEvent.change(input, { target: { value: "Close after dashboard handoff" } });
fireEvent.click(screen.getByTestId("onboarding-first-task-submit"));
await waitFor(() => {
expect(screen.getByRole("button", { name: "Go to Dashboard" })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: "Go to Dashboard" }));
await waitFor(() => {
expect(screen.queryByRole("dialog")).toBeNull();
});
expect(screen.getByTestId("modal-host-closed")).toBeInTheDocument();
});
it("task creation flow: Finish Setup button (without creating task) completes onboarding", async () => {
const renderResult = renderModal();
await advanceThroughSteps(renderResult, ["next", "next", "next"]);
fireEvent.click(screen.getByRole("button", { name: "Finish Setup" }));
await waitFor(() => {
expect(screen.getByText("All Set!")).toBeInTheDocument();
});
expect(mockMarkOnboardingCompleted).toHaveBeenCalled();
});
it("task creation flow: Create a New Task CTA completes onboarding and triggers external task dialog", async () => {
const renderResult = renderModal();
await advanceThroughSteps(renderResult, ["next", "next", "next"]);
fireEvent.click(screen.getByRole("button", { name: /Create a New Task/i }));
await waitFor(() => {
expect(mockMarkOnboardingCompleted).toHaveBeenCalled();
});
expect(renderResult.onOpenNewTask).toHaveBeenCalled();
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith({ modelOnboardingComplete: true });
});
it("task creation flow: Import from GitHub CTA completes onboarding and triggers import", async () => {
mockFetchAuthStatus.mockResolvedValue({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth" },
{ id: "github", name: "GitHub", authenticated: true, type: "oauth" },
],
});
const renderResult = renderModal();
await advanceThroughSteps(renderResult, ["next", "next", "next"]);
fireEvent.click(screen.getByRole("button", { name: /Import from GitHub/i }));
await waitFor(() => {
expect(mockMarkOnboardingCompleted).toHaveBeenCalled();
});
expect(renderResult.onOpenGitHubImport).toHaveBeenCalled();
expect(renderResult.onComplete).toHaveBeenCalled();
});
it("task creation flow: validation error on empty description does not call createTask", async () => {
const renderResult = renderModal();
await advanceThroughSteps(renderResult, ["next", "next", "next"]);
fireEvent.click(screen.getByTestId("onboarding-first-task-submit"));
expect(mockCreateTask).not.toHaveBeenCalled();
expect(screen.getByText(/please enter a task description/i)).toBeInTheDocument();
});
it("task creation flow: server error preserves typed description for retry", async () => {
mockCreateTask.mockRejectedValueOnce(new Error("Task API unavailable"));
const renderResult = renderModal();
await advanceThroughSteps(renderResult, ["next", "next", "next"]);
await simulateFirstTaskCreation(renderResult, "Retryable task");
await waitFor(() => {
expect(screen.getByTestId("onboarding-task-error")).toBeInTheDocument();
});
const input = screen.getByTestId("onboarding-first-task-input") as HTMLTextAreaElement;
expect(input.value).toBe("Retryable task");
});
it("task creation flow: Get Started button on complete step closes the modal", async () => {
const renderResult = renderModal();
await advanceThroughSteps(renderResult, ["next", "next", "next"]);
fireEvent.click(screen.getByRole("button", { name: "Finish Setup" }));
await waitFor(() => {
expect(screen.getByRole("button", { name: /Get Started/i })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /Get Started/i }));
expect(renderResult.onComplete).toHaveBeenCalled();
expect(screen.queryByRole("dialog")).toBeNull();
});
});
describe("revisit flow", () => {
it("revisit flow: no auto-open when onboarding is completed (server flag)", async () => {
mockFetchAuthStatus.mockResolvedValue({
providers: [{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" }],
});
mockFetchGlobalSettings.mockResolvedValue({
modelOnboardingComplete: true,
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
});
mockIsOnboardingCompleted.mockReturnValue(true);
const openModelOnboardingSpy = vi.fn();
const openSettingsSpy = vi.fn();
render(
<AuthOnboardingHarness
openModelOnboardingSpy={openModelOnboardingSpy}
openSettingsSpy={openSettingsSpy}
/>,
);
await waitFor(() => {
expect(mockFetchGlobalSettings).toHaveBeenCalledTimes(1);
});
expect(openModelOnboardingSpy).not.toHaveBeenCalled();
expect(openSettingsSpy).not.toHaveBeenCalled();
});
it("revisit flow: no resume card when onboarding is completed", () => {
mockGetOnboardingResumeStep.mockReturnValue(null);
const { container } = getResumeCard();
expect(container.firstChild).toBeNull();
});
it("revisit flow: PostOnboardingRecommendations renders after completion with incomplete setup", async () => {
mockIsOnboardingCompleted.mockReturnValue(true);
mockIsPostOnboardingDismissed.mockReturnValue(false);
mockFetchAuthStatus.mockResolvedValue({ providers: defaultAuthProviders });
mockFetchGlobalSettings.mockResolvedValue({
defaultProvider: undefined,
defaultModelId: undefined,
});
render(
<PostOnboardingRecommendations
onOpenSettings={vi.fn()}
onOpenModelOnboarding={vi.fn()}
/>,
);
await waitFor(() => {
expect(screen.getByText("Recommended Next Steps")).toBeInTheDocument();
});
expect(screen.getByText("Connect AI Provider")).toBeInTheDocument();
});
it("revisit flow: PostOnboardingRecommendations dismiss persists", async () => {
mockIsOnboardingCompleted.mockReturnValue(true);
mockIsPostOnboardingDismissed.mockReturnValue(false);
mockFetchAuthStatus.mockResolvedValue({ providers: defaultAuthProviders });
mockFetchGlobalSettings.mockResolvedValue({
defaultProvider: undefined,
defaultModelId: undefined,
});
render(
<PostOnboardingRecommendations
onOpenSettings={vi.fn()}
onOpenModelOnboarding={vi.fn()}
/>,
);
await waitFor(() => {
expect(screen.getByText("Recommended Next Steps")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: "Dismiss recommendations" }));
expect(mockDismissPostOnboardingRecommendations).toHaveBeenCalledTimes(1);
expect(screen.queryByText("Recommended Next Steps")).toBeNull();
});
it("revisit flow: PostOnboardingRecommendations does not render when everything is configured", async () => {
mockIsOnboardingCompleted.mockReturnValue(true);
mockIsPostOnboardingDismissed.mockReturnValue(false);
mockFetchAuthStatus.mockResolvedValue({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" },
{ id: "github", name: "GitHub", authenticated: true, type: "oauth" },
],
});
mockFetchGlobalSettings.mockResolvedValue({
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
});
const { container } = render(
<PostOnboardingRecommendations
onOpenSettings={vi.fn()}
onOpenModelOnboarding={vi.fn()}
/>,
);
await waitFor(() => {
expect(mockFetchGlobalSettings).toHaveBeenCalledTimes(1);
});
expect(container.firstChild).toBeNull();
});
it("revisit flow: re-opening modal after completion starts fresh at ai-setup", async () => {
mockGetOnboardingState.mockReturnValue({
currentStep: "complete",
completed: true,
completedSteps: ["ai-setup", "github", "first-task"],
skippedSteps: [],
dismissed: false,
stepData: {},
});
renderModal();
await waitFor(() => {
expect(screen.getByText("Set Up AI")).toBeInTheDocument();
});
const aiSetupIndicator = screen.getByText("AI Setup").closest(".model-onboarding-step-indicator");
expect(aiSetupIndicator).toHaveClass("active");
expect(screen.queryByText("All Set!")).toBeNull();
});
it("revisit flow: modal can be opened manually after auto-trigger was suppressed", async () => {
mockFetchGlobalSettings.mockResolvedValue({
modelOnboardingComplete: true,
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
});
mockIsOnboardingCompleted.mockReturnValue(true);
const openModelOnboardingSpy = vi.fn();
render(<AuthOnboardingHarness openModelOnboardingSpy={openModelOnboardingSpy} />);
await waitFor(() => {
expect(mockFetchGlobalSettings).toHaveBeenCalledTimes(1);
});
expect(openModelOnboardingSpy).not.toHaveBeenCalled();
renderModal();
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Next →" })).toBeInTheDocument();
});
});
});