testutil

package
v0.20.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 4, 2026 License: MIT Imports: 13 Imported by: 0

Documentation

Overview

Package testutil holds the helpers the Brigade test tree shares. It is imported only from tests and from the test-only entrypoints of the cmd/ packages; nothing under it is linked into the shipped binary.

P1-1 scope. The plan's full list (7.1: fakesock, fakeregistry, fakeadapter, dotenv, …) belongs to the phases that need it. What is here is what P1-1 needs and what P1-1 exercises: Env and Dirs to build a child environment explicitly, Build and BuildStamped to compile a repository binary the way the release does, RepoRoot, and RunID. Anything added later should keep the same rule: a helper with no caller and no test is not a helper, it is a claim.

Everything here is safe to call from a test that has already called t.Parallel: the package holds no mutable state, and every path it hands out is derived from the caller's own t.TempDir.

Index

Constants

View Source
const EnvTestFile = ".env.test"

EnvTestFile is the file `make supabase-env` writes from the running stack, gitignored, at the repository root.

View Source
const LiveTestVar = "BRIGADE_TEST_LIVE"

LiveTestVar is the explicit opt-in every live test in the tree waits on. A stack that answers is NOT consent: `make supabase-start supabase-env` leaves a listening stack and a .env.test behind for good, so before this gate existed every live test ran inside `make test` on any machine that had run those two recipes once — measured 2026-09-04 on this repository's dev machine, `go test -run TestIntegration ./internal/adapters/supabase/` was 32 PASS / 5 SKIP / 0 FAIL in 55.883 s with no BRIGADE_* variable set, and under `make test`'s `-race -shuffle=on` whole-tree load one of them (TestIntegrationAdversarialBroadcastPayloadIsIdsOnly) missed its 10 s broadcast window and then sat on an untimed websocket read until the Realtime server closed the un-heartbeated socket 60 s later, failing the commit gate with `read: failed to get reader: failed to read frame header: EOF`. The reads are bounded now, and this variable is why a developer's `make test` no longer reaches them at all.

`make test-integration` sets it (and BRIGADE_TEST_DOCKER beside it); CI's `supabase` job gets it from that same recipe. Nothing else sets it, which is what makes CLAUDE.md's "`make test` is Docker-free and stack-free" true on a developer's machine as well as on a bare runner.

View Source
const ModulePath = "github.com/appshapes/brigade"

ModulePath is this repository's Go module path. RepoRoot uses it to tell this module's go.mod from any other one it walks past.

View Source
const SupabaseDBURLVar = "SUPABASE_DB_URL"

SupabaseDBURLVar is the name `make supabase-env` writes the DSN under.

View Source
const VersionLDFlagTarget = ModulePath + "/internal/buildinfo.Version"

VersionLDFlagTarget is the linker symbol the release stamps the version into. It is declared here so that a test asserting the stamped version and the Makefile's -ldflags cannot drift apart silently: if the symbol moves, TestBuiltBinaryReportsTheStampedVersion in cmd/brigade goes red.

Variables

This section is empty.

Functions

func Build

func Build(tb testing.TB, pkg string) string

Build compiles a package of this repository into a directory belonging to the test and returns the absolute path of the binary.

pkg is a package pattern relative to the repository root, for example "./cmd/brigade". The build uses the release flags — -trimpath and CGO_ENABLED=0 — so that the artefact under test is the artefact that ships (plan 9.4).

-race is never passed: the race detector needs cgo and CGO_ENABLED=0 turns it off, which is exactly the combination plan 7.3 forbids. The test process itself is race-instrumented by `go test -race`; the child it builds is not.

The result is not cached across calls. A cache would need a directory outliving any single test, and testutil has no process-wide teardown hook to remove one; `go build` caches the link step itself, so a repeat build of an unchanged package is a copy.

func BuildStamped

func BuildStamped(tb testing.TB, pkg, version string) string

BuildStamped is Build with the version stamped the way the release ldflags stamp it. An empty version stamps nothing, which is the `go install` shape: buildinfo then falls back to the module version recorded in the binary's build info.

