Files
fusion/packages/dashboard/app/components/__tests__/onboarding-flow.test.tsx
Fusion a285c2988b feat(FN-2923): merge fusion/fn-2923
- Add task `source` provenance field tracking how tasks enter the system (e.g. `"manual"`, `"heartbeat"`, `"executor"`, `"triage"`, `"cron"`, `"routine"`)
- Wire `source` into shared create-task tools, executor agent, triage agent, cron runner, and routine runner
- Add `source` to heartbeat task creation in `agent-heartbeat.ts` and mission execution loop in `mission-execution-loop.ts`
- Wire `source` field into dashboard routes (`register-planning-subtask-routes.ts`, `register-task-workflow-routes.ts`) and GitHub integration (`register-git-github.ts`)
- Add `source` to the API layer and task store handlers in the dashboard
- Expose `source` in the `fn` CLI task command and `task-plan` extension tool
- Add integration tests for provenance across engine (agent-tools, agent-heartbeat, cron-runner, triage, pr-comment-handler) and dashboard routes
- Update existing tests to assert the correct source values
- Bulk-revert stale changeset files that were superseded by the v0.9.1 release commit

Commits merged:
- test(FN-2923): complete Step 14 — align tests with task source provenance
- test(FN-2923): complete Steps 12-13 — update and add provenance tests
- feat(FN-2923): complete Step 10 — wire CLI task provenance
- feat(FN-2923): complete Step 9 — wire dashboard UI and API source fields
- feat(FN-2923): complete Step 8 — wire dashboard route provenance
- feat(FN-2923): complete Step 7 — wire remaining engine provenance
- feat(FN-2923): complete Step 6 — add routine create-task provenance
- feat(FN-2923): complete Step 5 — add cron create-task provenance
- feat(FN-2923): complete Step 4 — add triage subtask source
- feat(FN-2923): complete Step 3 — add executor create-task source
- feat(FN-2923): complete Step 2 — add heartbeat task provenance
- feat(FN-2923): complete Step 1 — add provenance to shared create-task tools
- feat(FN-2945): merge fusion/fn-2945
- chore(release): v0.9.1

