Skip to content

Commit a614015

Browse files
fullstackjamclaude
andauthored
refactor(test): run L5 E2E on GH Actions macOS runner, drop Tart VMs (#26)
Cirrus CI was gated on `only_if: $CIRRUS_CRON` and the repo has no paid credits, so the ~900 lines of Tart-based L5 tests have never run. Replace the VM wrapper with a MacHost helper that exec's directly against the host, and trigger it from the free GitHub Actions macos-latest runner on release tags / workflow_dispatch. - testutil/tartvm.go -> testutil/machost.go: drop SSH/scp/expect-over-SSH and the tart clone/boot/destroy lifecycle; require CI=true or OPENBOOT_E2E_DESTRUCTIVE=1 to activate so local `go test -tags=e2e,vm` is a no-op rather than a foot-gun. - test/e2e/: rename TartVM -> MacHost across call sites; relax bare-system assumptions that no longer hold on a GH runner (brew is preinstalled, common CLI tools may be); make vmInstallHomebrew idempotent. - Drop .cirrus.yml; add macos-e2e workflow job gated on release tags / workflow_dispatch. - Makefile: trim the test-vm-release -run regex to tests that actually exist; reword comments to reflect destructive-host semantics. - CLAUDE.md / CONTRIBUTING.md: update L5 references. https://claude.ai/code/session_01Dg8nibLFZGKmeYgfUBQrsA Co-authored-by: Claude <noreply@anthropic.com>
1 parent 082da09 commit a614015

15 files changed

Lines changed: 209 additions & 442 deletions

.cirrus.yml

Lines changed: 0 additions & 84 deletions
This file was deleted.

.github/workflows/test.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,24 @@ jobs:
158158
if: always()
159159
run: kill $(cat /tmp/mock-pid) 2>/dev/null || true
160160

161+
macos-e2e:
162+
name: macos e2e (L5)
163+
runs-on: macos-latest
164+
# Only on release tags or manual dispatch — these are slow and destructive.
165+
if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch'
166+
timeout-minutes: 45
167+
steps:
168+
- name: Checkout code
169+
uses: actions/checkout@v4
170+
171+
- name: Set up Go
172+
uses: actions/setup-go@v5
173+
with:
174+
go-version-file: "go.mod"
175+
176+
- name: Run macOS E2E (release tier)
177+
run: make test-vm-release
178+
161179
cli-compat:
162180
name: old-cli compat
163181
runs-on: macos-latest

CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ make build-release VERSION=0.25.0 # optimized + UPX
2121
make test-unit # L1 (~15s) — pre-push hook
2222
make test-integration # L2 (~75s) — real brew/git/npm in temp dirs
2323
make test-e2e # L4 compiled binary
24-
make test-vm-release # L5 VM (~20m) — before tagging
24+
make test-vm-release # L5 destructive macOS (~20m) — before tagging
2525
make test-destructive # L6 — actually installs
2626
make test-coverage # coverage.out + coverage.html
2727

@@ -60,7 +60,7 @@ internal/
6060
ui/ # bubbletea Model pattern, lipgloss styling
6161
updater/ # Auto-update: check GitHub → download → replace
6262
test/{integration,e2e}/ # //go:build integration | e2e (+ vm, destructive, smoke)
63-
testutil/ # shared helpers + Tart VM helpers
63+
testutil/ # shared helpers + MacHost (destructive E2E on real macOS)
6464
scripts/
6565
install.sh # curl|bash installer
6666
hooks/ # pre-commit, pre-push (install via `make install-hooks`)

CONTRIBUTING.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,14 @@ Tests are split across six tiers. Which one runs where:
3838
| **L2 Integration** | Real `brew` / `git` / `npm` against temp dirs; real `httptest` servers | `make test-integration` (~75s) | CI on push/PR |
3939
| **L3 Contract schema** | JSON schema validation against [openboot-contract](https://github.com/openbootdotdev/openboot-contract) | (runs in CI only) | CI on push/PR |
4040
| **L4 E2E binary** | Compiled binary driven by scripts; `-tags=e2e` | `make test-e2e` | CI on release |
41-
| **L5 E2E VM** | [Tart](https://github.com/cirruslabs/tart) macOS VMs (install Homebrew, run real flows) | `make test-vm-quick` (2 min) / `test-vm-release` (20 min) / `test-vm-full` (60 min) | Manual, before tagging a release |
41+
| **L5 Destructive macOS** | Runs against a real macOS host (installs packages, modifies `~/.zshrc`, writes `defaults`) | `make test-vm-quick` / `test-vm-release` / `test-vm-full` — requires `CI=true` or `OPENBOOT_E2E_DESTRUCTIVE=1` | GH Actions `macos-latest` on release tags + manual dispatch |
4242
| **L6 Destructive** | Actually installs real packages into a real system | `make test-destructive` / `test-smoke` | CI on release, plus manual `workflow_dispatch` |
4343

4444
Rules of thumb:
4545

4646
- **Local dev:** run nothing manually if hooks are installed. `make test-unit` on demand when you want a sanity check. Skip L2+ unless you're touching code that interacts with real brew/git/npm.
4747
- **Before pushing:** `make test-unit` (the pre-push hook does this automatically).
48-
- **Before tagging a release:** `make test-vm-release` locally (needs Tart).
48+
- **Before tagging a release:** trigger the `macos-e2e` job via GitHub Actions (manual dispatch or tag push). To run locally on a throwaway macOS machine: `OPENBOOT_E2E_DESTRUCTIVE=1 make test-vm-release`.
4949

5050
## Git Hooks
5151

Makefile

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -44,31 +44,35 @@ test-all:
4444
$(MAKE) test-coverage
4545

4646
# =============================================================================
47-
# VM-based E2E tests (Tart VMs) — three levels
47+
# Destructive macOS E2E tests — three levels
4848
# =============================================================================
49+
#
50+
# These tests install real packages and modify ~/.zshrc / macOS defaults on
51+
# the host they run on. They are intended for ephemeral macOS CI runners
52+
# (GitHub Actions macos-latest) or a throwaway VM.
53+
#
54+
# On a developer machine `go test -tags="e2e,vm"` will skip unless you set
55+
# OPENBOOT_E2E_DESTRUCTIVE=1 (see testutil/machost.go). Don't set that
56+
# unless you mean it.
4957

50-
# L1: Quick validation (~2min) — run after code changes
51-
# Runs TestVM_Infra only: boots a VM and checks SSH/arch/tools, no package installs
58+
# L1: Quick sanity (~1min) — host/arch checks only, no package installs
5259
test-vm-quick: build
5360
go test -v -timeout 5m -tags="e2e,vm" -run "TestVM_Infra" ./test/e2e/...
5461

55-
# L2: Release validation (~20min) — run before tagging a release
56-
# Core user journeys: dry-run safety, install + verify, diff/clean cycle,
57-
# manual uninstall recovery, full setup, error messages
62+
# L2: Release validation (~20min) — core user journeys
5863
test-vm-release: build
5964
go test -v -timeout 30m -tags="e2e,vm" \
60-
-run "TestVM_Infra|TestVM_Journey_DryRun|TestVM_Journey_FirstTimeUser|TestVM_Journey_ManualUninstall|TestVM_Journey_DiffConsistency|TestVM_Journey_FullSetup|TestVM_Journey_ErrorMessages" \
65+
-run "TestVM_Infra|TestVM_Journey_DryRunIsCompletelySafe|TestVM_Journey_FirstTimeUser|TestVM_Journey_FullSetupConfiguresEverything|TestE2E_DryRunMinimal|TestE2E_SnapshotCapture" \
6166
./test/e2e/...
6267

63-
# L3: Full validation (~60min) — run for major releases or CI
64-
# All 48 tests: journeys + edge cases + commands + interactive
68+
# L3: Full validation (~60min) — everything under -tags="e2e,vm"
6569
test-vm-full: build
6670
go test -v -timeout 90m -tags="e2e,vm" ./test/e2e/...
6771

6872
# Aliases
6973
test-vm: test-vm-release
7074

71-
# Single VM test by name (e.g. make test-vm-run TEST=TestVM_Journey_DryRun)
75+
# Single test by name (e.g. make test-vm-run TEST=TestVM_Journey_DryRunIsCompletelySafe)
7276
test-vm-run: build
7377
go test -v -timeout 45m -tags="e2e,vm" -run $(TEST) ./test/e2e/...
7478

test/e2e/misc_e2e_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ func TestE2E_FullPreset_DryRun(t *testing.T) {
1414
t.Skip("skipping VM test in short mode")
1515
}
1616

17-
vm := testutil.NewTartVM(t)
17+
vm := testutil.NewMacHost(t)
1818
vmInstallHomebrew(t, vm)
1919
bin := vmCopyDevBinary(t, vm)
2020

test/e2e/openboot_e2e_test.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ func TestE2E_DryRunMinimal(t *testing.T) {
1717
t.Skip("skipping VM test in short mode")
1818
}
1919

20-
vm := testutil.NewTartVM(t)
20+
vm := testutil.NewMacHost(t)
2121
vmInstallHomebrew(t, vm)
2222
bin := vmCopyDevBinary(t, vm)
2323

@@ -35,7 +35,7 @@ func TestE2E_DryRunDeveloper(t *testing.T) {
3535
t.Skip("skipping VM test in short mode")
3636
}
3737

38-
vm := testutil.NewTartVM(t)
38+
vm := testutil.NewMacHost(t)
3939
vmInstallHomebrew(t, vm)
4040
bin := vmCopyDevBinary(t, vm)
4141

@@ -53,7 +53,7 @@ func TestE2E_SnapshotCapture(t *testing.T) {
5353
t.Skip("skipping VM test in short mode")
5454
}
5555

56-
vm := testutil.NewTartVM(t)
56+
vm := testutil.NewMacHost(t)
5757
vmInstallHomebrew(t, vm)
5858
bin := vmCopyDevBinary(t, vm)
5959

@@ -71,7 +71,7 @@ func TestE2E_InvalidPreset(t *testing.T) {
7171
t.Skip("skipping VM test in short mode")
7272
}
7373

74-
vm := testutil.NewTartVM(t)
74+
vm := testutil.NewMacHost(t)
7575
vmInstallHomebrew(t, vm)
7676
bin := vmCopyDevBinary(t, vm)
7777

@@ -91,7 +91,7 @@ func TestE2E_MissingGitConfig(t *testing.T) {
9191
t.Skip("skipping VM test in short mode")
9292
}
9393

94-
vm := testutil.NewTartVM(t)
94+
vm := testutil.NewMacHost(t)
9595
vmInstallHomebrew(t, vm)
9696
bin := vmCopyDevBinary(t, vm)
9797

@@ -108,7 +108,7 @@ func TestE2E_SnapshotWithOutput(t *testing.T) {
108108
t.Skip("skipping VM test in short mode")
109109
}
110110

111-
vm := testutil.NewTartVM(t)
111+
vm := testutil.NewMacHost(t)
112112
vmInstallHomebrew(t, vm)
113113
bin := vmCopyDevBinary(t, vm)
114114

@@ -127,7 +127,7 @@ func TestE2E_Diff_ThenClean_DryRun_SameSnapshot(t *testing.T) {
127127
}
128128

129129
// Verify diff and clean produce consistent results from the same snapshot
130-
vm := testutil.NewTartVM(t)
130+
vm := testutil.NewMacHost(t)
131131
vmInstallHomebrew(t, vm)
132132
bin := vmCopyDevBinary(t, vm)
133133

test/e2e/sync_shell_e2e_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ func TestE2E_Sync_Shell_CaptureShell(t *testing.T) {
1818
t.Skip("skipping VM test in short mode")
1919
}
2020

21-
vm := testutil.NewTartVM(t)
21+
vm := testutil.NewMacHost(t)
2222
installOhMyZsh(t, vm)
2323
bin := vmCopyDevBinary(t, vm)
2424

@@ -49,7 +49,7 @@ func TestE2E_Sync_Shell_NoPanic(t *testing.T) {
4949
t.Skip("skipping VM test in short mode")
5050
}
5151

52-
vm := testutil.NewTartVM(t)
52+
vm := testutil.NewMacHost(t)
5353
installOhMyZsh(t, vm)
5454
bin := vmCopyDevBinary(t, vm)
5555

test/e2e/vm_edge_cases_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ func TestVM_Edge_ShellActuallyWorks(t *testing.T) {
2727
t.Skip("skipping VM edge case in short mode")
2828
}
2929

30-
vm := testutil.NewTartVM(t)
30+
vm := testutil.NewMacHost(t)
3131
vmInstallHomebrew(t, vm)
3232
bin := vmCopyDevBinary(t, vm)
3333

0 commit comments

Comments
 (0)