func Env

func Env(tb testing.TB, extra ...string) []string

Env builds the environment for a child process from scratch and returns it in os.Environ form, ready for exec.Cmd.Env.

It does NOT copy the test process's environment. That is the point: the developer running the suite has a real CLAUDE_CONFIG_DIR (never ~/.claude on this project's machines) and real BRIGADE_* values, and a child that inherited them would read and write the developer's own state. Exactly one variable is carried over — PATH, without which nothing can be executed by name — and it is copied explicitly rather than by inheritance so that the exception is visible here and nowhere else.

TMPDIR is deliberately not redirected: unix sockets have to stay under /tmp, because macOS caps sun_path at 103 bytes and a t.TempDir path is already about 91 of them (plan 7.3).

extra is appended last, so a caller can override any variable above or add its own. Env creates the directories and fails the test if it cannot.

func Eventually

func Eventually(tb testing.TB, timeout, interval time.Duration, cond func() bool)

Eventually polls cond every interval until it returns true, and fails the test through tb.Fatalf when timeout elapses first (plan 7.3: no sleeps in assertions; a poll with a deadline is the replacement).

The timeout is a HANG CATCHER, not a performance bound. Choose it so a loaded `-race` run on a slow CI runner still passes with room to spare — a 5 s bound on a 300 ms operation tripped at 5.56 s on 2026-09-02 — and keep the interval short so a passing test does not wait out a whole interval it did not need.

The clock is the standard one, so under testing/synctest the polling loop advances the bubble's fake time and a timeout costs no wall time. cond is called at least once, before any wait.

func NewSleeper

func NewSleeper(tb testing.TB) int

NewSleeper starts `sleep 300` as a stand-in for a live Claude Code process and returns its pid (plan 9.5; the fake CLAUDE_PID of the watcher lifecycle tests).

The child is started with an explicit environment from Env — nothing of the test process's environment reaches it — and it is REAPED: a goroutine calls Wait the moment it starts, so that when the test (or the code under test) SIGTERMs the pid, kill(pid, 0) turns to ESRCH promptly instead of succeeding on a zombie for as long as nobody waits (E0-5 item 1; the same trap the procutil package exists for). A test that wants an UNREAPED corpse to prove the zombie path must start its own child and deliberately not wait — this helper is the reaped kind.

Cleanup sends SIGTERM and waits for the reap, escalating to SIGKILL if the child ignores it, so nothing survives the test that started it. A sleeper the test has already terminated is fine: the signal to a finished process is a no-op.

func RepoRoot

func RepoRoot(tb testing.TB) string

RepoRoot returns the absolute path of the repository root: the directory holding the go.mod that declares ModulePath.

It walks up from the working directory rather than from the compiled-in path of this source file, so it keeps working when the tree is moved and fails loudly rather than silently pointing at a stale location.

func RequireSupabaseDB

func RequireSupabaseDB(tb testing.TB) string

RequireSupabaseDB returns the local stack's Postgres DSN — the `postgres` superuser connection `make supabase-env` writes as SUPABASE_DB_URL. Without LiveTestVar it skips like RequireSupabase; with it, a missing DSN fails for the same reason a missing stack does.

It exists for the fixtures of plan 9.4 that nothing on the wire can build: backdating `last_seen_at` or a message's `created_at`, running brigade.gc_expired(), and reading auth.users to confirm what a principal is. That is the ONLY reason the test tree talks to Postgres, and only from a _test.go file; everything else goes through the adapter. The DSN carries the local stack's postgres password, so it is returned, never logged: no caller may put it in a t.Log, a failure message or a committed file.

The opt-in gate and the stack health check both run first (inside RequireSupabase), so a run without LiveTestVar costs nothing and a stale .env.test naming a stack that is not up fails on the two-second health probe rather than hanging on a dial.

func RunID

func RunID() string

RunID returns an identifier unique to one test run, in the form 20260831T142530Z-1f4b9c02 (plan 9.4).

