diff --git a/packages/app/e2e/regression/providers-console-signin.spec.ts b/packages/app/e2e/regression/providers-console-signin.spec.ts new file mode 100644 index 00000000000..17acc4aafd1 --- /dev/null +++ b/packages/app/e2e/regression/providers-console-signin.spec.ts @@ -0,0 +1,94 @@ +import { expect, test, type Page } from "@playwright/test" +import { mockOpenCodeServer } from "../utils/mock-server" + +const directory = "C:/Projects/providers-console-signin" + +const project = { + id: "proj_providers_console", + canonical: directory, + name: "Providers console", + vcs: "git", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], +} + +// Zen and the Console account share the id `opencode`: the provider comes from models.dev, the +// integration is renamed by OpencodePlugin and carries the account sign-in. +const zen = (paid: boolean) => ({ + id: "opencode", + name: "OpenCode Zen", + models: { + "claude-sonnet-4-6": { + id: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + cost: { input: paid ? 3 : 0, output: 0 }, + }, + }, +}) + +const integrations = (connections: unknown[]) => [ + { + id: "opencode", + name: "OpenCode Console", + methods: [ + { id: "device", type: "oauth", label: "OpenCode Console account" }, + { type: "key", label: "API key (service account)" }, + ], + connections, + }, + { id: "opencode-go", name: "OpenCode Go", methods: [{ type: "key" }], connections: [] }, + { id: "anthropic", name: "Anthropic", methods: [{ type: "key" }], connections: [] }, +] + +test.use({ viewport: { width: 1280, height: 900 } }) + +async function openProviders(page: Page, input: { paid: boolean; connections: unknown[] }) { + await mockOpenCodeServer(page, { + directory, + project, + provider: { all: [zen(input.paid)], connected: ["opencode"], default: {} }, + integrations: integrations(input.connections), + sessions: [], + pageMessages: () => ({ items: [] }), + }) + await page.addInitScript((directory) => { + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ projects: { local: [{ worktree: directory, expanded: true }] } }), + ) + }, directory) + await page.goto("/") + await page.getByRole("button", { name: "Settings", exact: true }).click() + const settings = page.getByTestId("settings-screen") + await settings.getByRole("tab", { name: "Providers", exact: true }).click() + await expect(settings.getByRole("heading", { name: "Popular providers" })).toBeVisible() + return settings +} + +test("fresh install offers the Console sign-in", async ({ page }) => { + const settings = await openProviders(page, { paid: false, connections: [] }) + await expect(settings.getByText("No connected providers")).toBeVisible() + await expect(settings.getByText("OpenCode Console", { exact: true })).toBeVisible() +}) + +test("a stored Zen API key keeps the Console sign-in available", async ({ page }) => { + const settings = await openProviders(page, { + paid: true, + connections: [{ type: "credential", id: "cred_v1", label: "API key", method: "key" }], + }) + const connected = settings.locator('[data-component="connected-providers-section"]') + await expect(connected.getByText("OpenCode Zen", { exact: true })).toBeVisible() + await expect(settings.getByText("OpenCode Console", { exact: true })).toBeVisible() +}) + +test("a Console account hides the sign-in row", async ({ page }) => { + const settings = await openProviders(page, { + paid: true, + connections: [{ type: "credential", id: "cred_account", label: "Clara Team", method: "oauth" }], + }) + const connected = settings.locator('[data-component="connected-providers-section"]') + await expect(connected.getByText("OpenCode Zen", { exact: true })).toBeVisible() + // Anthropic only exists in the integration fixture, so its row proves the integration list has loaded. + await expect(settings.getByText("Anthropic", { exact: true })).toBeVisible() + await expect(settings.getByText("OpenCode Console", { exact: true })).toHaveCount(0) +}) diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index f5d19f7a027..5a9f1210bcd 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -9,6 +9,7 @@ export interface MockServerConfig { server?: string provider: unknown | (() => unknown) integrationMethods?: Record + integrations?: unknown[] onConnectKey?: (input: { integrationID: string; body: unknown }) => void shells?: unknown[] configEntries?: unknown[] @@ -258,11 +259,13 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map Effect.succeed({ location: location(config), data: currentModels(providerConfig(config)) }), modelDefault: () => Effect.succeed({ location: location(config), data: currentDefaultModel(providerConfig(config)) }), - integrationList: () => Effect.succeed({ location: location(config), data: [] }), + integrationList: () => Effect.succeed({ location: location(config), data: config.integrations ?? [] }), integrationGet: (ctx) => Effect.succeed({ location: location(config), - data: { + data: config.integrations + ?.filter(record) + .find((integration) => integration.id === ctx.params.integrationID) ?? { id: ctx.params.integrationID, name: ctx.params.integrationID, methods: config.integrationMethods?.[ctx.params.integrationID] ?? [{ type: "key", label: "API key" }], diff --git a/packages/app/src/settings/providers/providers.tsx b/packages/app/src/settings/providers/providers.tsx index 7ec2e8e3c3c..4a72f02c371 100644 --- a/packages/app/src/settings/providers/providers.tsx +++ b/packages/app/src/settings/providers/providers.tsx @@ -8,6 +8,7 @@ import { useIntegrations } from "@/providers/catalog/integrations" import { createMemo, type Component, For, Show } from "solid-js" import { useLanguage } from "@/runtime/i18n/language" import { useServerSDK } from "@/runtime/server/client" +import { CONSOLE_INTEGRATION } from "@/providers/connect/controller" import { DialogConnectProvider, useProviderConnectController } from "@/providers/connect/dialog" import { SettingsList } from "@/settings/list" import "@/settings/settings.css" @@ -62,9 +63,17 @@ export const SettingsProviders: Component<{ const popular = createMemo(() => { const connectedIDs = new Set(connected().map((p) => p.id)) + // The Console account (integration `opencode`) shares its id with the Zen provider. A stored API + // key, including one imported from a v1 auth.json, makes Zen "connected" without any account, so + // the Popular list keeps the sign-in row until the active credential is an OAuth grant. Until the + // integration list arrives the row is still the models.dev Zen provider, so dedupe it as before. + const console = integrations.list().find((entry) => entry.id === CONSOLE_INTEGRATION) const items = providers .popular() - .filter((p) => !connectedIDs.has(p.id)) + .filter((p) => { + if (p.id !== CONSOLE_INTEGRATION || !console) return !connectedIDs.has(p.id) + return console.connections.find((connection) => connection.type === "credential")?.method !== "oauth" + }) .slice() items.sort((a, b) => popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)) return items