Skip to content
Closed
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
7 changes: 6 additions & 1 deletion packages/app/e2e/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
"compilerOptions": {
"noEmit": true,
"rootDir": "..",
"types": ["node", "bun"]
"types": [
"node",
"bun"
]
},
"include": [
"./performance/timeline-stability/**/*.spec.ts",
Expand All @@ -13,6 +16,8 @@
"./reproduction/timeline-suspense/**/*.ts",
"./reproduction/timeline-suspense/**/*.tsx",
"../src/pages/session/timeline/observe-element-offset.ts",
"../src/utils/path-key.ts",
"../src/context/global-sync/utils.ts",
"./regression/new-session-panel-corner.spec.ts",
"./regression/session-timeline-context-resize.spec.ts",
"./utils/**/*.ts"
Expand Down
181 changes: 181 additions & 0 deletions packages/app/e2e/utils/mock-catalog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { describe, expect, test } from "bun:test"
import { mockOpenCodeServer, providerCatalog } from "./mock-server"
import { normalizeProviderList } from "../../src/context/global-sync/utils"

/**
* GOAL: the e2e mock's v2 catalog routes must return something
* `normalizeProviderList` can actually consume.
*
* The mock had no `/api/provider`, `/api/model` or `/api/model/default` route
* at all, so those requests hit the catch-all `{}` and the app threw
* "Cannot read properties of undefined (reading 'all')" on every v2 spec that
* booted a project (#26). The first fix for that then threw
* "undefined is not an object (evaluating 'model.time.released')" instead,
* because the derived models were missing fields the normaliser reads
* unconditionally.
*
* Success means: for every provider shape the specs actually use, feeding the
* mock's derived catalog through the real normaliser does not throw and
* preserves the models. Asserting the contract, not the wiring — a route
* returning 200 proves nothing if the payload crashes the consumer.
*/
const config = (provider: unknown) =>
({ directory: "C:/Mock", project: { id: "proj" }, provider }) as unknown as Parameters<typeof providerCatalog>[0]

function normalize(provider: unknown) {
const catalog = providerCatalog(config(provider))
return normalizeProviderList(catalog.providers as never, catalog.models as never, catalog.defaultModel as never)
}

describe("mock server v2 catalog", () => {
test("an empty provider list normalizes to an empty catalog", () => {
const result = normalize({ all: [], connected: [], default: {} })
expect(result.all.size).toBe(0)
})

test("a provider with a model survives the normaliser", () => {
const result = normalize({
all: [{ id: "opencode", name: "OpenCode", models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } } }],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "test" },
})
expect([...result.all.keys()]).toEqual(["opencode"])
expect(Object.keys(result.all.get("opencode")!.models)).toEqual(["test"])
expect(result.defaultModel).toEqual({ providerID: "opencode", modelID: "test" })
})

test("model variants are carried across as the array the normaliser expects", () => {
const result = normalize({
all: [
{
id: "opencode",
name: "OpenCode",
models: {
"thinking-model": { id: "thinking-model", name: "Thinking", limit: { context: 200_000 }, variants: { high: {} } },
},
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "thinking-model" },
})
expect(Object.keys(result.all.get("opencode")!.models["thinking-model"]!.variants ?? {})).toEqual(["high"])
})

test("a function-valued provider config is resolved", () => {
const result = normalize(() => ({ all: [{ id: "p", name: "P", models: {} }], connected: [], default: {} }))
expect([...result.all.keys()]).toEqual(["p"])
})

test("a missing provider config does not throw", () => {
expect(() => normalize(undefined)).not.toThrow()
})
test("every derived model is a payload a real server could return", () => {
// The normaliser is tolerant: it accepted an ISO string for a numeric
// `time.released`, a missing `status` and a missing `limit.output`. So
// asserting "it does not throw" proves nothing about wire fidelity.
// These assert the ModelInfo contract's required fields directly.
const catalog = providerCatalog(
config({
all: [
{
id: "opencode",
name: "OpenCode",
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "test" },
}),
)
for (const provider of catalog.providers) {
expect(typeof provider.id).toBe("string")
expect(typeof provider.name).toBe("string")
expect(typeof provider.package).toBe("string")
}
expect(catalog.models.length).toBeGreaterThan(0)
for (const model of catalog.models) {
expect(typeof model.time.released).toBe("number")
expect(["alpha", "beta", "deprecated", "active"]).toContain(model.status)
expect(typeof model.enabled).toBe("boolean")
expect(typeof model.limit.context).toBe("number")
expect(typeof model.limit.output).toBe("number")
expect(Array.isArray(model.cost)).toBe(true)
expect(Array.isArray(model.variants)).toBe(true)
expect(Array.isArray(model.capabilities.input)).toBe(true)
expect(typeof model.capabilities.tools).toBe("boolean")
}
})
})

