feat(FN-2451): enforce connected-first AI onboarding provider order
- Rework ModelOnboardingModal provider rendering to use a single curated ordering across OAuth, API key, and Claude CLI cards while excluding GitHub - Prioritize already-connected providers first, then apply deterministic family/alias sorting so Claude CLI appears near the top and provider controls remain intact - Add onboarding-flow regression tests that verify provider ordering and preserved per-provider interactions after reordering - Update dashboard styling to use token-based onboarding error text class and adjust related tests for resilient async and accessible section targeting
This commit is contained in:
@@ -308,7 +308,7 @@ function ClaudeCliActionToast({
|
||||
}) {
|
||||
if (action.kind === "error") {
|
||||
return (
|
||||
<p className="onboarding-helper-text" style={{ color: "var(--danger)" }}>
|
||||
<p className="onboarding-helper-text onboarding-helper-text--error">
|
||||
{action.message}
|
||||
</p>
|
||||
);
|
||||
|
||||
@@ -163,6 +163,82 @@ function getProviderDisplayName(providerId: string): string {
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
const ONBOARDING_CURATED_PROVIDER_FAMILY_ORDER = [
|
||||
"anthropic",
|
||||
"claude-cli",
|
||||
"openai-codex",
|
||||
"gemini",
|
||||
"minimax",
|
||||
"kimi",
|
||||
"zai",
|
||||
] as const;
|
||||
|
||||
const ONBOARDING_PROVIDER_FAMILY_ALIASES: Record<string, (typeof ONBOARDING_CURATED_PROVIDER_FAMILY_ORDER)[number]> = {
|
||||
anthropic: "anthropic",
|
||||
"claude-cli": "claude-cli",
|
||||
"openai-codex": "openai-codex",
|
||||
google: "gemini",
|
||||
gemini: "gemini",
|
||||
minimax: "minimax",
|
||||
kimi: "kimi",
|
||||
moonshot: "kimi",
|
||||
"kimi-coding": "kimi",
|
||||
zai: "zai",
|
||||
};
|
||||
|
||||
const ONBOARDING_PROVIDER_ALIAS_ORDER: Record<string, string[]> = {
|
||||
gemini: ["google", "gemini"],
|
||||
kimi: ["kimi", "moonshot", "kimi-coding"],
|
||||
};
|
||||
|
||||
function getOnboardingProviderFamilyId(providerId: string): string {
|
||||
return ONBOARDING_PROVIDER_FAMILY_ALIASES[providerId] ?? providerId;
|
||||
}
|
||||
|
||||
function getOnboardingProviderCuratedRank(providerId: string): number {
|
||||
const familyId = getOnboardingProviderFamilyId(providerId);
|
||||
const rank = ONBOARDING_CURATED_PROVIDER_FAMILY_ORDER.indexOf(
|
||||
familyId as (typeof ONBOARDING_CURATED_PROVIDER_FAMILY_ORDER)[number],
|
||||
);
|
||||
return rank === -1 ? Number.POSITIVE_INFINITY : rank;
|
||||
}
|
||||
|
||||
function compareOnboardingProviders(a: AuthProvider, b: AuthProvider): number {
|
||||
if (a.authenticated !== b.authenticated) {
|
||||
return a.authenticated ? -1 : 1;
|
||||
}
|
||||
|
||||
const curatedRankA = getOnboardingProviderCuratedRank(a.id);
|
||||
const curatedRankB = getOnboardingProviderCuratedRank(b.id);
|
||||
|
||||
if (curatedRankA !== curatedRankB) {
|
||||
return curatedRankA - curatedRankB;
|
||||
}
|
||||
|
||||
const familyA = getOnboardingProviderFamilyId(a.id);
|
||||
const familyB = getOnboardingProviderFamilyId(b.id);
|
||||
if (familyA !== familyB) {
|
||||
return familyA.localeCompare(familyB);
|
||||
}
|
||||
|
||||
const aliasOrder = ONBOARDING_PROVIDER_ALIAS_ORDER[familyA];
|
||||
if (aliasOrder) {
|
||||
const aliasRankA = aliasOrder.indexOf(a.id);
|
||||
const aliasRankB = aliasOrder.indexOf(b.id);
|
||||
if (aliasRankA !== aliasRankB) {
|
||||
return (aliasRankA === -1 ? Number.POSITIVE_INFINITY : aliasRankA)
|
||||
- (aliasRankB === -1 ? Number.POSITIVE_INFINITY : aliasRankB);
|
||||
}
|
||||
}
|
||||
|
||||
const nameCompare = a.name.localeCompare(b.name);
|
||||
if (nameCompare !== 0) {
|
||||
return nameCompare;
|
||||
}
|
||||
|
||||
return a.id.localeCompare(b.id);
|
||||
}
|
||||
|
||||
function validateApiKeyFormat(providerId: string, key: string): string | null {
|
||||
const trimmedKey = key.trim();
|
||||
if (!trimmedKey) {
|
||||
@@ -1314,19 +1390,12 @@ export function ModelOnboardingModal({
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const oauthProviders = authProviders.filter(
|
||||
(p) => !p.type || p.type === "oauth",
|
||||
);
|
||||
const apiKeyProviders = authProviders.filter((p) => p.type === "api_key");
|
||||
const cliProviders = authProviders.filter((p) => p.type === "cli");
|
||||
|
||||
// Filter out GitHub from AI providers list
|
||||
const aiOauthProviders = oauthProviders.filter((p) => p.id !== "github");
|
||||
const aiApiKeyProviders = apiKeyProviders.filter((p) => p.id !== "github");
|
||||
|
||||
const githubStatus = getGitHubStatus();
|
||||
|
||||
const aiProviders = authProviders.filter((provider) => provider.id !== "github");
|
||||
const orderedAiProviders = [...aiProviders].sort(compareOnboardingProviders);
|
||||
const hasOauthProviders = orderedAiProviders.some((provider) => !provider.type || provider.type === "oauth");
|
||||
const hasApiKeyProviders = orderedAiProviders.some((provider) => provider.type === "api_key");
|
||||
const connectedAiProviders = aiProviders.filter((provider) => provider.authenticated);
|
||||
const hasAiProvider = connectedAiProviders.length > 0;
|
||||
const hasProjectSelected = Boolean(projectId);
|
||||
@@ -1605,154 +1674,149 @@ export function ModelOnboardingModal({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* OAuth Providers */}
|
||||
{aiOauthProviders.length > 0 && (
|
||||
<>
|
||||
{aiOauthProviders.map((provider) => (
|
||||
<div
|
||||
key={provider.id}
|
||||
className={`onboarding-provider-card${provider.authenticated ? " onboarding-provider-card--connected" : ""}`}
|
||||
>
|
||||
<div className="onboarding-provider-card__icon">
|
||||
<ProviderIcon provider={provider.id} size="md" />
|
||||
</div>
|
||||
<div className="onboarding-provider-card__body">
|
||||
<strong className="onboarding-provider-card__name">{provider.name}</strong>
|
||||
<span className="onboarding-provider-card__description">
|
||||
{getProviderInfo(provider.id).description}
|
||||
</span>
|
||||
<ProviderStatusBadge status={getProviderStatus(provider)} />
|
||||
</div>
|
||||
<div className="onboarding-provider-card__actions">
|
||||
{authActionInProgress === provider.id ? (
|
||||
<>
|
||||
<button className="btn btn-sm" disabled>
|
||||
{provider.authenticated
|
||||
? "Logging out…"
|
||||
: "Waiting for login…"}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => handleCancelLogin(provider.id)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
) : provider.authenticated ? (
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => handleLogout(provider.id)}
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => handleLogin(provider.id)}
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Show timeout message */}
|
||||
{loginOutcomes[provider.id] === "timeout" && authActionInProgress !== provider.id && (
|
||||
<p className="onboarding-helper-text" style={{ marginTop: 4 }}>
|
||||
Login timed out. Please try again.
|
||||
</p>
|
||||
)}
|
||||
{/* Show failure message */}
|
||||
{loginOutcomes[provider.id] === "failed" && authActionInProgress !== provider.id && (
|
||||
<p className="field-error" style={{ marginTop: 4 }}>
|
||||
Login failed. Please try again.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{orderedAiProviders.map((provider) => {
|
||||
if (provider.id === "claude-cli" && provider.type === "cli") {
|
||||
return (
|
||||
<ClaudeCliProviderCard
|
||||
key={provider.id}
|
||||
authenticated={provider.authenticated}
|
||||
onToggled={() => {
|
||||
// Refetch auth status so the parent provider list
|
||||
// reflects the new authenticated state.
|
||||
void loadAuthStatus();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
{/* OAuth login disclosure */}
|
||||
<OnboardingDisclosure summary="How does login work?">
|
||||
<p className="onboarding-helper-text">
|
||||
Clicking Login opens the provider's website in a new tab where you sign in.
|
||||
Once you authorize Fusion, this page will automatically detect the connection.
|
||||
Your credentials are never stored in Fusion.
|
||||
</p>
|
||||
</OnboardingDisclosure>
|
||||
</>
|
||||
)}
|
||||
if (provider.type === "api_key") {
|
||||
const providerInfo = getProviderInfo(provider.id);
|
||||
const apiKeyInfo = getApiKeyInfo(provider);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={provider.id}
|
||||
data-testid={`onboarding-provider-card-${provider.id}`}
|
||||
className={`onboarding-provider-card${provider.authenticated ? " onboarding-provider-card--connected" : ""}`}
|
||||
>
|
||||
<div className="onboarding-provider-card__icon">
|
||||
<ProviderIcon provider={provider.id} size="md" />
|
||||
</div>
|
||||
<div className="onboarding-provider-card__body">
|
||||
<strong className="onboarding-provider-card__name">
|
||||
<Key size={14} className="onboarding-provider-key-icon" />
|
||||
{provider.name}
|
||||
</strong>
|
||||
<span className="onboarding-provider-card__description">
|
||||
{providerInfo.description}
|
||||
</span>
|
||||
<ProviderStatusBadge status={getProviderStatus(provider)} />
|
||||
{provider.authenticated && provider.keyHint && (
|
||||
<span className="auth-key-hint">Key: {provider.keyHint}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="onboarding-provider-card__actions onboarding-provider-card__actions--api-key">
|
||||
<ApiKeyEntryForm
|
||||
provider={provider}
|
||||
apiKeyInfo={apiKeyInfo}
|
||||
inputValue={apiKeyInputs[provider.id] ?? ""}
|
||||
isSaving={authActionInProgress === provider.id}
|
||||
error={apiKeyErrors[provider.id]}
|
||||
success={apiKeySuccess[provider.id]}
|
||||
isConnected={provider.authenticated}
|
||||
onInputChange={handleApiKeyInputChange}
|
||||
onSave={handleSaveApiKey}
|
||||
onClear={handleClearApiKey}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
{/* API Key Providers */}
|
||||
{aiApiKeyProviders.length > 0 && (
|
||||
<>
|
||||
{aiApiKeyProviders.map((provider) => {
|
||||
const providerInfo = getProviderInfo(provider.id);
|
||||
const apiKeyInfo = getApiKeyInfo(provider);
|
||||
return (
|
||||
<div
|
||||
key={provider.id}
|
||||
data-testid={`onboarding-provider-card-${provider.id}`}
|
||||
className={`onboarding-provider-card${provider.authenticated ? " onboarding-provider-card--connected" : ""}`}
|
||||
>
|
||||
<div className="onboarding-provider-card__icon">
|
||||
<ProviderIcon provider={provider.id} size="md" />
|
||||
</div>
|
||||
<div className="onboarding-provider-card__body">
|
||||
<strong className="onboarding-provider-card__name">
|
||||
<Key size={14} className="onboarding-provider-key-icon" />
|
||||
{provider.name}
|
||||
</strong>
|
||||
<strong className="onboarding-provider-card__name">{provider.name}</strong>
|
||||
<span className="onboarding-provider-card__description">
|
||||
{providerInfo.description}
|
||||
{getProviderInfo(provider.id).description}
|
||||
</span>
|
||||
<ProviderStatusBadge status={getProviderStatus(provider)} />
|
||||
{provider.authenticated && provider.keyHint && (
|
||||
<span className="auth-key-hint">Key: {provider.keyHint}</span>
|
||||
</div>
|
||||
<div className="onboarding-provider-card__actions">
|
||||
{authActionInProgress === provider.id ? (
|
||||
<>
|
||||
<button className="btn btn-sm" disabled>
|
||||
{provider.authenticated
|
||||
? "Logging out…"
|
||||
: "Waiting for login…"}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => handleCancelLogin(provider.id)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
) : provider.authenticated ? (
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => handleLogout(provider.id)}
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => handleLogin(provider.id)}
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="onboarding-provider-card__actions onboarding-provider-card__actions--api-key">
|
||||
<ApiKeyEntryForm
|
||||
provider={provider}
|
||||
apiKeyInfo={apiKeyInfo}
|
||||
inputValue={apiKeyInputs[provider.id] ?? ""}
|
||||
isSaving={authActionInProgress === provider.id}
|
||||
error={apiKeyErrors[provider.id]}
|
||||
success={apiKeySuccess[provider.id]}
|
||||
isConnected={provider.authenticated}
|
||||
onInputChange={handleApiKeyInputChange}
|
||||
onSave={handleSaveApiKey}
|
||||
onClear={handleClearApiKey}
|
||||
/>
|
||||
</div>
|
||||
{loginOutcomes[provider.id] === "timeout" && authActionInProgress !== provider.id && (
|
||||
<p className="onboarding-helper-text" style={{ marginTop: 4 }}>
|
||||
Login timed out. Please try again.
|
||||
</p>
|
||||
)}
|
||||
{loginOutcomes[provider.id] === "failed" && authActionInProgress !== provider.id && (
|
||||
<p className="field-error" style={{ marginTop: 4 }}>
|
||||
Login failed. Please try again.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* API key disclosure */}
|
||||
<OnboardingDisclosure summary="What is an API key?">
|
||||
<p className="onboarding-helper-text">
|
||||
An API key is a secret token that authenticates Fusion with the provider.
|
||||
You can find your key in the provider's dashboard under API settings.
|
||||
Keys are stored securely on your machine.
|
||||
</p>
|
||||
</OnboardingDisclosure>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* OAuth login disclosure */}
|
||||
{hasOauthProviders && (
|
||||
<OnboardingDisclosure summary="How does login work?">
|
||||
<p className="onboarding-helper-text">
|
||||
Clicking Login opens the provider's website in a new tab where you sign in.
|
||||
Once you authorize Fusion, this page will automatically detect the connection.
|
||||
Your credentials are never stored in Fusion.
|
||||
</p>
|
||||
</OnboardingDisclosure>
|
||||
)}
|
||||
|
||||
{/* Claude CLI — synthetic provider card. Rendered alongside
|
||||
OAuth + API-key cards but with its own action set
|
||||
(Enable/Disable + Test) since it's backed by a binary
|
||||
probe rather than stored credentials. */}
|
||||
{cliProviders.some((p) => p.id === "claude-cli") && (
|
||||
<ClaudeCliProviderCard
|
||||
authenticated={
|
||||
cliProviders.find((p) => p.id === "claude-cli")?.authenticated ?? false
|
||||
}
|
||||
onToggled={() => {
|
||||
// Refetch auth status so the parent provider list
|
||||
// reflects the new authenticated state.
|
||||
void loadAuthStatus();
|
||||
}}
|
||||
/>
|
||||
{/* API key disclosure */}
|
||||
{hasApiKeyProviders && (
|
||||
<OnboardingDisclosure summary="What is an API key?">
|
||||
<p className="onboarding-helper-text">
|
||||
An API key is a secret token that authenticates Fusion with the provider.
|
||||
You can find your key in the provider's dashboard under API settings.
|
||||
Keys are stored securely on your machine.
|
||||
</p>
|
||||
</OnboardingDisclosure>
|
||||
)}
|
||||
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Model Selection */}
|
||||
|
||||
@@ -1364,9 +1364,11 @@ describe("QuickEntryBox", () => {
|
||||
expect(props.onCreate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// After creation, focus is restored → autoExpand triggers isExpanded=true
|
||||
// But disclosure should still be false (controls hidden)
|
||||
expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(true);
|
||||
// After creation, focus is restored asynchronously.
|
||||
// autoExpand should set textarea expanded while disclosure remains hidden.
|
||||
await waitFor(() => {
|
||||
expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(true);
|
||||
});
|
||||
expect(controls?.hasAttribute("hidden")).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -1050,7 +1050,7 @@ describe("SettingsModal", () => {
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
// Click on "Project Models" (project-scoped models section)
|
||||
fireEvent.click(screen.getByText("Project Models"));
|
||||
fireEvent.click(screen.getByRole("button", { name: /Project Models/ }));
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
// Select planning model
|
||||
@@ -1080,7 +1080,7 @@ describe("SettingsModal", () => {
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
// Click on "Project Models" (project-scoped models section)
|
||||
fireEvent.click(screen.getByText("Project Models"));
|
||||
fireEvent.click(screen.getByRole("button", { name: /Project Models/ }));
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
// Select execution model
|
||||
@@ -1103,7 +1103,7 @@ describe("SettingsModal", () => {
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
// Click on "Project Models" (project-scoped models section)
|
||||
fireEvent.click(screen.getByText("Project Models"));
|
||||
fireEvent.click(screen.getByRole("button", { name: /Project Models/ }));
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
const planningFallbackTrigger = screen.getByLabelText("Planning Fallback Model");
|
||||
@@ -1138,7 +1138,7 @@ describe("SettingsModal", () => {
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
// Model presets are in Project Models section
|
||||
fireEvent.click(screen.getByText("Project Models"));
|
||||
fireEvent.click(screen.getByRole("button", { name: /Project Models/ }));
|
||||
await user.click(screen.getByText("Add Preset"));
|
||||
|
||||
await user.type(screen.getByLabelText("Name"), "Budget");
|
||||
@@ -1165,7 +1165,7 @@ describe("SettingsModal", () => {
|
||||
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Project Models"));
|
||||
fireEvent.click(screen.getByRole("button", { name: /Project Models/ }));
|
||||
|
||||
const presetList = container.querySelector(".settings-preset-list");
|
||||
expect(presetList).toBeTruthy();
|
||||
@@ -1195,7 +1195,7 @@ describe("SettingsModal", () => {
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
// Auto-select presets are in Project Models section
|
||||
fireEvent.click(screen.getByText("Project Models"));
|
||||
fireEvent.click(screen.getByRole("button", { name: /Project Models/ }));
|
||||
await user.click(screen.getByLabelText("Auto-select preset based on task size"));
|
||||
fireEvent.change(screen.getByLabelText("Small tasks (S):"), { target: { value: "budget" } });
|
||||
fireEvent.change(screen.getByLabelText("Medium tasks (M):"), { target: { value: "normal" } });
|
||||
@@ -1318,7 +1318,7 @@ describe("SettingsModal", () => {
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
// Project Models section has planning and validator model dropdowns
|
||||
fireEvent.click(screen.getByText("Project Models"));
|
||||
fireEvent.click(screen.getByRole("button", { name: /Project Models/ }));
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
// Both dropdowns should be present
|
||||
@@ -1332,7 +1332,7 @@ describe("SettingsModal", () => {
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
// Project Models section has planning model dropdown
|
||||
fireEvent.click(screen.getByText("Project Models"));
|
||||
fireEvent.click(screen.getByRole("button", { name: /Project Models/ }));
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
// Open planning model dropdown and select a model
|
||||
@@ -1355,7 +1355,7 @@ describe("SettingsModal", () => {
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
// Project Models section has validator model dropdown
|
||||
fireEvent.click(screen.getByText("Project Models"));
|
||||
fireEvent.click(screen.getByRole("button", { name: /Project Models/ }));
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
// Open validator model dropdown and select a model
|
||||
@@ -1410,7 +1410,7 @@ describe("SettingsModal", () => {
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
// Navigate to Project Models section and change planning model
|
||||
fireEvent.click(screen.getByText("Project Models"));
|
||||
fireEvent.click(screen.getByRole("button", { name: /Project Models/ }));
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
const planningTrigger = screen.getByLabelText("Planning Model");
|
||||
@@ -1455,7 +1455,7 @@ describe("SettingsModal", () => {
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
// Navigate to Project Models section
|
||||
fireEvent.click(screen.getByText("Project Models"));
|
||||
fireEvent.click(screen.getByRole("button", { name: /Project Models/ }));
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
// Verify the planning lane shows as overridden
|
||||
@@ -1483,7 +1483,7 @@ describe("SettingsModal", () => {
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
// Navigate to Project Models section
|
||||
fireEvent.click(screen.getByText("Project Models"));
|
||||
fireEvent.click(screen.getByRole("button", { name: /Project Models/ }));
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
// Verify lanes show as inherited (don't change anything)
|
||||
@@ -3321,7 +3321,7 @@ describe("SettingsModal", () => {
|
||||
expect(modelsIcon).toBeTruthy();
|
||||
|
||||
// Switch to Project Models → should show project scope banner
|
||||
fireEvent.click(screen.getByText("Project Models"));
|
||||
fireEvent.click(screen.getByRole("button", { name: /Project Models/ }));
|
||||
const projectModelsBanner = container.querySelector(".settings-scope-project");
|
||||
expect(projectModelsBanner).toBeTruthy();
|
||||
expect(projectModelsBanner?.textContent).toContain("project");
|
||||
|
||||
@@ -76,6 +76,14 @@ vi.mock("../ProviderIcon", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
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 {
|
||||
@@ -239,6 +247,26 @@ function hasSavedStateCall(
|
||||
});
|
||||
}
|
||||
|
||||
function getAiSetupProviderOrder(): string[] {
|
||||
const aiSetup = document.querySelector(".model-onboarding-ai-setup");
|
||||
if (!aiSetup) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.from(aiSetup.children)
|
||||
.map((element) => {
|
||||
const testId = element.getAttribute("data-testid");
|
||||
if (testId?.startsWith("onboarding-provider-card-")) {
|
||||
return testId.replace("onboarding-provider-card-", "");
|
||||
}
|
||||
if (testId === "claude-cli-provider-card") {
|
||||
return "claude-cli";
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((providerId): providerId is string => providerId !== null);
|
||||
}
|
||||
|
||||
interface AuthOnboardingHarnessProps {
|
||||
openModelOnboardingSpy?: () => void;
|
||||
openSettingsSpy?: (section?: string) => void;
|
||||
@@ -385,6 +413,79 @@ describe("onboarding flow integration", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("AI setup provider ordering", () => {
|
||||
it("renders providers in curated onboarding order with connected-first priority and claude-cli near top", 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: "openai-codex", name: "OpenAI Codex", authenticated: false, type: "oauth" },
|
||||
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" },
|
||||
{ id: "zai", name: "Zhipu AI", authenticated: false, type: "api_key" },
|
||||
{ id: "minimax", name: "MiniMax", authenticated: false, type: "api_key" },
|
||||
{ id: "google", name: "Google", authenticated: false, type: "oauth" },
|
||||
{ id: "gemini", name: "Gemini", authenticated: true, type: "oauth" },
|
||||
{ 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();
|
||||
});
|
||||
|
||||
const providerOrder = getAiSetupProviderOrder();
|
||||
|
||||
expect(providerOrder).toEqual([
|
||||
"anthropic",
|
||||
"gemini",
|
||||
"openrouter",
|
||||
"claude-cli",
|
||||
"openai-codex",
|
||||
"google",
|
||||
"minimax",
|
||||
"moonshot",
|
||||
"zai",
|
||||
"openai",
|
||||
]);
|
||||
|
||||
expect(providerOrder).not.toContain("github");
|
||||
expect(providerOrder.indexOf("openrouter")).toBeLessThan(providerOrder.indexOf("claude-cli"));
|
||||
expect(providerOrder.indexOf("claude-cli")).toBeLessThan(providerOrder.indexOf("openai-codex"));
|
||||
expect(providerOrder.indexOf("openrouter")).toBeLessThan(providerOrder.indexOf("openai"));
|
||||
});
|
||||
|
||||
it("preserves provider-specific controls and status badges after reordering", async () => {
|
||||
mockFetchAuthStatus.mockResolvedValue({
|
||||
providers: [
|
||||
{ id: "openai-codex", name: "OpenAI Codex", authenticated: false, type: "oauth" },
|
||||
{ id: "claude-cli", name: "Anthropic — via Claude CLI", authenticated: false, type: "cli" },
|
||||
{ id: "minimax", name: "MiniMax", authenticated: false, type: "api_key" },
|
||||
],
|
||||
});
|
||||
|
||||
renderModal();
|
||||
|
||||
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(within(codexCard).getByTestId("provider-status-badge")).toHaveTextContent("Not connected");
|
||||
|
||||
expect(screen.getByTestId("claude-cli-provider-card")).toHaveAttribute("data-authenticated", "false");
|
||||
|
||||
const minimaxCard = screen.getByTestId("onboarding-provider-card-minimax");
|
||||
expect(within(minimaxCard).getByTestId("onboarding-apikey-input-minimax")).toBeInTheDocument();
|
||||
expect(within(minimaxCard).getByTestId("onboarding-apikey-save-minimax")).toBeInTheDocument();
|
||||
expect(within(minimaxCard).getByTestId("provider-status-badge")).toHaveTextContent("Not connected");
|
||||
});
|
||||
});
|
||||
|
||||
describe("dismissal flow", () => {
|
||||
it("dismissal flow: dismissing on GitHub step saves state with currentStep=github", async () => {
|
||||
const renderResult = renderModal();
|
||||
|
||||
Reference in New Issue
Block a user