Documentation
¶
Overview ¶
Package cmdtest is the test harness for the triage plugin's CLI.
It provides three layers:
- Run / RunWithStdin — invoke a *cli.Command in-process with captured stdin/stdout/stderr. No subprocess, no built binary required.
- Assert* — vocabulary for asserting on the captured bytes. Helpers fail with t.Fatal on mismatch and include the captured streams in their failure messages, so debugging a red test does not require re-running with extra logging.
- Isolate — file-system + env isolation for tests that touch the data directory, slash-command target directory, or git context.
Phase 6 (pivot-to-ai-as-code) scope decision ¶
Pre-Phase-6 the harness drove the in-process triage cmd tree that the `tai` binary embedded directly. Post-Phase-6 the same cmd tree is the entry point for the standalone `triage` plugin binary that the host invokes via subprocess.
The harness still drives the cmd tree IN-PROCESS, not via subprocess exec. Two reasons:
- The host-side subprocess wiring (env-var injection, stdio passthrough, exit-code propagation) is verified by core's plugin-host tests (TC-PLG-002, TC-PLG-005). Those tests run a POSIX shell-stub plugin and assert the host fulfils the contract; the plugin-side consumption (taiplugin.Load → storage path → verb dispatch) is exercised by this harness's in-process tests. Re-testing both ends in the same suite would duplicate coverage AND add a build-binary-then-exec dependency to every TC-IMP/TRG/INST test.
- The verb-dispatch logic exercised by these tests is the same regardless of whether the cmd tree is reached in-process or via subprocess. The harness pins what only the triage cmd tree owns; the subprocess transport is the host's responsibility.
A future revision MAY introduce an `ExecRoot` variant that builds the triage binary and exec's it for end-to-end coverage of both transports. Today's TC-IDs do not require it.
Conventions:
Every test that maps to a BDD case in plugins/triage/test-cases.md (or core/test-cases.md for CMD-001/002/008 and the ERR cases) names the TC-ID in its function name AND its t.Run subtest descriptions, so a failure surfaces the trace back to the spec. Example:
func TestVersion_TCCMD001_prints_version_string(t *testing.T) { ... }
The pattern is Test<Cmd>_<TCID>_<short_description>, where <TCID> is the TC-ID with hyphens removed (TC-CMD-001 → TCCMD001).
Helpers t.Helper() themselves so failure line numbers point at the caller, not at this file.
The assert vocabulary is intentionally narrow. If you reach for a helper that does not exist, prefer adding it here over inlining the check — the harness is the contract every test obeys.
Index ¶
- func AssertError(t *testing.T, r Result)
- func AssertErrorFooter(t *testing.T, r Result, wantCode string, wantExitCode int)
- func AssertExitCode(t *testing.T, r Result, want int)
- func AssertNoError(t *testing.T, r Result)
- func AssertStderrContains(t *testing.T, r Result, substr string)
- func AssertStderrEmpty(t *testing.T, r Result)
- func AssertStdoutContains(t *testing.T, r Result, substr string)
- func AssertStdoutEquals(t *testing.T, r Result, want string)
- func AssertStdoutMatches(t *testing.T, r Result, pattern string)
- func Chdir(t *testing.T, dir string)
- type Isolated
- type Result
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func AssertError ¶
AssertError fails the test when r.Err is nil. Use AssertErrorFooter when the test is specifically about the foundation's error contract; this helper only verifies that *some* error occurred.
func AssertErrorFooter ¶
AssertErrorFooter asserts that the LAST line of stderr is the foundation's error-contract footer and that:
- the embedded code equals wantCode, and
- (when wantExitCode >= 0) the embedded numeric exit code equals wantExitCode. Pass -1 to skip the numeric check.
The spec requires the footer to be the literal last line written to stderr (trailing newline allowed); this helper enforces that — content after the matched footer fails the assertion.
This is the primary error-path assertion every TC-ERR test will use once add-tai-foundation lands.
func AssertExitCode ¶
AssertExitCode fails the test when r.ExitCode != want.
func AssertNoError ¶
AssertNoError fails the test when r.Err is non-nil.
func AssertStderrContains ¶
AssertStderrContains fails the test when substr does not appear in stderr.
func AssertStderrEmpty ¶
AssertStderrEmpty fails the test when stderr contains any bytes.
func AssertStdoutContains ¶
AssertStdoutContains fails the test when substr does not appear in stdout.
func AssertStdoutEquals ¶
AssertStdoutEquals fails when stdout differs from want byte-for-byte. Use sparingly — substring/regex matching is more forgiving and friendlier to revising output formatting.
func AssertStdoutMatches ¶
AssertStdoutMatches fails the test when stdout does not match the regex.
func Chdir ¶
Chdir changes the process's working directory to dir for the test's lifetime, restoring the original cwd via t.Cleanup.
The process cwd is global state — this helper is UNSAFE under t.Parallel(). The guard above panics if a second test attempts to Chdir while another test's Chdir scope is still active.
Types ¶
type Isolated ¶
type Isolated struct {
// Root is the test's private tmp directory. Use it to write fixtures
// or inspect what tai produced.
Root string
// Home is the private HOME directory; everything under Root/home.
Home string
// DataDir is the resolved TAI_DATA_DIR; everything under Root/data.
DataDir string
}
Isolated is a fully-isolated test environment: a tmp directory rooted at t.TempDir() with a private HOME (and USERPROFILE on Windows), a private TAI_DATA_DIR, and any other env vars callers explicitly set.
All environment changes are torn down via t.Cleanup, so tests do not leak state into each other when run with -parallel.
func Isolate ¶
Isolate creates an Isolated test environment and wires the relevant env vars for the duration of the test:
- HOME is set on every platform.
- USERPROFILE is set on Windows (where it is the conventional home directory pointer).
- TAI_DATA_DIR is set to a per-test data directory.
- XDG_DATA_HOME is unset (not merely emptied) so the foundation's data-directory resolver does not see a host-machine value.
Tests that want to override additional env vars can call t.Setenv after Isolate returns; those overrides are also torn down on t.Cleanup.
Note: this does NOT change the process's working directory. Tests that need a specific cwd (e.g. to exercise repo detection) should call Chdir.
type Result ¶
type Result struct {
// Stdout is the bytes written to the command's Writer.
Stdout string
// Stderr is the bytes written to the command's ErrWriter.
Stderr string
// ExitCode is the OS exit code the binary would have produced. It is
// derived from the error returned by cli.Command.Run: nil → 0; any
// non-nil error that implements cli.ExitCoder → that code; any other
// non-nil error → 1 (the default core/cmd/tai/main.go produces).
ExitCode int
// Err is the raw error returned by cli.Command.Run. Tests that care
// about the user-observable error contract should prefer
// AssertErrorFooter and AssertExitCode (which encode the spec); Err
// is exposed for assertions that need the underlying Go value.
Err error
}
Result captures everything tests need to assert against after running the tai CLI.
func Run ¶
Run invokes cmd with the given argv (NOT including the executable name — the harness prepends "triage" for you, matching the post-Phase-6 binary name set on NewRoot's Name field). Stdin is empty; stdout and stderr are captured into the returned Result.
The cmd's Writer / ErrWriter / Reader fields are overwritten by this call; callers should pass a freshly-built command (typically cmd.NewRoot()).
func RunWithStdin ¶
RunWithStdin is like Run but pipes stdin as the command's standard input.
Execution flows through pkg/cliexec so panic recovery matches the production binary; stderr is post-processed by pkg/cliout.WriteError on a non-nil error so tests observe the same foundation-template footer the user does.
urfave/cli does NOT propagate Writer/ErrWriter/Reader to subcommands; the harness walks the tree and sets all three on every descendant so a subcommand's c.Writer.Write reaches the captured buffer.