/**
* GOAL: the three v2 catalog ROUTES exist and answer with the right envelope.
*
* The tests above exercise `providerCatalog()` directly, which means deleting
* or renaming the routes that serve it would leave them all green — the exact
* hole that let `/api/provider` fall through to the catch-all `{}` in the
* first place. These drive the real route handler through a stub page, so the
* wiring itself is covered without needing a browser.
*/
describe("mock server v2 catalog routes", () => {
async function fulfilFor(pathname: string) {
let handler: ((route: unknown) => Promise<unknown>) | undefined
const page = {
route: (_pattern: string, fn: (route: unknown) => Promise<unknown>) => {
handler = fn
return Promise.resolve()
},
on: () => {},
} as unknown as Parameters<typeof mockOpenCodeServer>[0]

await mockOpenCodeServer(page, config({
all: [{ id: "opencode", name: "OpenCode", models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } } }],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "test" },
}) as unknown as Parameters<typeof mockOpenCodeServer>[1])

expect(handler).toBeDefined()
let fulfilled: { status?: number; body?: string } | undefined
const port = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
await handler!({
request: () => ({ url: () => `http://127.0.0.1:${port}${pathname}`, method: () => "GET", postDataJSON: () => ({}) }),
fulfill: async (response: { status?: number; body?: string }) => {
fulfilled = response
},
fallback: async () => {
throw new Error(`route fell through to fallback: ${pathname}`)
},
})
expect(fulfilled, `${pathname} was not fulfilled`).toBeDefined()
return JSON.parse(fulfilled!.body ?? "null")
}

test("/api/provider returns the provider array in a location envelope", async () => {
const body = await fulfilFor("/api/provider")
expect(body).toHaveProperty("location")
expect(Array.isArray(body.data)).toBe(true)
expect(body.data.map((p: { id: string }) => p.id)).toEqual(["opencode"])
})

test("/api/model returns the model array in a location envelope", async () => {
const body = await fulfilFor("/api/model")
expect(body).toHaveProperty("location")
expect(Array.isArray(body.data)).toBe(true)
expect(body.data.map((m: { id: string }) => m.id)).toEqual(["test"])
})

test("/api/model/default returns the configured default", async () => {
const body = await fulfilFor("/api/model/default")
expect(body).toHaveProperty("location")
expect(body.data?.id).toBe("test")
})

test("none of the three routes answers with the catch-all empty object", async () => {
// The original bug: these paths hit `json(route, {})` at the bottom of the
// handler, so `providers.data` was undefined and the app crashed.
for (const pathname of ["/api/provider", "/api/model", "/api/model/default"]) {
const body = await fulfilFor(pathname)
expect(body, `${pathname} returned the catch-all empty object`).not.toEqual({})
expect(body?.data, `${pathname} has no data field`).toBeDefined()
}
})
})
87 changes: 87 additions & 0 deletions packages/app/e2e/utils/mock-server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Page, Route } from "@playwright/test"
import type { ModelDefaultOutput, ModelListOutput, ProviderListOutput } from "@opencode-ai/client/promise"

const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/status", "/vcs/diff"])
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"])
Expand Down Expand Up @@ -79,6 +80,15 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true })
if (path === "/provider")
return json(route, typeof config.provider === "function" ? config.provider() : config.provider)
// The v2 catalog routes. Without these, `/api/provider` fell through to the
// catch-all `{}` at the bottom of this handler, so `providers.data` was
// undefined and `normalizeProviderList` threw
// "Cannot read properties of undefined (reading 'all')" on every v2 spec
// that boots a project. That surfaced as a "Failed to reload" toast and a
// review panel whose interaction layer never came up (#26).
if (path === "/api/provider") return json(route, { location: location(config), data: providerCatalog(config).providers })
if (path === "/api/model") return json(route, { location: location(config), data: providerCatalog(config).models })
if (path === "/api/model/default") return json(route, { location: location(config), data: providerCatalog(config).defaultModel })
if (path === "/provider/auth") return json(route, config.integrationMethods ?? {})
const legacyAuth = path.match(/^\/auth\/([^/]+)$/)?.[1]
if (legacyAuth && route.request().method() === "PUT") {
Expand Down Expand Up @@ -314,6 +324,83 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
})
}

