Compare commits

...

6 Commits

Author SHA1 Message Date
Kit Langton 4aa2a47ee2 test(cli): tier-A read-only command smoke tests
Adds smoke tests for the seven read-only commands that share the
run-shaped lifecycle:

  - mcp list
  - providers list
  - models
  - agent list
  - session list
  - stats
  - db path

Each test asserts only that the command exits 0 and produces sane
output in the harness's isolated env. They're not behavioral tests —
they're the cheapest possible signal that the dependency-layer wiring
(config load, DB init, server boot, provider resolution) doesn't crash
for the broad class of "no inputs, no side effects" commands. A
regression in any shared layer will fail one or more of these tests.

Pushes behavioral coverage of the CLI surface from ~14% to ~50%.

Findings while wiring assertions:

  - \`providers list\` reflects credentials + env vars, not
    config-injected static providers. The assertion verifies the
    section headers ("Credentials" / "Environment") to lock in the
    output shape.
  - \`db path\` returns SQLite's \`:memory:\` under harness isolation
    (no on-disk pollution between tests). The assertion accepts either
    \`:memory:\` or a real path.

7/7 pass in ~7s; full CLI suite stays green at 331/331.
2026-05-18 21:23:55 -04:00
Kit Langton af1e67d850 test(cli): stabilize acp --help snapshot against random home path length
yargs wraps the \`[string] [default: "..."]\` clause based on the
pre-normalized default value's character length, so a different random
tmpdir width produces a different leading-whitespace count on the
wrapped continuation line. After normalizing the path to \`<HOME>\` we
were left with a one-space drift between runs.

Collapse the wrap-dependent whitespace immediately before the clause
so the snapshot is byte-stable regardless of home path length.

Verified by deleting the .snap and regenerating across 3 runs.
2026-05-18 21:22:59 -04:00
Kit Langton 13e68a5892 refactor(test/lib): simplify cli-process helpers per Effect idioms
Applied review findings from the simplify pass:

1. Extract fromBunStream(name, get) and forkStderrDrain(stream, into)
   helpers — 4 duplicated Stream.fromReadableStream call sites collapse
   to one factory, and the identical stderr drain across serve/acp is
   now a single helper. Error messages now include the underlying cause
   instead of swallowing it.

2. acp.send awaits proc.stdin.write's backpressure promise. The bare
   Effect.sync was discarding the Promise<number> form, which can
   reorder ndjson lines under pipe-buffer-full conditions and corrupt
   framing.

3. acp.close drops the try/catch around proc.stdin.end() — idempotent
   in Bun, the bare catch only masked future regressions.

4. Effect.ignore on stream drains is now Effect.ignore({ log: true })
   so a real protocol or decode error surfaces in test debug output
   instead of disappearing silently.

5. Help-snapshots replaces the manual failures[] accumulator + continue
   with Effect.partition. Same behavior, no mutable state, declarative.

324/324 CLI tests stay green; typecheck clean.
2026-05-18 21:11:29 -04:00
Kit Langton 8db5e421ab test(cli): help-text snapshots for every CLI command
One test file. Spawns `opencode <cmd> --help` for every documented
command + key subcommand (35 in total) in parallel under concurrency:8,
snapshots the stderr output (yargs writes --help to stderr, not stdout).

Snapshots are normalized — the tmpdir prefix that bleeds through
`acp --cwd`'s default is rewritten to `<HOME>` so test runs in
different sandboxes stay stable. macOS `/private` realpath form and
the unresolved `os.tmpdir()` form both covered.

Pinned snapshots catch flag removals, renames, reordering, and exit-code
regressions across the entire user-facing CLI surface in one place.
Diff in the .snap file is the surface-change report.

Excluded: `opencode completion --help` is a yargs built-in that emits
top-level help and exits 1; not a real opencode command.

~6s wall-clock thanks to parallel spawns.
2026-05-18 21:11:29 -04:00
Kit Langton 8513472542 refactor(test/lib): use Effect.timeoutOrElse for acp release shutdown
Replaces raw Promise.race + setTimeout in the acp builder's release
finalizer with an Effect.gen that expresses the graceful-then-SIGTERM
race using Effect.timeoutOrElse(Duration.seconds(2)). Same behavior,
no leaked timer, consistent with the serve builder.
2026-05-18 21:10:06 -04:00
Kit Langton 29cf700361 test(cli): subprocess integration tests for opencode acp
Adds the second long-lived-command builder to the cli-process harness:
`opencode.acp(opts)` spawns the real CLI in JSON-RPC-over-stdio mode
and returns a duplex handle (`send`/`receive`/`close`/`exited`) scoped
to the test's lifetime. Stdin EOF triggers a clean shutdown; the scope
finalizer also falls back to SIGTERM after 2s for a hung child.

ACP frames each JSON-RPC message as one ndjson line on stdout. The
builder forks a scope-bound `Stream.fromReadableStream` + `splitLines`
pipeline that feeds parsed responses into a `Queue.unbounded`, so tests
can `yield* acp.receive` without worrying about backpressure or framing.

Two smoke tests:
- `initialize` round-trip — sends the protocol handshake from the ACP
  README and asserts the response advertises the same protocolVersion
  and a non-empty agentCapabilities block.
- Clean shutdown on stdin EOF — proves the scope finalizer's stdin.end()
  triggers a graceful exit, not a SIGTERM-fallback exit.
2026-05-18 21:10:06 -04:00
5 changed files with 1075 additions and 20 deletions
@@ -0,0 +1,70 @@
// Subprocess integration tests for `opencode acp`. ACP is a JSON-RPC
// protocol spoken over stdin/stdout (not HTTP) — see src/acp/README.md.
// This is the only test tier that exercises the full pipe of bun startup →
// server boot → ACP agent init → stdio framing → graceful shutdown.
import { describe, expect } from "bun:test"
import { Duration, Effect } from "effect"
import { cliIt } from "../../lib/cli-process"
describe("opencode acp (subprocess)", () => {
// Smoke test: send the `initialize` request from src/acp/README.md and
// assert the response advertises the same protocol version and a non-empty
// capabilities block. If this fails, every other ACP test will too — start
// debugging here.
cliIt.live(
"responds to initialize with protocolVersion 1 and capabilities",
({ opencode }) =>
Effect.gen(function* () {
const acp = yield* opencode.acp()
yield* acp.send({
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: { protocolVersion: 1 },
})
// Tight deadline — the response should arrive within a few seconds
// once startup completes. A hang means the agent never finished init,
// which is a real regression and not a tuning issue.
const response = (yield* acp.receive.pipe(Effect.timeout(Duration.seconds(10)))) as {
jsonrpc: string
id: number
result?: { protocolVersion: number; agentCapabilities: Record<string, unknown> }
error?: unknown
}
expect(response.jsonrpc).toBe("2.0")
expect(response.id).toBe(1)
expect(response.error).toBeUndefined()
expect(response.result?.protocolVersion).toBe(1)
expect(response.result?.agentCapabilities).toBeDefined()
}),
60_000,
)
// Lock in the scope-close kill path. ACP's clean shutdown is "EOF on stdin"
// — if a future refactor breaks the stdin-end branch in the handler, the
// process would only exit on SIGTERM fallback (2s in the harness). This
// test passing within the inner-scope assertion proves the EOF path works.
cliIt.live(
"exits cleanly when stdin is closed (scope close)",
({ opencode }) =>
Effect.gen(function* () {
const exitedPromise = yield* Effect.scoped(
Effect.gen(function* () {
const acp = yield* opencode.acp()
// Capture the Promise — scope-close fires the finalizer which
// ends stdin, and ACP should exit gracefully.
return acp.exited
}),
)
const code = yield* Effect.promise(() => exitedPromise)
// Bun returns a number for normal exit. Anything goes for SIGTERM,
// but we still require resolution within the test timeout.
expect(typeof code === "number" || code === null).toBe(true)
}),
60_000,
)
})
@@ -0,0 +1,623 @@
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode acp --help 1`] = `
"opencode acp
start ACP (Agent Client Protocol) server
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]
--port port to listen on [number] [default: 0]
--hostname hostname to listen on [string] [default: "127.0.0.1"]
--mdns enable mDNS service discovery (defaults hostname to 0.0.0.0)
[boolean] [default: false]
--mdns-domain custom domain name for mDNS service (default: opencode.local)
[string] [default: "opencode.local"]
--cors additional domains to allow for CORS [array] [default: []]
--cwd working directory [string] [default: "<HOME>"]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode mcp --help 1`] = `
"opencode mcp
manage MCP (Model Context Protocol) servers
Commands:
opencode mcp add add an MCP server
opencode mcp list list MCP servers and their status [aliases: ls]
opencode mcp auth [name] authenticate with an OAuth-enabled MCP server
opencode mcp logout [name] remove OAuth credentials for an MCP server
opencode mcp debug <name> debug OAuth connection for an MCP server
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode attach --help 1`] = `
"opencode attach <url>
attach to a running opencode server
Positionals:
url http://localhost:4096 [string] [required]
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]
--dir directory to run in [string]
-c, --continue continue the last session [boolean]
-s, --session session id to continue [string]
--fork fork the session when continuing (use with --continue or --session) [boolean]
-p, --password basic auth password (defaults to OPENCODE_SERVER_PASSWORD) [string]
-u, --username basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')[string]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode run --help 1`] = `
"opencode run [message..]
run opencode with a message
Positionals:
message message to send [array] [default: []]
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]
--command the command to run, use message for args [string]
-c, --continue continue the last session [boolean]
-s, --session session id to continue [string]
--fork fork the session before continuing (requires --continue or
--session) [boolean]
--share share the session [boolean]
-m, --model model to use in the format of provider/model [string]
--agent agent to use [string]
--format format: default (formatted) or json (raw JSON events)
[string] [choices: "default", "json"] [default: "default"]
-f, --file file(s) to attach to message [array]
--title title for the session (uses truncated prompt if no value
provided) [string]
--attach attach to a running opencode server (e.g.,
http://localhost:4096) [string]
-p, --password basic auth password (defaults to OPENCODE_SERVER_PASSWORD)
[string]
-u, --username basic auth username (defaults to OPENCODE_SERVER_USERNAME or
'opencode') [string]
--dir directory to run in, path on remote server if attaching
[string]
--port port for the local server (defaults to random port if no value
provided) [number]
--variant model variant (provider-specific reasoning effort, e.g., high,
max, minimal) [string]
--thinking show thinking blocks [boolean]
--replay replay visible session history on interactive resume
[boolean] [default: false]
--replay-limit cap visible interactive replay to the newest N messages
[number]
-i, --interactive run in direct interactive split-footer mode
[boolean] [default: false]
--dangerously-skip-permissions auto-approve permissions that are not explicitly denied
(dangerous!) [boolean] [default: false]
--demo enable direct interactive demo slash commands; pass one as the
message to run it immediately [boolean] [default: false]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode debug --help 1`] = `
"opencode debug
debugging and troubleshooting tools
Commands:
opencode debug config show resolved configuration
opencode debug lsp LSP debugging utilities
opencode debug rg ripgrep debugging utilities
opencode debug file file system debugging utilities
opencode debug scrap list all known projects
opencode debug skill list all available skills
opencode debug snapshot snapshot debugging utilities
opencode debug startup print startup timing
opencode debug agent <name> show agent configuration details
opencode debug v2 debug v2 catalog and built-in plugins
opencode debug info show debug information
opencode debug paths show global paths (data, config, cache, state)
opencode debug wait wait indefinitely (for debugging)
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode providers --help 1`] = `
"opencode providers
manage AI providers and credentials
Commands:
opencode providers list list providers and credentials [aliases: ls]
opencode providers login [url] log in to a provider
opencode providers logout log out from a configured provider
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode agent --help 1`] = `
"opencode agent
manage agents
Commands:
opencode agent create create a new agent
opencode agent list list all available agents
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode upgrade --help 1`] = `
"opencode upgrade [target]
upgrade opencode to the latest or a specific version
Positionals:
target version to upgrade to, for ex '0.1.48' or 'v0.1.48' [string]
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]
-m, --method installation method to use
[string] [choices: "curl", "npm", "pnpm", "bun", "brew", "choco", "scoop"]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode uninstall --help 1`] = `
"opencode uninstall
uninstall opencode and remove all related files
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]
-c, --keep-config keep configuration files [boolean] [default: false]
-d, --keep-data keep session data and snapshots [boolean] [default: false]
--dry-run show what would be removed without removing [boolean] [default: false]
-f, --force skip confirmation prompts [boolean] [default: false]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode serve --help 1`] = `
"opencode serve
starts a headless opencode server
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]
--port port to listen on [number] [default: 0]
--hostname hostname to listen on [string] [default: "127.0.0.1"]
--mdns enable mDNS service discovery (defaults hostname to 0.0.0.0)
[boolean] [default: false]
--mdns-domain custom domain name for mDNS service (default: opencode.local)
[string] [default: "opencode.local"]
--cors additional domains to allow for CORS [array] [default: []]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode web --help 1`] = `
"opencode web
start opencode server and open web interface
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]
--port port to listen on [number] [default: 0]
--hostname hostname to listen on [string] [default: "127.0.0.1"]
--mdns enable mDNS service discovery (defaults hostname to 0.0.0.0)
[boolean] [default: false]
--mdns-domain custom domain name for mDNS service (default: opencode.local)
[string] [default: "opencode.local"]
--cors additional domains to allow for CORS [array] [default: []]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode models --help 1`] = `
"opencode models [provider]
list all available models
Positionals:
provider provider ID to filter models by [string]
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]
--verbose use more verbose model output (includes metadata like costs) [boolean]
--refresh refresh the models cache from models.dev [boolean]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode stats --help 1`] = `
"opencode stats
show token usage and cost statistics
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]
--days show stats for the last N days (default: all time) [number]
--tools number of tools to show (default: all) [number]
--models show model statistics (default: hidden). Pass a number to show top N, otherwise
shows all
--project filter by project (default: all projects, empty string: current project)[string]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode export --help 1`] = `
"opencode export [sessionID]
export session data as JSON
Positionals:
sessionID session id to export [string]
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]
--sanitize redact sensitive transcript and file data [boolean]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode import --help 1`] = `
"opencode import <file>
import session data from JSON file or URL
Positionals:
file path to JSON file or share URL [string] [required]
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode github --help 1`] = `
"opencode github
manage GitHub agent
Commands:
opencode github install install the GitHub agent
opencode github run run the GitHub agent
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode pr --help 1`] = `
"opencode pr <number>
fetch and checkout a GitHub PR branch, then run opencode
Positionals:
number PR number to checkout [number] [required]
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode session --help 1`] = `
"opencode session
manage sessions
Commands:
opencode session list list sessions
opencode session delete <sessionID> delete a session
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode plugin --help 1`] = `
"opencode plugin <module>
install plugin and update config
Positionals:
module npm module name [string] [required]
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]
-g, --global install in global config [boolean] [default: false]
-f, --force replace existing plugin version [boolean] [default: false]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode db --help 1`] = `
"opencode db
database tools
Commands:
opencode db [query] open an interactive sqlite3 shell or run a query [default]
opencode db path print the database path
opencode db migrate migrate JSON data to SQLite (merges with existing data)
Positionals:
query SQL query to execute [string]
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]
--format Output format [string] [choices: "json", "tsv"] [default: "tsv"]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode mcp list --help 1`] = `
"opencode mcp list
list MCP servers and their status
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode mcp add --help 1`] = `
"opencode mcp add
add an MCP server
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode mcp auth --help 1`] = `
"opencode mcp auth [name]
authenticate with an OAuth-enabled MCP server
Commands:
opencode mcp auth list list OAuth-capable MCP servers and their auth status [aliases: ls]
Positionals:
name name of the MCP server [string]
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode mcp logout --help 1`] = `
"opencode mcp logout [name]
remove OAuth credentials for an MCP server
Positionals:
name name of the MCP server [string]
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode providers list --help 1`] = `
"opencode providers list
list providers and credentials
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode providers login --help 1`] = `
"opencode providers login [url]
log in to a provider
Positionals:
url opencode auth provider [string]
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]
-p, --provider provider id or name to log in to (skips provider selection) [string]
-m, --method login method label (skips method selection) [string]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode providers logout --help 1`] = `
"opencode providers logout
log out from a configured provider
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode agent create --help 1`] = `
"opencode agent create
create a new agent
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]
--path directory path to generate the agent file [string]
--description what the agent should do [string]
--mode agent mode [string] [choices: "all", "primary", "subagent"]
--permissions, --tools comma-separated list of permissions to allow (default: all).
Available: "bash, read, edit, glob, grep, webfetch, task, todowrite,
websearch, lsp, skill" [string]
-m, --model model to use in the format of provider/model [string]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode agent list --help 1`] = `
"opencode agent list
list all available agents
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode session list --help 1`] = `
"opencode session list
list sessions
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]
-n, --max-count limit to N most recent sessions [number]
--format output format [string] [choices: "table", "json"] [default: "table"]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode session delete --help 1`] = `
"opencode session delete <sessionID>
delete a session
Positionals:
sessionID session ID to delete [string] [required]
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode github install --help 1`] = `
"opencode github install
install the GitHub agent
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode github run --help 1`] = `
"opencode github run
run the GitHub agent
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]
--event GitHub mock event to run the agent for [string]
--token GitHub personal access token (github_pat_********) [string]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode db path --help 1`] = `
"opencode db path
print the database path
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]"
`;
@@ -0,0 +1,134 @@
// Help-text snapshots for every CLI command + key subcommand. Catches
// accidental flag removals, renames, and reordering in a single sweep —
// any change to the user-visible CLI surface shows up here as a diff.
//
// This is the broad coverage layer that makes the future Effect CLI
// migration (yargs → effect-smol/cli) safe to attempt: if a refactor
// preserves the surface, the snapshots stay green; if it doesn't, the
// diff tells you exactly which command(s) changed.
//
// Snapshots are taken at COLUMNS=120 so wrapping is stable across
// terminal sizes. The default opencode tui command is excluded —
// `opencode --help` includes an ASCII banner that pulls in the install
// version (changes per release), so we'd snapshot a moving target.
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import fs from "node:fs"
import os from "node:os"
import { cliIt } from "../../lib/cli-process"
// Strips dynamic content that varies per run so snapshots are stable.
// Currently only the tmpdir prefix bleeds in (via `--cwd` defaults that
// resolve to `process.cwd()`). Add new patterns here as they surface.
//
// On macOS `os.tmpdir()` returns `/var/folders/...` but `process.cwd()`
// inside the child returns the realpath `/private/var/folders/...` — so
// we strip both forms.
const TMP = os.tmpdir()
const REAL_TMP = fs.realpathSync(TMP)
function normalize(text: string): string {
return (
text
.replaceAll(REAL_TMP, "<TMPDIR>")
.replaceAll(TMP, "<TMPDIR>")
.replace(/<TMPDIR>\/oc-cli-[a-z0-9]+/g, "<HOME>")
// yargs wraps the `[string] [default: "..."]` clause based on the
// pre-normalized default's character length, so different random home
// path widths produce different leading-whitespace counts on the
// wrapped continuation. Collapse the wrap-dependent whitespace.
.replace(/\s+\[string\] \[default: "<HOME>"\]/g, ' [string] [default: "<HOME>"]')
)
}
// Top-level commands. Order matches what `opencode --help` prints today;
// keep it in that order so the snapshot file reads as a table of contents.
// `completion` is intentionally excluded — it's a yargs built-in that emits
// top-level help on `--help` and exits 1; not a real opencode command.
const TOP_LEVEL = [
"acp",
"mcp",
"attach",
"run",
"debug",
"providers", // aliased to `auth`
"agent",
"upgrade",
"uninstall",
"serve",
"web",
"models",
"stats",
"export",
"import",
"github",
"pr",
"session",
"plugin",
"db",
] as const
// Subcommands worth pinning. Not exhaustive — the goal is one snapshot per
// distinct argv shape, not every leaf. Add new entries when a subcommand
// gains user-visible flags that we want to lock in.
const SUBCOMMANDS = [
["mcp", "list"],
["mcp", "add"],
["mcp", "auth"],
["mcp", "logout"],
["providers", "list"],
["providers", "login"],
["providers", "logout"],
["agent", "create"],
["agent", "list"],
["session", "list"],
["session", "delete"],
["github", "install"],
["github", "run"],
["db", "path"],
] as const
// Fixed wrap width so a developer's terminal doesn't affect snapshots.
// yargs honors COLUMNS; CI runners typically default to 80 which produces
// different wraps from a 200-col local terminal.
const SNAPSHOT_ENV = { COLUMNS: "120" }
describe("opencode CLI help-text snapshots", () => {
// Single test, parallel spawns. Each command's help fires under
// `concurrency: 8` — wall-clock stays under ~10s even for ~35 commands,
// versus ~1 minute if we serialized.
cliIt.live(
"every documented command emits stable help text",
({ opencode }) =>
Effect.gen(function* () {
const argvs: Array<readonly string[]> = [...TOP_LEVEL.map((c) => [c] as const), ...SUBCOMMANDS]
// Spawn in parallel, then assert in argv order so snapshot output is
// deterministic and per-command failures don't abort the rest of
// the sweep. `Effect.partition` is the canonical "run all, separate
// failures from successes" primitive — no mutable accumulator needed.
const [failures, results] = yield* Effect.partition(
argvs,
(argv) =>
Effect.gen(function* () {
const result = yield* opencode.spawn([...argv, "--help"], { env: SNAPSHOT_ENV })
if (result.exitCode !== 0) {
return yield* Effect.fail(`opencode ${argv.join(" ")}: exit ${result.exitCode}`)
}
return { argv, result }
}),
{ concurrency: 8 },
)
for (const { argv, result } of results) {
// yargs writes --help to stderr, not stdout. Snapshotting stderr
// means our test catches the help body; stdout for these commands
// is expected to be empty.
expect(normalize(result.stderr)).toMatchSnapshot(`opencode ${argv.join(" ")} --help`)
}
if (failures.length > 0) {
throw new Error(`Help text failed for:\n ${failures.join("\n ")}`)
}
}),
180_000,
)
})
@@ -0,0 +1,113 @@
// Tier-A smoke tests for read-only commands. Each test asserts only that the
// command exits 0 and produces *some* output in the isolated harness env.
//
// These are not behavioral tests — they're the cheapest possible signal that
// the dependency-layer wiring (config load, DB init, server boot, provider
// resolution) doesn't crash for the broad class of "no inputs, no side
// effects" commands. A regression in any shared layer (an Effect.fail that
// propagates out of a service constructor, a renamed env var, a broken DB
// migration) will fail one or more of these tests.
//
// If a future change should make one of these commands intentionally fail in
// an empty env, update the assertion + add a note explaining the new contract.
//
// Speed: each test pays ~1.5s for bun startup. 7 tests serialize within this
// file. Tracked as task #16 — investigate bun pre-warm if the suite grows.
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { cliIt } from "../../lib/cli-process"
describe("opencode read-only commands (smoke)", () => {
// `mcp list` reads MCP server config and pings each one. With the empty
// OPENCODE_CONFIG_CONTENT={} we provide, no servers should be configured
// and the command should report that cleanly.
cliIt.live(
"mcp list: exits 0",
({ opencode }) =>
Effect.gen(function* () {
const r = yield* opencode.spawn(["mcp", "list"])
opencode.expectExit(r, 0, "mcp list")
}),
60_000,
)
// `providers list` enumerates credentials + env-resolved providers.
// (Not config-injected ones — those don't appear here by design.) We just
// assert it produced the two section headers, which proves the resolver
// walked both sources without crashing.
cliIt.live(
"providers list: exits 0 and prints credential/environment sections",
({ opencode }) =>
Effect.gen(function* () {
const r = yield* opencode.spawn(["providers", "list"])
opencode.expectExit(r, 0, "providers list")
expect(r.stdout).toContain("Credentials")
expect(r.stdout).toContain("Environment")
}),
60_000,
)
// `models` lists models from configured providers. Our test/test-model
// should appear because it's wired into the test provider config.
cliIt.live(
"models: exits 0 and lists the test model",
({ opencode }) =>
Effect.gen(function* () {
const r = yield* opencode.spawn(["models"])
opencode.expectExit(r, 0, "models")
expect(r.stdout).toContain("test/test-model")
}),
60_000,
)
// `agent list` walks the agent config. Empty config means no agents
// configured; the command should still exit 0 with a "no agents" line or
// similar. We don't pin the message — just exit cleanly.
cliIt.live(
"agent list: exits 0",
({ opencode }) =>
Effect.gen(function* () {
const r = yield* opencode.spawn(["agent", "list"])
opencode.expectExit(r, 0, "agent list")
}),
60_000,
)
// `session list` reads the session DB. Fresh OPENCODE_TEST_HOME means
// empty DB. Exit 0 with no sessions.
cliIt.live(
"session list: exits 0",
({ opencode }) =>
Effect.gen(function* () {
const r = yield* opencode.spawn(["session", "list"])
opencode.expectExit(r, 0, "session list")
}),
60_000,
)
// `stats` aggregates token usage from the session DB. Empty DB → all zeros.
cliIt.live(
"stats: exits 0",
({ opencode }) =>
Effect.gen(function* () {
const r = yield* opencode.spawn(["stats"])
opencode.expectExit(r, 0, "stats")
}),
60_000,
)
// `db path` prints the DB file location. Under harness isolation the DB
// resolves to SQLite's `:memory:` (no on-disk pollution between tests);
// in production it'd be a path under OPENCODE_TEST_HOME / XDG_DATA_HOME.
// Accept either form — both prove the resolver ran without crashing.
cliIt.live(
"db path: exits 0 and prints a path or :memory:",
({ opencode }) =>
Effect.gen(function* () {
const r = yield* opencode.spawn(["db", "path"])
opencode.expectExit(r, 0, "db path")
expect(r.stdout.trim()).toMatch(/^(:memory:|[/\\].+\.(db|sqlite|sqlite3))$/i)
}),
60_000,
)
})
+135 -20
View File
@@ -18,7 +18,7 @@
// 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 { Deferred, Duration, Effect, Layer, Queue, Scope, Stream } from "effect"
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
import path from "node:path"
import fs from "node:fs/promises"
@@ -33,6 +33,30 @@ const cliEntry = path.join(opencodeRoot, "src/index.ts")
export const testModelID = "test/test-model"
// Wrap a Bun subprocess pipe (or any ReadableStream<Uint8Array>) as a Stream.
// Centralizes the `evaluate` + `onError` boilerplate and tags errors with the
// stream name so a stderr/stdout failure is greppable in logs.
function fromBunStream(name: string, get: () => ReadableStream<Uint8Array>) {
return Stream.fromReadableStream({
evaluate: get,
onError: (cause) => new Error(`${name} stream error: ${String(cause)}`),
})
}
// Long-lived processes (serve, acp) all want the same stderr drain: read every
// chunk, push to a tail buffer, swallow stream errors (the child closing the
// pipe is normal). `log: true` surfaces a real protocol error to logs so a
// regression doesn't silently disappear.
function forkStderrDrain(stream: ReadableStream<Uint8Array>, into: string[]) {
return Effect.forkScoped(
fromBunStream("stderr", () => stream).pipe(
Stream.decodeText(),
Stream.runForEach((chunk) => Effect.sync(() => into.push(chunk))),
Effect.ignore({ log: true }),
),
)
}
function isolatedEnv(home: string, configJson: string): Record<string, string> {
return {
OPENCODE_TEST_HOME: home,
@@ -98,6 +122,28 @@ export type ServeHandle = {
readonly exited: Promise<number>
}
// `opencode acp` speaks newline-delimited JSON-RPC over stdin/stdout. It is
// long-lived and exits cleanly when stdin is closed. The handle exposes the
// duplex stream as send/receive rather than raw pipes so tests don't have to
// reimplement framing on every call site.
export type AcpOpts = SpawnOpts & {
readonly cwd?: string
readonly extraArgs?: string[]
}
export type AcpHandle = {
// Writes a single JSON-RPC message to the child's stdin as one ndjson line.
readonly send: (msg: object) => Effect.Effect<void>
// Resolves with the next parsed JSON-RPC line from the child's stdout.
// Lines are buffered in a queue so multiple receives in a row won't drop
// anything. Pair with `Effect.timeout` if a test wants a deadline.
readonly receive: Effect.Effect<unknown>
// Closes stdin. ACP exits cleanly on stdin EOF; the scope finalizer also
// calls this, so tests only need it when asserting exit behavior.
readonly close: () => void
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>
@@ -105,6 +151,9 @@ export type OpencodeCli = {
// 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>
// Spawn `opencode acp` and return a duplex JSON-RPC handle. Long-lived:
// the subprocess exits on stdin close, which the scope finalizer triggers.
readonly acp: (opts?: AcpOpts) => Effect.Effect<AcpHandle, 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>
@@ -200,20 +249,10 @@ export function withCliFixture<A, E>(
}).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.
// Tail buffer so timeout failures can include stderr context. The fork
// also keeps the OS pipe buffer from filling and wedging the child.
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,
),
)
yield* forkStderrDrain(proc.stderr, stderrChunks)
// Watch stdout line-by-line for the listening sentinel. Format
// (see src/cli/cmd/serve.ts):
@@ -221,17 +260,14 @@ export function withCliFixture<A, E>(
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(
fromBunStream("stdout", () => proc.stdout).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,
Effect.ignore({ log: true }),
),
)
@@ -260,7 +296,86 @@ export function withCliFixture<A, E>(
} satisfies ServeHandle
})
const opencode: OpencodeCli = { run, serve, spawn, expectExit, parseJsonEvents }
const acp = Effect.fn("opencode.acp")(function* (opts?: AcpOpts) {
const argv = ["acp"]
if (opts?.cwd) argv.push("--cwd", opts.cwd)
if (opts?.extraArgs) argv.push(...opts.extraArgs)
// Acquire the subprocess. Release ends stdin (clean shutdown — ACP exits
// on stdin EOF) and falls back to SIGTERM if it doesn't exit promptly.
// Either way we await proc.exited so the test scope doesn't leak.
const proc = yield* Effect.acquireRelease(
Effect.sync(() =>
Bun.spawn(["bun", "run", "--conditions=browser", cliEntry, ...argv], {
cwd: opts?.cwd ?? home,
env: { ...process.env, ...env, ...opts?.env },
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
}),
),
(p) =>
// Graceful shutdown: close stdin (ACP exits on EOF), give it a
// window to exit, then SIGTERM. The Effect.timeoutOrElse expresses
// exactly that race without raw setTimeout or Promise.race.
Effect.gen(function* () {
yield* Effect.sync(() => p.stdin.end())
yield* Effect.promise(() => p.exited).pipe(
Effect.timeoutOrElse({
duration: Duration.seconds(2),
orElse: () =>
Effect.sync(() => {
p.kill()
}),
}),
)
yield* Effect.promise(() => p.exited)
}).pipe(Effect.ignore),
)
const stderrChunks: string[] = []
yield* forkStderrDrain(proc.stderr, stderrChunks)
// Each ndjson line becomes one queue entry. JSON.parse failures are
// surfaced as the raw string so a malformed protocol message doesn't
// silently wedge the test in `receive`.
const responses = yield* Queue.unbounded<unknown>()
yield* Effect.forkScoped(
fromBunStream("stdout", () => proc.stdout).pipe(
Stream.decodeText(),
Stream.splitLines,
Stream.runForEach((line) => {
if (line.length === 0) return Effect.void
let parsed: unknown
try {
parsed = JSON.parse(line)
} catch {
parsed = { _rawLine: line }
}
return Queue.offer(responses, parsed)
}),
Effect.ignore({ log: true }),
),
)
return {
// `proc.stdin.write` returns `number | Promise<number>`. The promise
// form is the backpressure signal — if we don't await it, rapid
// successive sends can interleave under pipe-buffer-full conditions
// and corrupt the ndjson framing.
send: (msg: object) =>
Effect.promise(async () => {
const ret = proc.stdin.write(JSON.stringify(msg) + "\n")
if (typeof ret !== "number") await ret
}),
receive: Queue.take(responses),
// proc.stdin.end() is idempotent in Bun; no try/catch needed.
close: () => proc.stdin.end(),
exited: proc.exited as Promise<number>,
} satisfies AcpHandle
})
const opencode: OpencodeCli = { run, serve, acp, spawn, expectExit, parseJsonEvents }
return yield* fn({ llm, home, opencode })
// FetchHttpClient is provided so test bodies can `yield* HttpClient.HttpClient`