Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d2e75ef49 |
@@ -1,6 +1,8 @@
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { Option, Schema } from "effect"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import type { PromptInfo } from "../component/prompt/history"
|
||||
import { tryJsonConfig } from "@/util/json"
|
||||
|
||||
export type HomeRoute = {
|
||||
type: "home"
|
||||
@@ -21,17 +23,25 @@ export type PluginRoute = {
|
||||
|
||||
export type Route = HomeRoute | SessionRoute | PluginRoute
|
||||
|
||||
const HOME_ROUTE: Route = { type: "home" }
|
||||
|
||||
// Schema covers only the env-fed shape — `prompt` is set programmatically and
|
||||
// not expected from OPENCODE_ROUTE, so we keep it out of validation.
|
||||
export const RouteSchema = Schema.Union([
|
||||
Schema.Struct({ type: Schema.Literal("home") }),
|
||||
Schema.Struct({ type: Schema.Literal("session"), sessionID: Schema.String }),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("plugin"),
|
||||
id: Schema.String,
|
||||
data: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
}),
|
||||
])
|
||||
|
||||
export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
|
||||
name: "Route",
|
||||
init: (props: { initialRoute?: Route }) => {
|
||||
const [store, setStore] = createStore<Route>(
|
||||
props.initialRoute ??
|
||||
(process.env["OPENCODE_ROUTE"]
|
||||
? JSON.parse(process.env["OPENCODE_ROUTE"])
|
||||
: {
|
||||
type: "home",
|
||||
}),
|
||||
)
|
||||
const fromEnv = tryJsonConfig(process.env["OPENCODE_ROUTE"], RouteSchema, "OPENCODE_ROUTE")
|
||||
const [store, setStore] = createStore<Route>(props.initialRoute ?? Option.getOrElse(fromEnv, () => HOME_ROUTE))
|
||||
|
||||
return {
|
||||
get data() {
|
||||
|
||||
@@ -106,6 +106,21 @@ export function FormatError(input: unknown): string | undefined {
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
// InvalidConfigError: malformed env-var JSON at a safety boundary
|
||||
if (isTaggedError(input, "InvalidConfigError")) {
|
||||
const source = stringField(input, "source") ?? ""
|
||||
const value = stringField(input, "value") ?? ""
|
||||
const reason = stringField(input, "reason") ?? ""
|
||||
return [
|
||||
`Error: ${source} is invalid and cannot be applied.`,
|
||||
` Value: '${value}'`,
|
||||
` Reason: ${reason}`,
|
||||
"",
|
||||
`opencode will not start with a malformed ${source}. Set ${source}`,
|
||||
`correctly or unset it to use defaults.`,
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
// UICancelledError: user cancelled an interactive CLI prompt
|
||||
if (isTaggedError(input, "UICancelledError") || NamedError.hasName(input, "UICancelledError")) {
|
||||
return ""
|
||||
|
||||
@@ -14,6 +14,7 @@ import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/instal
|
||||
import { existsSync } from "fs"
|
||||
import { Account } from "@/account/account"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { requireJsonConfig } from "@/util/json"
|
||||
import type { ConsoleState } from "./console-state"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
@@ -703,7 +704,8 @@ export const layer = Layer.effect(
|
||||
}
|
||||
|
||||
if (Flag.OPENCODE_PERMISSION) {
|
||||
result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION))
|
||||
const decoded = requireJsonConfig(Flag.OPENCODE_PERMISSION, ConfigPermission.Info, "OPENCODE_PERMISSION")
|
||||
result.permission = mergeDeep(result.permission ?? {}, decoded)
|
||||
}
|
||||
|
||||
if (result.tools) {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Option, Result, Schema } from "effect"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
|
||||
const log = Log.create({ service: "config" })
|
||||
|
||||
const decodeJsonUnknown = Schema.decodeUnknownResult(Schema.UnknownFromJsonString)
|
||||
|
||||
export class InvalidConfigError extends Schema.TaggedErrorClass<InvalidConfigError>()("InvalidConfigError", {
|
||||
source: Schema.String,
|
||||
value: Schema.String,
|
||||
reason: Schema.String,
|
||||
}) {}
|
||||
|
||||
// Decode JSON config at a safety boundary. On malformed JSON or schema
|
||||
// mismatch, throws `InvalidConfigError`. On success, returns the typed value.
|
||||
// Use when defaulting silently is dangerous (e.g. permissions).
|
||||
export function requireJsonConfig<S extends Schema.Decoder<unknown>>(raw: string, schema: S, source: string): S["Type"] {
|
||||
const decoded = decode(raw, schema)
|
||||
if (Result.isFailure(decoded)) {
|
||||
throw new InvalidConfigError({ source, value: raw, reason: decoded.failure })
|
||||
}
|
||||
return decoded.success
|
||||
}
|
||||
|
||||
// Decode JSON config at a UX-preference boundary. Returns `None` for absent,
|
||||
// empty, malformed, or schema-mismatched input, logging a warn so the user
|
||||
// can debug. Use when defaulting silently is benign (e.g. theme, route).
|
||||
export function tryJsonConfig<S extends Schema.Decoder<unknown>>(
|
||||
raw: string | undefined,
|
||||
schema: S,
|
||||
source: string,
|
||||
): Option.Option<S["Type"]> {
|
||||
if (!raw) return Option.none()
|
||||
const decoded = decode(raw, schema)
|
||||
if (Result.isFailure(decoded)) {
|
||||
log.warn(`ignoring invalid ${source}`, { value: raw, reason: decoded.failure })
|
||||
return Option.none()
|
||||
}
|
||||
return Option.some(decoded.success)
|
||||
}
|
||||
|
||||
function decode<S extends Schema.Decoder<unknown>>(raw: string, schema: S): Result.Result<S["Type"], string> {
|
||||
const decodeSchema = Schema.decodeUnknownResult(schema)
|
||||
const parsed = decodeJsonUnknown(raw)
|
||||
if (Result.isFailure(parsed)) return Result.fail(parsed.failure.toString())
|
||||
const decoded = decodeSchema(parsed.success)
|
||||
if (Result.isFailure(decoded)) return Result.fail(decoded.failure.toString())
|
||||
return Result.succeed(decoded.success)
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
// Subprocess integration tests for `opencode run` (non-interactive mode).
|
||||
// These exercise the real CLI binary against a TestLLMServer running in the
|
||||
// same process. See `test/lib/run-process.ts` for the harness — each test uses
|
||||
// `opencode.run(message, opts?)` to spawn `bun src/index.ts run ...` with
|
||||
// `OPENCODE_CONFIG_CONTENT` providing the test provider config inline.
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { runIt } from "../../lib/run-process"
|
||||
|
||||
describe("opencode run (non-interactive subprocess)", () => {
|
||||
// Happy path: prompt completes, output reaches stdout, process exits 0.
|
||||
// If this fails, all the others likely will too — debug here first.
|
||||
runIt.live(
|
||||
"exits 0 and writes the response to stdout on a successful prompt",
|
||||
({ llm, opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* llm.text("hello from the test llm")
|
||||
const result = yield* opencode.run("say hi")
|
||||
opencode.expectExit(result, 0)
|
||||
expect(result.stdout).toContain("hello from the test llm")
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
|
||||
// Regression for #27371: an unknown model used to hang the process forever
|
||||
// waiting on a session.status === idle event that never arrived. The fix
|
||||
// makes the SDK call surface an error promptly so the process exits nonzero.
|
||||
// We assert nonzero exit AND wall-clock under the harness timeout — a hang
|
||||
// would expire the timeout and produce a different (signal-killed) failure.
|
||||
runIt.live(
|
||||
"exits nonzero promptly when the model is unknown (regression for #27371)",
|
||||
({ opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* opencode.run("say hi", {
|
||||
model: "test/nonexistent-model",
|
||||
timeoutMs: 15_000,
|
||||
})
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.durationMs).toBeLessThan(15_000)
|
||||
}),
|
||||
30_000,
|
||||
)
|
||||
|
||||
// Locks in the current behavior: when the LLM stream errors mid-response
|
||||
// (the prompt was accepted, then the upstream provider failed), opencode
|
||||
// emits a session.error event and the process exits 0 today.
|
||||
//
|
||||
// This is debatable — a future cleanup might flip it to exit 1. If you're
|
||||
// changing this expectation, do it deliberately and say so in the PR.
|
||||
runIt.live(
|
||||
"mid-stream LLM error still exits 0 today (contract lock-in)",
|
||||
({ llm, opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* llm.fail("upstream provider exploded mid-stream")
|
||||
const result = yield* opencode.run("trigger midstream error", { timeoutMs: 30_000 })
|
||||
expect(result.exitCode).toBe(0)
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
|
||||
// --format json puts one JSON object per line on stdout for each emitted
|
||||
// event. Consumers (CI scripts, tooling) parse this stream. Asserts the
|
||||
// shape so a future event-emit change has to update this expectation.
|
||||
runIt.live(
|
||||
"--format json emits parseable line-delimited JSON to stdout",
|
||||
({ llm, opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* llm.text("structured output")
|
||||
const result = yield* opencode.run("say hi", { format: "json" })
|
||||
opencode.expectExit(result, 0)
|
||||
|
||||
const events = opencode.parseJsonEvents(result.stdout)
|
||||
expect(events.length).toBeGreaterThan(0)
|
||||
for (const evt of events) {
|
||||
expect(typeof evt.type).toBe("string")
|
||||
expect(typeof evt.sessionID).toBe("string")
|
||||
}
|
||||
// At least one `text` event should appear with the LLM's response.
|
||||
const text = events.find((e) => e.type === "text")
|
||||
expect(text).toBeDefined()
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Option } from "effect"
|
||||
import { RouteSchema } from "@/cli/cmd/tui/context/route"
|
||||
import { tryJsonConfig } from "@/util/json"
|
||||
|
||||
const parse = (raw: string | undefined) => tryJsonConfig(raw, RouteSchema, "OPENCODE_ROUTE")
|
||||
|
||||
describe("tryJsonConfig with RouteSchema", () => {
|
||||
test("returns None for undefined input", () => {
|
||||
expect(Option.isNone(parse(undefined))).toBe(true)
|
||||
})
|
||||
|
||||
test("returns None for empty string", () => {
|
||||
expect(Option.isNone(parse(""))).toBe(true)
|
||||
})
|
||||
|
||||
test("returns None for malformed JSON", () => {
|
||||
expect(Option.isNone(parse("{not json"))).toBe(true)
|
||||
})
|
||||
|
||||
test("returns None when JSON does not match route schema", () => {
|
||||
expect(Option.isNone(parse(`{"type":"unknown"}`))).toBe(true)
|
||||
})
|
||||
|
||||
test("returns Some for valid home route", () => {
|
||||
expect(Option.getOrThrow(parse(`{"type":"home"}`))).toEqual({ type: "home" })
|
||||
})
|
||||
|
||||
test("returns Some for valid session route", () => {
|
||||
expect(Option.getOrThrow(parse(`{"type":"session","sessionID":"abc123"}`))).toEqual({
|
||||
type: "session",
|
||||
sessionID: "abc123",
|
||||
})
|
||||
})
|
||||
|
||||
test("returns Some for valid plugin route", () => {
|
||||
expect(Option.getOrThrow(parse(`{"type":"plugin","id":"foo","data":{"x":1}}`))).toEqual({
|
||||
type: "plugin",
|
||||
id: "foo",
|
||||
data: { x: 1 },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect, describe, mock, afterEach, beforeEach } from "bun:test"
|
||||
import { Effect, Exit, Layer, Option } from "effect"
|
||||
import { Effect, Layer, Option } from "effect"
|
||||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||
import { Config } from "@/config/config"
|
||||
import { ConfigManaged } from "@/config/managed"
|
||||
@@ -13,7 +13,7 @@ import { Account } from "../../src/account/account"
|
||||
import { AccessToken, AccountID, OrgID } from "../../src/account/schema"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Env } from "../../src/env"
|
||||
import { provideTestInstance, provideTmpdirInstance, TestInstance, withTestInstance } from "../fixture/fixture"
|
||||
import { provideTestInstance, provideTmpdirInstance, withTestInstance } from "../fixture/fixture"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { InstanceRuntime } from "@/project/instance-runtime"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
@@ -94,6 +94,14 @@ const listDirs = (ctx: InstanceContext) =>
|
||||
Effect.provide(layer),
|
||||
),
|
||||
)
|
||||
const ready = (ctx: InstanceContext) =>
|
||||
Effect.runPromise(
|
||||
Config.Service.use((svc) => provideCurrentInstance(svc.waitForDependencies(), ctx)).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(layer),
|
||||
),
|
||||
)
|
||||
|
||||
// Get managed config directory from environment (set in preload.ts)
|
||||
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
|
||||
|
||||
@@ -115,25 +123,6 @@ async function writeConfig(dir: string, config: object, name = "opencode.json")
|
||||
await Filesystem.write(path.join(dir, name), JSON.stringify(config))
|
||||
}
|
||||
|
||||
const writeConfigEffect = (dir: string, config: object, name = "opencode.json") =>
|
||||
Effect.promise(() => writeConfig(dir, config, name))
|
||||
|
||||
function withProcessEnv<A, E, R>(key: string, value: string, effect: Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const original = process.env[key]
|
||||
process.env[key] = value
|
||||
return original
|
||||
}),
|
||||
() => effect,
|
||||
(original) =>
|
||||
Effect.sync(() => {
|
||||
if (original !== undefined) process.env[key] = original
|
||||
else delete process.env[key]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async function check(map: (dir: string) => string) {
|
||||
if (process.platform !== "win32") return
|
||||
await using globalTmp = await tmpdir()
|
||||
@@ -221,24 +210,43 @@ test("does not create global config when OPENCODE_CONFIG_DIR is set", async () =
|
||||
}
|
||||
})
|
||||
|
||||
it.instance(
|
||||
"loads JSON config file",
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service.use((svc) => svc.get())
|
||||
expect(config.model).toBe("test/model")
|
||||
expect(config.username).toBe("testuser")
|
||||
}),
|
||||
{ config: { model: "test/model", username: "testuser" } },
|
||||
)
|
||||
test("loads JSON config file", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await writeConfig(dir, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
model: "test/model",
|
||||
username: "testuser",
|
||||
})
|
||||
},
|
||||
})
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
const config = await load(ctx)
|
||||
expect(config.model).toBe("test/model")
|
||||
expect(config.username).toBe("testuser")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it.instance(
|
||||
"loads shell config field",
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service.use((svc) => svc.get())
|
||||
expect(config.shell).toBe("bash")
|
||||
}),
|
||||
{ config: { shell: "bash" } },
|
||||
)
|
||||
test("loads shell config field", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await writeConfig(dir, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
shell: "bash",
|
||||
})
|
||||
},
|
||||
})
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
const config = await load(ctx)
|
||||
expect(config.shell).toBe("bash")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("updates config and preserves empty shell sentinel", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
@@ -322,23 +330,41 @@ test("updates global config and omits empty shell key in jsonc", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
it.instance(
|
||||
"loads formatter boolean config",
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service.use((svc) => svc.get())
|
||||
expect(config.formatter).toBe(true)
|
||||
}),
|
||||
{ config: { formatter: true } },
|
||||
)
|
||||
test("loads formatter boolean config", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await writeConfig(dir, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
formatter: true,
|
||||
})
|
||||
},
|
||||
})
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
const config = await load(ctx)
|
||||
expect(config.formatter).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it.instance(
|
||||
"loads lsp boolean config",
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service.use((svc) => svc.get())
|
||||
expect(config.lsp).toBe(true)
|
||||
}),
|
||||
{ config: { lsp: true } },
|
||||
)
|
||||
test("loads lsp boolean config", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await writeConfig(dir, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
lsp: true,
|
||||
})
|
||||
},
|
||||
})
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
const config = await load(ctx)
|
||||
expect(config.lsp).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("loads project config from Git Bash and MSYS2 paths on Windows", async () => {
|
||||
// Git Bash and MSYS2 both use /<drive>/... paths on Windows.
|
||||
@@ -379,118 +405,124 @@ test("ignores legacy tui keys in opencode config", async () => {
|
||||
})
|
||||
})
|
||||
|
||||
it.instance("loads JSONC config file", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() =>
|
||||
Filesystem.write(
|
||||
path.join(test.directory, "opencode.jsonc"),
|
||||
test("loads JSONC config file", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.jsonc"),
|
||||
`{
|
||||
// This is a comment
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"model": "test/model",
|
||||
"username": "testuser"
|
||||
}`,
|
||||
),
|
||||
)
|
||||
const config = yield* Config.Service.use((svc) => svc.get())
|
||||
expect(config.model).toBe("test/model")
|
||||
expect(config.username).toBe("testuser")
|
||||
}),
|
||||
)
|
||||
)
|
||||
},
|
||||
})
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
const config = await load(ctx)
|
||||
expect(config.model).toBe("test/model")
|
||||
expect(config.username).toBe("testuser")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it.instance("jsonc overrides json in the same directory", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* writeConfigEffect(
|
||||
test.directory,
|
||||
{
|
||||
test("jsonc overrides json in the same directory", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await writeConfig(
|
||||
dir,
|
||||
{
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
model: "base",
|
||||
username: "base",
|
||||
},
|
||||
"opencode.jsonc",
|
||||
)
|
||||
await writeConfig(dir, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
model: "base",
|
||||
username: "base",
|
||||
},
|
||||
"opencode.jsonc",
|
||||
)
|
||||
yield* writeConfigEffect(test.directory, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
model: "override",
|
||||
})
|
||||
const config = yield* Config.Service.use((svc) => svc.get())
|
||||
expect(config.model).toBe("base")
|
||||
expect(config.username).toBe("base")
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("handles environment variable substitution", () =>
|
||||
withProcessEnv(
|
||||
"TEST_VAR",
|
||||
"test-user",
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* writeConfigEffect(test.directory, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
username: "{env:TEST_VAR}",
|
||||
model: "override",
|
||||
})
|
||||
const config = yield* Config.Service.use((svc) => svc.get())
|
||||
expect(config.username).toBe("test-user")
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
})
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
const config = await load(ctx)
|
||||
expect(config.model).toBe("base")
|
||||
expect(config.username).toBe("base")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it.instance("preserves env variables when adding $schema to config", () =>
|
||||
withProcessEnv(
|
||||
"PRESERVE_VAR",
|
||||
"secret_value",
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
// Config without $schema - should trigger auto-add
|
||||
yield* Effect.promise(() =>
|
||||
Filesystem.write(
|
||||
path.join(test.directory, "opencode.json"),
|
||||
test("handles environment variable substitution", async () => {
|
||||
const originalEnv = process.env["TEST_VAR"]
|
||||
process.env["TEST_VAR"] = "test-user"
|
||||
|
||||
try {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await writeConfig(dir, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
username: "{env:TEST_VAR}",
|
||||
})
|
||||
},
|
||||
})
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
const config = await load(ctx)
|
||||
expect(config.username).toBe("test-user")
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
if (originalEnv !== undefined) {
|
||||
process.env["TEST_VAR"] = originalEnv
|
||||
} else {
|
||||
delete process.env["TEST_VAR"]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test("preserves env variables when adding $schema to config", async () => {
|
||||
const originalEnv = process.env["PRESERVE_VAR"]
|
||||
process.env["PRESERVE_VAR"] = "secret_value"
|
||||
|
||||
try {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
// Config without $schema - should trigger auto-add
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
username: "{env:PRESERVE_VAR}",
|
||||
}),
|
||||
),
|
||||
)
|
||||
const config = yield* Config.Service.use((svc) => svc.get())
|
||||
expect(config.username).toBe("secret_value")
|
||||
|
||||
// Read the file to verify the env variable was preserved
|
||||
const content = yield* Effect.promise(() => Filesystem.readText(path.join(test.directory, "opencode.json")))
|
||||
expect(content).toContain("{env:PRESERVE_VAR}")
|
||||
expect(content).not.toContain("secret_value")
|
||||
expect(content).toContain("$schema")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.instance("handles file inclusion substitution", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() => Filesystem.write(path.join(test.directory, "included.txt"), "test-user"))
|
||||
yield* writeConfigEffect(test.directory, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
username: "{file:included.txt}",
|
||||
)
|
||||
},
|
||||
})
|
||||
const config = yield* Config.Service.use((svc) => svc.get())
|
||||
expect(config.username).toBe("test-user")
|
||||
}),
|
||||
)
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
const config = await load(ctx)
|
||||
expect(config.username).toBe("secret_value")
|
||||
|
||||
it.instance("handles file inclusion with replacement tokens", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() =>
|
||||
Filesystem.write(path.join(test.directory, "included.md"), "const out = await Bun.$`echo hi`"),
|
||||
)
|
||||
yield* writeConfigEffect(test.directory, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
username: "{file:included.md}",
|
||||
// Read the file to verify the env variable was preserved
|
||||
const content = await Filesystem.readText(path.join(tmp.path, "opencode.json"))
|
||||
expect(content).toContain("{env:PRESERVE_VAR}")
|
||||
expect(content).not.toContain("secret_value")
|
||||
expect(content).toContain("$schema")
|
||||
},
|
||||
})
|
||||
const config = yield* Config.Service.use((svc) => svc.get())
|
||||
expect(config.username).toBe("const out = await Bun.$`echo hi`")
|
||||
}),
|
||||
)
|
||||
} finally {
|
||||
if (originalEnv !== undefined) {
|
||||
process.env["PRESERVE_VAR"] = originalEnv
|
||||
} else {
|
||||
delete process.env["PRESERVE_VAR"]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test("resolves env templates in account config with account token", async () => {
|
||||
const originalControlToken = process.env["OPENCODE_CONSOLE_TOKEN"]
|
||||
@@ -557,50 +589,105 @@ test("resolves env templates in account config with account token", async () =>
|
||||
}
|
||||
})
|
||||
|
||||
it.instance("validates config schema and throws on invalid fields", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* writeConfigEffect(test.directory, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
invalid_field: "should cause error",
|
||||
})
|
||||
const exit = yield* Config.Service.use((svc) => svc.get()).pipe(Effect.exit)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
}),
|
||||
)
|
||||
test("handles file inclusion substitution", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Filesystem.write(path.join(dir, "included.txt"), "test-user")
|
||||
await writeConfig(dir, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
username: "{file:included.txt}",
|
||||
})
|
||||
},
|
||||
})
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
const config = await load(ctx)
|
||||
expect(config.username).toBe("test-user")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it.instance("throws error for invalid JSON", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() => Filesystem.write(path.join(test.directory, "opencode.json"), "{ invalid json }"))
|
||||
const exit = yield* Config.Service.use((svc) => svc.get()).pipe(Effect.exit)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
}),
|
||||
)
|
||||
test("handles file inclusion with replacement tokens", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Filesystem.write(path.join(dir, "included.md"), "const out = await Bun.$`echo hi`")
|
||||
await writeConfig(dir, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
username: "{file:included.md}",
|
||||
})
|
||||
},
|
||||
})
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
const config = await load(ctx)
|
||||
expect(config.username).toBe("const out = await Bun.$`echo hi`")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it.instance("handles agent configuration", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* writeConfigEffect(test.directory, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
agent: {
|
||||
test_agent: {
|
||||
test("validates config schema and throws on invalid fields", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await writeConfig(dir, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
invalid_field: "should cause error",
|
||||
})
|
||||
},
|
||||
})
|
||||
await provideTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
// Strict schema should throw an error for invalid fields
|
||||
await expect(load(ctx)).rejects.toThrow()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("throws error for invalid JSON", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Filesystem.write(path.join(dir, "opencode.json"), "{ invalid json }")
|
||||
},
|
||||
})
|
||||
await provideTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
await expect(load(ctx)).rejects.toThrow()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("handles agent configuration", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await writeConfig(dir, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
agent: {
|
||||
test_agent: {
|
||||
model: "test/model",
|
||||
temperature: 0.7,
|
||||
description: "test agent",
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
const config = await load(ctx)
|
||||
expect(config.agent?.["test_agent"]).toEqual(
|
||||
expect.objectContaining({
|
||||
model: "test/model",
|
||||
temperature: 0.7,
|
||||
description: "test agent",
|
||||
},
|
||||
},
|
||||
})
|
||||
const config = yield* Config.Service.use((svc) => svc.get())
|
||||
expect(config.agent?.["test_agent"]).toEqual(
|
||||
expect.objectContaining({
|
||||
model: "test/model",
|
||||
temperature: 0.7,
|
||||
description: "test agent",
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("treats agent variant as model-scoped setting (not provider option)", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ConfigPermission } from "@/config/permission"
|
||||
import { InvalidConfigError, requireJsonConfig } from "@/util/json"
|
||||
|
||||
const decode = (raw: string) => requireJsonConfig(raw, ConfigPermission.Info, "OPENCODE_PERMISSION")
|
||||
|
||||
describe("requireJsonConfig with ConfigPermission.Info", () => {
|
||||
test("throws InvalidConfigError on malformed JSON", () => {
|
||||
let caught: unknown
|
||||
try {
|
||||
decode("{not json")
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(InvalidConfigError)
|
||||
const data = caught as InvalidConfigError
|
||||
expect(data.source).toBe("OPENCODE_PERMISSION")
|
||||
expect(data.value).toBe("{not json")
|
||||
expect(typeof data.reason).toBe("string")
|
||||
expect(data.reason.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test("throws InvalidConfigError on shape mismatch", () => {
|
||||
let caught: unknown
|
||||
try {
|
||||
// `banana` is not a valid Action ("ask" | "allow" | "deny")
|
||||
decode(`{"bash":"banana"}`)
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(InvalidConfigError)
|
||||
const data = caught as InvalidConfigError
|
||||
expect(data.source).toBe("OPENCODE_PERMISSION")
|
||||
expect(data.value).toBe(`{"bash":"banana"}`)
|
||||
expect(data.reason.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test("returns decoded value for Action shorthand", () => {
|
||||
expect(decode(`"ask"`)).toEqual({ "*": "ask" })
|
||||
})
|
||||
|
||||
test("returns decoded value for object of per-target rules", () => {
|
||||
expect(decode(`{"bash":"ask","edit":"allow"}`)).toEqual({
|
||||
bash: "ask",
|
||||
edit: "allow",
|
||||
})
|
||||
})
|
||||
|
||||
test("returns decoded value for nested Object rule", () => {
|
||||
expect(decode(`{"bash":{"rm *":"deny","ls":"allow"}}`)).toEqual({
|
||||
bash: { "rm *": "deny", ls: "allow" },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,185 +0,0 @@
|
||||
// Subprocess test harness for the `opencode run` CLI.
|
||||
//
|
||||
// This is the missing test tier: every other `cli/run/*.test.ts` is a unit
|
||||
// test of an extracted helper. Nothing actually exercises the `RunCommand`
|
||||
// handler end-to-end. Bugs that span argv parsing → server boot → SDK call →
|
||||
// event consumption → exit code (like the original /event race or the
|
||||
// non-interactive hang #27371) are invisible to in-process tests.
|
||||
//
|
||||
// The harness uses opencode's built-in test affordances to spawn the real CLI
|
||||
// hermetically:
|
||||
// - OPENCODE_CONFIG_CONTENT : provider config inline, no files to find
|
||||
// - OPENCODE_TEST_HOME : pins os.homedir() → tmpdir
|
||||
// - OPENCODE_DISABLE_PROJECT_CONFIG : skip walking up for opencode.json
|
||||
// - OPENCODE_PURE : skip external plugin discovery + install
|
||||
// - OPENCODE_DISABLE_AUTOUPDATE / AUTOCOMPACT / MODELS_FETCH : no background work
|
||||
//
|
||||
// Plus HOME / XDG_* pointing at the tmpdir for belt-and-suspenders isolation.
|
||||
//
|
||||
// The custom `test` provider points at a TestLLMServer running in the same
|
||||
// process at a random port. The CLI subprocess talks to it over real HTTP.
|
||||
import type { TestOptions } from "bun:test"
|
||||
import * as Scope from "effect/Scope"
|
||||
import { Effect } from "effect"
|
||||
import path from "node:path"
|
||||
import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import { Process } from "@/util/process"
|
||||
import { TestLLMServer } from "./llm-server"
|
||||
import { testProviderConfig } from "./test-provider"
|
||||
import { it } from "./effect"
|
||||
|
||||
const opencodeRoot = path.resolve(import.meta.dir, "../../")
|
||||
const cliEntry = path.join(opencodeRoot, "src/index.ts")
|
||||
|
||||
export const testModelID = "test/test-model"
|
||||
|
||||
function isolatedEnv(home: string, configJson: string): Record<string, string> {
|
||||
return {
|
||||
OPENCODE_TEST_HOME: home,
|
||||
HOME: home,
|
||||
XDG_CONFIG_HOME: path.join(home, ".config"),
|
||||
XDG_DATA_HOME: path.join(home, ".local/share"),
|
||||
XDG_STATE_HOME: path.join(home, ".local/state"),
|
||||
XDG_CACHE_HOME: path.join(home, ".cache"),
|
||||
OPENCODE_CONFIG_CONTENT: configJson,
|
||||
OPENCODE_DISABLE_PROJECT_CONFIG: "1",
|
||||
OPENCODE_PURE: "1",
|
||||
OPENCODE_DISABLE_AUTOUPDATE: "1",
|
||||
OPENCODE_DISABLE_AUTOCOMPACT: "1",
|
||||
OPENCODE_DISABLE_MODELS_FETCH: "1",
|
||||
OPENCODE_AUTH_CONTENT: "{}",
|
||||
}
|
||||
}
|
||||
|
||||
export type RunResult = {
|
||||
readonly exitCode: number
|
||||
readonly stdout: string
|
||||
readonly stderr: string
|
||||
readonly durationMs: number
|
||||
}
|
||||
|
||||
type SpawnOpts = { readonly timeoutMs?: number; readonly env?: Record<string, string> }
|
||||
|
||||
// A `RunOpts` is the typed equivalent of constructing argv for `opencode run`.
|
||||
// New flags should land here so tests stay grep-able and refactor-safe.
|
||||
export type RunOpts = SpawnOpts & {
|
||||
readonly model?: string
|
||||
readonly agent?: string
|
||||
readonly format?: "default" | "json"
|
||||
readonly command?: string
|
||||
readonly printLogs?: boolean
|
||||
readonly extraArgs?: string[]
|
||||
}
|
||||
|
||||
export type OpencodeCli = {
|
||||
// High-level: run a single prompt against the test model.
|
||||
readonly run: (message: string, opts?: RunOpts) => Effect.Effect<RunResult>
|
||||
// Escape hatch: any CLI invocation with full control over argv.
|
||||
readonly spawn: (args: string[], opts?: SpawnOpts) => Effect.Effect<RunResult>
|
||||
// Convenience assertion. Dumps captured stderr/stdout on mismatch so CI
|
||||
// failures are debuggable without re-running locally.
|
||||
readonly expectExit: (result: RunResult, expected: number, label?: string) => void
|
||||
// Parse `--format json` stdout into one event object per non-empty line.
|
||||
// The CLI writes `JSON.stringify({ type, sessionID, ... }) + EOL` for each
|
||||
// event (see src/cli/cmd/run.ts `emit`). Throws if any line is malformed
|
||||
// so tests fail loudly rather than silently skipping data.
|
||||
readonly parseJsonEvents: (stdout: string) => Array<Record<string, unknown>>
|
||||
}
|
||||
|
||||
export type RunFixture = {
|
||||
readonly llm: TestLLMServer["Service"]
|
||||
readonly home: string
|
||||
readonly opencode: OpencodeCli
|
||||
}
|
||||
|
||||
// `withRunFixture(fn)` provisions a TestLLMServer + tmpdir + spawn helper and
|
||||
// invokes fn. Cleans up the tmpdir on scope exit.
|
||||
//
|
||||
// Note on the R channel: TestLLMServer.layer is provided internally so the
|
||||
// caller doesn't need to wire it up. The fixture's lifetime is tied to the
|
||||
// surrounding Scope.
|
||||
export function withRunFixture<A, E>(
|
||||
fn: (input: RunFixture) => Effect.Effect<A, E>,
|
||||
): Effect.Effect<A, E | unknown, Scope.Scope> {
|
||||
return Effect.gen(function* () {
|
||||
const llm = yield* TestLLMServer
|
||||
|
||||
const home = path.join(os.tmpdir(), "oc-run-" + Math.random().toString(36).slice(2))
|
||||
yield* Effect.promise(() => fs.mkdir(home, { recursive: true }))
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => fs.rm(home, { recursive: true, force: true }).catch(() => undefined)),
|
||||
)
|
||||
|
||||
const configJson = JSON.stringify(testProviderConfig(llm.url))
|
||||
const env = isolatedEnv(home, configJson)
|
||||
|
||||
const spawn = (args: string[], opts?: SpawnOpts): Effect.Effect<RunResult> =>
|
||||
Effect.promise(async () => {
|
||||
const start = Date.now()
|
||||
// Process.run pipes stdout/stderr by default and returns them as Buffers.
|
||||
const result = await Process.run(["bun", "run", "--conditions=browser", cliEntry, ...args], {
|
||||
cwd: home,
|
||||
timeout: opts?.timeoutMs ?? 30_000,
|
||||
env: { ...process.env, ...env, ...opts?.env },
|
||||
nothrow: true,
|
||||
})
|
||||
return {
|
||||
exitCode: result.code,
|
||||
stdout: result.stdout.toString(),
|
||||
stderr: result.stderr.toString(),
|
||||
durationMs: Date.now() - start,
|
||||
}
|
||||
})
|
||||
|
||||
const run = (message: string, opts?: RunOpts): Effect.Effect<RunResult> => {
|
||||
const argv: string[] = ["run"]
|
||||
if (opts?.printLogs) argv.push("--print-logs")
|
||||
argv.push("--model", opts?.model ?? testModelID)
|
||||
if (opts?.agent) argv.push("--agent", opts.agent)
|
||||
if (opts?.format) argv.push("--format", opts.format)
|
||||
if (opts?.command) argv.push("--command", opts.command)
|
||||
if (opts?.extraArgs) argv.push(...opts.extraArgs)
|
||||
argv.push(message)
|
||||
return spawn(argv, opts)
|
||||
}
|
||||
|
||||
const opencode: OpencodeCli = { run, spawn, expectExit, parseJsonEvents }
|
||||
|
||||
return yield* fn({ llm, home, opencode })
|
||||
}).pipe(Effect.provide(TestLLMServer.layer))
|
||||
}
|
||||
|
||||
function parseJsonEvents(stdout: string): Array<Record<string, unknown>> {
|
||||
return stdout
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
.map((line) => JSON.parse(line) as Record<string, unknown>)
|
||||
}
|
||||
|
||||
// Convenience for the common assertion pattern. Dumps stderr/stdout when
|
||||
// the exit code doesn't match — saves debugging time on CI failures.
|
||||
function expectExit(result: RunResult, expected: number, label = "opencode") {
|
||||
if (result.exitCode === expected) return
|
||||
const tail = (s: string, n: number) => (s.length > n ? "..." + s.slice(-n) : s)
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[${label}] expected exit ${expected}, got ${result.exitCode} after ${result.durationMs}ms`)
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[${label}] stderr (last 2000):\n${tail(result.stderr, 2000)}`)
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[${label}] stdout (last 500):\n${tail(result.stdout, 500)}`)
|
||||
throw new Error(`${label}: expected exit ${expected}, got ${result.exitCode}`)
|
||||
}
|
||||
|
||||
// `runIt.live(name, fixture => effect)` is the same as
|
||||
// `it.live(name, () => withRunFixture(fixture))` — one fewer nesting level at
|
||||
// every call site. Use this for any test that needs the opencode CLI fixture.
|
||||
//
|
||||
// Only `.live` is exposed because subprocess tests must run against the real
|
||||
// clock — a TestClock-paused environment can't drive a child process. If you
|
||||
// need `.only` or `.skip`, fall back to `it.live` + `withRunFixture` directly.
|
||||
export const runIt = {
|
||||
live: <A, E>(name: string, body: (input: RunFixture) => Effect.Effect<A, E>, opts?: number | TestOptions) =>
|
||||
it.live(name, () => withRunFixture(body), opts),
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
// Shared provider config for tests that need opencode to talk to a fake LLM
|
||||
// over a real HTTP endpoint. Registers a single provider `test` with a single
|
||||
// model `test-model` (i.e. `--model test/test-model`), pointed at the URL the
|
||||
// caller supplies (typically a TestLLMServer instance).
|
||||
//
|
||||
// Used by:
|
||||
// - test/lib/run-process.ts (subprocess CLI tests)
|
||||
// - test/server/httpapi-sdk.test.ts (in-process SDK tests)
|
||||
export function testProviderConfig(llmUrl: string) {
|
||||
return {
|
||||
formatter: false,
|
||||
lsp: false,
|
||||
provider: {
|
||||
test: {
|
||||
name: "Test",
|
||||
id: "test",
|
||||
env: [],
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
models: {
|
||||
"test-model": {
|
||||
id: "test-model",
|
||||
name: "Test Model",
|
||||
attachment: false,
|
||||
reasoning: false,
|
||||
temperature: false,
|
||||
tool_call: true,
|
||||
release_date: "2025-01-01",
|
||||
limit: { context: 100_000, output: 10_000 },
|
||||
cost: { input: 0, output: 0 },
|
||||
options: {},
|
||||
},
|
||||
},
|
||||
options: { apiKey: "test-key", baseURL: llmUrl },
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,6 @@ import path from "path"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, TestInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { awaitWithTimeout, testEffect } from "../lib/effect"
|
||||
import { testProviderConfig } from "../lib/test-provider"
|
||||
|
||||
const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))
|
||||
const it = testEffect(
|
||||
@@ -100,6 +99,39 @@ function authorization(username: string, password: string) {
|
||||
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
|
||||
}
|
||||
|
||||
function providerConfig(url: string) {
|
||||
return {
|
||||
formatter: false,
|
||||
lsp: false,
|
||||
provider: {
|
||||
test: {
|
||||
name: "Test",
|
||||
id: "test",
|
||||
env: [],
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
models: {
|
||||
"test-model": {
|
||||
id: "test-model",
|
||||
name: "Test Model",
|
||||
attachment: false,
|
||||
reasoning: false,
|
||||
temperature: false,
|
||||
tool_call: true,
|
||||
release_date: "2025-01-01",
|
||||
limit: { context: 100000, output: 10000 },
|
||||
cost: { input: 0, output: 0 },
|
||||
options: {},
|
||||
},
|
||||
},
|
||||
options: {
|
||||
apiKey: "test-key",
|
||||
baseURL: url,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function call<T>(request: () => Promise<T>) {
|
||||
return Effect.promise(request)
|
||||
}
|
||||
@@ -251,7 +283,7 @@ function withStandardProject<A, E>(
|
||||
function withFakeLlm<A, E>(serverPath: ServerPath, run: (input: LlmProjectFixture) => Effect.Effect<A, E, TestScope>) {
|
||||
return Effect.gen(function* () {
|
||||
const llm = yield* TestLLMServer
|
||||
return yield* withProject(serverPath, { config: testProviderConfig(llm.url) }, (input) => run({ ...input, llm }))
|
||||
return yield* withProject(serverPath, { config: providerConfig(llm.url) }, (input) => run({ ...input, llm }))
|
||||
}).pipe(Effect.provide(TestLLMServer.layer))
|
||||
}
|
||||
|
||||
@@ -265,7 +297,7 @@ function withFakeLlmProject<A, E>(
|
||||
return yield* withProject(
|
||||
serverPath,
|
||||
{
|
||||
config: testProviderConfig(llm.url),
|
||||
config: providerConfig(llm.url),
|
||||
setup: options.setup,
|
||||
},
|
||||
(input) => run({ ...input, llm }),
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Option, Schema } from "effect"
|
||||
import { InvalidConfigError, requireJsonConfig, tryJsonConfig } from "@/util/json"
|
||||
|
||||
const PointSchema = Schema.Struct({
|
||||
x: Schema.Number,
|
||||
y: Schema.Number,
|
||||
})
|
||||
|
||||
describe("requireJsonConfig", () => {
|
||||
test("returns decoded value on success", () => {
|
||||
expect(requireJsonConfig(`{"x":1,"y":2}`, PointSchema, "TEST")).toEqual({ x: 1, y: 2 })
|
||||
})
|
||||
|
||||
test("throws InvalidConfigError on malformed JSON", () => {
|
||||
let caught: unknown
|
||||
try {
|
||||
requireJsonConfig("not json", PointSchema, "TEST")
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(InvalidConfigError)
|
||||
const err = caught as InvalidConfigError
|
||||
expect(err.source).toBe("TEST")
|
||||
expect(err.value).toBe("not json")
|
||||
expect(err.reason.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test("throws InvalidConfigError on schema mismatch", () => {
|
||||
let caught: unknown
|
||||
try {
|
||||
requireJsonConfig(`{"x":"oops"}`, PointSchema, "TEST")
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(InvalidConfigError)
|
||||
const err = caught as InvalidConfigError
|
||||
expect(err.source).toBe("TEST")
|
||||
expect(err.value).toBe(`{"x":"oops"}`)
|
||||
expect(err.reason.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test("InvalidConfigError carries _tag for downstream matching", () => {
|
||||
let caught: unknown
|
||||
try {
|
||||
requireJsonConfig("not json", PointSchema, "TEST")
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect((caught as { _tag: string })._tag).toBe("InvalidConfigError")
|
||||
})
|
||||
})
|
||||
|
||||
describe("tryJsonConfig", () => {
|
||||
test("returns Some on success", () => {
|
||||
const decoded = tryJsonConfig(`{"x":1,"y":2}`, PointSchema, "TEST")
|
||||
expect(Option.isSome(decoded)).toBe(true)
|
||||
expect(Option.getOrThrow(decoded)).toEqual({ x: 1, y: 2 })
|
||||
})
|
||||
|
||||
test("returns None for undefined input", () => {
|
||||
expect(Option.isNone(tryJsonConfig(undefined, PointSchema, "TEST"))).toBe(true)
|
||||
})
|
||||
|
||||
test("returns None for empty string", () => {
|
||||
expect(Option.isNone(tryJsonConfig("", PointSchema, "TEST"))).toBe(true)
|
||||
})
|
||||
|
||||
test("returns None for malformed JSON", () => {
|
||||
expect(Option.isNone(tryJsonConfig("not json", PointSchema, "TEST"))).toBe(true)
|
||||
})
|
||||
|
||||
test("returns None on schema mismatch", () => {
|
||||
expect(Option.isNone(tryJsonConfig(`{"x":"oops"}`, PointSchema, "TEST"))).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -71,8 +71,6 @@ Repeated setup work, long sleeps/timeouts, serial integration tests, filesystem/
|
||||
| First provider config/env/filtering block can use Effect-aware instance fixtures | Migrated six `tmpdir` + `withTestInstance` cases to `it.instance` | 6.06s | 6.07s | keep | Neutral timing, but removes manual config file writes and instance plumbing; use as the pattern for later provider slices. |
|
||||
| Custom provider/model config cases can use Effect-aware instance fixtures | Migrated three more config-heavy provider cases to `it.instance` | 6.07s | 6.12s | keep | Neutral timing within noise, but continues removing manual config file writes on top of the first provider fixture PR. |
|
||||
| Provider env precedence and model lookup cases can use Effect-aware instance fixtures | Migrated four more provider lookup/default-model cases to `it.instance` | 6.12s | 6.36s | keep | Noisy 5-run median; kept as a small stacked cleanup slice but do not claim speedup from this migration. |
|
||||
| Simple config load cases can use Effect-aware instance fixtures | Migrated JSON, shell, formatter, and lsp config load cases to `it.instance` | 14.18s | 3.93s | keep | Three-run medians before/after; removes manual `tmpdir` + `withTestInstance` setup from the first simple config block. |
|
||||
| Config template, file include, and simple agent cases can use Effect-aware instance fixtures | Migrated JSONC, env/file substitution, invalid config, and agent config cases to `it.instance` | 1.87s | 1.90s | keep | Stacked on the first config slice; neutral timing but removes more manual `tmpdir` + instance plumbing. |
|
||||
|
||||
## Profiling Results
|
||||
|
||||
|
||||
Reference in New Issue
Block a user