/**
* Derive the v2 catalog responses from the same `config.provider` the v1
* `/provider` route serves, so a spec configures providers once and both
* protocols agree.
*/
export function providerCatalog(config: MockServerConfig): {
providers: ProviderListOutput["data"]
models: NonNullable<ModelListOutput["data"]>
defaultModel: ModelDefaultOutput["data"]
} {
const raw = typeof config.provider === "function" ? config.provider() : config.provider
const source = (raw ?? {}) as {
all?: { id?: string; name?: string; options?: unknown; models?: Record<string, unknown> }[]
connected?: string[]
default?: { providerID?: string; modelID?: string }
}
const all = Array.isArray(source.all) ? source.all : []
// Typed against the client contract on purpose. The first version of this
// helper hand-rolled shapes and emitted an ISO string for a numeric
// `time.released`, no `status`, no `enabled` and no `limit.output` -- values
// no real server could return. normalizeProviderList tolerated them, so the
// specs passed while the payload was fiction. Annotating the return makes
// that a compile error instead of a latent trap.
const providers = all.map((provider) => ({
id: provider.id ?? "",
name: provider.name ?? provider.id ?? "",
// required by ProviderV2Info
package: `@opencode-ai/${provider.id ?? "provider"}`,
settings: (provider.options ?? {}) as Record<string, never>,
})) as ProviderListOutput["data"]
const models = all.flatMap((provider) =>
Object.entries(provider.models ?? {}).map(([id, model]) => {
const value = model as Record<string, any>
const cost = value.cost
? [
{
input: value.cost.input ?? 0,
output: value.cost.output ?? 0,
cache: { read: value.cost.cache?.read ?? 0, write: value.cost.cache?.write ?? 0 },
},
]
: []
const capabilities = value.capabilities ?? {}
return {
id,
modelID: value.api?.id ?? id,
providerID: provider.id ?? "",
name: value.name ?? id,
family: value.family ?? "",
package: value.api?.npm,
settings: (value.options ?? {}) as Record<string, never>,
headers: value.headers ?? {},
capabilities: {
tools: capabilities.tools ?? true,
input: Array.isArray(capabilities.input) ? capabilities.input : ["text"],
output: Array.isArray(capabilities.output) ? capabilities.output : ["text"],
},
variants: Object.entries(value.variants ?? {}).map(([variantID, settings]) => ({
id: variantID,
settings: (settings ?? {}) as Record<string, never>,
})),
// `released` is a number in the contract, not a date string.
time: { released: value.release_date ? Date.parse(value.release_date) : 0 },
cost,
status: value.status ?? "active",
enabled: value.enabled ?? true,
// `output` is required alongside `context`; fixtures only set context.
limit: { context: value.limit?.context ?? 0, output: value.limit?.output ?? 0 },
}
}),
) as NonNullable<ModelListOutput["data"]>
const chosen = source.default?.modelID
? models.find((model) => model.id === source.default!.modelID && model.providerID === source.default!.providerID)
: undefined
return { providers, models, defaultModel: (chosen ?? models[0] ?? null) as ModelDefaultOutput["data"] }
}

function location(config: MockServerConfig) {
return {
directory: config.directory,
Expand Down
2 changes: 1 addition & 1 deletion packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"build": "vite build",
"serve": "vite preview",
"test": "bun run test:unit && bun run test:browser",
"test:unit": "bun test --conditions=solid --only-failures --preload ./happydom.ts ./src",
"test:unit": "bun test --conditions=solid --only-failures --preload ./happydom.ts ./src ./e2e/utils",
"test:browser": "bun test --conditions=browser --preload ./happydom.ts ./test-browser",
"test:unit:watch": "bun test --conditions=solid --watch --preload ./happydom.ts ./src",
"test:e2e": "playwright test",
Expand Down
8 changes: 7 additions & 1 deletion packages/app/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,13 @@ const reuse = !process.env.CI
const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined
export default defineConfig({
testDir: "./e2e",
testIgnore: process.env.OPENCODE_PERFORMANCE === "1" ? "performance/**/*.test.ts" : "performance/**",
testIgnore: [
// bun tests that live beside the e2e helpers they cover. Playwright runs
// under Node, whose ESM loader rejects `bun:test`, so collecting one fails
// the whole run before any spec executes.
"utils/**/*.test.ts",
process.env.OPENCODE_PERFORMANCE === "1" ? "performance/**/*.test.ts" : "performance/**",
],
outputDir: "./e2e/test-results",
timeout: 60_000,
expect: {
Expand Down
Loading