Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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

Expand Down
82 changes: 82 additions & 0 deletions packages/codev-gateway/test/backend.test.ts
Original file line number Diff line number Diff line change
@@ -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 <base>/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/)
})
})
227 changes: 227 additions & 0 deletions packages/tui/test/custom-provider.view.test.tsx
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof createTestRenderer>>, 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<ReturnType<typeof createTestRenderer>>, 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<void>((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<ReturnType<typeof boot>>) {
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<ReturnType<typeof createTestRenderer>>, 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)
Loading