On a fresh install useAuthOnboarding's effect ran at mount before the setup wizard's 500ms auto-open timer fired. The one-shot ref locked, and the resolved fetch could either stack model onboarding on top of the wizard or never re-trigger after the wizard closed. - Gate the trigger on projectId being set so the wizard owns the bootstrap phase; the auth check only fires once a project exists. - Re-check setupWizardOpen via a ref when the auth fetch resolves to avoid stacking onboarding on top of a wizard opened mid-fetch. - Release the one-shot in that suppressed branch so the effect retries when the wizard closes. Adds two regression tests: fresh-install handoff and mid-fetch wizard suppression. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
507 lines
16 KiB
TypeScript
507 lines
16 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { renderHook, waitFor } from "@testing-library/react";
|
|
import { useAuthOnboarding } from "../useAuthOnboarding";
|
|
import * as api from "../../api";
|
|
|
|
// Mock model-onboarding-state for isOnboardingCompleted
|
|
const mockIsOnboardingCompleted = vi.fn();
|
|
const mockTrackOnboardingEvent = vi.fn();
|
|
|
|
vi.mock("../../api", () => ({
|
|
fetchAuthStatus: vi.fn(),
|
|
fetchGlobalSettings: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../../components/model-onboarding-state", () => ({
|
|
isOnboardingCompleted: (...args: unknown[]) => mockIsOnboardingCompleted(...args),
|
|
ONBOARDING_FLOW_STEPS: ["ai-setup", "github", "project-setup", "first-task"],
|
|
}));
|
|
|
|
vi.mock("../../components/onboarding-events", () => ({
|
|
trackOnboardingEvent: (...args: unknown[]) => mockTrackOnboardingEvent(...args),
|
|
getOnboardingSessionId: () => "test-session-id",
|
|
}));
|
|
|
|
const mockFetchAuthStatus = vi.mocked(api.fetchAuthStatus);
|
|
const mockFetchGlobalSettings = vi.mocked(api.fetchGlobalSettings);
|
|
|
|
describe("useAuthOnboarding", () => {
|
|
const openModelOnboarding = vi.fn();
|
|
const openSettings = vi.fn();
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockTrackOnboardingEvent.mockReset();
|
|
});
|
|
|
|
// --- Trigger branches ---
|
|
|
|
it("opens onboarding when no providers are authenticated and onboarding is incomplete", async () => {
|
|
mockFetchAuthStatus.mockResolvedValue({
|
|
providers: [{ id: "openai", name: "OpenAI", authenticated: false }],
|
|
});
|
|
mockFetchGlobalSettings.mockResolvedValue({
|
|
modelOnboardingComplete: false,
|
|
defaultProvider: undefined,
|
|
defaultModelId: undefined,
|
|
} as never);
|
|
|
|
renderHook(() =>
|
|
useAuthOnboarding({
|
|
projectId: "proj_123",
|
|
setupWizardOpen: false,
|
|
openModelOnboarding,
|
|
openSettings,
|
|
}),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(openModelOnboarding).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
expect(mockTrackOnboardingEvent).toHaveBeenCalledWith("onboarding:auto-triggered", { trigger: "first-run" });
|
|
expect(openSettings).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("opens onboarding when modelOnboardingComplete is undefined (first-run detection)", async () => {
|
|
mockFetchAuthStatus.mockResolvedValue({
|
|
providers: [{ id: "openai", name: "OpenAI", authenticated: false }],
|
|
});
|
|
// Explicit first-run: modelOnboardingComplete is undefined (not explicitly false)
|
|
mockFetchGlobalSettings.mockResolvedValue({
|
|
modelOnboardingComplete: undefined,
|
|
defaultProvider: undefined,
|
|
defaultModelId: undefined,
|
|
} as never);
|
|
|
|
renderHook(() =>
|
|
useAuthOnboarding({
|
|
projectId: "proj_123",
|
|
setupWizardOpen: false,
|
|
openModelOnboarding,
|
|
openSettings,
|
|
}),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(openModelOnboarding).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
expect(openSettings).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("opens authentication settings when onboarding is complete but no providers are authenticated", async () => {
|
|
mockFetchAuthStatus.mockResolvedValue({
|
|
providers: [{ id: "anthropic", name: "Anthropic", authenticated: false }],
|
|
});
|
|
mockFetchGlobalSettings.mockResolvedValue({
|
|
modelOnboardingComplete: true,
|
|
defaultProvider: "anthropic",
|
|
defaultModelId: "claude-sonnet-4-5",
|
|
} as never);
|
|
|
|
renderHook(() =>
|
|
useAuthOnboarding({
|
|
projectId: "proj_123",
|
|
setupWizardOpen: false,
|
|
openModelOnboarding,
|
|
openSettings,
|
|
}),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(openSettings).toHaveBeenCalledWith("authentication");
|
|
});
|
|
|
|
expect(mockTrackOnboardingEvent).toHaveBeenCalledWith("onboarding:auto-triggered", { trigger: "missing-provider" });
|
|
expect(openModelOnboarding).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("opens onboarding when providers are authenticated but default model is missing", async () => {
|
|
mockFetchAuthStatus.mockResolvedValue({
|
|
providers: [{ id: "anthropic", name: "Anthropic", authenticated: true }],
|
|
});
|
|
mockFetchGlobalSettings.mockResolvedValue({
|
|
modelOnboardingComplete: false,
|
|
defaultProvider: undefined,
|
|
defaultModelId: undefined,
|
|
} as never);
|
|
|
|
renderHook(() =>
|
|
useAuthOnboarding({
|
|
projectId: "proj_123",
|
|
setupWizardOpen: false,
|
|
openModelOnboarding,
|
|
openSettings,
|
|
}),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(openModelOnboarding).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
expect(openSettings).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does NOT auto-open when authenticated provider exists and default model is configured", async () => {
|
|
mockFetchAuthStatus.mockResolvedValue({
|
|
providers: [{ id: "anthropic", name: "Anthropic", authenticated: true }],
|
|
});
|
|
mockFetchGlobalSettings.mockResolvedValue({
|
|
modelOnboardingComplete: true,
|
|
defaultProvider: "anthropic",
|
|
defaultModelId: "claude-sonnet-4-5",
|
|
} as never);
|
|
|
|
renderHook(() =>
|
|
useAuthOnboarding({
|
|
projectId: "proj_123",
|
|
setupWizardOpen: false,
|
|
openModelOnboarding,
|
|
openSettings,
|
|
}),
|
|
);
|
|
|
|
// Give time for any async calls to resolve
|
|
await waitFor(() => {
|
|
expect(mockFetchAuthStatus).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
// Neither onboarding nor settings should open
|
|
expect(openModelOnboarding).not.toHaveBeenCalled();
|
|
expect(openSettings).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does nothing when auth status fetch fails", async () => {
|
|
mockFetchAuthStatus.mockRejectedValueOnce(new Error("network"));
|
|
|
|
renderHook(() =>
|
|
useAuthOnboarding({
|
|
projectId: "proj_123",
|
|
setupWizardOpen: false,
|
|
openModelOnboarding,
|
|
openSettings,
|
|
}),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetchAuthStatus).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
expect(openModelOnboarding).not.toHaveBeenCalled();
|
|
expect(openSettings).not.toHaveBeenCalled();
|
|
expect(mockFetchGlobalSettings).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not auto-trigger model onboarding when setupWizardOpen is true", async () => {
|
|
mockFetchAuthStatus.mockResolvedValue({
|
|
providers: [{ id: "openai", name: "OpenAI", authenticated: false }],
|
|
});
|
|
mockFetchGlobalSettings.mockResolvedValue({
|
|
modelOnboardingComplete: false,
|
|
defaultProvider: undefined,
|
|
defaultModelId: undefined,
|
|
} as never);
|
|
|
|
renderHook(() =>
|
|
useAuthOnboarding({
|
|
projectId: "proj_123",
|
|
setupWizardOpen: true,
|
|
openModelOnboarding,
|
|
openSettings,
|
|
}),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(openModelOnboarding).not.toHaveBeenCalled();
|
|
expect(mockFetchAuthStatus).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
it("auto-triggers model onboarding when setupWizardOpen transitions from true to false", async () => {
|
|
mockFetchAuthStatus.mockResolvedValue({
|
|
providers: [{ id: "openai", name: "OpenAI", authenticated: false }],
|
|
});
|
|
mockFetchGlobalSettings.mockResolvedValue({
|
|
modelOnboardingComplete: false,
|
|
defaultProvider: undefined,
|
|
defaultModelId: undefined,
|
|
} as never);
|
|
|
|
const { rerender } = renderHook(
|
|
({ setupWizardOpen }: { setupWizardOpen: boolean }) =>
|
|
useAuthOnboarding({
|
|
projectId: "proj_123",
|
|
setupWizardOpen,
|
|
openModelOnboarding,
|
|
openSettings,
|
|
}),
|
|
{
|
|
initialProps: { setupWizardOpen: true },
|
|
},
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(openModelOnboarding).not.toHaveBeenCalled();
|
|
expect(mockFetchAuthStatus).not.toHaveBeenCalled();
|
|
});
|
|
|
|
rerender({ setupWizardOpen: false });
|
|
|
|
await waitFor(() => {
|
|
expect(openModelOnboarding).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
it("does not auto-trigger before a projectId exists (fresh install pre-wizard race)", async () => {
|
|
mockFetchAuthStatus.mockResolvedValue({
|
|
providers: [{ id: "openai", name: "OpenAI", authenticated: false }],
|
|
});
|
|
mockFetchGlobalSettings.mockResolvedValue({
|
|
modelOnboardingComplete: false,
|
|
defaultProvider: undefined,
|
|
defaultModelId: undefined,
|
|
} as never);
|
|
|
|
const { rerender } = renderHook(
|
|
({ projectId, setupWizardOpen }: { projectId: string | undefined; setupWizardOpen: boolean }) =>
|
|
useAuthOnboarding({
|
|
projectId,
|
|
setupWizardOpen,
|
|
openModelOnboarding,
|
|
openSettings,
|
|
}),
|
|
{
|
|
initialProps: { projectId: undefined as string | undefined, setupWizardOpen: false },
|
|
},
|
|
);
|
|
|
|
// No project yet — the setup wizard owns this phase. Don't fetch or open.
|
|
await waitFor(() => {
|
|
expect(mockFetchAuthStatus).not.toHaveBeenCalled();
|
|
expect(openModelOnboarding).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// Setup wizard opens, user fills it out…
|
|
rerender({ projectId: undefined, setupWizardOpen: true });
|
|
// …completes it: project registered, wizard closes.
|
|
rerender({ projectId: "proj_new", setupWizardOpen: false });
|
|
|
|
await waitFor(() => {
|
|
expect(openModelOnboarding).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
it("does not stack model onboarding on top of a setup wizard that opens mid-fetch", async () => {
|
|
let resolveAuth: (value: { providers: Array<{ id: string; name: string; authenticated: boolean }> }) => void = () => {};
|
|
mockFetchAuthStatus.mockReturnValue(
|
|
new Promise((resolve) => {
|
|
resolveAuth = resolve;
|
|
}) as never,
|
|
);
|
|
mockFetchGlobalSettings.mockResolvedValue({
|
|
modelOnboardingComplete: false,
|
|
defaultProvider: undefined,
|
|
defaultModelId: undefined,
|
|
} as never);
|
|
|
|
const { rerender } = renderHook(
|
|
({ setupWizardOpen }: { setupWizardOpen: boolean }) =>
|
|
useAuthOnboarding({
|
|
projectId: "proj_123",
|
|
setupWizardOpen,
|
|
openModelOnboarding,
|
|
openSettings,
|
|
}),
|
|
{ initialProps: { setupWizardOpen: false } },
|
|
);
|
|
|
|
// Wizard opens while fetch is still pending.
|
|
rerender({ setupWizardOpen: true });
|
|
resolveAuth({ providers: [{ id: "openai", name: "OpenAI", authenticated: false }] });
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetchAuthStatus).toHaveBeenCalled();
|
|
});
|
|
// Onboarding must not open while wizard is up.
|
|
expect(openModelOnboarding).not.toHaveBeenCalled();
|
|
|
|
// After wizard closes, onboarding takes over.
|
|
rerender({ setupWizardOpen: false });
|
|
await waitFor(() => {
|
|
expect(openModelOnboarding).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
// --- One-shot guard ---
|
|
|
|
it("does not re-trigger onboarding when projectId changes after initial bootstrap", async () => {
|
|
mockFetchAuthStatus.mockResolvedValue({
|
|
providers: [{ id: "openai", name: "OpenAI", authenticated: false }],
|
|
});
|
|
mockFetchGlobalSettings.mockResolvedValue({
|
|
modelOnboardingComplete: false,
|
|
defaultProvider: undefined,
|
|
defaultModelId: undefined,
|
|
} as never);
|
|
|
|
// First render with project 1
|
|
const { rerender } = renderHook(
|
|
({ projectId }: { projectId: string }) =>
|
|
useAuthOnboarding({
|
|
projectId,
|
|
setupWizardOpen: false,
|
|
openModelOnboarding,
|
|
openSettings,
|
|
}),
|
|
{
|
|
initialProps: { projectId: "proj_1" },
|
|
},
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(openModelOnboarding).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
// Simulate project context churn - change projectId prop
|
|
rerender({ projectId: "proj_2" });
|
|
|
|
// Onboarding should NOT open again (one-shot guard prevents repeat)
|
|
expect(openModelOnboarding).toHaveBeenCalledTimes(1);
|
|
expect(openSettings).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not re-trigger when openModelOnboarding reference changes", async () => {
|
|
mockFetchAuthStatus.mockResolvedValue({
|
|
providers: [{ id: "openai", name: "OpenAI", authenticated: false }],
|
|
});
|
|
mockFetchGlobalSettings.mockResolvedValue({
|
|
modelOnboardingComplete: false,
|
|
defaultProvider: undefined,
|
|
defaultModelId: undefined,
|
|
} as never);
|
|
|
|
const { rerender } = renderHook(
|
|
({ open }: { open: () => void }) =>
|
|
useAuthOnboarding({
|
|
projectId: "proj_123",
|
|
setupWizardOpen: false,
|
|
openModelOnboarding: open,
|
|
openSettings,
|
|
}),
|
|
{
|
|
initialProps: { open: openModelOnboarding },
|
|
},
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(openModelOnboarding).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
// Simulate a new function reference (e.g., after modal manager re-render)
|
|
const newOpenOnboarding = vi.fn();
|
|
rerender({ open: newOpenOnboarding });
|
|
|
|
// Should NOT trigger again
|
|
expect(openModelOnboarding).toHaveBeenCalledTimes(1);
|
|
expect(newOpenOnboarding).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not re-trigger when openSettings reference changes", async () => {
|
|
mockFetchAuthStatus.mockResolvedValue({
|
|
providers: [{ id: "anthropic", name: "Anthropic", authenticated: false }],
|
|
});
|
|
mockFetchGlobalSettings.mockResolvedValue({
|
|
modelOnboardingComplete: true,
|
|
defaultProvider: "anthropic",
|
|
defaultModelId: "claude-sonnet-4-5",
|
|
} as never);
|
|
|
|
const { rerender } = renderHook(
|
|
({ open }: { open: (section?: string) => void }) =>
|
|
useAuthOnboarding({
|
|
projectId: "proj_123",
|
|
setupWizardOpen: false,
|
|
openModelOnboarding,
|
|
openSettings: open,
|
|
}),
|
|
{
|
|
initialProps: { open: openSettings },
|
|
},
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(openSettings).toHaveBeenCalledWith("authentication");
|
|
});
|
|
|
|
// Simulate a new function reference
|
|
const newOpenSettings = vi.fn();
|
|
rerender({ open: newOpenSettings });
|
|
|
|
// Should NOT trigger again
|
|
expect(openSettings).toHaveBeenCalledTimes(1);
|
|
expect(newOpenSettings).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// --- isOnboardingCompleted integration ---
|
|
|
|
it("auto-open is suppressed when isOnboardingCompleted() returns true even if modelOnboardingComplete is undefined", async () => {
|
|
mockFetchAuthStatus.mockResolvedValue({
|
|
providers: [{ id: "openai", name: "OpenAI", authenticated: false }],
|
|
});
|
|
mockFetchGlobalSettings.mockResolvedValue({
|
|
modelOnboardingComplete: undefined,
|
|
defaultProvider: undefined,
|
|
defaultModelId: undefined,
|
|
} as never);
|
|
// Simulate locally completed onboarding
|
|
mockIsOnboardingCompleted.mockReturnValue(true);
|
|
|
|
renderHook(() =>
|
|
useAuthOnboarding({
|
|
projectId: "proj_123",
|
|
setupWizardOpen: false,
|
|
openModelOnboarding,
|
|
openSettings,
|
|
}),
|
|
);
|
|
|
|
// Give time for any async calls to resolve
|
|
await waitFor(() => {
|
|
expect(mockFetchAuthStatus).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
// Neither onboarding nor settings should open because local completion suppresses it
|
|
expect(openModelOnboarding).not.toHaveBeenCalled();
|
|
expect(openSettings).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("auto-open still fires when neither server nor local completion flag is set", async () => {
|
|
mockFetchAuthStatus.mockResolvedValue({
|
|
providers: [{ id: "openai", name: "OpenAI", authenticated: false }],
|
|
});
|
|
mockFetchGlobalSettings.mockResolvedValue({
|
|
modelOnboardingComplete: false,
|
|
defaultProvider: undefined,
|
|
defaultModelId: undefined,
|
|
} as never);
|
|
// No local completion
|
|
mockIsOnboardingCompleted.mockReturnValue(false);
|
|
|
|
renderHook(() =>
|
|
useAuthOnboarding({
|
|
projectId: "proj_123",
|
|
setupWizardOpen: false,
|
|
openModelOnboarding,
|
|
openSettings,
|
|
}),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(openModelOnboarding).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
expect(openSettings).not.toHaveBeenCalled();
|
|
});
|
|
});
|