Files changed:
.changeset/active-agents-no-stuck-connecting.md    | 13 ---
 .changeset/active-agents-panel-hoist-heartbeat.md  | 13 ---
 .changeset/fix-agent-heartbeat-terminal-links.md   |  5 --
 .changeset/fix-experimental-defaults.md            |  5 --
 .changeset/improve-git-manager-diff-pane.md        |  5 --
 .changeset/insights-two-pane-layout.md             |  5 --
 .changeset/show-planning-tasks-immediately.md      |  5 --
 CHANGELOG.md                                       | 92 ++++++++++++++++++++++
 package.json                                       |  2 +-
 packages/cli-alias/CHANGELOG.md                    | 15 ++++
 packages/cli-alias/package.json                    |  2 +-
 packages/cli/CHANGELOG.md                          | 12 +++
 packages/cli/package.json                          |  2 +-
 packages/cli/src/__tests__/task-plan.test.ts       |  1 +
 packages/cli/src/commands/__tests__/task.test.ts   | 15 +++-
 packages/cli/src/commands/task.ts                  | 15 +++-
 packages/cli/src/extension.ts                      |  9 +++
 packages/core/CHANGELOG.md                         |  7 ++
 packages/core/package.json                         |  2 +-
 packages/dashboard/CHANGELOG.md                    | 14 ++++
 .../app/__tests__/agent-css-classes.test.ts        |  3 +-
 packages/dashboard/app/__tests__/api.test.ts       | 13 +++
 packages/dashboard/app/api/legacy.ts               |  2 +
 packages/dashboard/app/components/AgentsView.css   | 22 +++---
 .../app/components/ModelOnboardingModal.tsx        |  5 +-
 packages/dashboard/app/components/TodoView.tsx     |  2 +
 .../__tests__/ModelOnboardingModal.test.tsx        |  4 +-
 .../app/components/__tests__/QuickChatFAB.test.tsx | 14 +---
 .../app/components/__tests__/TodoView.test.tsx     |  4 +-
 .../__tests__/agents-view-mobile.test.tsx          |  5 +-
 .../components/__tests__/onboarding-flow.test.tsx  |  2 +-
 .../app/hooks/__tests__/useTaskHandlers.test.ts    |  4 +-
 packages/dashboard/app/hooks/useTaskHandlers.ts    |  4 +-
 packages/dashboard/package.json                    |  2 +-
 packages/dashboard/src/__tests__/routes.test.ts    | 14 ++++
 packages/dashboard/src/routes.ts                   |  4 +
 .../dashboard/src/routes/register-git-github.ts    | 12 +++
 .../src/routes/register-planning-subtask-routes.ts |  3 +
 .../src/routes/register-task-workflow-routes.ts    |  7 ++
 packages/desktop/CHANGELOG.md                      |  7 ++
 packages/desktop/package.json                      |  2 +-
 packages/engine/CHANGELOG.md                       | 11 +++
 packages/engine/package.json                       |  2 +-
 .../engine/src/__tests__/agent-heartbeat.test.ts   | 10 +++
 .../src/__tests__/agent-tools-delegation.test.ts   |  2 +
 packages/engine/src/__tests__/agent-tools.test.ts  | 37 +++++++++
 packages/engine/src/__tests__/cron-runner.test.ts  |  4 +
 .../src/__tests__/pr-comment-handler.test.ts       |  8 ++
 packages/engine/src/__tests__/triage.test.ts       |  2 +
 packages/engine/src/agent-heartbeat.ts             | 13 ++-
 packages/engine/src/agent-tools.ts                 | 13 ++-
 packages/engine/src/cron-runner.ts                 |  7 +-
 packages/engine/src/executor.ts                    |  2 +-
 packages/engine/src/mission-execution-loop.ts      |  8 ++
 packages/engine/src/pr-comment-handler.ts          |  5 ++
 packages/engine/src/project-engine.ts              |  8 ++
 packages/engine/src/routine-runner.ts              |  4 +
 packages/engine/src/triage.ts                      |  4 +
 packages/mobile/CHANGELOG.md                       |  7 ++
 packages/mobile/package.json                       |  2 +-
 packages/pi-claude-cli/CHANGELOG.md                |  7 ++
 packages/pi-claude-cli/package.json                |  2 +-
 packages/plugin-sdk/CHANGELOG.md                   | 10 +++
 packages/plugin-sdk/package.json                   |  2 +-
 .../examples/fusion-plugin-auto-label/CHANGELOG.md |  8 ++
 .../examples/fusion-plugin-auto-label/package.json |  2 +-
 .../examples/fusion-plugin-ci-status/CHANGELOG.md  |  8 ++
 .../examples/fusion-plugin-ci-status/package.json  |  2 +-
 .../fusion-plugin-notification/CHANGELOG.md        |  8 ++
 .../fusion-plugin-notification/package.json        |  2 +-
 .../fusion-plugin-settings-demo/CHANGELOG.md       |  8 ++
 .../fusion-plugin-settings-demo/package.json       |  2 +-
 plugins/fusion-plugin-hermes-runtime/CHANGELOG.md  |  8 ++
 plugins/fusion-plugin-hermes-runtime/package.json  |  2 +-
 .../fusion-plugin-openclaw-runtime/CHANGELOG.md    |  8 ++
 .../fusion-plugin-openclaw-runtime/package.json    |  2 +-
 .../fusion-plugin-paperclip-runtime/CHANGELOG.md   |  8 ++
 .../fusion-plugin-paperclip-runtime/package.json   |  2 +-
 78 files changed, 499 insertions(+), 115 deletions(-)

Fusion-Task-Id: FN-2923
2026-04-29 17:23:22 -07:00

1264 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("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();
});
});
});