From b784da5e00743fa60df5132e71d9fe73f0a3463e Mon Sep 17 00:00:00 2001 From: minhn4 Date: Tue, 14 Jul 2026 16:06:49 +0700 Subject: [PATCH 1/2] test: cover custom-provider gateway flow (#75/#76) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unit-test the model-discovery/validation backend calls (fetchModels, validateApiKey) against a real loopback server — endpoint construction, /v1 normalization, empty/error handling. E2e-test the flow end to end through the TUI: the gate offers all three options, Connect provider opens upstream's dialog, a base URL discovers models and PATCHes the keyless provider block into codev.json (key to auth.json), and an empty base URL stores only the credential. Co-Authored-By: Claude Fable 5 --- packages/codev-gateway/test/backend.test.ts | 82 +++++++ .../tui/test/custom-provider.view.test.tsx | 227 ++++++++++++++++++ 2 files changed, 309 insertions(+) create mode 100644 packages/codev-gateway/test/backend.test.ts create mode 100644 packages/tui/test/custom-provider.view.test.tsx diff --git a/packages/codev-gateway/test/backend.test.ts b/packages/codev-gateway/test/backend.test.ts new file mode 100644 index 0000000000..fdd5fc870a --- /dev/null +++ b/packages/codev-gateway/test/backend.test.ts @@ -0,0 +1,82 @@ +import { afterAll, describe, expect, test } from "bun:test" +import { fetchModels, validateApiKey } from "../src/backend" + +// A real loopback server (not a mock) that records the path + auth header of the +// last request, so tests assert the exact endpoint fetchModels/validateApiKey +// build from a user-pasted base URL. Each test installs its own handler. +let handler: (req: Request, url: URL) => Response = () => new Response("unset", { status: 500 }) +const seen: { path: string; auth: string | null }[] = [] +const server = Bun.serve({ + port: 0, + fetch(req) { + const url = new URL(req.url) + seen.push({ path: url.pathname, auth: req.headers.get("authorization") }) + return handler(req, url) + }, +}) +const base = `http://127.0.0.1:${server.port}` + +afterAll(() => server.stop(true)) + +describe("fetchModels", () => { + test("returns the model ids from an OpenAI-shaped /v1/models response", async () => { + handler = () => Response.json({ data: [{ id: "a" }, { id: "b" }] }) + seen.length = 0 + const ids = await fetchModels("sk-test", base) + expect(ids).toEqual(["a", "b"]) + // Hits /v1/models with a bearer token; base carried no /v1 suffix. + expect(seen.at(-1)).toEqual({ path: "/v1/models", auth: "Bearer sk-test" }) + }) + + test("does not double the /v1 segment when the base already has one", async () => { + handler = () => Response.json({ data: [{ id: "a" }] }) + seen.length = 0 + await fetchModels("k", `${base}/v1`) + expect(seen.at(-1)?.path).toBe("/v1/models") + }) + + test("drops empty and non-string ids", async () => { + handler = () => Response.json({ data: [{ id: "a" }, { id: "" }, {}, { id: "b" }] }) + const ids = await fetchModels("k", base) + expect(ids).toEqual(["a", "b"]) + }) + + test("throws on a non-2xx response, surfacing the backend error", async () => { + handler = () => Response.json({ error: "bad key" }, { status: 401 }) + await expect(fetchModels("k", base)).rejects.toThrow(/401.*bad key/) + }) + + test("throws when the gateway returns no models", async () => { + handler = () => Response.json({ data: [] }) + await expect(fetchModels("k", base)).rejects.toThrow(/no models/) + }) +}) + +describe("validateApiKey", () => { + test("returns true on a 2xx from /key/info at the gateway root", async () => { + handler = () => Response.json({ ok: true }) + seen.length = 0 + expect(await validateApiKey("sk-test", base)).toBe(true) + // /key/info lives at the root, so a trailing /v1 is stripped before joining. + expect(seen.at(-1)).toEqual({ path: "/key/info", auth: "Bearer sk-test" }) + }) + + test("strips a trailing /v1 before appending /key/info", async () => { + handler = () => Response.json({ ok: true }) + seen.length = 0 + await validateApiKey("k", `${base}/v1`) + expect(seen.at(-1)?.path).toBe("/key/info") + }) + + test("returns false on 401/403 (invalid or revoked key)", async () => { + handler = () => new Response("nope", { status: 401 }) + expect(await validateApiKey("k", base)).toBe(false) + handler = () => new Response("nope", { status: 403 }) + expect(await validateApiKey("k", base)).toBe(false) + }) + + test("throws on other errors so callers can distinguish unreachable from bad key", async () => { + handler = () => new Response("boom", { status: 500 }) + await expect(validateApiKey("k", base)).rejects.toThrow(/500/) + }) +}) diff --git a/packages/tui/test/custom-provider.view.test.tsx b/packages/tui/test/custom-provider.view.test.tsx new file mode 100644 index 0000000000..32075d5105 --- /dev/null +++ b/packages/tui/test/custom-provider.view.test.tsx @@ -0,0 +1,227 @@ +import { expect, mock, test } from "bun:test" +import type { TuiPluginApi } from "@opencode-ai/plugin/tui" +import { createTestRenderer } from "@opentui/core/testing" +import { Effect } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { Global } from "@opencode-ai/core/global" +import { createTuiResolvedConfig } from "./fixture/tui-runtime" +import { createEventSource, createFetch, directory, json, type FetchHandler } from "./fixture/tui-sdk" + +const GATE = "Configure CoDev Code" + +async function readBody(input: RequestInfo | URL, init?: RequestInit) { + if (input instanceof Request) return input.clone().json().catch(() => undefined) + if (typeof init?.body === "string") return JSON.parse(init.body) + return undefined +} + +async function waitFrame(setup: Awaited>, fn: (frame: string) => boolean) { + const start = Date.now() + while (true) { + await setup.renderOnce() + const frame = setup.captureCharFrame() + if (fn(frame)) return frame + if (Date.now() - start > 15_000) throw new Error("timed out waiting for frame:\n" + frame) + await Bun.sleep(25) + } +} + +async function waitUntil(setup: Awaited>, fn: () => boolean) { + const start = Date.now() + while (!fn()) { + await setup.renderOnce() + if (Date.now() - start > 15_000) throw new Error("timed out waiting for condition") + await Bun.sleep(25) + } +} + +// Boots the app with an injected fetch that captures the SDK writes the flow +// makes (PUT /auth/{id}, PATCH /global/config) and answers instance.dispose, +// delegating every other (sync) call to the default fixture. `override` can add +// stateful provider responses. fetchModels uses the GLOBAL fetch, so the base +// URL the test types must point at a real loopback server, not this one. +async function boot(override?: FetchHandler, onConfigWrite?: (body: any) => void) { + const setup = await createTestRenderer({ width: 100, height: 30, useThread: false }) + const core = await import("@opentui/core") + mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer })) + const events = createEventSource() + const base = createFetch(override, events) + const captured = { auth: [] as { providerID: string; body: unknown }[], config: [] as any[] } + + const fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input)) + const method = (input instanceof Request ? input.method : (init?.method ?? "GET")).toUpperCase() + if (method === "PUT" && url.pathname.startsWith("/auth/")) { + captured.auth.push({ providerID: decodeURIComponent(url.pathname.slice("/auth/".length)), body: await readBody(input, init) }) + return json({}) + } + if (method === "PATCH" && url.pathname === "/global/config") { + const body = await readBody(input, init) + captured.config.push(body) + // Flip fixture state before returning, so the flow's follow-up bootstrap + // sees the new provider (no race against the response resolving). + onConfigWrite?.(body) + return json({}) + } + if (method === "POST" && url.pathname === "/instance/dispose") return json({}) + return base.fetch(input, init) + }) as typeof globalThis.fetch + + let api: TuiPluginApi | undefined + let started!: () => void + const ready = new Promise((resolve) => { + started = resolve + }) + const { run } = await import("../src/app") + const task = Effect.runPromise( + run({ + url: "http://test", + directory, + config: createTuiResolvedConfig({ plugin_enabled: {} }), + fetch, + events: events.source, + args: { continue: true }, + pluginHost: { + async start(input) { + api = input.api + started() + }, + async dispose() {}, + }, + }).pipe(Effect.provide(AppNodeBuilder.build(Global.node))), + ) + await ready + return { setup, task, captured, api: () => api } +} + +async function teardown(boot_: Awaited>) { + boot_.api()?.keymap.dispatchCommand("app.exit") + await boot_.task + if (!boot_.setup.renderer.isDestroyed) boot_.setup.renderer.destroy() + mock.restore() +} + +// Types a value into the focused prompt and submits it. +async function answer(setup: Awaited>, value: string) { + if (value) await setup.mockInput.typeText(value) + setup.mockInput.pressEnter() +} + +test("the gate offers SSO, Custom provider, and Connect provider", async () => { + const boot_ = await boot() + try { + const frame = await waitFrame(boot_.setup, (f) => f.includes(GATE)) + expect(frame).toContain("Login with SSO") + expect(frame).toContain("Custom provider") + expect(frame).toContain("Connect provider") + } finally { + await teardown(boot_) + } +}) + +test("Connect provider opens the upstream connect dialog", async () => { + const boot_ = await boot() + try { + await waitFrame(boot_.setup, (f) => f.includes(GATE)) + boot_.setup.mockInput.pressArrow("down") + boot_.setup.mockInput.pressArrow("down") + boot_.setup.mockInput.pressEnter() + await waitFrame(boot_.setup, (f) => f.includes("Connect a provider")) + } finally { + await teardown(boot_) + } +}) + +test("Custom provider with a base URL discovers models and writes the codev.json block", async () => { + const models = Bun.serve({ + port: 0, + fetch: () => Response.json({ data: [{ id: "mock-small" }, { id: "mock-large" }] }), + }) + const modelsBase = `http://127.0.0.1:${models.port}` + let configured = false + const provider = { + id: "mockgw", + name: "mockgw", + models: { "mock-small": { name: "mock-small" }, "mock-large": { name: "mock-large" } }, + } + const boot_ = await boot( + (url) => { + // After the block is written, bootstrap must surface the provider so the + // flow lands in the model picker instead of the credential-only fallback. + if (url.pathname === "/config/providers") + return json(configured ? { providers: [provider], default: { mockgw: "mock-small" } } : { providers: [], default: {} }) + if (url.pathname === "/provider") + return json( + configured + ? { all: [provider], default: {}, connected: ["mockgw"] } + : { all: [], default: {}, connected: [] }, + ) + return undefined + }, + () => (configured = true), + ) + try { + await waitFrame(boot_.setup, (f) => f.includes(GATE)) + boot_.setup.mockInput.pressArrow("down") // Custom provider + boot_.setup.mockInput.pressEnter() + + await waitFrame(boot_.setup, (f) => f.includes("Provider id")) + await answer(boot_.setup, "mockgw") + await waitFrame(boot_.setup, (f) => f.includes("API key")) + await answer(boot_.setup, "sk-mock") + await waitFrame(boot_.setup, (f) => f.includes("OpenAI-compatible endpoint")) + await answer(boot_.setup, modelsBase) + + await waitUntil(boot_.setup, () => boot_.captured.config.length > 0) + + // The credential goes to auth.json, keyless; the provider block goes to codev.json. + expect(boot_.captured.auth).toEqual([{ providerID: "mockgw", body: { type: "api", key: "sk-mock" } }]) + expect(boot_.captured.config).toHaveLength(1) + expect(boot_.captured.config[0]).toEqual({ + provider: { + mockgw: { + options: { baseURL: `${modelsBase}/v1` }, + models: { + "mock-small": { name: "mock-small", limit: { context: 196608, output: 65536 } }, + "mock-large": { name: "mock-large", limit: { context: 196608, output: 65536 } }, + }, + }, + }, + }) + + // Provider registered: the flow lands in the model picker for it (titled + // by the provider) rather than the credential-only toast. The picker's own + // model rendering is upstream's concern, so assert only that we reached it. + await waitFrame(boot_.setup, (f) => f.includes("mockgw") && f.includes("Search")) + } finally { + models.stop(true) + await teardown(boot_) + } +}, 60_000) + +test("Custom provider with no base URL stores only the credential", async () => { + const boot_ = await boot() + try { + await waitFrame(boot_.setup, (f) => f.includes(GATE)) + boot_.setup.mockInput.pressArrow("down") // Custom provider + boot_.setup.mockInput.pressEnter() + + await waitFrame(boot_.setup, (f) => f.includes("Provider id")) + await answer(boot_.setup, "credx") + await waitFrame(boot_.setup, (f) => f.includes("API key")) + await answer(boot_.setup, "sk-cred") + await waitFrame(boot_.setup, (f) => f.includes("OpenAI-compatible endpoint")) + await answer(boot_.setup, "") // empty base URL -> credential-only + + // The credential is stored; crucially, no provider block is written to + // codev.json (that is the whole point of the credential-only terminal). + await waitUntil(boot_.setup, () => boot_.captured.auth.length > 0) + // Let the rest of the flow (dispose + bootstrap) settle so a config write, + // if one were wrongly issued, would have landed. + await waitUntil(boot_.setup, () => boot_.captured.config.length > 0 || boot_.setup.captureCharFrame().includes(GATE)) + expect(boot_.captured.auth).toEqual([{ providerID: "credx", body: { type: "api", key: "sk-cred" } }]) + expect(boot_.captured.config).toHaveLength(0) + } finally { + await teardown(boot_) + } +}, 60_000) From 9d1828b1e3cd7beb6d49f82ad6e11fc63dfbc2de Mon Sep 17 00:00:00 2001 From: minhn4 Date: Tue, 14 Jul 2026 16:08:50 +0700 Subject: [PATCH 2/2] minor changes --- CHANGELOG.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70fab59937..1560717a53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ file is the human-readable summary of what changed and why. ### Session screen +- **Scrollback-style layout** — the codev logo + tip render top-left as the + transcript's first entry and the prompt rides directly below the last message, + instead of a centered banner with a bottom-anchored input and a void between + them. Once the conversation fills the screen, the prompt pins to the bottom + and the transcript sticky-scrolls as before; paging up reaches the banner at + the top of the scrollback. + ([#55](https://github.com/quickbeard/codev-code/pull/55)) - **Start in a session, not the home screen** — running `codev` lands directly in the session layout with a new empty session instead of the home screen. Empty leftover sessions are reused (at most one per directory), `session.new` @@ -17,13 +24,6 @@ file is the human-readable summary of what changed and why. real conversation. Also fixed a startup "Unexpected server error" toast caused by the route switch fetching the `"dummy"` placeholder session ID. ([#48](https://github.com/quickbeard/codev-code/pull/48)) -- **Scrollback-style layout** — the codev logo + tip render top-left as the - transcript's first entry and the prompt rides directly below the last message, - instead of a centered banner with a bottom-anchored input and a void between - them. Once the conversation fills the screen, the prompt pins to the bottom - and the transcript sticky-scrolls as before; paging up reaches the banner at - the top of the scrollback. - ([#55](https://github.com/quickbeard/codev-code/pull/55)) ### Sidebar