Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a474e92433 |
@@ -1,16 +1,16 @@
|
|||||||
// Subprocess integration tests for `opencode run` (non-interactive mode).
|
// Subprocess integration tests for `opencode run` (non-interactive mode).
|
||||||
// These exercise the real CLI binary against a TestLLMServer running in the
|
// 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
|
// 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.run(message, opts?)` to spawn `bun src/index.ts run ...` with
|
||||||
// `OPENCODE_CONFIG_CONTENT` providing the test provider config inline.
|
// `OPENCODE_CONFIG_CONTENT` providing the test provider config inline.
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { cliIt } from "../../lib/cli-process"
|
import { runIt } from "../../lib/run-process"
|
||||||
|
|
||||||
describe("opencode run (non-interactive subprocess)", () => {
|
describe("opencode run (non-interactive subprocess)", () => {
|
||||||
// Happy path: prompt completes, output reaches stdout, process exits 0.
|
// Happy path: prompt completes, output reaches stdout, process exits 0.
|
||||||
// If this fails, all the others likely will too — debug here first.
|
// If this fails, all the others likely will too — debug here first.
|
||||||
cliIt.live(
|
runIt.live(
|
||||||
"exits 0 and writes the response to stdout on a successful prompt",
|
"exits 0 and writes the response to stdout on a successful prompt",
|
||||||
({ llm, opencode }) =>
|
({ llm, opencode }) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -27,7 +27,7 @@ describe("opencode run (non-interactive subprocess)", () => {
|
|||||||
// makes the SDK call surface an error promptly so the process exits nonzero.
|
// 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
|
// We assert nonzero exit AND wall-clock under the harness timeout — a hang
|
||||||
// would expire the timeout and produce a different (signal-killed) failure.
|
// would expire the timeout and produce a different (signal-killed) failure.
|
||||||
cliIt.live(
|
runIt.live(
|
||||||
"exits nonzero promptly when the model is unknown (regression for #27371)",
|
"exits nonzero promptly when the model is unknown (regression for #27371)",
|
||||||
({ opencode }) =>
|
({ opencode }) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -47,7 +47,7 @@ describe("opencode run (non-interactive subprocess)", () => {
|
|||||||
//
|
//
|
||||||
// This is debatable — a future cleanup might flip it to exit 1. If you're
|
// 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.
|
// changing this expectation, do it deliberately and say so in the PR.
|
||||||
cliIt.live(
|
runIt.live(
|
||||||
"mid-stream LLM error still exits 0 today (contract lock-in)",
|
"mid-stream LLM error still exits 0 today (contract lock-in)",
|
||||||
({ llm, opencode }) =>
|
({ llm, opencode }) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -61,7 +61,7 @@ describe("opencode run (non-interactive subprocess)", () => {
|
|||||||
// --format json puts one JSON object per line on stdout for each emitted
|
// --format json puts one JSON object per line on stdout for each emitted
|
||||||
// event. Consumers (CI scripts, tooling) parse this stream. Asserts the
|
// event. Consumers (CI scripts, tooling) parse this stream. Asserts the
|
||||||
// shape so a future event-emit change has to update this expectation.
|
// shape so a future event-emit change has to update this expectation.
|
||||||
cliIt.live(
|
runIt.live(
|
||||||
"--format json emits parseable line-delimited JSON to stdout",
|
"--format json emits parseable line-delimited JSON to stdout",
|
||||||
({ llm, opencode }) =>
|
({ llm, opencode }) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
// Subprocess integration tests for `opencode serve`. Spawns the real CLI in
|
|
||||||
// headless mode and exercises it over HTTP — this is the only test tier that
|
|
||||||
// catches bugs spanning argv → server boot → routing → instance loading.
|
|
||||||
//
|
|
||||||
// `serve` is long-lived: the harness returns a handle (url/port/kill/exited)
|
|
||||||
// and kills the process when the test scope closes. The OS-assigned port is
|
|
||||||
// parsed off the "listening on http://..." line.
|
|
||||||
import { describe, expect } from "bun:test"
|
|
||||||
import { Effect } from "effect"
|
|
||||||
import { HttpClient } from "effect/unstable/http"
|
|
||||||
import { cliIt } from "../../lib/cli-process"
|
|
||||||
|
|
||||||
describe("opencode serve (subprocess)", () => {
|
|
||||||
// Smoke test: server starts, binds a port, and /global/health responds.
|
|
||||||
// If this fails, all other serve tests likely will too — debug here first.
|
|
||||||
cliIt.live(
|
|
||||||
"starts, binds a port, and serves /global/health",
|
|
||||||
({ opencode }) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const server = yield* opencode.serve()
|
|
||||||
expect(server.port).toBeGreaterThan(0)
|
|
||||||
expect(server.url).toMatch(/^http:\/\//)
|
|
||||||
|
|
||||||
const client = yield* HttpClient.HttpClient
|
|
||||||
const res = yield* client.get(`${server.url}/global/health`)
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
// GlobalHealth schema is { success: true, ... } | { success: false, error }.
|
|
||||||
// We don't lock in further shape here — any 200 with parseable JSON is
|
|
||||||
// enough proof the routing + auth-bypass + instance loading is alive.
|
|
||||||
const body = yield* res.json
|
|
||||||
expect(body).toBeDefined()
|
|
||||||
}),
|
|
||||||
60_000,
|
|
||||||
)
|
|
||||||
|
|
||||||
// The scope-close finalizer must actually terminate the child. Without this
|
|
||||||
// test a regression in the kill path (e.g. a future refactor that forgets
|
|
||||||
// to wire the finalizer) would leak processes on every test run.
|
|
||||||
cliIt.live(
|
|
||||||
"kills the subprocess on scope close",
|
|
||||||
({ opencode }) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
// Inner scope so we can observe `.exited` resolving after it closes.
|
|
||||||
const exitedPromise = yield* Effect.scoped(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const server = yield* opencode.serve()
|
|
||||||
// Capture the Promise, not the resolved value — scope closes after
|
|
||||||
// this gen returns, at which point the finalizer kills the child.
|
|
||||||
return server.exited
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
// After scope close: finalizer fired, process must have exited.
|
|
||||||
const code = yield* Effect.promise(() => exitedPromise)
|
|
||||||
// Bun reports the exit code; SIGTERM-killed processes return non-null
|
|
||||||
// (typically 143 on POSIX). We just require resolution within a sane
|
|
||||||
// window — anything else means the kill didn't take.
|
|
||||||
expect(typeof code === "number" || code === null).toBe(true)
|
|
||||||
}),
|
|
||||||
60_000,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -68,6 +68,13 @@ const load = (ctx: InstanceContext) =>
|
|||||||
Effect.runPromise(
|
Effect.runPromise(
|
||||||
Config.Service.use((svc) => provideCurrentInstance(svc.get(), ctx)).pipe(Effect.scoped, Effect.provide(layer)),
|
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) =>
|
const saveGlobal = (config: Config.Info) =>
|
||||||
Effect.runPromise(
|
Effect.runPromise(
|
||||||
Config.Service.use((svc) => svc.updateGlobal(config)).pipe(
|
Config.Service.use((svc) => svc.updateGlobal(config)).pipe(
|
||||||
@@ -110,8 +117,6 @@ async function writeConfig(dir: string, config: object, name = "opencode.json")
|
|||||||
|
|
||||||
const writeConfigEffect = (dir: string, config: object, name = "opencode.json") =>
|
const writeConfigEffect = (dir: string, config: object, name = "opencode.json") =>
|
||||||
Effect.promise(() => writeConfig(dir, config, name))
|
Effect.promise(() => writeConfig(dir, config, name))
|
||||||
const mkdirEffect = (dir: string) => Effect.promise(() => fs.mkdir(dir, { recursive: true }))
|
|
||||||
const writeTextEffect = (file: string, content: string) => Effect.promise(() => Filesystem.write(file, content))
|
|
||||||
|
|
||||||
function withProcessEnv<A, E, R>(key: string, value: string, effect: Effect.Effect<A, E, R>) {
|
function withProcessEnv<A, E, R>(key: string, value: string, effect: Effect.Effect<A, E, R>) {
|
||||||
return Effect.acquireUseRelease(
|
return Effect.acquireUseRelease(
|
||||||
@@ -235,23 +240,29 @@ it.instance(
|
|||||||
{ config: { shell: "bash" } },
|
{ config: { shell: "bash" } },
|
||||||
)
|
)
|
||||||
|
|
||||||
it.instance("updates config and preserves empty shell sentinel", () =>
|
test("updates config and preserves empty shell sentinel", async () => {
|
||||||
Effect.gen(function* () {
|
await using tmp = await tmpdir({
|
||||||
const test = yield* TestInstance
|
init: async (dir) => {
|
||||||
yield* writeConfigEffect(
|
await writeConfig(
|
||||||
test.directory,
|
dir,
|
||||||
{ $schema: "https://opencode.ai/config.json", shell: "bash" },
|
{
|
||||||
|
$schema: "https://opencode.ai/config.json",
|
||||||
|
shell: "bash",
|
||||||
|
},
|
||||||
"config.json",
|
"config.json",
|
||||||
)
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await withTestInstance({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async (ctx) => {
|
||||||
|
await save({ shell: "" }, ctx)
|
||||||
|
|
||||||
yield* Config.Service.use((svc) => svc.update(ConfigParse.schema(Config.Info, { shell: "" }, "test:config")))
|
const writtenConfig = await Filesystem.readJson<{ shell?: string }>(path.join(tmp.path, "config.json"))
|
||||||
|
|
||||||
const writtenConfig = yield* Effect.promise(() =>
|
|
||||||
Filesystem.readJson<{ shell?: string }>(path.join(test.directory, "config.json")),
|
|
||||||
)
|
|
||||||
expect(writtenConfig.shell).toBe("")
|
expect(writtenConfig.shell).toBe("")
|
||||||
}),
|
},
|
||||||
)
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test("updates global config and omits empty shell key in json", async () => {
|
test("updates global config and omits empty shell key in json", async () => {
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
@@ -591,10 +602,10 @@ it.instance("handles agent configuration", () =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.instance("treats agent variant as model-scoped setting (not provider option)", () =>
|
test("treats agent variant as model-scoped setting (not provider option)", async () => {
|
||||||
Effect.gen(function* () {
|
await using tmp = await tmpdir({
|
||||||
const test = yield* TestInstance
|
init: async (dir) => {
|
||||||
yield* writeConfigEffect(test.directory, {
|
await writeConfig(dir, {
|
||||||
$schema: "https://opencode.ai/config.json",
|
$schema: "https://opencode.ai/config.json",
|
||||||
agent: {
|
agent: {
|
||||||
test_agent: {
|
test_agent: {
|
||||||
@@ -604,7 +615,13 @@ it.instance("treats agent variant as model-scoped setting (not provider option)"
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const config = yield* Config.Service.use((svc) => svc.get())
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await withTestInstance({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async (ctx) => {
|
||||||
|
const config = await load(ctx)
|
||||||
const agent = config.agent?.["test_agent"]
|
const agent = config.agent?.["test_agent"]
|
||||||
|
|
||||||
expect(agent?.variant).toBe("xhigh")
|
expect(agent?.variant).toBe("xhigh")
|
||||||
@@ -612,13 +629,14 @@ it.instance("treats agent variant as model-scoped setting (not provider option)"
|
|||||||
max_tokens: 123,
|
max_tokens: 123,
|
||||||
})
|
})
|
||||||
expect(agent?.options).not.toHaveProperty("variant")
|
expect(agent?.options).not.toHaveProperty("variant")
|
||||||
}),
|
},
|
||||||
)
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it.instance("handles command configuration", () =>
|
test("handles command configuration", async () => {
|
||||||
Effect.gen(function* () {
|
await using tmp = await tmpdir({
|
||||||
const test = yield* TestInstance
|
init: async (dir) => {
|
||||||
yield* writeConfigEffect(test.directory, {
|
await writeConfig(dir, {
|
||||||
$schema: "https://opencode.ai/config.json",
|
$schema: "https://opencode.ai/config.json",
|
||||||
command: {
|
command: {
|
||||||
test_command: {
|
test_command: {
|
||||||
@@ -628,32 +646,49 @@ it.instance("handles command configuration", () =>
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const config = yield* Config.Service.use((svc) => svc.get())
|
},
|
||||||
|
})
|
||||||
|
await withTestInstance({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async (ctx) => {
|
||||||
|
const config = await load(ctx)
|
||||||
expect(config.command?.["test_command"]).toEqual({
|
expect(config.command?.["test_command"]).toEqual({
|
||||||
template: "test template",
|
template: "test template",
|
||||||
description: "test command",
|
description: "test command",
|
||||||
agent: "test_agent",
|
agent: "test_agent",
|
||||||
})
|
})
|
||||||
}),
|
},
|
||||||
)
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it.instance("migrates autoshare to share field", () =>
|
test("migrates autoshare to share field", async () => {
|
||||||
Effect.gen(function* () {
|
await using tmp = await tmpdir({
|
||||||
const test = yield* TestInstance
|
init: async (dir) => {
|
||||||
yield* writeConfigEffect(test.directory, {
|
await Filesystem.write(
|
||||||
|
path.join(dir, "opencode.json"),
|
||||||
|
JSON.stringify({
|
||||||
$schema: "https://opencode.ai/config.json",
|
$schema: "https://opencode.ai/config.json",
|
||||||
autoshare: true,
|
autoshare: true,
|
||||||
})
|
|
||||||
const config = yield* Config.Service.use((svc) => svc.get())
|
|
||||||
expect(config.share).toBe("auto")
|
|
||||||
expect(config.autoshare).toBe(true)
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await withTestInstance({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async (ctx) => {
|
||||||
|
const config = await load(ctx)
|
||||||
|
expect(config.share).toBe("auto")
|
||||||
|
expect(config.autoshare).toBe(true)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it.instance("migrates mode field to agent field", () =>
|
test("migrates mode field to agent field", async () => {
|
||||||
Effect.gen(function* () {
|
await using tmp = await tmpdir({
|
||||||
const test = yield* TestInstance
|
init: async (dir) => {
|
||||||
yield* writeConfigEffect(test.directory, {
|
await Filesystem.write(
|
||||||
|
path.join(dir, "opencode.json"),
|
||||||
|
JSON.stringify({
|
||||||
$schema: "https://opencode.ai/config.json",
|
$schema: "https://opencode.ai/config.json",
|
||||||
mode: {
|
mode: {
|
||||||
test_mode: {
|
test_mode: {
|
||||||
@@ -661,8 +696,14 @@ it.instance("migrates mode field to agent field", () =>
|
|||||||
temperature: 0.5,
|
temperature: 0.5,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
})
|
})
|
||||||
const config = yield* Config.Service.use((svc) => svc.get())
|
await withTestInstance({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async (ctx) => {
|
||||||
|
const config = await load(ctx)
|
||||||
expect(config.agent?.["test_mode"]).toEqual({
|
expect(config.agent?.["test_mode"]).toEqual({
|
||||||
model: "test/model",
|
model: "test/model",
|
||||||
temperature: 0.5,
|
temperature: 0.5,
|
||||||
@@ -670,22 +711,31 @@ it.instance("migrates mode field to agent field", () =>
|
|||||||
options: {},
|
options: {},
|
||||||
permission: {},
|
permission: {},
|
||||||
})
|
})
|
||||||
}),
|
},
|
||||||
)
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it.instance("loads config from .opencode directory", () =>
|
test("loads config from .opencode directory", async () => {
|
||||||
Effect.gen(function* () {
|
await using tmp = await tmpdir({
|
||||||
const test = yield* TestInstance
|
init: async (dir) => {
|
||||||
yield* mkdirEffect(path.join(test.directory, ".opencode", "agent"))
|
const opencodeDir = path.join(dir, ".opencode")
|
||||||
yield* writeTextEffect(
|
await fs.mkdir(opencodeDir, { recursive: true })
|
||||||
path.join(test.directory, ".opencode", "agent", "test.md"),
|
const agentDir = path.join(opencodeDir, "agent")
|
||||||
|
await fs.mkdir(agentDir, { recursive: true })
|
||||||
|
|
||||||
|
await Filesystem.write(
|
||||||
|
path.join(agentDir, "test.md"),
|
||||||
`---
|
`---
|
||||||
model: test/model
|
model: test/model
|
||||||
---
|
---
|
||||||
Test agent prompt`,
|
Test agent prompt`,
|
||||||
)
|
)
|
||||||
|
},
|
||||||
const config = yield* Config.Service.use((svc) => svc.get())
|
})
|
||||||
|
await withTestInstance({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async (ctx) => {
|
||||||
|
const config = await load(ctx)
|
||||||
expect(config.agent?.["test"]).toEqual(
|
expect(config.agent?.["test"]).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
name: "test",
|
name: "test",
|
||||||
@@ -693,15 +743,18 @@ Test agent prompt`,
|
|||||||
prompt: "Test agent prompt",
|
prompt: "Test agent prompt",
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}),
|
},
|
||||||
)
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it.instance("agent markdown permission config preserves user key order", () =>
|
test("agent markdown permission config preserves user key order", async () => {
|
||||||
Effect.gen(function* () {
|
await using tmp = await tmpdir({
|
||||||
const test = yield* TestInstance
|
init: async (dir) => {
|
||||||
yield* mkdirEffect(path.join(test.directory, ".opencode", "agent"))
|
const agentDir = path.join(dir, ".opencode", "agent")
|
||||||
yield* writeTextEffect(
|
await fs.mkdir(agentDir, { recursive: true })
|
||||||
path.join(test.directory, ".opencode", "agent", "ordered.md"),
|
|
||||||
|
await Filesystem.write(
|
||||||
|
path.join(agentDir, "ordered.md"),
|
||||||
`---
|
`---
|
||||||
permission:
|
permission:
|
||||||
bash: allow
|
bash: allow
|
||||||
@@ -710,18 +763,28 @@ permission:
|
|||||||
---
|
---
|
||||||
Ordered permissions`,
|
Ordered permissions`,
|
||||||
)
|
)
|
||||||
|
},
|
||||||
const config = yield* Config.Service.use((svc) => svc.get())
|
})
|
||||||
|
await withTestInstance({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async (ctx) => {
|
||||||
|
const config = await load(ctx)
|
||||||
expect(Object.keys(config.agent?.ordered?.permission ?? {})).toEqual(["bash", "*", "edit"])
|
expect(Object.keys(config.agent?.ordered?.permission ?? {})).toEqual(["bash", "*", "edit"])
|
||||||
}),
|
},
|
||||||
)
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it.instance("loads agents from .opencode/agents (plural)", () =>
|
test("loads agents from .opencode/agents (plural)", async () => {
|
||||||
Effect.gen(function* () {
|
await using tmp = await tmpdir({
|
||||||
const test = yield* TestInstance
|
init: async (dir) => {
|
||||||
yield* mkdirEffect(path.join(test.directory, ".opencode", "agents", "nested"))
|
const opencodeDir = path.join(dir, ".opencode")
|
||||||
yield* writeTextEffect(
|
await fs.mkdir(opencodeDir, { recursive: true })
|
||||||
path.join(test.directory, ".opencode", "agents", "helper.md"),
|
|
||||||
|
const agentsDir = path.join(opencodeDir, "agents")
|
||||||
|
await fs.mkdir(path.join(agentsDir, "nested"), { recursive: true })
|
||||||
|
|
||||||
|
await Filesystem.write(
|
||||||
|
path.join(agentsDir, "helper.md"),
|
||||||
`---
|
`---
|
||||||
model: test/model
|
model: test/model
|
||||||
mode: subagent
|
mode: subagent
|
||||||
@@ -729,16 +792,21 @@ mode: subagent
|
|||||||
Helper agent prompt`,
|
Helper agent prompt`,
|
||||||
)
|
)
|
||||||
|
|
||||||
yield* writeTextEffect(
|
await Filesystem.write(
|
||||||
path.join(test.directory, ".opencode", "agents", "nested", "child.md"),
|
path.join(agentsDir, "nested", "child.md"),
|
||||||
`---
|
`---
|
||||||
model: test/model
|
model: test/model
|
||||||
mode: subagent
|
mode: subagent
|
||||||
---
|
---
|
||||||
Nested agent prompt`,
|
Nested agent prompt`,
|
||||||
)
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const config = yield* Config.Service.use((svc) => svc.get())
|
await withTestInstance({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async (ctx) => {
|
||||||
|
const config = await load(ctx)
|
||||||
|
|
||||||
expect(config.agent?.["helper"]).toMatchObject({
|
expect(config.agent?.["helper"]).toMatchObject({
|
||||||
name: "helper",
|
name: "helper",
|
||||||
@@ -753,30 +821,41 @@ Nested agent prompt`,
|
|||||||
mode: "subagent",
|
mode: "subagent",
|
||||||
prompt: "Nested agent prompt",
|
prompt: "Nested agent prompt",
|
||||||
})
|
})
|
||||||
}),
|
},
|
||||||
)
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it.instance("loads commands from .opencode/command (singular)", () =>
|
test("loads commands from .opencode/command (singular)", async () => {
|
||||||
Effect.gen(function* () {
|
await using tmp = await tmpdir({
|
||||||
const test = yield* TestInstance
|
init: async (dir) => {
|
||||||
yield* mkdirEffect(path.join(test.directory, ".opencode", "command", "nested"))
|
const opencodeDir = path.join(dir, ".opencode")
|
||||||
yield* writeTextEffect(
|
await fs.mkdir(opencodeDir, { recursive: true })
|
||||||
path.join(test.directory, ".opencode", "command", "hello.md"),
|
|
||||||
|
const commandDir = path.join(opencodeDir, "command")
|
||||||
|
await fs.mkdir(path.join(commandDir, "nested"), { recursive: true })
|
||||||
|
|
||||||
|
await Filesystem.write(
|
||||||
|
path.join(commandDir, "hello.md"),
|
||||||
`---
|
`---
|
||||||
description: Test command
|
description: Test command
|
||||||
---
|
---
|
||||||
Hello from singular command`,
|
Hello from singular command`,
|
||||||
)
|
)
|
||||||
|
|
||||||
yield* writeTextEffect(
|
await Filesystem.write(
|
||||||
path.join(test.directory, ".opencode", "command", "nested", "child.md"),
|
path.join(commandDir, "nested", "child.md"),
|
||||||
`---
|
`---
|
||||||
description: Nested command
|
description: Nested command
|
||||||
---
|
---
|
||||||
Nested command template`,
|
Nested command template`,
|
||||||
)
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const config = yield* Config.Service.use((svc) => svc.get())
|
await withTestInstance({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async (ctx) => {
|
||||||
|
const config = await load(ctx)
|
||||||
|
|
||||||
expect(config.command?.["hello"]).toEqual({
|
expect(config.command?.["hello"]).toEqual({
|
||||||
description: "Test command",
|
description: "Test command",
|
||||||
@@ -787,30 +866,41 @@ Nested command template`,
|
|||||||
description: "Nested command",
|
description: "Nested command",
|
||||||
template: "Nested command template",
|
template: "Nested command template",
|
||||||
})
|
})
|
||||||
}),
|
},
|
||||||
)
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it.instance("loads commands from .opencode/commands (plural)", () =>
|
test("loads commands from .opencode/commands (plural)", async () => {
|
||||||
Effect.gen(function* () {
|
await using tmp = await tmpdir({
|
||||||
const test = yield* TestInstance
|
init: async (dir) => {
|
||||||
yield* mkdirEffect(path.join(test.directory, ".opencode", "commands", "nested"))
|
const opencodeDir = path.join(dir, ".opencode")
|
||||||
yield* writeTextEffect(
|
await fs.mkdir(opencodeDir, { recursive: true })
|
||||||
path.join(test.directory, ".opencode", "commands", "hello.md"),
|
|
||||||
|
const commandsDir = path.join(opencodeDir, "commands")
|
||||||
|
await fs.mkdir(path.join(commandsDir, "nested"), { recursive: true })
|
||||||
|
|
||||||
|
await Filesystem.write(
|
||||||
|
path.join(commandsDir, "hello.md"),
|
||||||
`---
|
`---
|
||||||
description: Test command
|
description: Test command
|
||||||
---
|
---
|
||||||
Hello from plural commands`,
|
Hello from plural commands`,
|
||||||
)
|
)
|
||||||
|
|
||||||
yield* writeTextEffect(
|
await Filesystem.write(
|
||||||
path.join(test.directory, ".opencode", "commands", "nested", "child.md"),
|
path.join(commandsDir, "nested", "child.md"),
|
||||||
`---
|
`---
|
||||||
description: Nested command
|
description: Nested command
|
||||||
---
|
---
|
||||||
Nested command template`,
|
Nested command template`,
|
||||||
)
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const config = yield* Config.Service.use((svc) => svc.get())
|
await withTestInstance({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async (ctx) => {
|
||||||
|
const config = await load(ctx)
|
||||||
|
|
||||||
expect(config.command?.["hello"]).toEqual({
|
expect(config.command?.["hello"]).toEqual({
|
||||||
description: "Test command",
|
description: "Test command",
|
||||||
@@ -821,29 +911,34 @@ Nested command template`,
|
|||||||
description: "Nested command",
|
description: "Nested command",
|
||||||
template: "Nested command template",
|
template: "Nested command template",
|
||||||
})
|
})
|
||||||
}),
|
},
|
||||||
)
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it.instance("updates config and writes to file", () =>
|
test("updates config and writes to file", async () => {
|
||||||
Effect.gen(function* () {
|
await using tmp = await tmpdir()
|
||||||
const test = yield* TestInstance
|
await withTestInstance({
|
||||||
yield* Config.Service.use((svc) =>
|
directory: tmp.path,
|
||||||
svc.update(ConfigParse.schema(Config.Info, { model: "updated/model" }, "test:config")),
|
fn: async (ctx) => {
|
||||||
)
|
const newConfig = { model: "updated/model" }
|
||||||
|
await save(newConfig as any, ctx)
|
||||||
|
|
||||||
const writtenConfig = yield* Effect.promise(() =>
|
const writtenConfig = await Filesystem.readJson<{ model: string }>(path.join(tmp.path, "config.json"))
|
||||||
Filesystem.readJson<{ model: string }>(path.join(test.directory, "config.json")),
|
|
||||||
)
|
|
||||||
expect(writtenConfig.model).toBe("updated/model")
|
expect(writtenConfig.model).toBe("updated/model")
|
||||||
}),
|
},
|
||||||
)
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it.instance("gets config directories", () =>
|
test("gets config directories", async () => {
|
||||||
Effect.gen(function* () {
|
await using tmp = await tmpdir()
|
||||||
const dirs = yield* Config.Service.use((svc) => svc.directories())
|
await withTestInstance({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async (ctx) => {
|
||||||
|
const dirs = await listDirs(ctx)
|
||||||
expect(dirs.length).toBeGreaterThanOrEqual(1)
|
expect(dirs.length).toBeGreaterThanOrEqual(1)
|
||||||
}),
|
},
|
||||||
)
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test("does not try to install dependencies in read-only OPENCODE_CONFIG_DIR", async () => {
|
test("does not try to install dependencies in read-only OPENCODE_CONFIG_DIR", async () => {
|
||||||
if (process.platform === "win32") return
|
if (process.platform === "win32") return
|
||||||
|
|||||||
@@ -1,309 +0,0 @@
|
|||||||
// 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 { Deferred, Duration, Effect, Layer, Scope, Stream } from "effect"
|
|
||||||
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
|
|
||||||
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[]
|
|
||||||
}
|
|
||||||
|
|
||||||
// `opencode serve` is a long-lived process — it never exits on its own.
|
|
||||||
// `serve(opts)` therefore returns a handle inside the caller's Scope: the
|
|
||||||
// subprocess is killed when the scope closes (test end), and the URL the
|
|
||||||
// server actually bound to (port 0 means OS-assigned) is parsed off stdout.
|
|
||||||
export type ServeOpts = SpawnOpts & {
|
|
||||||
readonly port?: number
|
|
||||||
readonly hostname?: string
|
|
||||||
readonly extraArgs?: string[]
|
|
||||||
// How long to wait for the "listening on http://..." line before failing.
|
|
||||||
// Default 15s — startup is dominated by bun's transpile + plugin init, not
|
|
||||||
// the actual listen() call.
|
|
||||||
readonly readyTimeoutMs?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ServeHandle = {
|
|
||||||
// Full URL the server is bound to, e.g. "http://127.0.0.1:54321". Use this
|
|
||||||
// as the base for HTTP requests in tests — never assume the port.
|
|
||||||
readonly url: string
|
|
||||||
readonly hostname: string
|
|
||||||
readonly port: number
|
|
||||||
// Sends SIGTERM. The scope finalizer also calls this, so tests rarely need
|
|
||||||
// to invoke it directly — useful for tests that assert exit behavior.
|
|
||||||
readonly kill: () => void
|
|
||||||
// Resolves with the exit code once the process exits. Bun returns a number.
|
|
||||||
readonly exited: Promise<number>
|
|
||||||
}
|
|
||||||
|
|
||||||
export type OpencodeCli = {
|
|
||||||
// High-level: run a single prompt against the test model. Short-lived.
|
|
||||||
readonly run: (message: string, opts?: RunOpts) => Effect.Effect<RunResult>
|
|
||||||
// Spawn `opencode serve` and wait until it's listening. Long-lived: the
|
|
||||||
// returned handle is killed when the caller's Scope closes. Fails if the
|
|
||||||
// listening line doesn't appear within `readyTimeoutMs`.
|
|
||||||
readonly serve: (opts?: ServeOpts) => Effect.Effect<ServeHandle, Error, Scope.Scope>
|
|
||||||
// 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>>
|
|
||||||
}
|
|
||||||
|
|
||||||
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, Scope.Scope | HttpClient.HttpClient>,
|
|
||||||
): 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 serve = Effect.fn("opencode.serve")(function* (opts?: ServeOpts) {
|
|
||||||
const argv = ["serve"]
|
|
||||||
// Default port 0 — let the OS pick a free port, parse the actual one
|
|
||||||
// off stdout. Hard-coded ports flake under parallel tests.
|
|
||||||
argv.push("--port", String(opts?.port ?? 0))
|
|
||||||
if (opts?.hostname) argv.push("--hostname", opts.hostname)
|
|
||||||
if (opts?.extraArgs) argv.push(...opts.extraArgs)
|
|
||||||
|
|
||||||
// Acquire the subprocess; release sends SIGTERM and awaits exit on
|
|
||||||
// scope close. Wrapped in Effect.ignore so a flaky kill doesn't surface
|
|
||||||
// as a finalizer error during test teardown.
|
|
||||||
const proc = yield* Effect.acquireRelease(
|
|
||||||
Effect.sync(() =>
|
|
||||||
Bun.spawn(["bun", "run", "--conditions=browser", cliEntry, ...argv], {
|
|
||||||
cwd: home,
|
|
||||||
env: { ...process.env, ...env, ...opts?.env },
|
|
||||||
stdout: "pipe",
|
|
||||||
stderr: "pipe",
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
(p) =>
|
|
||||||
Effect.promise(() => {
|
|
||||||
p.kill()
|
|
||||||
return p.exited
|
|
||||||
}).pipe(Effect.ignore),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Drain stderr in a scope-bound fork. Without this the OS pipe buffer
|
|
||||||
// eventually fills and the child blocks on its next log call. Kept as a
|
|
||||||
// tail buffer so timeout failures can include context.
|
|
||||||
const stderrChunks: string[] = []
|
|
||||||
yield* Effect.forkScoped(
|
|
||||||
Stream.fromReadableStream({
|
|
||||||
evaluate: () => proc.stderr,
|
|
||||||
onError: () => new Error("stderr stream error"),
|
|
||||||
}).pipe(
|
|
||||||
Stream.decodeText(),
|
|
||||||
Stream.runForEach((chunk) => Effect.sync(() => stderrChunks.push(chunk))),
|
|
||||||
Effect.ignore,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Watch stdout line-by-line for the listening sentinel. Format
|
|
||||||
// (see src/cli/cmd/serve.ts):
|
|
||||||
// "opencode server listening on http://<host>:<port>"
|
|
||||||
const readyRe = /listening on (http:\/\/([^\s:]+):(\d+))/
|
|
||||||
const readyDeferred = yield* Deferred.make<{ url: string; hostname: string; port: number }>()
|
|
||||||
yield* Effect.forkScoped(
|
|
||||||
Stream.fromReadableStream({
|
|
||||||
evaluate: () => proc.stdout,
|
|
||||||
onError: () => new Error("stdout stream error"),
|
|
||||||
}).pipe(
|
|
||||||
Stream.decodeText(),
|
|
||||||
Stream.splitLines,
|
|
||||||
Stream.runForEach((line) => {
|
|
||||||
const m = line.match(readyRe)
|
|
||||||
return m ? Deferred.succeed(readyDeferred, { url: m[1], hostname: m[2], port: Number(m[3]) }) : Effect.void
|
|
||||||
}),
|
|
||||||
Effect.ignore,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const readyTimeoutMs = opts?.readyTimeoutMs ?? 15_000
|
|
||||||
const match = yield* Deferred.await(readyDeferred).pipe(
|
|
||||||
Effect.timeoutOrElse({
|
|
||||||
duration: Duration.millis(readyTimeoutMs),
|
|
||||||
orElse: () =>
|
|
||||||
Effect.fail(
|
|
||||||
new Error(
|
|
||||||
`opencode serve did not become ready within ${readyTimeoutMs}ms\n` +
|
|
||||||
`stderr (last 2000):\n${stderrChunks.join("").slice(-2000)}`,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
url: match.url,
|
|
||||||
hostname: match.hostname,
|
|
||||||
port: match.port,
|
|
||||||
kill: () => {
|
|
||||||
proc.kill()
|
|
||||||
},
|
|
||||||
exited: proc.exited as Promise<number>,
|
|
||||||
} satisfies ServeHandle
|
|
||||||
})
|
|
||||||
|
|
||||||
const opencode: OpencodeCli = { run, serve, spawn, expectExit, parseJsonEvents }
|
|
||||||
|
|
||||||
return yield* fn({ llm, home, opencode })
|
|
||||||
// FetchHttpClient is provided so test bodies can `yield* HttpClient.HttpClient`
|
|
||||||
// and hit endpoints on `opencode.serve()` without rolling their own fetch.
|
|
||||||
}).pipe(Effect.provide(Layer.mergeAll(TestLLMServer.layer, FetchHttpClient.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.
|
|
||||||
// Body's R is `Scope.Scope | never` so tests can yield* scope-requiring
|
|
||||||
// resources (e.g. `opencode.serve`) without an extra `Effect.scoped` wrapper —
|
|
||||||
// `withCliFixture`'s outer scope is the natural lifetime.
|
|
||||||
export const cliIt = {
|
|
||||||
live: <A, E>(
|
|
||||||
name: string,
|
|
||||||
body: (input: CliFixture) => Effect.Effect<A, E, Scope.Scope | HttpClient.HttpClient>,
|
|
||||||
opts?: number | TestOptions,
|
|
||||||
) => it.live(name, () => withCliFixture(body), opts),
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
// 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
-4
@@ -52,7 +52,7 @@ Repeated setup work, long sleeps/timeouts, serial integration tests, filesystem/
|
|||||||
## Hypothesis Loop
|
## Hypothesis Loop
|
||||||
|
|
||||||
| Hypothesis | Change | Before | After | Decision | Notes |
|
| 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. |
|
| 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. |
|
| 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. |
|
| `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. |
|
||||||
@@ -73,9 +73,6 @@ Repeated setup work, long sleeps/timeouts, serial integration tests, filesystem/
|
|||||||
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
|
||||||
| `.opencode` agent and command file-loading cases can use Effect-aware instance fixtures | Migrated singular/plural agent and command markdown fixture cases to `it.instance` | 7.21s | 1.87s | keep | Parent baseline was noisy (7.42, 7.21, 2.83); after runs were stable at 1.87, 1.98, 1.83. Keep as cleanup with no broad claim. |
|
|
||||||
|
|
||||||
## Profiling Results
|
## Profiling Results
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user