wisp is a terminal, so "does it work?" ultimately means "when a person launches the binary and types, do the right bytes appear on screen?" The test suite answers that at three widening scopes, plus a one-command loop that ties them together.
| Scope | Where | What it proves | Needs |
|---|---|---|---|
| Unit | internal/*/*_test.go |
each layer in isolation — VT parser, palette, config, transport, egress proxy (HTTP CONNECT + SOCKS5), local PTY, headless frontend | Go toolchain only |
| Integration (in-process) | internal/app/integration_test.go |
the layers compose: local shell → PTY → engine → grid, asserted against the rendered snapshot | Go toolchain only |
| End-to-end (black-box) | internal/e2e/ |
the shipped binary launches a shell, renders, forwards keystrokes, runs commands, and injects the proxy env — driven over a real PTY exactly as a user would | Go toolchain + PTY (any Linux/macOS) |
The first two run under the default go test ./.... The third is gated behind
the e2e build tag so the default run stays fast and hermetic.
internal/e2e is the piece that catches what unit tests cannot — flag parsing,
proxy startup, raw-mode terminal handling, and the stdio frontend all only exist
in the assembled binary. The harness:
- compiles
cmd/wisponce (the default pure-Go / stdio flavor), - launches
wisp -no-tailnet …as its own process with a real PTY for stdio (viagithub.com/creack/pty);-no-tailnetbacks the egress proxy with the OS network stack so no tsnet node or credentials are needed, - types into the PTY and asserts on the bytes wisp paints back — including that
$WISPand$ALL_PROXYwere injected into the shell's environment.
No tailnet or display is involved, so it runs unchanged in CI.
go test -tags e2e -count=1 ./internal/e2e/...-no-tailnet is what the hermetic tests use, and it bypasses wisp's whole reason
for existing: the embedded tsnet node. internal/e2e/tailnet_test.go covers
that real path — running curl in the shell against a tailnet-only URL through
the embedded proxy — but it needs a tailnet and a reachable HTTP resource, so it
is opt-in: it skips unless credentials are in the environment.
Authentication uses a Tailscale OAuth client secret, not a long-lived auth
key. wisp's tsnet exchanges the secret for a short-lived, tagged key at startup
(see internal/transport/tsnet.go), so nothing
durable is stored. This is Tailscale's recommended way to authenticate headless
nodes: the OAuth client is scoped to a tag and revocable from the admin console.
| Env var | Required? | Meaning |
|---|---|---|
WISP_E2E_TS_CLIENT_SECRET |
yes | OAuth client secret (tskey-client-…) with the auth_keys scope |
WISP_E2E_TS_TAGS |
yes | comma-separated ACL tag(s) the client owns, e.g. tag:ci |
WISP_E2E_URL |
yes | an HTTP URL reachable only over the tailnet |
WISP_E2E_EXPECT |
yes | a substring expected in that URL's response body |
WISP_E2E_CONTROL_URL |
no | Headscale / self-hosted control plane URL |
- In the Tailscale admin console, define a tag (e.g.
tag:ci) in your ACLs and grant the tag access to your test HTTP resource. - Settings → OAuth clients → Generate a client with the
auth_keyswrite scope, attached totag:ci. - Store the generated secret as
WISP_E2E_TS_CLIENT_SECRET. Revoke it any time from the same screen; it grants nothing beyond minting keys for that tag.
When wisp's tsnet sees a tskey-client-… secret it mints a fresh auth key with
defaults ephemeral=true, preauthorized=false. You can override them
inline on the secret: tskey-client-…?preauthorized=true&ephemeral=true.
ephemeral=true(default) is what keeps CI clean: each run registers a new node that the control plane auto-removes minutes after wisp exits. The test pairs this with a throwaway-state-dirand-ephemeral, so runs never accumulate machines in the admin console.preauthorized=trueis worth adding if your tailnet has device approval enabled — otherwise the freshly minted node sits unapproved and the test times out waiting to come online.
This is the flip side of how wisp behaves for real users: node identity lives in
-state-dir (default ~/.config/wisp/tsnet), which persists. A normal user
restarting wisp reuses the same machine — tsnet finds the existing state and
re-registers under that identity, ignoring the auth key/secret entirely. A new
machine appears only when the state dir is empty/throwaway (as in CI) or
TSNET_FORCE_LOGIN=1 is set. So: persistent state dir → reuse; ephemeral +
fresh state dir → new, self-cleaning node per run.
export WISP_E2E_TS_CLIENT_SECRET=tskey-client-...
export WISP_E2E_TS_TAGS=tag:ci
export WISP_E2E_URL=http://dev-box.internal/health
export WISP_E2E_EXPECT=ok
go test -tags e2e -run TestLiveTailnet ./internal/e2e/...In CI these come from repository secrets (WISP_E2E_*), wired into the
e2e job in .github/workflows/ci.yml. The
secret is passed to wisp through the environment (TS_CLIENT_SECRET), never on
the command line, so it can't surface in a process listing. On fork PRs — where
secrets are unavailable — the live test simply skips, and the hermetic localhost
tests still run.
Even more keyless: tsnet also supports workload identity federation (the runner's GitHub OIDC token, no stored secret at all), but that path links the
tailscale.com/feature/identityfederationpackage, which pulls the AWS SDK into the binary. wisp keeps that out of the default build, so OAuth — one scoped, revocable secret — is the recommended approach here.
scripts/autotest.sh is the single command that runs
the whole gauntlet — gofmt, vet, go test -race ./..., build, and the e2e
suite — and exits non-zero on the first failure. It is what the Claude Code
auto-test loop invokes each iteration: one clean exit code means "wisp works",
one non-zero means "here is the broken step, fix it".
scripts/autotest.sh # run the gauntlet once
scripts/autotest.sh --quick # skip e2e (fast inner loop while editing)
scripts/autotest.sh --loop # repeat until something fails (surfaces flakiness/races)
scripts/autotest.sh --loop 20 # repeat at most 20 timesTo have Claude Code build-and-verify wisp on a loop, point its /loop at the
script:
/loop 10m scripts/autotest.sh
Each tick runs the gauntlet; on a non-zero exit Claude has the failing step and
output in context and can fix it before the next tick. --loop mode inside the
script is the complementary tool for shaking out flaky failures (races,
timing) without re-spawning a session each time.