Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 12ac69ee91 | |||
| 9a19e84265 | |||
| 338666d13e | |||
| 7b8a1037a0 | |||
| ee5cf45ef9 | |||
| 2e1593dea5 | |||
| 0f3d168fdd |
@@ -0,0 +1,84 @@
|
||||
// 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/cli-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 { cliIt } from "../../lib/cli-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.
|
||||
cliIt.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.
|
||||
cliIt.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.
|
||||
cliIt.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.
|
||||
cliIt.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,
|
||||
)
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect, describe, mock, afterEach, beforeEach } from "bun:test"
|
||||
import { Effect, Layer, Option } from "effect"
|
||||
import { Effect, Exit, 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, withTestInstance } from "../fixture/fixture"
|
||||
import { provideTestInstance, provideTmpdirInstance, TestInstance, withTestInstance } from "../fixture/fixture"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { InstanceRuntime } from "@/project/instance-runtime"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
@@ -68,13 +68,6 @@ const load = (ctx: InstanceContext) =>
|
||||
Effect.runPromise(
|
||||
Config.Service.use((svc) => provideCurrentInstance(svc.get(), ctx)).pipe(Effect.scoped, Effect.provide(layer)),
|
||||
)
|
||||
const save = (config: Config.Info, ctx: InstanceContext) =>
|
||||
Effect.runPromise(
|
||||
Config.Service.use((svc) => provideCurrentInstance(svc.update(config), ctx)).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(layer),
|
||||
),
|
||||
)
|
||||
const saveGlobal = (config: Config.Info) =>
|
||||
Effect.runPromise(
|
||||
Config.Service.use((svc) => svc.updateGlobal(config)).pipe(
|
||||
@@ -94,14 +87,6 @@ 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!
|
||||
|
||||
@@ -123,6 +108,25 @@ 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()
|
||||
@@ -210,67 +214,42 @@ test("does not create global config when OPENCODE_CONFIG_DIR is set", async () =
|
||||
}
|
||||
})
|
||||
|
||||
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 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 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")
|
||||
},
|
||||
})
|
||||
})
|
||||
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("updates config and preserves empty shell sentinel", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await writeConfig(
|
||||
dir,
|
||||
{
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
shell: "bash",
|
||||
},
|
||||
"config.json",
|
||||
)
|
||||
},
|
||||
})
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
await save({ shell: "" }, ctx)
|
||||
it.instance("updates config and preserves empty shell sentinel", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* writeConfigEffect(
|
||||
test.directory,
|
||||
{ $schema: "https://opencode.ai/config.json", shell: "bash" },
|
||||
"config.json",
|
||||
)
|
||||
|
||||
const writtenConfig = await Filesystem.readJson<{ shell?: string }>(path.join(tmp.path, "config.json"))
|
||||
expect(writtenConfig.shell).toBe("")
|
||||
},
|
||||
})
|
||||
})
|
||||
yield* Config.Service.use((svc) => svc.update(ConfigParse.schema(Config.Info, { shell: "" }, "test:config")))
|
||||
|
||||
const writtenConfig = yield* Effect.promise(() =>
|
||||
Filesystem.readJson<{ shell?: string }>(path.join(test.directory, "config.json")),
|
||||
)
|
||||
expect(writtenConfig.shell).toBe("")
|
||||
}),
|
||||
)
|
||||
|
||||
test("updates global config and omits empty shell key in json", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
@@ -330,41 +309,23 @@ test("updates global config and omits empty shell key in jsonc", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
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 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 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)
|
||||
},
|
||||
})
|
||||
})
|
||||
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 project config from Git Bash and MSYS2 paths on Windows", async () => {
|
||||
// Git Bash and MSYS2 both use /<drive>/... paths on Windows.
|
||||
@@ -405,124 +366,118 @@ test("ignores legacy tui keys in opencode config", async () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("loads JSONC config file", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.jsonc"),
|
||||
it.instance("loads JSONC config file", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() =>
|
||||
Filesystem.write(
|
||||
path.join(test.directory, "opencode.jsonc"),
|
||||
`{
|
||||
// This is a comment
|
||||
"$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")
|
||||
},
|
||||
})
|
||||
})
|
||||
),
|
||||
)
|
||||
const config = yield* Config.Service.use((svc) => svc.get())
|
||||
expect(config.model).toBe("test/model")
|
||||
expect(config.username).toBe("testuser")
|
||||
}),
|
||||
)
|
||||
|
||||
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, {
|
||||
it.instance("jsonc overrides json in the same directory", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* writeConfigEffect(
|
||||
test.directory,
|
||||
{
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
model: "override",
|
||||
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}",
|
||||
})
|
||||
},
|
||||
})
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
const config = await load(ctx)
|
||||
expect(config.model).toBe("base")
|
||||
expect(config.username).toBe("base")
|
||||
},
|
||||
})
|
||||
})
|
||||
const config = yield* Config.Service.use((svc) => svc.get())
|
||||
expect(config.username).toBe("test-user")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
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"),
|
||||
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"),
|
||||
JSON.stringify({
|
||||
username: "{env:PRESERVE_VAR}",
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
const config = await load(ctx)
|
||||
expect(config.username).toBe("secret_value")
|
||||
),
|
||||
)
|
||||
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 = 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")
|
||||
},
|
||||
// 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}",
|
||||
})
|
||||
} finally {
|
||||
if (originalEnv !== undefined) {
|
||||
process.env["PRESERVE_VAR"] = originalEnv
|
||||
} else {
|
||||
delete process.env["PRESERVE_VAR"]
|
||||
}
|
||||
}
|
||||
})
|
||||
const config = yield* Config.Service.use((svc) => svc.get())
|
||||
expect(config.username).toBe("test-user")
|
||||
}),
|
||||
)
|
||||
|
||||
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}",
|
||||
})
|
||||
const config = yield* Config.Service.use((svc) => svc.get())
|
||||
expect(config.username).toBe("const out = await Bun.$`echo hi`")
|
||||
}),
|
||||
)
|
||||
|
||||
test("resolves env templates in account config with account token", async () => {
|
||||
const originalControlToken = process.env["OPENCODE_CONSOLE_TOKEN"]
|
||||
@@ -589,218 +544,132 @@ test("resolves env templates in account config with account token", async () =>
|
||||
}
|
||||
})
|
||||
|
||||
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("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 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("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("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({
|
||||
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: {
|
||||
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({
|
||||
init: async (dir) => {
|
||||
await writeConfig(dir, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
agent: {
|
||||
test_agent: {
|
||||
model: "openai/gpt-5.2",
|
||||
variant: "xhigh",
|
||||
max_tokens: 123,
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
const config = await load(ctx)
|
||||
const agent = config.agent?.["test_agent"]
|
||||
|
||||
expect(agent?.variant).toBe("xhigh")
|
||||
expect(agent?.options).toMatchObject({
|
||||
max_tokens: 123,
|
||||
})
|
||||
expect(agent?.options).not.toHaveProperty("variant")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("handles command configuration", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await writeConfig(dir, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
command: {
|
||||
test_command: {
|
||||
template: "test template",
|
||||
description: "test command",
|
||||
agent: "test_agent",
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
const config = await load(ctx)
|
||||
expect(config.command?.["test_command"]).toEqual({
|
||||
template: "test template",
|
||||
description: "test command",
|
||||
agent: "test_agent",
|
||||
})
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("migrates autoshare to share field", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
autoshare: true,
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
const config = await load(ctx)
|
||||
expect(config.share).toBe("auto")
|
||||
expect(config.autoshare).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("migrates mode field to agent field", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
mode: {
|
||||
test_mode: {
|
||||
model: "test/model",
|
||||
temperature: 0.5,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
const config = await load(ctx)
|
||||
expect(config.agent?.["test_mode"]).toEqual({
|
||||
},
|
||||
})
|
||||
const config = yield* Config.Service.use((svc) => svc.get())
|
||||
expect(config.agent?.["test_agent"]).toEqual(
|
||||
expect.objectContaining({
|
||||
model: "test/model",
|
||||
temperature: 0.5,
|
||||
mode: "primary",
|
||||
options: {},
|
||||
permission: {},
|
||||
})
|
||||
},
|
||||
})
|
||||
})
|
||||
temperature: 0.7,
|
||||
description: "test agent",
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("treats agent variant as model-scoped setting (not provider option)", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* writeConfigEffect(test.directory, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
agent: {
|
||||
test_agent: {
|
||||
model: "openai/gpt-5.2",
|
||||
variant: "xhigh",
|
||||
max_tokens: 123,
|
||||
},
|
||||
},
|
||||
})
|
||||
const config = yield* Config.Service.use((svc) => svc.get())
|
||||
const agent = config.agent?.["test_agent"]
|
||||
|
||||
expect(agent?.variant).toBe("xhigh")
|
||||
expect(agent?.options).toMatchObject({
|
||||
max_tokens: 123,
|
||||
})
|
||||
expect(agent?.options).not.toHaveProperty("variant")
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("handles command configuration", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* writeConfigEffect(test.directory, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
command: {
|
||||
test_command: {
|
||||
template: "test template",
|
||||
description: "test command",
|
||||
agent: "test_agent",
|
||||
},
|
||||
},
|
||||
})
|
||||
const config = yield* Config.Service.use((svc) => svc.get())
|
||||
expect(config.command?.["test_command"]).toEqual({
|
||||
template: "test template",
|
||||
description: "test command",
|
||||
agent: "test_agent",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("migrates autoshare to share field", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* writeConfigEffect(test.directory, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
autoshare: true,
|
||||
})
|
||||
const config = yield* Config.Service.use((svc) => svc.get())
|
||||
expect(config.share).toBe("auto")
|
||||
expect(config.autoshare).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("migrates mode field to agent field", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* writeConfigEffect(test.directory, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
mode: {
|
||||
test_mode: {
|
||||
model: "test/model",
|
||||
temperature: 0.5,
|
||||
},
|
||||
},
|
||||
})
|
||||
const config = yield* Config.Service.use((svc) => svc.get())
|
||||
expect(config.agent?.["test_mode"]).toEqual({
|
||||
model: "test/model",
|
||||
temperature: 0.5,
|
||||
mode: "primary",
|
||||
options: {},
|
||||
permission: {},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
test("loads config from .opencode directory", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
@@ -1002,30 +871,26 @@ Nested command template`,
|
||||
})
|
||||
})
|
||||
|
||||
test("updates config and writes to file", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
const newConfig = { model: "updated/model" }
|
||||
await save(newConfig as any, ctx)
|
||||
it.instance("updates config and writes to file", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Config.Service.use((svc) =>
|
||||
svc.update(ConfigParse.schema(Config.Info, { model: "updated/model" }, "test:config")),
|
||||
)
|
||||
|
||||
const writtenConfig = await Filesystem.readJson<{ model: string }>(path.join(tmp.path, "config.json"))
|
||||
expect(writtenConfig.model).toBe("updated/model")
|
||||
},
|
||||
})
|
||||
})
|
||||
const writtenConfig = yield* Effect.promise(() =>
|
||||
Filesystem.readJson<{ model: string }>(path.join(test.directory, "config.json")),
|
||||
)
|
||||
expect(writtenConfig.model).toBe("updated/model")
|
||||
}),
|
||||
)
|
||||
|
||||
test("gets config directories", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
const dirs = await listDirs(ctx)
|
||||
expect(dirs.length).toBeGreaterThanOrEqual(1)
|
||||
},
|
||||
})
|
||||
})
|
||||
it.instance("gets config directories", () =>
|
||||
Effect.gen(function* () {
|
||||
const dirs = yield* Config.Service.use((svc) => svc.directories())
|
||||
expect(dirs.length).toBeGreaterThanOrEqual(1)
|
||||
}),
|
||||
)
|
||||
|
||||
test("does not try to install dependencies in read-only OPENCODE_CONFIG_DIR", async () => {
|
||||
if (process.platform === "win32") return
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
// Subprocess test harness for the opencode CLI. Spawns the real binary against
|
||||
// a TestLLMServer running in-process at a random port, with full env isolation.
|
||||
//
|
||||
// This is the missing test tier: in-process tests can't catch bugs that span
|
||||
// argv parsing → server boot → SDK call → event consumption → exit code (like
|
||||
// the original /event race or #27371's invalid-model hang).
|
||||
//
|
||||
// Configuration flows through opencode's built-in test affordances:
|
||||
// - 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.
|
||||
//
|
||||
// Today only `opencode.run` is fully wired. The shape supports adding more
|
||||
// builders (`opencode.serve(opts)`, `opencode.acp(opts)`, `opencode.auth(...)`)
|
||||
// without changing the fixture. Long-lived commands like `serve` will need a
|
||||
// different return shape — see the TODO at the bottom of OpencodeCli.
|
||||
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
|
||||
}
|
||||
|
||||
export type SpawnOpts = { readonly timeoutMs?: number; readonly env?: Record<string, string> }
|
||||
|
||||
// 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. Short-lived.
|
||||
readonly run: (message: string, opts?: RunOpts) => Effect.Effect<RunResult>
|
||||
// Escape hatch: any CLI invocation with full control over argv. Used to test
|
||||
// commands that don't yet have a typed builder.
|
||||
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 on a malformed line so
|
||||
// tests fail loudly rather than silently skipping data.
|
||||
readonly parseJsonEvents: (stdout: string) => Array<Record<string, unknown>>
|
||||
// TODO: long-lived builders for `serve` / `acp` / etc. need a different
|
||||
// return shape — they yield a handle with .url / .kill and live inside the
|
||||
// surrounding Scope. Add when the first long-lived command is tested.
|
||||
}
|
||||
|
||||
export type CliFixture = {
|
||||
readonly llm: TestLLMServer["Service"]
|
||||
readonly home: string
|
||||
readonly opencode: OpencodeCli
|
||||
}
|
||||
|
||||
// Provisions a TestLLMServer + tmpdir + spawn helper and invokes fn. Cleans
|
||||
// up the tmpdir on scope exit. 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 withCliFixture<A, E>(
|
||||
fn: (input: CliFixture) => 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-cli-" + 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}`)
|
||||
}
|
||||
|
||||
// `cliIt.live(name, fixture => effect)` is the same as
|
||||
// `it.live(name, () => withCliFixture(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` + `withCliFixture` directly.
|
||||
export const cliIt = {
|
||||
live: <A, E>(name: string, body: (input: CliFixture) => Effect.Effect<A, E>, opts?: number | TestOptions) =>
|
||||
it.live(name, () => withCliFixture(body), opts),
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// 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,6 +23,7 @@ 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(
|
||||
@@ -99,39 +100,6 @@ 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)
|
||||
}
|
||||
@@ -283,7 +251,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: providerConfig(llm.url) }, (input) => run({ ...input, llm }))
|
||||
return yield* withProject(serverPath, { config: testProviderConfig(llm.url) }, (input) => run({ ...input, llm }))
|
||||
}).pipe(Effect.provide(TestLLMServer.layer))
|
||||
}
|
||||
|
||||
@@ -297,7 +265,7 @@ function withFakeLlmProject<A, E>(
|
||||
return yield* withProject(
|
||||
serverPath,
|
||||
{
|
||||
config: providerConfig(llm.url),
|
||||
config: testProviderConfig(llm.url),
|
||||
setup: options.setup,
|
||||
},
|
||||
(input) => run({ ...input, llm }),
|
||||
|
||||
+24
-20
@@ -51,26 +51,30 @@ Repeated setup work, long sleeps/timeouts, serial integration tests, filesystem/
|
||||
|
||||
## Hypothesis Loop
|
||||
|
||||
| Hypothesis | Change | Before | After | Decision | Notes |
|
||||
| --------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | --------- | ------- | -------- | --------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Repeated full-suite runs are too expensive for discovery | Switched full-suite benchmark to one run and added per-file profiler | ~250s/run | pending | keep | Bun has no slowest-test reporter in this version; profile files directly. |
|
||||
| Plugin install concurrency test spends time spawning more workers than needed to exercise lock contention | Reduced worker counts from 12/10/8 to 6/6/5; kept `holdMs: 30` | 7.800s | 6.204s | keep | Median from 3 targeted runs; still covers concurrent cross-process writes to server, server+tui, and existing json config. |
|
||||
| `httpapi-listen` PTY route tests pay for git repositories they do not assert on | Removed `git: true` from temp dirs while keeping config setup | 10.554s | 7.818s | keep | Median from 3 targeted runs; HTTP routes, tickets, websocket upgrade, restart, and no-auth paths still pass. |
|
||||
| `workspace.waitForSync` timeout test waits the full production timeout | Added optional timeout parameter defaulting to production timeout; timeout test uses 25ms | 12.949s | 8.305s | keep | Median from 3 targeted runs; production callers keep the 5000ms default. |
|
||||
| `config.test` waits after dependencies even though `.gitignore` is written synchronously | Removed obsolete 1000ms sleep from writable `OPENCODE_CONFIG_DIR` test | 10.270s | 9.433s | keep | Median from 5 targeted runs because one run was noisy; simpler test and no fixed sleep. |
|
||||
| SDK parity helpers create git repos for tests that only need files/config/session state | Changed `withProject` default to no git; explicit git init test still opts into no-git fixture | 8.011s | 5.180s | keep | Median from 5 targeted runs because first run was cold/noisy. |
|
||||
| Provider plugin filter test waits on plugin dependency readiness setup | Marked local plugin dependencies ready using the existing fixture helper | 7.543s | 6.366s | keep | Median from 3 targeted runs; matches neighboring plugin provider test setup. |
|
||||
| HTTP provider tests generate local plugins without dependency-ready fixture state | Marked generated `.opencode` plugin fixtures dependency-ready | 7.905s | 2.980s | keep | Median from 3 targeted runs; avoids unrelated plugin dependency setup in route tests. |
|
||||
| TUI plugin lifecycle timeout coverage waits the full production cleanup timeout | Added optional runtime dispose timeout override and used 25ms in the timeout test | 7.330s | 1.507s | keep | Median from 3 targeted runs; production default remains 5000ms. |
|
||||
| Skill tool test initializes git even though it only reads local skill files | Removed `git: true` from the temporary directory fixture | 2.320s | 1.425s | keep | Single targeted rerun; still exercises skill discovery, permission request, and bundled file output. |
|
||||
| Prompt shell semantics tests initialize git though they only assert shell/session behavior | Removed `git: true` from shell-focused prompt fixtures while preserving config setup | 26.930s | 23.400s | keep | Three targeted reruns passed after the change: 23.80s, 23.55s, 23.40s. |
|
||||
| Remaining prompt behavior tests mostly do not require repository state | Removed git setup from safe loop/reference/error fixtures; restored shell queue/cancel cases | 23.400s | 19.610s | keep | Safety review found shell runner readiness depends on git-backed setup in several tests; current single rerun passes. |
|
||||
| Session processor effect tests do not require repository state | Removed git setup from all processor-effect temp server fixtures | 12.500s | 9.230s | keep | Two targeted reruns passed after the change: 9.61s, 9.23s. |
|
||||
| HTTP listen PTY ticket tests restart the same listener topology twice | Folded directory-scoped ticket regression into the broader unsafe-ticket test | 7.051s | 6.170s | keep | Two targeted reruns passed after the change: 6.76s, 6.17s; still covers mint failure and successful same-directory upgrade. |
|
||||
| File watcher readiness can write before async native subscriptions are active | Retried short readiness writes and accepted symlink-realpath HEAD events | failed | 4.62s | keep | Three sequential focused watcher runs passed: 4.62s, 4.57s, 4.64s; full suite no longer failed in `watcher.test.ts`. |
|
||||
| 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. |
|
||||
| Hypothesis | Change | Before | After | Decision | Notes |
|
||||
| --------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | --------- | ------- | -------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Repeated full-suite runs are too expensive for discovery | Switched full-suite benchmark to one run and added per-file profiler | ~250s/run | pending | keep | Bun has no slowest-test reporter in this version; profile files directly. |
|
||||
| Plugin install concurrency test spends time spawning more workers than needed to exercise lock contention | Reduced worker counts from 12/10/8 to 6/6/5; kept `holdMs: 30` | 7.800s | 6.204s | keep | Median from 3 targeted runs; still covers concurrent cross-process writes to server, server+tui, and existing json config. |
|
||||
| `httpapi-listen` PTY route tests pay for git repositories they do not assert on | Removed `git: true` from temp dirs while keeping config setup | 10.554s | 7.818s | keep | Median from 3 targeted runs; HTTP routes, tickets, websocket upgrade, restart, and no-auth paths still pass. |
|
||||
| `workspace.waitForSync` timeout test waits the full production timeout | Added optional timeout parameter defaulting to production timeout; timeout test uses 25ms | 12.949s | 8.305s | keep | Median from 3 targeted runs; production callers keep the 5000ms default. |
|
||||
| `config.test` waits after dependencies even though `.gitignore` is written synchronously | Removed obsolete 1000ms sleep from writable `OPENCODE_CONFIG_DIR` test | 10.270s | 9.433s | keep | Median from 5 targeted runs because one run was noisy; simpler test and no fixed sleep. |
|
||||
| SDK parity helpers create git repos for tests that only need files/config/session state | Changed `withProject` default to no git; explicit git init test still opts into no-git fixture | 8.011s | 5.180s | keep | Median from 5 targeted runs because first run was cold/noisy. |
|
||||
| Provider plugin filter test waits on plugin dependency readiness setup | Marked local plugin dependencies ready using the existing fixture helper | 7.543s | 6.366s | keep | Median from 3 targeted runs; matches neighboring plugin provider test setup. |
|
||||
| HTTP provider tests generate local plugins without dependency-ready fixture state | Marked generated `.opencode` plugin fixtures dependency-ready | 7.905s | 2.980s | keep | Median from 3 targeted runs; avoids unrelated plugin dependency setup in route tests. |
|
||||
| TUI plugin lifecycle timeout coverage waits the full production cleanup timeout | Added optional runtime dispose timeout override and used 25ms in the timeout test | 7.330s | 1.507s | keep | Median from 3 targeted runs; production default remains 5000ms. |
|
||||
| Skill tool test initializes git even though it only reads local skill files | Removed `git: true` from the temporary directory fixture | 2.320s | 1.425s | keep | Single targeted rerun; still exercises skill discovery, permission request, and bundled file output. |
|
||||
| Prompt shell semantics tests initialize git though they only assert shell/session behavior | Removed `git: true` from shell-focused prompt fixtures while preserving config setup | 26.930s | 23.400s | keep | Three targeted reruns passed after the change: 23.80s, 23.55s, 23.40s. |
|
||||
| Remaining prompt behavior tests mostly do not require repository state | Removed git setup from safe loop/reference/error fixtures; restored shell queue/cancel cases | 23.400s | 19.610s | keep | Safety review found shell runner readiness depends on git-backed setup in several tests; current single rerun passes. |
|
||||
| Session processor effect tests do not require repository state | Removed git setup from all processor-effect temp server fixtures | 12.500s | 9.230s | keep | Two targeted reruns passed after the change: 9.61s, 9.23s. |
|
||||
| HTTP listen PTY ticket tests restart the same listener topology twice | Folded directory-scoped ticket regression into the broader unsafe-ticket test | 7.051s | 6.170s | keep | Two targeted reruns passed after the change: 6.76s, 6.17s; still covers mint failure and successful same-directory upgrade. |
|
||||
| File watcher readiness can write before async native subscriptions are active | Retried short readiness writes and accepted symlink-realpath HEAD events | failed | 4.62s | keep | Three sequential focused watcher runs passed: 4.62s, 4.57s, 4.64s; full suite no longer failed in `watcher.test.ts`. |
|
||||
| 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. |
|
||||
| Agent option, command, and legacy migration config cases can use Effect-aware instance fixtures | Migrated agent variant, command, autoshare, and mode migration cases to `it.instance` | 1.90s | 1.83s | keep | Stacked on the config template slice; small neutral-to-positive timing and less manual setup. |
|
||||
| Local config update and directory cases can use Effect-aware instance fixtures | Migrated local `update` and `directories` cases to `it.instance` | 1.77s | 1.71s | keep | Three-run medians; small positive/neutral timing, removes manual instance plumbing, and eliminates one existing unsafe cast. |
|
||||
|
||||
## Profiling Results
|
||||
|
||||
|
||||
Reference in New Issue
Block a user