Integration tests put it in every name they create so that two runs — or two developers sharing one stack — never collide and no reset is needed between them. It is sortable by time on purpose: a leftover row says when it was made.

func WriteExecutable added in v0.6.0

func WriteExecutable(tb testing.TB, path string, body []byte)

WriteExecutable writes body to path as a 0700 file that this process, or a child of it, will exec, and fails the test if it cannot.

It exists because os.WriteFile followed by exec is not safe in a process that forks concurrently (golang/go#22315). os.WriteFile opens the file without taking syscall.ForkLock, so a fork in flight on another goroutine hands the still-open write fd to its child — O_CLOEXEC does not help, the child keeps the fd until its own execve — and an execve of the file in that window fails with ETXTBSY ("text file busy"). Nothing retries it: os/exec returns the error, adapterkit maps it to spawn_error, the conformance launcher to "spawn failed". CI hit it four times in eight days on the 4-CPU Linux runner (runs 34232764450, 34355720402, 34516308749, 34537042672); a synthetic harness (4 writers, 4 forkers, linux 6.12 at 2 CPUs) put it at 697 of 30,000 execs (2.3%).

Holding syscall.ForkLock for reading across open, write and close removes the mechanism rather than narrowing it: syscall.forkExec takes the lock for writing around the clone, so no fork can begin while the fd exists, and a fork already in flight holds the write lock until the clone returns — on Linux with CLONE_VFORK, not before the child has exec'd. A child can only inherit an fd that exists when it is cloned, and under the lock none does. 0 of 66,000 in the same harness.

The file is always a NEW inode (the old one is unlinked first). An fd leaked by an earlier unguarded write of the same path refers to the old inode, and Linux through 6.19 wakes the vfork parent (exec_mmap) before it closes the child's O_CLOEXEC fds (do_close_on_exec), so waiting for the lock alone does not wait for that fd: 1 of 96,000 with a lock-only barrier, 0 of 54,000 with a fresh inode. That is what lets a testscript Setup hook rewrite the files testscript already extracted.

macOS does not enforce ETXTBSY (0 of 400 unguarded), so the lock costs nothing there and the helper is the same on both platforms.

func WriteExecutableFile added in v0.6.0

func WriteExecutableFile(path string, body []byte) error

WriteExecutableFile is WriteExecutable for a caller without a testing.TB: a testscript Setup hook or a TestMain.

Types

type Dirs

type Dirs struct {
	// Root is the directory the others live under.
	Root string
	// Home backs $HOME.
	Home string
	// ClaudeConfig backs $CLAUDE_CONFIG_DIR. Never hardcode ~/.claude
	// anywhere: this is the only config dir a test may touch.
	ClaudeConfig string
	// PluginRoot backs $CLAUDE_PLUGIN_ROOT.
	PluginRoot string
	// XDGConfig, XDGState and XDGCache back the XDG base directories the
	// adapter kit resolves its files from.
	XDGConfig string
	XDGState  string
	XDGCache  string
	// BrigadeConfig and BrigadeState back $BRIGADE_CONFIG_DIR and
	// $BRIGADE_STATE_DIR.
	BrigadeConfig string
	BrigadeState  string
	// FSRoot backs $BRIGADE_FS_ROOT, the filesystem adapter's store.
	FSRoot string
}

Dirs are the directories a child process started by a test is confined to. Every path Brigade or Claude Code would otherwise take from the developer's own account has one here instead, so a test — or a detached watcher a test leaked — cannot reach the real ~/.claude* tree, the real XDG directories or the real home.

The zero Dirs is not usable; build one with NewDirs.

func NewDirs

func NewDirs(root string) Dirs

NewDirs lays out a Dirs under root. It creates nothing; call Dirs.Mkdir for that.

func (Dirs) Mkdir

func (d Dirs) Mkdir() error

Mkdir creates every directory in the layout, 0700 as the plan's file modes require (T7, T14).

func (Dirs) Vars

func (d Dirs) Vars() []string

Vars returns the directory half of a child environment, in os.Environ form.

$HOME is deliberately absent. Env sets it to Dirs.Home; testscript sets its own HOME=/no-home so that any ~ resolution inside a script fails loudly, and a caller building script variables must not overwrite that.

type SupabaseEnv

type SupabaseEnv struct {
	URL            string
	PublishableKey string
}

SupabaseEnv is what an integration test needs of the local stack (plan 9.4): the API url and the publishable key. Never the secret key, the service-role key or the JWT secret — those stay out of the adapter and out of every test that ships (7.7).

func RequireSupabase

func RequireSupabase(tb testing.TB) SupabaseEnv

RequireSupabase returns the local stack's url and publishable key, or skips the test. Every live test is opt-in behind LiveTestVar: without it this skips before it looks at anything — no .env.test read, no health probe — so `make test` is Docker-free AND stack-free even on a machine whose local stack is up.

With the opt-in set, the values come from SUPABASE_URL and SUPABASE_PUBLISHABLE_KEY in the process environment (`make test-integration` sources .env.test first), else from .env.test at the repository root. From here on a missing pair or a stack whose auth service does not answer /auth/v1/health within two seconds is a FAILURE, not a skip: the opt-in says a stack is expected, and a skip here would let `make test-integration` and CI's `supabase` job exit 0 having executed nothing (measured 2026-09-04 before this rule: with the variable set and no stack, 4/4 SKIP, exit 0).

The keys are read here and nowhere else in the test tree. Nothing under testutil is linked into the shipped binary.

Directories

Path Synopsis
Package fakeadapter is a scripted BAP/1 adapter used as a test fixture (plan 9.5; brief section 2.10).
Package fakeadapter is a scripted BAP/1 adapter used as a test fixture (plan 9.5; brief section 2.10).
Package fakeregistry is the test double for Claude Code's session registry (plan 9.5, A.3): an fstest.MapFS holding <pid>.json entries in the observed shape, served through the fs.FS the registry reader takes, wrapped in a recorder that FAILS THE TEST when any name ending in .key is opened and records every name that was.
Package fakeregistry is the test double for Claude Code's session registry (plan 9.5, A.3): an fstest.MapFS holding <pid>.json entries in the observed shape, served through the fs.FS the registry reader takes, wrapped in a recorder that FAILS THE TEST when any name ending in .key is opened and records every name that was.
Package fakesock is the fake inbox socket of plan 9.5: a unix-domain server standing in for a Claude Code session's `CLAUDE_CODE_MESSAGING_SOCKET`, so the socket poster (6.7, U-17, U-19, U-20) and later the watcher can be tested against a real socket with no Claude process anywhere near.
Package fakesock is the fake inbox socket of plan 9.5: a unix-domain server standing in for a Claude Code session's `CLAUDE_CODE_MESSAGING_SOCKET`, so the socket poster (6.7, U-17, U-19, U-20) and later the watcher can be tested against a real socket with no Claude process anywhere near.
Package fakesync is a fake sync adapter for tests of the harness side of the sync-adapter protocol (folder-sync plan §4.3, docs/sync-adapters.md): a POSIX shell script the test writes into its own temp directory and launches as `/bin/sh <script> <verb>` — an argv array, never `sh -c`, and the fresh file is READ by the shell rather than exec'd, which is what macOS's first-exec assessment and Linux's ETXTBSY need (the repository's rule for a script fixture).
Package fakesync is a fake sync adapter for tests of the harness side of the sync-adapter protocol (folder-sync plan §4.3, docs/sync-adapters.md): a POSIX shell script the test writes into its own temp directory and launches as `/bin/sh <script> <verb>` — an argv array, never `sh -c`, and the fresh file is READ by the shell rather than exec'd, which is what macOS's first-exec assessment and Linux's ETXTBSY need (the repository's rule for a script fixture).
Package tscmd holds the custom testscript commands the Brigade scripts use.
Package tscmd holds the custom testscript commands the Brigade scripts use.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL