diff --git a/.claude/hooks/post-tool-use.sh b/.claude/hooks/post-tool-use.sh index 91838ba..ad17ff6 100755 --- a/.claude/hooks/post-tool-use.sh +++ b/.claude/hooks/post-tool-use.sh @@ -41,6 +41,11 @@ pkg_dir=$(dirname "$rel_path") # Run go vet on just the touched package — fast (<1s warm). if ! out=$(go vet "./$pkg_dir/..." 2>&1); then + # "matched no packages" means the package uses build constraints that exclude + # the default build (e.g. //go:build e2e && vm). Not a real vet failure. + case "$out" in + *"matched no packages"*) exit 0 ;; + esac printf '[openboot post-tool-use] go vet failed for ./%s:\n%s\n' "$pkg_dir" "$out" >&2 exit 2 fi diff --git a/Makefile b/Makefile index d32791e..2639b71 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,12 @@ .PHONY: test-unit test-e2e test-coverage test-all \ - test-vm test-vm-run test-vm-inner test-vm-inner-run \ + test-vm test-vm-run test-vm-parallel test-vm-inner test-vm-inner-run \ install-hooks uninstall-hooks +# VM A: install/journey tests that touch real system state (longest-running). +VM_A_TESTS := TestVM_Journey_FirstTimeUser|TestVM_Journey_DryRunIsCompletelySafe|TestVM_Journey_FullSetupConfiguresEverything|TestVM_Interactive_InstallScript +# VM B: all other VM tests — dotfiles, macOS, edge cases, smoke, real-install, sync. +VM_B_TESTS := TestVM_Journey_Dotfiles|TestVM_Journey_MacOS|TestVM_Edge_|TestSmoke_|TestE2E_ + BINARY_NAME=openboot BINARY_PATH=./$(BINARY_NAME) VERSION ?= dev @@ -30,8 +35,8 @@ test-all: # ============================================================================= # Tart VM e2e — destructive tests run inside a throwaway Tart VM provisioned -# by scripts/vm/run.sh. The 12 files in test/e2e/ run via the `e2e,vm` build -# tag; the VM driver SSHs in and invokes `make test-vm-inner`. +# by scripts/vm/run.sh. Files tagged `e2e,vm` run via `make test-vm-inner`; +# files tagged `e2e && !vm` (auth, snapshot_api) run as L3 on the host. # # Requires Apple Silicon + Tart installed locally. See scripts/vm/README.md # for one-time setup. The relevant targets are defined immediately below @@ -46,12 +51,23 @@ test-vm: build test-vm-run: build scripts/vm/run.sh "test-vm-inner-run TEST=$(TEST)" +# Developer-facing: runs e2e in two parallel VMs — VM A (system tests) and +# VM B (mock-server tests). Requires ~16 GB RAM and 8 cores free. +# Exit code is non-zero if either VM fails. +test-vm-parallel: build + @OPENBOOT_VM_TEST='$(VM_A_TESTS)' scripts/vm/run.sh test-vm-inner & PID_A=$$!; \ + OPENBOOT_VM_TEST='$(VM_B_TESTS)' scripts/vm/run.sh test-vm-inner & PID_B=$$!; \ + A_EXIT=0; B_EXIT=0; \ + wait $$PID_A || A_EXIT=$$?; \ + wait $$PID_B || B_EXIT=$$?; \ + [ $$A_EXIT -eq 0 ] && [ $$B_EXIT -eq 0 ] + # In-VM: invoked over SSH by run.sh — not called by developers directly. test-vm-inner: go test -v -timeout 60m -tags="e2e,vm" ./test/e2e/... test-vm-inner-run: - go test -v -timeout 45m -tags="e2e,vm" -run $(TEST) ./test/e2e/... + go test -v -timeout 45m -tags="e2e,vm" -run '$(TEST)' ./test/e2e/... build: go build -ldflags="$(LDFLAGS)" -o $(BINARY_PATH) ./cmd/openboot diff --git a/scripts/vm/run.sh b/scripts/vm/run.sh index 35fe60c..7038d29 100755 --- a/scripts/vm/run.sh +++ b/scripts/vm/run.sh @@ -21,6 +21,7 @@ TARGET="$1" BASE="${OPENBOOT_VM_BASE:-macos-tahoe-base}" KEEP="${OPENBOOT_VM_KEEP:-0}" +VM_TEST="${OPENBOOT_VM_TEST:-}" VM="openboot-ephemeral-$$" # Pre-flight — all checks before we create any disk state. @@ -78,12 +79,18 @@ tart exec "$VM" sh -c '/opt/homebrew/bin/mise install go@latest && /opt/homebrew REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" rsync -az --delete \ --exclude='/.git/objects' \ - --exclude='/openboot' \ --exclude='/coverage.out' \ --exclude='/coverage.html' \ -e "ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o IdentitiesOnly=yes -i $SSH_KEY" \ "$REPO_ROOT/" "admin@${VM_IP}:/Users/admin/openboot/" -# Execute target inside VM — use mise exec to put Go on PATH +# Execute target inside VM — use mise exec to put Go on PATH. +# When OPENBOOT_VM_TEST is set, run only the matching tests; single-quote the +# value so that regexp metacharacters (| [ ]) survive the remote shell intact. +if [ -n "$VM_TEST" ]; then + MAKE_CMD="test-vm-inner-run TEST='${VM_TEST}'" +else + MAKE_CMD="${TARGET}" +fi ssh_exec "$VM_IP" "$SSH_KEY" \ - "cd /Users/admin/openboot && CI=true OPENBOOT_IN_VM=1 /opt/homebrew/bin/mise exec go -- make ${TARGET}" + "cd /Users/admin/openboot && CI=true OPENBOOT_IN_VM=1 /opt/homebrew/bin/mise exec go -- make ${MAKE_CMD}" diff --git a/test/e2e/auth_e2e_test.go b/test/e2e/auth_e2e_test.go index 82e3aaa..ac63d5f 100644 --- a/test/e2e/auth_e2e_test.go +++ b/test/e2e/auth_e2e_test.go @@ -1,6 +1,6 @@ -//go:build e2e && vm +//go:build e2e && !vm -// Package e2e contains VM-based E2E tests for the login/logout commands, +// Package e2e contains E2E tests for the login/logout commands, // exercising the full OAuth device flow via the compiled binary against a // local mock HTTP server. // @@ -12,7 +12,6 @@ package e2e import ( "encoding/json" - "fmt" "net/http" "net/http/httptest" "os" @@ -37,13 +36,8 @@ import ( // User expectation: after running `openboot login`, a valid auth.json containing // the token returned by the server should exist at ~/.openboot/auth.json. func TestE2E_Login_SuccessfulOAuthFlow(t *testing.T) { - if testing.Short() { - t.Skip("skipping e2e test in short mode") - } - - vm := testutil.NewMacHost(t) - bin := vmCopyDevBinary(t, vm) - tmpHome := t.TempDir() + binary := testutil.BuildTestBinary(t) + home := t.TempDir() // Mock API: /start returns a code; /poll immediately approves. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -65,20 +59,15 @@ func TestE2E_Login_SuccessfulOAuthFlow(t *testing.T) { http.NotFound(w, r) } })) - defer srv.Close() + t.Cleanup(srv.Close) - // Inline env overrides guarantee HOME and OPENBOOT_API_URL win over any - // inherited values in the bash subprocess. - cmd := fmt.Sprintf( - "HOME=%q OPENBOOT_API_URL=%q OPENBOOT_DISABLE_AUTOUPDATE=1 PATH=%q %s login", - tmpHome, srv.URL, brewPath, bin, - ) - output, err := vm.Run(cmd) + stdout, stderr, err := runBinary(t, binary, isolatedEnv(home, srv.URL), "login") + output := stdout + stderr t.Logf("login output:\n%s", output) require.NoError(t, err, "login should succeed against mock server") // The binary must have written auth.json with the token from our server. - authFile := filepath.Join(tmpHome, ".openboot", "auth.json") + authFile := filepath.Join(home, ".openboot", "auth.json") data, readErr := os.ReadFile(authFile) require.NoError(t, readErr, "auth.json should exist after successful login") @@ -93,23 +82,14 @@ func TestE2E_Login_SuccessfulOAuthFlow(t *testing.T) { // "already logged in" when a valid auth.json already exists, without hitting // the OAuth flow at all. func TestE2E_Login_AlreadyAuthenticated(t *testing.T) { - if testing.Short() { - t.Skip("skipping e2e test in short mode") - } + binary := testutil.BuildTestBinary(t) + home := t.TempDir() + writeTestAuthFile(t, home, "obt_existing", "existinguser") - vm := testutil.NewMacHost(t) - bin := vmCopyDevBinary(t, vm) - tmpHome := t.TempDir() - writeTestAuthFile(t, tmpHome, "obt_existing", "existinguser") - - cmd := fmt.Sprintf( - "HOME=%q OPENBOOT_DISABLE_AUTOUPDATE=1 PATH=%q %s login", - tmpHome, brewPath, bin, - ) - output, err := vm.Run(cmd) + stdout, stderr, err := runBinary(t, binary, isolatedEnv(home, ""), "login") + output := stdout + stderr t.Logf("login output:\n%s", output) require.NoError(t, err, "login should succeed when already authenticated") - // loginCmd prints: ui.Success(fmt.Sprintf("Already logged in as %s", stored.Username)) assert.Contains(t, output, "Already logged in as existinguser", "output should say already logged in with the username") } @@ -117,23 +97,14 @@ func TestE2E_Login_AlreadyAuthenticated(t *testing.T) { // TestE2E_Login_ServerUnavailable verifies that `openboot login` returns a // non-zero exit code and a meaningful error when the auth API is unreachable. func TestE2E_Login_ServerUnavailable(t *testing.T) { - if testing.Short() { - t.Skip("skipping e2e test in short mode") - } - - vm := testutil.NewMacHost(t) - bin := vmCopyDevBinary(t, vm) - tmpHome := t.TempDir() + binary := testutil.BuildTestBinary(t) + home := t.TempDir() // Port 19999 has nothing listening. - cmd := fmt.Sprintf( - "HOME=%q OPENBOOT_API_URL=%q OPENBOOT_DISABLE_AUTOUPDATE=1 PATH=%q %s login", - tmpHome, "http://127.0.0.1:19999", brewPath, bin, - ) - output, err := vm.Run(cmd) + stdout, stderr, err := runBinary(t, binary, isolatedEnv(home, "http://127.0.0.1:19999"), "login") + output := stdout + stderr t.Logf("login output:\n%s", output) assert.Error(t, err, "login should fail when server is unreachable") - // loginCmd returns: fmt.Errorf("login failed: %w", err) assert.Contains(t, output, "login failed", "error output should say 'login failed', got: %s", output) } @@ -142,13 +113,8 @@ func TestE2E_Login_ServerUnavailable(t *testing.T) { // "authorization code expired" error from the poll endpoint so the user // knows to run `openboot login` again — rather than hanging until timeout. func TestE2E_Login_ExpiredCodeRejected(t *testing.T) { - if testing.Short() { - t.Skip("skipping e2e test in short mode") - } - - vm := testutil.NewMacHost(t) - bin := vmCopyDevBinary(t, vm) - tmpHome := t.TempDir() + binary := testutil.BuildTestBinary(t) + home := t.TempDir() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") @@ -166,21 +132,17 @@ func TestE2E_Login_ExpiredCodeRejected(t *testing.T) { http.NotFound(w, r) } })) - defer srv.Close() + t.Cleanup(srv.Close) - cmd := fmt.Sprintf( - "HOME=%q OPENBOOT_API_URL=%q OPENBOOT_DISABLE_AUTOUPDATE=1 PATH=%q %s login", - tmpHome, srv.URL, brewPath, bin, - ) - output, err := vm.Run(cmd) + stdout, stderr, err := runBinary(t, binary, isolatedEnv(home, srv.URL), "login") + output := stdout + stderr t.Logf("login output:\n%s", output) assert.Error(t, err, "login should fail when code is expired") - // pollOnce returns: fmt.Errorf("authorization code expired; please run 'openboot login' again") assert.Contains(t, output, "expired", "error output should mention the expired code, got: %s", output) // auth.json must NOT have been written after a failed login. - authFile := filepath.Join(tmpHome, ".openboot", "auth.json") + authFile := filepath.Join(home, ".openboot", "auth.json") assert.NoFileExists(t, authFile, "auth.json must not be created after failed login") } @@ -191,49 +153,31 @@ func TestE2E_Login_ExpiredCodeRejected(t *testing.T) { // TestE2E_Logout_WhenAuthenticated verifies that `openboot logout` removes the // auth.json token file and confirms the username in its output. func TestE2E_Logout_WhenAuthenticated(t *testing.T) { - if testing.Short() { - t.Skip("skipping e2e test in short mode") - } + binary := testutil.BuildTestBinary(t) + home := t.TempDir() + writeTestAuthFile(t, home, "obt_logout_token", "logoutuser") - vm := testutil.NewMacHost(t) - bin := vmCopyDevBinary(t, vm) - tmpHome := t.TempDir() - writeTestAuthFile(t, tmpHome, "obt_logout_token", "logoutuser") - - cmd := fmt.Sprintf( - "HOME=%q OPENBOOT_DISABLE_AUTOUPDATE=1 PATH=%q %s logout", - tmpHome, brewPath, bin, - ) - output, err := vm.Run(cmd) + stdout, stderr, err := runBinary(t, binary, isolatedEnv(home, ""), "logout") + output := stdout + stderr t.Logf("logout output:\n%s", output) require.NoError(t, err, "logout should succeed") - // logoutCmd prints: ui.Success(fmt.Sprintf("Logged out of %s", stored.Username)) assert.Contains(t, output, "Logged out of logoutuser", "output should confirm logout with username") - authFile := filepath.Join(tmpHome, ".openboot", "auth.json") + authFile := filepath.Join(home, ".openboot", "auth.json") assert.NoFileExists(t, authFile, "auth.json should be deleted after logout") } // TestE2E_Logout_WhenNotAuthenticated verifies that `openboot logout` handles // the "not logged in" state gracefully (exit 0, informative message, no crash). func TestE2E_Logout_WhenNotAuthenticated(t *testing.T) { - if testing.Short() { - t.Skip("skipping e2e test in short mode") - } - - vm := testutil.NewMacHost(t) - bin := vmCopyDevBinary(t, vm) - tmpHome := t.TempDir() + binary := testutil.BuildTestBinary(t) + home := t.TempDir() - cmd := fmt.Sprintf( - "HOME=%q OPENBOOT_DISABLE_AUTOUPDATE=1 PATH=%q %s logout", - tmpHome, brewPath, bin, - ) - output, err := vm.Run(cmd) + stdout, stderr, err := runBinary(t, binary, isolatedEnv(home, ""), "logout") + output := stdout + stderr t.Logf("logout output:\n%s", output) require.NoError(t, err, "logout should not fail when not logged in") - // logoutCmd prints: ui.Info("Not logged in.") assert.Contains(t, output, "Not logged in", "output should say 'Not logged in', got: %s", output) } @@ -242,10 +186,10 @@ func TestE2E_Logout_WhenNotAuthenticated(t *testing.T) { // helpers // ============================================================================= -// writeTestAuthFile writes a non-expired auth.json under tmpHome/.openboot/. -func writeTestAuthFile(t *testing.T, tmpHome, token, username string) { +// writeTestAuthFile writes a non-expired auth.json under home/.openboot/. +func writeTestAuthFile(t *testing.T, home, token, username string) { t.Helper() - authDir := filepath.Join(tmpHome, ".openboot") + authDir := filepath.Join(home, ".openboot") require.NoError(t, os.MkdirAll(authDir, 0700)) stored := auth.StoredAuth{ diff --git a/test/e2e/cli_binary_e2e_test.go b/test/e2e/cli_binary_e2e_test.go new file mode 100644 index 0000000..259ae27 --- /dev/null +++ b/test/e2e/cli_binary_e2e_test.go @@ -0,0 +1,44 @@ +//go:build e2e && !vm + +// Basic binary behavior tests that require the compiled binary but no system +// state or VM — pure argument validation and version checks. + +package e2e + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/openbootdotdev/openboot/testutil" +) + +// TestSmoke_VersionMatchesBuild verifies the compiled binary reports a version. +func TestSmoke_VersionMatchesBuild(t *testing.T) { + binary := testutil.BuildTestBinary(t) + home := t.TempDir() + + stdout, stderr, err := runBinary(t, binary, isolatedEnv(home, ""), "version") + output := stdout + stderr + require.NoError(t, err, "version command should succeed") + assert.Contains(t, output, "OpenBoot v", "version output should contain version prefix") +} + +// TestE2E_InvalidPreset verifies that an unrecognised preset name causes a +// non-zero exit and an error message mentioning the bad value. +// This is pure argument validation — the binary exits before touching any +// system state, so no VM is needed. +func TestE2E_InvalidPreset(t *testing.T) { + binary := testutil.BuildTestBinary(t) + home := t.TempDir() + + stdout, stderr, err := runBinary(t, binary, isolatedEnv(home, ""), + "install", "--preset", "invalid-preset-xyz", "--dry-run", "--silent") + output := stdout + stderr + assert.Error(t, err, "invalid preset should cause command to fail") + assert.True(t, + strings.Contains(output, "invalid") || strings.Contains(output, "unknown") || strings.Contains(output, "error"), + "error output should mention invalid preset, got: %s", output) +} diff --git a/test/e2e/dotfiles_e2e_test.go b/test/e2e/dotfiles_e2e_test.go index 180199a..f041830 100644 --- a/test/e2e/dotfiles_e2e_test.go +++ b/test/e2e/dotfiles_e2e_test.go @@ -61,7 +61,7 @@ func TestVM_Journey_DotfilesClonedAndLinked(t *testing.T) { symsBefore := strings.TrimSpace(beforeOut) output, err := vmRunDevBinaryWithGit(t, vm, bin, - "--preset minimal --silent --dotfiles clone --shell skip --macos skip") + "install --preset minimal --silent --dotfiles clone --shell skip --macos skip") t.Logf("dotfiles setup output:\n%s", output) require.NoError(t, err, "install with --dotfiles clone should succeed") @@ -104,7 +104,7 @@ func TestVM_Journey_DotfilesClonedAndLinked(t *testing.T) { t.Run("second_install_is_idempotent", func(t *testing.T) { _, err := vmRunDevBinaryWithGit(t, vm, bin, - "--preset minimal --silent --dotfiles clone --shell skip --macos skip") + "install --preset minimal --silent --dotfiles clone --shell skip --macos skip") assert.NoError(t, err, "running --dotfiles clone a second time should not fail") }) @@ -127,14 +127,14 @@ func TestVM_Journey_DotfilesLink_OnlyLinks(t *testing.T) { // Pre-clone so the dotfiles directory exists. _, err := vmRunDevBinaryWithGit(t, vm, bin, - "--preset minimal --silent --dotfiles clone --shell skip --macos skip") + "install --preset minimal --silent --dotfiles clone --shell skip --macos skip") require.NoError(t, err, "pre-clone should succeed") // Record the current origin commit to confirm link-only does not fetch. commitBefore, _ := vm.Run("git -C ~/.dotfiles rev-parse HEAD 2>/dev/null") _, err = vmRunDevBinaryWithGit(t, vm, bin, - "--preset minimal --silent --dotfiles link --shell skip --macos skip") + "install --preset minimal --silent --dotfiles link --shell skip --macos skip") require.NoError(t, err, "--dotfiles link should succeed when repo exists") t.Run("repo_commit_unchanged", func(t *testing.T) { diff --git a/test/e2e/macos_defaults_e2e_test.go b/test/e2e/macos_defaults_e2e_test.go index d43d345..d4ddfb4 100644 --- a/test/e2e/macos_defaults_e2e_test.go +++ b/test/e2e/macos_defaults_e2e_test.go @@ -49,7 +49,7 @@ func TestVM_Journey_MacOSDefaults_AllCategoriesWritten(t *testing.T) { bin := vmCopyDevBinary(t, vm) output, err := vmRunDevBinaryWithGit(t, vm, bin, - "--preset minimal --silent --shell skip --dotfiles skip --macos configure") + "install --preset minimal --silent --shell skip --dotfiles skip --macos configure") t.Logf("macOS configure output:\n%s", output) require.NoError(t, err, "install with --macos configure should succeed") @@ -156,7 +156,7 @@ func TestVM_Journey_MacOSDefaults_ScreenshotsDirCreated(t *testing.T) { _, _ = vm.Run("rm -rf ~/Screenshots") _, err := vmRunDevBinaryWithGit(t, vm, bin, - "--preset minimal --silent --shell skip --dotfiles skip --macos configure") + "install --preset minimal --silent --shell skip --dotfiles skip --macos configure") require.NoError(t, err, "install with --macos configure should succeed") out, _ := vm.Run("test -d ~/Screenshots && echo exists || echo missing") @@ -189,7 +189,7 @@ func TestVM_Journey_MacOSDefaults_DryRunWritesNothing(t *testing.T) { ) _, err = vmRunDevBinaryWithGit(t, vm, bin, - "--preset minimal --silent --shell skip --dotfiles skip --macos configure --dry-run") + "install --preset minimal --silent --shell skip --dotfiles skip --macos configure --dry-run") require.NoError(t, err, "dry-run should succeed") after, _ := vm.Run( diff --git a/test/e2e/misc_e2e_test.go b/test/e2e/misc_e2e_test.go deleted file mode 100644 index a6a7a34..0000000 --- a/test/e2e/misc_e2e_test.go +++ /dev/null @@ -1,29 +0,0 @@ -//go:build e2e && vm - -package e2e - -import ( - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/openbootdotdev/openboot/testutil" -) - -func TestE2E_FullPreset_DryRun(t *testing.T) { - if testing.Short() { - t.Skip("skipping VM test in short mode") - } - - vm := testutil.NewMacHost(t) - vmInstallHomebrew(t, vm) - bin := vmCopyDevBinary(t, vm) - - env := map[string]string{ - "PATH": brewPath, - "OPENBOOT_GIT_NAME": "Test User", - "OPENBOOT_GIT_EMAIL": "test@example.com", - } - output, err := vm.RunWithEnv(env, bin+" --preset full --dry-run --silent") - assert.NoError(t, err, "dry-run with full preset should succeed, output: %s", output) -} diff --git a/test/e2e/openboot_e2e_test.go b/test/e2e/openboot_e2e_test.go deleted file mode 100644 index 598fe54..0000000 --- a/test/e2e/openboot_e2e_test.go +++ /dev/null @@ -1,148 +0,0 @@ -//go:build e2e && vm - -package e2e - -import ( - "encoding/json" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/openbootdotdev/openboot/testutil" -) - -func TestE2E_DryRunMinimal(t *testing.T) { - if testing.Short() { - t.Skip("skipping VM test in short mode") - } - - vm := testutil.NewMacHost(t) - vmInstallHomebrew(t, vm) - bin := vmCopyDevBinary(t, vm) - - env := map[string]string{ - "PATH": brewPath, - "OPENBOOT_GIT_NAME": "Test User", - "OPENBOOT_GIT_EMAIL": "test@example.com", - } - output, err := vm.RunWithEnv(env, bin+" --preset minimal --dry-run --silent") - assert.NoError(t, err, "dry-run with minimal preset should succeed, output: %s", output) -} - -func TestE2E_DryRunDeveloper(t *testing.T) { - if testing.Short() { - t.Skip("skipping VM test in short mode") - } - - vm := testutil.NewMacHost(t) - vmInstallHomebrew(t, vm) - bin := vmCopyDevBinary(t, vm) - - env := map[string]string{ - "PATH": brewPath, - "OPENBOOT_GIT_NAME": "Test User", - "OPENBOOT_GIT_EMAIL": "test@example.com", - } - output, err := vm.RunWithEnv(env, bin+" --preset developer --dry-run --silent") - assert.NoError(t, err, "dry-run with developer preset should succeed, output: %s", output) -} - -func TestE2E_SnapshotCapture(t *testing.T) { - if testing.Short() { - t.Skip("skipping VM test in short mode") - } - - vm := testutil.NewMacHost(t) - vmInstallHomebrew(t, vm) - bin := vmCopyDevBinary(t, vm) - - output, err := vmRunDevBinary(t, vm, bin, "snapshot --json") - require.NoError(t, err, "snapshot command should succeed, output: %s", output) - - var snapshotData map[string]interface{} - err = json.Unmarshal([]byte(output), &snapshotData) - assert.NoError(t, err, "snapshot output should be valid JSON") - assert.Greater(t, len(output), 0, "snapshot output should not be empty") -} - -func TestE2E_InvalidPreset(t *testing.T) { - if testing.Short() { - t.Skip("skipping VM test in short mode") - } - - vm := testutil.NewMacHost(t) - vmInstallHomebrew(t, vm) - bin := vmCopyDevBinary(t, vm) - - env := map[string]string{ - "PATH": brewPath, - "OPENBOOT_GIT_NAME": "Test User", - "OPENBOOT_GIT_EMAIL": "test@example.com", - } - output, err := vm.RunWithEnv(env, bin+" --preset invalid-preset-xyz --dry-run --silent") - assert.Error(t, err, "invalid preset should cause command to fail") - assert.True(t, strings.Contains(output, "invalid") || strings.Contains(output, "unknown") || strings.Contains(output, "error"), - "error output should mention invalid preset, got: %s", output) -} - -func TestE2E_MissingGitConfig(t *testing.T) { - if testing.Short() { - t.Skip("skipping VM test in short mode") - } - - vm := testutil.NewMacHost(t) - vmInstallHomebrew(t, vm) - bin := vmCopyDevBinary(t, vm) - - // Run without OPENBOOT_GIT_NAME / OPENBOOT_GIT_EMAIL so git config is absent - output, err := vmRunDevBinary(t, vm, bin, "--preset minimal --dry-run --silent") - if err == nil { - t.Logf("Command succeeded when git config was missing. This may be OK if git is already configured globally.") - t.Logf("Output: %s", output) - } -} - -func TestE2E_SnapshotWithOutput(t *testing.T) { - if testing.Short() { - t.Skip("skipping VM test in short mode") - } - - vm := testutil.NewMacHost(t) - vmInstallHomebrew(t, vm) - bin := vmCopyDevBinary(t, vm) - - output, err := vmRunDevBinary(t, vm, bin, "snapshot --json") - require.NoError(t, err, "snapshot --json should succeed, output: %s", output) - - var data map[string]interface{} - err = json.Unmarshal([]byte(output), &data) - assert.NoError(t, err, "snapshot output should be valid JSON") - assert.Greater(t, len(output), 0) -} - -func TestE2E_Diff_ThenClean_DryRun_SameSnapshot(t *testing.T) { - if testing.Short() { - t.Skip("skipping VM test in short mode") - } - - // Verify diff and clean produce consistent results from the same snapshot - vm := testutil.NewMacHost(t) - vmInstallHomebrew(t, vm) - bin := vmCopyDevBinary(t, vm) - - // Write a minimal snapshot to the VM - remotePath := "/tmp/e2e-combined-snapshot.json" - vmWriteTestSnapshot(t, vm, remotePath, []string{"git"}, []string{}, []string{}) - - // Run diff - diffOutput, diffErr := vmRunDevBinary(t, vm, bin, "diff --from "+remotePath) - - // Run clean --dry-run - cleanOutput, cleanErr := vmRunDevBinary(t, vm, bin, "clean --from "+remotePath+" --dry-run") - - // Both should succeed - assert.NoError(t, diffErr, "diff should succeed, output: %s", diffOutput) - assert.NoError(t, cleanErr, "clean --dry-run should succeed, output: %s", cleanOutput) -} diff --git a/test/e2e/real_install_test.go b/test/e2e/real_install_test.go index 8452e9b..344d771 100644 --- a/test/e2e/real_install_test.go +++ b/test/e2e/real_install_test.go @@ -6,10 +6,8 @@ import ( "encoding/json" "os" "os/exec" - "path/filepath" "strings" "testing" - "time" "github.com/openbootdotdev/openboot/testutil" "github.com/stretchr/testify/assert" @@ -59,16 +57,10 @@ func TestE2E_InstallMultiplePackages(t *testing.T) { } binary := testutil.BuildTestBinary(t) - // When: we install multiple packages - tmpConfig := createTempConfig(t, `{ - "packages": { - "brew": ["bat", "fd"] - } - }`) - defer os.Remove(tmpConfig) - + // When: we install multiple packages via the minimal preset (includes bat + fd) cmd := exec.Command(binary, "install", "--packages-only", "--silent", "--preset", "minimal") cmd.Env = append(os.Environ(), + "PATH=/opt/homebrew/bin:/opt/homebrew/sbin:"+os.Getenv("PATH"), "OPENBOOT_GIT_NAME=Test User", "OPENBOOT_GIT_EMAIL=test@example.com", ) @@ -93,13 +85,13 @@ func TestE2E_InstallMultiplePackages(t *testing.T) { } } -func TestE2E_SnapshotRestoreRealPackages(t *testing.T) { +func TestE2E_SnapshotCapture_RecordsInstalledPackage(t *testing.T) { binary := testutil.BuildTestBinary(t) testPkg := "ripgrep" // Given: ripgrep is installed if !testutil.IsPackageInstalled(testPkg) { - cmd := exec.Command("brew", "install", testPkg) + cmd := exec.Command("/opt/homebrew/bin/brew", "install", testPkg) require.NoError(t, cmd.Run(), "test setup: should install ripgrep") } @@ -117,7 +109,7 @@ func TestE2E_SnapshotRestoreRealPackages(t *testing.T) { err = json.Unmarshal([]byte(snapshotJSON), &snapshot) require.NoError(t, err, "snapshot should be valid JSON") - // Then: snapshot should contain ripgrep + // Then: snapshot should contain ripgrep in formulae packages, ok := snapshot["packages"].(map[string]interface{}) require.True(t, ok, "snapshot should have packages field") @@ -132,130 +124,5 @@ func TestE2E_SnapshotRestoreRealPackages(t *testing.T) { } } assert.True(t, foundRipgrep, "snapshot should contain ripgrep") - - // Save snapshot to file - tmpDir := t.TempDir() - snapshotPath := filepath.Join(tmpDir, "test-snapshot.json") - err = os.WriteFile(snapshotPath, []byte(snapshotJSON), 0644) - require.NoError(t, err) - - // When: we uninstall ripgrep and restore from snapshot - testutil.UninstallPackage(t, testPkg) - assert.False(t, testutil.IsPackageInstalled(testPkg), "ripgrep should be uninstalled") - - // Note: Actual restore would require implementing --restore flag - // For now, we verify the snapshot format is correct - content, err := os.ReadFile(snapshotPath) - require.NoError(t, err) - assert.Greater(t, len(content), 100, "snapshot should have substantial content") -} - -func TestE2E_InstallWithInvalidPackage(t *testing.T) { - // Test that brew handles an invalid package gracefully by attempting - // to install a non-existent formula directly. - invalidPkg := "this-package-definitely-does-not-exist-12345" - - cmd := exec.Command("brew", "install", invalidPkg) - output, err := cmd.CombinedOutput() - outStr := string(output) - t.Logf("Brew error output: %s", outStr) - - // Then: brew should return a non-zero exit code for invalid packages - assert.Error(t, err, "brew install of non-existent package should fail") - assert.True(t, strings.Contains(outStr, "No formulae or casks found") || - strings.Contains(outStr, "No available formula") || - strings.Contains(outStr, "not found"), - "should indicate package not found") -} - -func TestE2E_DryRunDoesNotInstall(t *testing.T) { - binary := testutil.BuildTestBinary(t) - testPkg := "cowsay" - - // Given: cowsay is not installed - testutil.EnsurePackageNotInstalled(t, testPkg) - - // When: we run with --dry-run - tmpConfig := createTempConfig(t, `{ - "packages": { - "brew": ["`+testPkg+`"] - } - }`) - defer os.Remove(tmpConfig) - - cmd := exec.Command(binary, "install", "--dry-run", "--packages-only", "--silent", "--preset", "minimal") - cmd.Env = append(os.Environ(), - "OPENBOOT_GIT_NAME=Test User", - "OPENBOOT_GIT_EMAIL=test@example.com", - ) - - output, err := cmd.CombinedOutput() - t.Logf("Dry-run output: %s", string(output)) - - // Then: cowsay should still not be installed - assert.NoError(t, err, "dry-run should succeed") - assert.False(t, testutil.IsPackageInstalled(testPkg), "dry-run should not actually install packages") -} - -func TestE2E_BrewUpdateBeforeInstall(t *testing.T) { - binary := testutil.BuildTestBinary(t) - - // Given: we request brew update - cmd := exec.Command(binary, "install", "--update", "--dry-run", "--packages-only", "--silent", "--preset", "minimal") - cmd.Env = append(os.Environ(), - "OPENBOOT_GIT_NAME=Test User", - "OPENBOOT_GIT_EMAIL=test@example.com", - ) - - start := time.Now() - output, err := cmd.CombinedOutput() - duration := time.Since(start) - - t.Logf("Update output: %s", string(output)) - t.Logf("Duration: %v", duration) - - // Then: command should succeed (update may happen or skip if recent) - assert.NoError(t, err, "update command should succeed") -} - -func TestE2E_GitConfigSetup(t *testing.T) { - binary := testutil.BuildTestBinary(t) - testName := "Test E2E User" - testEmail := "e2e-test@example.com" - - // Given: we have test git credentials - cmd := exec.Command(binary, "install", "--packages-only", "--silent", "--preset", "minimal") - cmd.Env = append(os.Environ(), - "OPENBOOT_GIT_NAME="+testName, - "OPENBOOT_GIT_EMAIL="+testEmail, - ) - - // When: we run openboot - output, err := cmd.CombinedOutput() - t.Logf("Output: %s", string(output)) - - // Then: command should handle git config - assert.NoError(t, err, "should succeed with git config") - - // Verify git config is accessible (system-level test) - gitNameCheck := exec.Command("git", "config", "--global", "user.name") - nameOutput, _ := gitNameCheck.Output() - t.Logf("Git user.name: %s", string(nameOutput)) - - gitEmailCheck := exec.Command("git", "config", "--global", "user.email") - emailOutput, _ := gitEmailCheck.Output() - t.Logf("Git user.email: %s", string(emailOutput)) } -func createTempConfig(t *testing.T, jsonContent string) string { - tmpFile, err := os.CreateTemp("", "openboot-config-*.json") - require.NoError(t, err) - - _, err = tmpFile.WriteString(jsonContent) - require.NoError(t, err) - - err = tmpFile.Close() - require.NoError(t, err) - - return tmpFile.Name() -} diff --git a/test/e2e/smoke_test.go b/test/e2e/smoke_test.go index 93a2afa..144ed3c 100644 --- a/test/e2e/smoke_test.go +++ b/test/e2e/smoke_test.go @@ -49,7 +49,7 @@ func TestSmoke_InstallAndVerifySnapshot(t *testing.T) { require.False(t, beforeFormulae[testPkg], "cowsay should not be in before snapshot") // When: install cowsay via brew directly (simulates what openboot does) - installCmd := exec.Command("brew", "install", testPkg) + installCmd := exec.Command("/opt/homebrew/bin/brew", "install", testPkg) require.NoError(t, installCmd.Run(), "brew install cowsay should succeed") t.Cleanup(func() { testutil.UninstallPackage(t, testPkg) }) @@ -101,13 +101,3 @@ func TestSmoke_DryRunNoSideEffects(t *testing.T) { "dry-run should not change installed npm packages") } -func TestSmoke_VersionMatchesBuild(t *testing.T) { - binary := testutil.BuildTestBinary(t) - - cmd := exec.Command(binary, "version") - output, err := cmd.CombinedOutput() - outStr := string(output) - - require.NoError(t, err, "version command should succeed") - assert.Contains(t, outStr, "OpenBoot v", "version output should contain version prefix") -} diff --git a/test/e2e/snapshot_api_e2e_test.go b/test/e2e/snapshot_api_e2e_test.go index ad07cb2..c215f30 100644 --- a/test/e2e/snapshot_api_e2e_test.go +++ b/test/e2e/snapshot_api_e2e_test.go @@ -1,7 +1,7 @@ -//go:build e2e && vm +//go:build e2e && !vm -// Package e2e contains VM-based E2E tests for the snapshot publish and import -// commands exercised via the compiled binary. +// Package e2e contains E2E tests for the snapshot publish and import +// commands exercised via the compiled binary against a local mock HTTP server. // // Gaps filled: // - `snapshot --publish`: HTTP POST/PUT path was never run end-to-end; @@ -43,19 +43,11 @@ import ( // // Gap: the PUT path (update existing config) was never exercised via the binary. func TestE2E_Snapshot_Publish_UpdatesExistingConfig(t *testing.T) { - if testing.Short() { - t.Skip("skipping e2e test in short mode") - } - - vm := testutil.NewMacHost(t) - vmInstallHomebrew(t, vm) - bin := vmCopyDevBinary(t, vm) - tmpHome := t.TempDir() + binary := testutil.BuildTestBinary(t) + home := t.TempDir() - // Pre-write auth and sync source so the binary skips the login flow and - // resolves the target slug from the stored sync source. - writePublishAuthFile(t, tmpHome, "obt_pub_token", "pubuser") - writePublishSyncSource(t, tmpHome, "pubuser", "my-existing-config") + writePublishAuthFile(t, home, "obt_pub_token", "pubuser") + writePublishSyncSource(t, home, "pubuser", "my-existing-config") var receivedMethod string var receivedAuth string @@ -69,17 +61,13 @@ func TestE2E_Snapshot_Publish_UpdatesExistingConfig(t *testing.T) { w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]string{"slug": "my-existing-config"}) //nolint:errcheck // test helper default: - // Return an empty packages list so any background catalog fetch succeeds. json.NewEncoder(w).Encode(map[string]interface{}{"packages": []interface{}{}}) //nolint:errcheck // test helper } })) - defer srv.Close() + t.Cleanup(srv.Close) - cmd := fmt.Sprintf( - "HOME=%q OPENBOOT_API_URL=%q OPENBOOT_DISABLE_AUTOUPDATE=1 PATH=%q %s snapshot --publish", - tmpHome, srv.URL, brewPath, bin, - ) - output, err := vm.Run(cmd) + stdout, stderr, err := runBinary(t, binary, isolatedEnv(home, srv.URL), "snapshot", "--publish") + output := stdout + stderr t.Logf("publish output:\n%s", output) require.NoError(t, err, "snapshot --publish should succeed") @@ -102,16 +90,10 @@ func TestE2E_Snapshot_Publish_UpdatesExistingConfig(t *testing.T) { // // verifying that an explicit --slug forces PUT even without a stored sync source. func TestE2E_Snapshot_Publish_ExplicitSlugUpdate(t *testing.T) { - if testing.Short() { - t.Skip("skipping e2e test in short mode") - } + binary := testutil.BuildTestBinary(t) + home := t.TempDir() - vm := testutil.NewMacHost(t) - vmInstallHomebrew(t, vm) - bin := vmCopyDevBinary(t, vm) - tmpHome := t.TempDir() - - writePublishAuthFile(t, tmpHome, "obt_slug_token", "sluguser") + writePublishAuthFile(t, home, "obt_slug_token", "sluguser") srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") @@ -122,13 +104,10 @@ func TestE2E_Snapshot_Publish_ExplicitSlugUpdate(t *testing.T) { } json.NewEncoder(w).Encode(map[string]interface{}{"packages": []interface{}{}}) //nolint:errcheck // test helper })) - defer srv.Close() + t.Cleanup(srv.Close) - cmd := fmt.Sprintf( - "HOME=%q OPENBOOT_API_URL=%q OPENBOOT_DISABLE_AUTOUPDATE=1 PATH=%q %s snapshot --publish --slug my-config", - tmpHome, srv.URL, brewPath, bin, - ) - output, err := vm.Run(cmd) + stdout, stderr, err := runBinary(t, binary, isolatedEnv(home, srv.URL), "snapshot", "--publish", "--slug", "my-config") + output := stdout + stderr t.Logf("publish --slug output:\n%s", output) require.NoError(t, err, "snapshot --publish --slug should succeed") assert.True(t, @@ -143,17 +122,11 @@ func TestE2E_Snapshot_Publish_ExplicitSlugUpdate(t *testing.T) { // // Gap: slug conflicts were never exercised via the compiled binary. func TestE2E_Snapshot_Publish_ConflictError(t *testing.T) { - if testing.Short() { - t.Skip("skipping e2e test in short mode") - } + binary := testutil.BuildTestBinary(t) + home := t.TempDir() - vm := testutil.NewMacHost(t) - vmInstallHomebrew(t, vm) - bin := vmCopyDevBinary(t, vm) - tmpHome := t.TempDir() - - writePublishAuthFile(t, tmpHome, "obt_conflict_token", "conflictuser") - writePublishSyncSource(t, tmpHome, "conflictuser", "existing-slug") + writePublishAuthFile(t, home, "obt_conflict_token", "conflictuser") + writePublishSyncSource(t, home, "conflictuser", "existing-slug") srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") @@ -164,13 +137,10 @@ func TestE2E_Snapshot_Publish_ConflictError(t *testing.T) { } json.NewEncoder(w).Encode(map[string]interface{}{"packages": []interface{}{}}) //nolint:errcheck // test helper })) - defer srv.Close() + t.Cleanup(srv.Close) - cmd := fmt.Sprintf( - "HOME=%q OPENBOOT_API_URL=%q OPENBOOT_DISABLE_AUTOUPDATE=1 PATH=%q %s snapshot --publish", - tmpHome, srv.URL, brewPath, bin, - ) - output, err := vm.Run(cmd) + stdout, stderr, err := runBinary(t, binary, isolatedEnv(home, srv.URL), "snapshot", "--publish") + output := stdout + stderr t.Logf("conflict output:\n%s", output) assert.Error(t, err, "snapshot --publish should fail on 409") assert.True(t, @@ -190,21 +160,13 @@ func TestE2E_Snapshot_Publish_ConflictError(t *testing.T) { // Gap: only the internal/cli unit test covered this; the binary's error path // was never exercised. func TestE2E_Snapshot_Import_InsecureHTTP_Rejected(t *testing.T) { - if testing.Short() { - t.Skip("skipping e2e test in short mode") - } - - vm := testutil.NewMacHost(t) - bin := vmCopyDevBinary(t, vm) - tmpHome := t.TempDir() + binary := testutil.BuildTestBinary(t) + home := t.TempDir() - // http:// (not https://) must be rejected before any network connection. insecureURL := "http://127.0.0.1:19998/snap.json" - cmd := fmt.Sprintf( - "HOME=%q OPENBOOT_DISABLE_AUTOUPDATE=1 PATH=%q %s snapshot --import %q --dry-run", - tmpHome, brewPath, bin, insecureURL, - ) - output, err := vm.Run(cmd) + stdout, stderr, err := runBinary(t, binary, isolatedEnv(home, ""), + "snapshot", "--import", insecureURL, "--dry-run") + output := stdout + stderr t.Logf("insecure import output:\n%s", output) assert.Error(t, err, "importing from http:// should fail") assert.True(t, @@ -216,21 +178,13 @@ func TestE2E_Snapshot_Import_InsecureHTTP_Rejected(t *testing.T) { // TestE2E_Snapshot_Import_DownloadError verifies that the binary returns a // meaningful error when an HTTPS download fails (e.g., server not found). func TestE2E_Snapshot_Import_DownloadError(t *testing.T) { - if testing.Short() { - t.Skip("skipping e2e test in short mode") - } - - vm := testutil.NewMacHost(t) - bin := vmCopyDevBinary(t, vm) - tmpHome := t.TempDir() + binary := testutil.BuildTestBinary(t) + home := t.TempDir() // This host / port does not exist so the TLS handshake fails. badURL := "https://127.0.0.1:19997/snap.json" - cmd := fmt.Sprintf( - "HOME=%q OPENBOOT_DISABLE_AUTOUPDATE=1 PATH=%q %s snapshot --import %q", - tmpHome, brewPath, bin, badURL, - ) - output, err := vm.Run(cmd) + stdout, stderr, err := runBinary(t, binary, isolatedEnv(home, ""), "snapshot", "--import", badURL) + output := stdout + stderr t.Logf("download error output:\n%s", output) assert.Error(t, err, "import from unreachable URL should fail") assert.True(t, @@ -244,9 +198,9 @@ func TestE2E_Snapshot_Import_DownloadError(t *testing.T) { // ============================================================================= // writePublishAuthFile writes a valid non-expired auth.json for publish tests. -func writePublishAuthFile(t *testing.T, tmpHome, token, username string) { +func writePublishAuthFile(t *testing.T, home, token, username string) { t.Helper() - authDir := filepath.Join(tmpHome, ".openboot") + authDir := filepath.Join(home, ".openboot") require.NoError(t, os.MkdirAll(authDir, 0700)) stored := auth.StoredAuth{ @@ -262,9 +216,9 @@ func writePublishAuthFile(t *testing.T, tmpHome, token, username string) { // writePublishSyncSource writes a sync_source.json so the binary can resolve a // target slug without interactive prompts. -func writePublishSyncSource(t *testing.T, tmpHome, username, slug string) { +func writePublishSyncSource(t *testing.T, home, username, slug string) { t.Helper() - dir := filepath.Join(tmpHome, ".openboot") + dir := filepath.Join(home, ".openboot") require.NoError(t, os.MkdirAll(dir, 0700)) src := syncpkg.SyncSource{ diff --git a/test/e2e/sync_shell_e2e_test.go b/test/e2e/sync_shell_e2e_test.go index c3ddaf2..d997239 100644 --- a/test/e2e/sync_shell_e2e_test.go +++ b/test/e2e/sync_shell_e2e_test.go @@ -59,7 +59,7 @@ func TestE2E_Sync_Shell_NoPanic(t *testing.T) { _, err := vm.Run("printf '%s' '" + escaped + "' > /tmp/shell-config.json") require.NoError(t, err) - out, _ := vmRunDevBinaryWithGit(t, vm, bin, "--from /tmp/shell-config.json --silent --dry-run") + out, _ := vmRunDevBinaryWithGit(t, vm, bin, "install --from /tmp/shell-config.json --silent --dry-run") t.Logf("dry-run output:\n%s", out) assert.NotContains(t, out, "panic", "binary should not panic with shell config") } diff --git a/test/e2e/vm_edge_cases_test.go b/test/e2e/vm_edge_cases_test.go index 3e68fce..594231b 100644 --- a/test/e2e/vm_edge_cases_test.go +++ b/test/e2e/vm_edge_cases_test.go @@ -33,7 +33,7 @@ func TestVM_Edge_ShellActuallyWorks(t *testing.T) { bin := vmCopyDevBinary(t, vm) // Install with shell setup - _, err := vmRunDevBinaryWithGit(t, vm, bin, "--preset minimal --silent --shell install --dotfiles skip --macos skip") + _, err := vmRunDevBinaryWithGit(t, vm, bin, "install --preset minimal --silent --shell install --dotfiles skip --macos skip") require.NoError(t, err) t.Run("zsh_login_shell_starts", func(t *testing.T) { diff --git a/test/e2e/vm_helpers_test.go b/test/e2e/vm_helpers_test.go index 6a7f1cf..93ad2e6 100644 --- a/test/e2e/vm_helpers_test.go +++ b/test/e2e/vm_helpers_test.go @@ -15,8 +15,8 @@ import ( const brewPath = "/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" -// vmInstallViaBrewTap installs Homebrew and openboot via brew tap in the VM. -// This mirrors the real `curl | bash` user journey. +// vmInstallViaBrewTap installs openboot via the install.sh script (curl | bash). +// Requires a TTY — prefer vmInstallViaBrew for non-interactive contexts. // Returns the installed openboot version string. func vmInstallViaBrewTap(t *testing.T, vm *testutil.MacHost) string { t.Helper() @@ -37,6 +37,29 @@ func vmInstallViaBrewTap(t *testing.T, vm *testutil.MacHost) string { return strings.TrimSpace(version) } +// vmInstallViaBrew installs openboot via `brew tap && brew install` — no TTY required. +// Use this instead of vmInstallViaBrewTap when running over SSH without -t. +// Returns the installed openboot version string. +func vmInstallViaBrew(t *testing.T, vm *testutil.MacHost) string { + t.Helper() + vmInstallHomebrew(t, vm) + + script := strings.Join([]string{ + fmt.Sprintf("export PATH=%q", brewPath), + "brew tap openbootdotdev/openboot 2>/dev/null || true", + "brew install openboot", + }, " && ") + + output, err := vm.Run(script) + t.Logf("brew install openboot:\n%s", output) + if err != nil { + t.Fatalf("failed to install openboot via brew tap: %v", err) + } + + version, _ := vm.Run(fmt.Sprintf("export PATH=%q && openboot version", brewPath)) + return strings.TrimSpace(version) +} + // vmInstallHomebrew ensures Homebrew is installed on the host. // GitHub Actions macOS runners ship with Homebrew preinstalled, so this // skips the install when brew is already on PATH and only bootstraps it @@ -107,8 +130,12 @@ func vmRunDevBinaryWithGit(t *testing.T, vm *testutil.MacHost, binaryPath, args } // installOhMyZsh installs Oh-My-Zsh non-interactively in the VM. +// Idempotent: skips if ~/.oh-my-zsh already exists. func installOhMyZsh(t *testing.T, vm *testutil.MacHost) { t.Helper() + if _, err := vm.Run("test -d ~/.oh-my-zsh"); err == nil { + return + } script := `sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended` output, err := vm.Run(script) t.Logf("oh-my-zsh install: %s", output) diff --git a/test/e2e/vm_interactive_test.go b/test/e2e/vm_interactive_test.go index 7ddef6d..3c48cd7 100644 --- a/test/e2e/vm_interactive_test.go +++ b/test/e2e/vm_interactive_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/openbootdotdev/openboot/testutil" ) @@ -19,7 +20,14 @@ func TestVM_Interactive_InstallScript(t *testing.T) { } vm := testutil.NewMacHost(t) - vmInstallViaBrewTap(t, vm) // Install first + vmInstallViaBrew(t, vm) // Install first (no TTY required) + + // expect is required for interactive tests. + if _, err := vm.Run(fmt.Sprintf("export PATH=%q && command -v expect", brewPath)); err != nil { + out, installErr := vm.Run(fmt.Sprintf("export PATH=%q && brew install expect", brewPath)) + t.Logf("install expect: %s", out) + require.NoError(t, installErr, "should install expect for interactive tests") + } t.Run("reinstall_answer_no", func(t *testing.T) { cmd := fmt.Sprintf( diff --git a/test/e2e/vm_user_journey_test.go b/test/e2e/vm_user_journey_test.go index eff502e..d66546a 100644 --- a/test/e2e/vm_user_journey_test.go +++ b/test/e2e/vm_user_journey_test.go @@ -14,6 +14,7 @@ package e2e import ( + "fmt" "strings" "testing" @@ -40,24 +41,24 @@ func TestVM_Journey_FirstTimeUser(t *testing.T) { vm := testutil.NewMacHost(t) - // Step 1: openboot shouldn't leak in from a prior step. We don't assert on - // rg/fd/bat/fzf absence anymore — GitHub Actions runners vary in what - // ships preinstalled, and the post-install checks below are the - // load-bearing assertion. + // Clean up any openboot left over from a prior test run. + vm.Run(fmt.Sprintf("export PATH=%q && brew uninstall openboot 2>/dev/null || true", brewPath)) //nolint:errcheck // best-effort cleanup + + // Step 1: openboot shouldn't leak in from a prior step. t.Run("bare_system_has_no_openboot", func(t *testing.T) { out, _ := vm.Run("which openboot 2>/dev/null || echo not-found") assert.Contains(t, out, "not-found", "openboot should not exist before install") }) - // Step 2: Install via curl | bash (the real user journey) - t.Run("curl_bash_installs_everything", func(t *testing.T) { - version := vmInstallViaBrewTap(t, vm) + // Step 2: Install via brew tap (no TTY required; mirrors the curl|bash tap path). + t.Run("installs_via_brew_tap", func(t *testing.T) { + version := vmInstallViaBrew(t, vm) assert.Contains(t, version, "OpenBoot v", "should report version after install") }) // Step 3: Run openboot with minimal preset t.Run("minimal_preset_installs_usable_tools", func(t *testing.T) { - output, err := vmRunOpenbootWithGit(t, vm, "--preset minimal --silent --packages-only") + output, err := vmRunOpenbootWithGit(t, vm, "install --preset minimal --silent --packages-only") t.Logf("install output:\n%s", output) require.NoError(t, err, "minimal preset should succeed") @@ -111,7 +112,7 @@ func TestVM_Journey_DryRunIsCompletelySafe(t *testing.T) { beforeScreenshots, _ := vm.Run("test -d ~/Screenshots && echo exists || echo missing") // Run dry-run with FULL preset (maximum possible changes) - output, err := vmRunDevBinaryWithGit(t, vm, bin, "--preset full --dry-run --silent") + output, err := vmRunDevBinaryWithGit(t, vm, bin, "install --preset full --dry-run --silent") t.Logf("dry-run output:\n%s", output) assert.NoError(t, err) @@ -171,7 +172,7 @@ func TestVM_Journey_FullSetupConfiguresEverything(t *testing.T) { bin := vmCopyDevBinary(t, vm) output, err := vmRunDevBinaryWithGit(t, vm, bin, - "--preset minimal --silent --shell install --dotfiles clone --macos configure") + "install --preset minimal --silent --shell install --dotfiles clone --macos configure") t.Logf("full setup:\n%s", output) require.NoError(t, err)