Skip to content

Commit 83f3e17

Browse files
roodboiclaude
andcommitted
test(e2e): real CLI harness with turborepo fixture + worktree scenarios
Isolated (HACK_HOME) end-to-end suite driving the working-tree CLI: tier 1 (init, env secrets, worktree key sharing, registry dedupe, branch defaulting, docs sync drift, doctor, non-TTY hygiene) and an opt-in docker tier (up/down, lifecycle hooks, parallel primary+worktree branch instances). bun run test:e2e:local / test:e2e:local:docker. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent abc695d commit 83f3e17

17 files changed

Lines changed: 2787 additions & 0 deletions

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@
5757
"typecheck": "bun run turbo:typecheck",
5858
"uninstall:local": "bun run --cwd packages/cli uninstall:local",
5959
"test": "bun run turbo:test",
60+
"test:e2e:local": "bun tests/e2e/run.ts",
61+
"test:e2e:local:docker": "HACK_E2E_DOCKER=1 bun tests/e2e/run.ts",
6062
"test:e2e:gateway": "bun run scripts/gateway-e2e.ts",
6163
"test:e2e:dispatch": "bun run scripts/dispatch-e2e.ts",
6264
"test:e2e:railway": "bun run scripts/railway-node-e2e.ts"

tests/e2e/README.md

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
# hack CLI end-to-end harness
2+
3+
Drives the ACTUAL working-tree CLI (`bun <repoRoot>/index.ts <args>`, non-TTY
4+
by design) against a disposable turborepo-style Bun monorepo, so changes to
5+
init/env/worktrees/lifecycle/doctor are verified against real behavior — not
6+
just unit tests.
7+
8+
## Running
9+
10+
```sh
11+
bun run test:e2e:local # tier 1 (no docker required)
12+
bun run test:e2e:local:docker # tier 1 + tier 2 (docker + real global infra)
13+
14+
bun tests/e2e/run.ts --list # list scenarios
15+
bun tests/e2e/run.ts --only=init # run a subset by name
16+
HACK_E2E_KEEP=1 bun tests/e2e/run.ts --only=doctor # keep temp dirs for debugging
17+
```
18+
19+
Exit codes: `0` all pass/skip, `1` any scenario failed, `2` isolation canary
20+
failed (nothing ran).
21+
22+
## Isolation model (HACK_HOME)
23+
24+
Every CLI invocation runs with `HACK_HOME=<fresh tempdir>` plus
25+
`HACK_SETUP_SYNC_MODE=off`, `HACK_NO_INTERACTIVE=1`, `NO_COLOR=1`,
26+
`TERM=dumb`, and stdin closed. Global state (projects registry, global
27+
config) must land under `HACK_HOME`, never under the real `~/.hack`.
28+
29+
Because the CLI must honor `HACK_HOME` for this to be safe, the runner
30+
executes a fail-fast canary before any scenario:
31+
32+
1. **Probe A (read-only)**`hack config get --global name` with `HACK_HOME`
33+
set; the CLI must report its global-config path under the temp dir. If it
34+
doesn't, `HACK_HOME` is not honored and the whole run aborts (exit 2)
35+
without writing anything.
36+
2. **Probe B (write)** — a throwaway canary project is registered via
37+
`hack config get name`; the canary name must NOT appear in the real
38+
`~/.hack/projects.json` afterwards (the entry is removed best-effort if it
39+
does — a targeted removal, so concurrent legitimate registry writes are
40+
never clobbered) and a `projects.json` containing the canary must appear
41+
under `HACK_HOME`.
42+
43+
If the canary fails, fix the `HACK_HOME` override seam
44+
(`src/lib/config-paths.ts`, `src/lib/projects-registry.ts`) before trusting
45+
any e2e result.
46+
47+
## Tiers
48+
49+
- **Tier 1 (`local`)** — runs everywhere; no docker needed:
50+
`automation-check`, `init`, `env-secrets`, `worktree-secrets`,
51+
`worktree-registry`, `worktree-branch-default`, `agent-docs-sync`,
52+
`doctor` (doctor tolerates missing docker — it must report, not crash).
53+
- **Tier 2 (`docker`)** — opt-in via `HACK_E2E_DOCKER=1`; requires a running
54+
docker daemon, the machine's global `hack-dev` network (`hack global
55+
install`), and pulls `oven/bun:1`: `up-down`, `lifecycle-host-process`,
56+
`worktree-parallel-up`. When docker or the network is missing these SKIP
57+
with a reason instead of failing. Lifecycle host processes only start via
58+
`hack up` (no standalone lifecycle command), which is why that scenario is
59+
tier 2.
60+
61+
## Fixture
62+
63+
`fixture.ts` scaffolds, per scenario, a fresh git repo with:
64+
65+
- root `package.json` (workspaces `apps/*`, `packages/*`) + `turbo.json`
66+
- `apps/web` / `apps/api``Bun.serve` servers ("ok web" / JSON) with
67+
Dockerfiles; compose runs them via `oven/bun:1` + volume mount (no build
68+
step) for speed
69+
- `packages/shared` — tiny ts lib consumed by both apps (relative import so
70+
containers need no `bun install`)
71+
- optional `.hack/` (hack.config.json, docker-compose.yml with caddy labels
72+
`caddy` / `caddy.reverse_proxy` / `caddy.tls=internal` on the `hack-dev`
73+
network, `hack.env.default.yaml`) following `src/templates.ts`; project
74+
name/dev_host are random (`e2e-<hex>.hack`) so runs can never collide with
75+
real projects
76+
- helpers for linked worktrees (`git worktree add`) and commits
77+
78+
## Cleanup guarantees
79+
80+
- Per scenario, the fixture temp root and the `HACK_HOME` temp dir are
81+
removed in a `finally` (skip with `HACK_E2E_KEEP=1`).
82+
- Docker scenarios run `hack down` (primary + branch instances) AND a raw
83+
`docker compose -p <project> down --volumes --remove-orphans` sweep in
84+
their own `finally`, keyed by the random fixture name.
85+
- The canary restores the real registry snapshot best-effort if a leak is
86+
ever detected (and aborts the run).
87+
88+
## Adding a scenario
89+
90+
1. Create `tests/e2e/scenarios/<name>.ts` exporting a `Scenario`
91+
(`name`, `tier`, `summary`, `run(ctx)`); files must NOT end in `.test.ts`
92+
(the unit runner globs `tests/**/*.test.ts`).
93+
2. Use `ctx.cli({ args, cwd })` for every CLI call (isolation is applied for
94+
you), `expect`/`expectExit`/`extractJsonObject` from `harness.ts` for
95+
assertions, and `ctx.skip("reason")` for legitimate environment gaps.
96+
3. Build fixtures with `createMonorepoFixture` under `ctx.tempRoot`.
97+
4. Register it in the `ALL_SCENARIOS` list in `run.ts`.
98+
5. Docker scenarios: call `requireDockerPreconditions` first and wrap the
99+
body in `try/finally` with `downBestEffort`.

0 commit comments

Comments
 (0)