Files
kilocode/packages/kilo-vscode/tests/unit/provider-action.test.ts
T
Marius 42bb938c09 feat(vscode): multi-provider selection with connect/disconnect/OAuth flows (#7295)
* tmp

* tmp

* tmp

* tmp

* tmp

* feat(vscode): multi-provider selection with connect/disconnect/OAuth flows

Reimplements the provider management UI in the VS Code extension settings:

- Split Providers tab into Models (model selection) and Providers (connection management)
- Kilo Gateway always shown at top with login state from profile data
- Provider connect dialog with API key and OAuth (code + auto) flows
- Custom provider dialog for OpenAI-compatible providers via base URL
- Provider action request-response pairing via createProviderAction utility
- Coalesced fetchAndSendProviders prevents request floods (single in-flight + one queued)
- SSE event deduplication: server.instance.disposed filtered by workspace directory
- Login guard prevents concurrent device auth flows
- Model selection fallback chain: override > mode config > global config > recents > kilo-auto
- Recent models persisted in extension globalState (last 5, deduplicated)
- Shared validation in src/shared/ (provider-model.ts, custom-provider.ts)
- 16 locale translations for all new UI strings
- Unit tests for custom-provider validation, model selection, provider actions, visibility

* Add tests

* chore: update kilo-vscode visual regression baselines

* fix(vscode): log swallowed auth.remove errors for configured providers

When a configured provider has both a config entry and an auth store
entry, and auth.remove fails transiently, the error was silently
swallowed. Now logs a warning so the failure is visible in debug output.

* fix(vscode): validate recentModels shape and enforce size limit on extension side

Webview messages are an untrusted boundary. persistRecents and
requestRecents now validate array shape (providerID/modelID must be
strings) and enforce RECENT_LIMIT=5 on the extension side, not just
in the webview. Malformed globalState entries are filtered out on read.

* fix(vscode): normalize directory paths in server.instance.disposed filter

Use path.resolve() when comparing the event directory against the
workspace directory. Prevents false mismatches from trailing slashes
or case differences on case-insensitive filesystems.

* fix(vscode): remove duplicate requestRecents case branches (dead code)

Three consecutive case "requestRecents" blocks existed from iterative
refinement. Only the first executes in a switch. Removed the two dead
duplicates, keeping the validated version using validateRecents().

* fix(vscode): don't navigate to profile after login

Login can now be triggered from the settings tab (Kilo Gateway sign-in
button). The forced navigation to the profile view after login left the
user stuck on the profile tab with no way back to settings without
closing and reopening. The profile data push is sufficient — the
settings Kilo row updates reactively via server.profileData().

* fix(vscode): mask API key input in custom provider dialog

Add type="password" to the API key TextField so it renders as dots
instead of plain text.

* refactor(vscode): extract inline styles from ProviderConnectDialog to CSS classes

Move repeated inline styles to chat.css classes:
- .provider-connect-body (text body styling)
- .provider-connect-code-label (confirmation code label)
- .provider-connect-code (monospace code block)
- .provider-connect-status (spinner + status text row)

* fix: revert unrelated opencode/package.json changes

* chore: update kilo-vscode visual regression baselines

* fix(vscode): send rollback configUpdated on failed config save

When global.config.update fails, send configUpdated with the last known
good config so the webview clears its saving flag and reverts optimistic
state. Without this, a failed save leaves saving=true forever, causing
subsequent configLoaded messages to be ignored.

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-20 10:20:24 +01:00

144 lines
3.7 KiB
TypeScript

import { describe, expect, it } from "bun:test"
import { createProviderAction } from "../../webview-ui/src/utils/provider-action"
import type { ExtensionMessage, WebviewMessage } from "../../webview-ui/src/types/messages"
function createTransport() {
const sent: WebviewMessage[] = []
let handler: ((message: ExtensionMessage) => void) | undefined
return {
sent,
receive(message: ExtensionMessage) {
handler?.(message)
},
postMessage(message: WebviewMessage) {
sent.push(message)
},
onMessage(next: (message: ExtensionMessage) => void) {
handler = next
return () => {
if (handler === next) {
handler = undefined
}
}
},
}
}
describe("createProviderAction", () => {
it("routes terminal provider messages by request id", () => {
const transport = createTransport()
const action = createProviderAction(transport)
const seen: string[] = []
action.send(
{
type: "connectProvider",
providerID: "openai",
apiKey: "sk-test",
},
{
onConnected: (message) => seen.push(`connected:${message.providerID}`),
},
)
const sent = transport.sent[0]
expect(sent?.type).toBe("connectProvider")
expect("requestId" in (sent ?? {}) ? sent.requestId : "").toBeString()
const requestId = "requestId" in (sent ?? {}) ? sent.requestId : ""
transport.receive({
type: "providerConnected",
requestId,
providerID: "openai",
})
transport.receive({
type: "providerConnected",
requestId,
providerID: "openai",
})
expect(seen).toEqual(["connected:openai"])
action.dispose()
})
it("keeps concurrent requests isolated", () => {
const transport = createTransport()
const action = createProviderAction(transport)
const seen: string[] = []
action.send(
{
type: "authorizeProviderOAuth",
providerID: "anthropic",
method: 0,
},
{
onOAuthReady: (message) => seen.push(`oauth:${message.authorization.method}`),
},
)
action.send(
{
type: "disconnectProvider",
providerID: "openai",
},
{
onDisconnected: (message) => seen.push(`disconnect:${message.providerID}`),
},
)
const oauth = transport.sent[0]
const disconnect = transport.sent[1]
const oauthId = "requestId" in (oauth ?? {}) ? oauth.requestId : ""
const disconnectId = "requestId" in (disconnect ?? {}) ? disconnect.requestId : ""
transport.receive({
type: "providerDisconnected",
requestId: disconnectId,
providerID: "openai",
})
transport.receive({
type: "providerOAuthReady",
requestId: oauthId,
providerID: "anthropic",
authorization: { url: "https://example.com", method: "code", instructions: "Code: 1234" },
})
expect(seen).toEqual(["disconnect:openai", "oauth:code"])
action.dispose()
})
it("can drop stale requests", () => {
const transport = createTransport()
const action = createProviderAction(transport)
const seen: string[] = []
const requestId = action.send(
{
type: "saveCustomProvider",
providerID: "myprovider",
config: {
name: "My Provider",
options: { baseURL: "https://example.com/v1" },
models: { "model-1": { name: "Model One" } },
},
},
{
onError: (message) => seen.push(message.message),
},
)
action.clear(requestId)
transport.receive({
type: "providerActionError",
requestId,
providerID: "myprovider",
action: "connect",
message: "boom",
})
expect(seen).toEqual([])
action.dispose()
})
})