|
| 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