From 18b9cec50d6b058cf991e6c29d643a7717d07196 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 19 May 2026 10:58:12 -0400 Subject: [PATCH] test(cli): help-text snapshots for every CLI command (#28267) --- .../__snapshots__/help-snapshots.test.ts.snap | 623 ++++++++++++++++++ .../test/cli/help/help-snapshots.test.ts | 139 ++++ packages/opencode/test/lib/cli-process.ts | 83 ++- 3 files changed, 803 insertions(+), 42 deletions(-) create mode 100644 packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap create mode 100644 packages/opencode/test/cli/help/help-snapshots.test.ts diff --git a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap new file mode 100644 index 0000000000..22ad59aaa2 --- /dev/null +++ b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap @@ -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: ""]" +`; + +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 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 + +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 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 + +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 + +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 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 + +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 + +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]" +`; diff --git a/packages/opencode/test/cli/help/help-snapshots.test.ts b/packages/opencode/test/cli/help/help-snapshots.test.ts new file mode 100644 index 0000000000..e9b1bb6413 --- /dev/null +++ b/packages/opencode/test/cli/help/help-snapshots.test.ts @@ -0,0 +1,139 @@ +// 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 + // Windows emits CRLF on stderr; collapse first so the rest of the + // pipeline doesn't need separate Windows-vs-POSIX branches. + .replaceAll("\r\n", "\n") + .replaceAll(REAL_TMP, "") + .replaceAll(TMP, "") + // The harness writes the random home dir at `/oc-cli-XXX` on + // POSIX, `\oc-cli-XXX` on Windows. Strip either form. + .replace(/[/\\]oc-cli-[a-z0-9]+/g, "") + // 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 (or even + // line-wraps onto a fresh line on Windows). `\s+` matches both forms. + .replace(/\s+\[string\] \[default: ""\]/g, ' [string] [default: ""]') + ) +} + +// 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 = [...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, + ) +}) diff --git a/packages/opencode/test/lib/cli-process.ts b/packages/opencode/test/lib/cli-process.ts index 8481214a82..1f11bb4e79 100644 --- a/packages/opencode/test/lib/cli-process.ts +++ b/packages/opencode/test/lib/cli-process.ts @@ -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) 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) { + 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, 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 { return { OPENCODE_TEST_HOME: home, @@ -225,20 +249,10 @@ export function withCliFixture( }).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): @@ -246,17 +260,14 @@ export function withCliFixture( 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 }), ), ) @@ -323,26 +334,14 @@ export function withCliFixture( ) 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) // 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() 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) => { @@ -355,23 +354,23 @@ export function withCliFixture( } return Queue.offer(responses, parsed) }), - Effect.ignore, + Effect.ignore({ log: true }), ), ) return { + // `proc.stdin.write` returns `number | Promise`. 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.sync(() => { - proc.stdin.write(JSON.stringify(msg) + "\n") + Effect.promise(async () => { + const ret = proc.stdin.write(JSON.stringify(msg) + "\n") + if (typeof ret !== "number") await ret }), receive: Queue.take(responses), - close: () => { - try { - proc.stdin.end() - } catch { - // already closed - } - }, + // proc.stdin.end() is idempotent in Bun; no try/catch needed. + close: () => proc.stdin.end(), exited: proc.exited as Promise, } satisfies AcpHandle })