diff --git a/internal/cli/snapshot_http_test.go b/internal/cli/snapshot_http_test.go new file mode 100644 index 0000000..4b3095c --- /dev/null +++ b/internal/cli/snapshot_http_test.go @@ -0,0 +1,175 @@ +package cli + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/openbootdotdev/openboot/internal/snapshot" +) + +// --------------------------------------------------------------------------- +// downloadSnapshotBytes — real TLS path and size-cap invariant. +// +// output_test.go covers the mock-transport paths (success, 404, bad URL). +// These tests add: a real httptest.TLSServer (exercising the full TLS stack +// through ts.Client()) and the 10 MiB LimitReader cap. +// --------------------------------------------------------------------------- + +func TestDownloadSnapshotBytes_TLSServer_Success(t *testing.T) { + want := `{"version":1,"packages":{"formulae":["git"],"casks":[],"taps":[],"npm":[]}}` + ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(want)) //nolint:errcheck // test helper + })) + defer ts.Close() + + got, err := downloadSnapshotBytes(ts.URL, ts.Client()) + require.NoError(t, err) + assert.JSONEq(t, want, string(got)) +} + +func TestDownloadSnapshotBytes_TLSServer_NotFound(t *testing.T) { + ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + })) + defer ts.Close() + + _, err := downloadSnapshotBytes(ts.URL, ts.Client()) + require.Error(t, err) + assert.Contains(t, err.Error(), "HTTP 404") +} + +func TestDownloadSnapshotBytes_SizeCappedAt10MiB(t *testing.T) { + // LimitReader silently truncates oversized responses so a rogue server + // can't trigger an OOM. The caller gets at most 10 MiB. + bigPayload := make([]byte, 11<<20) // 11 MiB + ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(bigPayload) //nolint:errcheck // test helper + })) + defer ts.Close() + + got, err := downloadSnapshotBytes(ts.URL, ts.Client()) + require.NoError(t, err) + assert.Equal(t, 10<<20, len(got), "response must be capped at 10 MiB") +} + +// --------------------------------------------------------------------------- +// postSnapshotToAPI — no prior test coverage existed for this function. +// +// Uses httptest.NewServer (plain HTTP) because postSnapshotToAPI constructs +// its own http.Client with the default (non-TLS) transport, which happily +// connects to http:// test servers. +// --------------------------------------------------------------------------- + +func TestPostSnapshotToAPI_NewConfig_POSTReturnsSlug(t *testing.T) { + withNoSnapshotBrowser(t) + + var gotMethod, gotAuth, gotContentType string + var gotBody map[string]interface{} + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotAuth = r.Header.Get("Authorization") + gotContentType = r.Header.Get("Content-Type") + body, _ := io.ReadAll(r.Body) + json.Unmarshal(body, &gotBody) //nolint:errcheck // test helper + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]string{"slug": "my-new-config"}) //nolint:errcheck // test helper + })) + defer ts.Close() + + snap := &snapshot.Snapshot{} + resultSlug, err := postSnapshotToAPI(snap, "My Setup", "desc", "public", "obt_token", ts.URL, "") + require.NoError(t, err) + assert.Equal(t, "my-new-config", resultSlug) + assert.Equal(t, http.MethodPost, gotMethod, "new config must use POST") + assert.Equal(t, "Bearer obt_token", gotAuth, "must include Bearer token") + assert.Equal(t, "application/json", gotContentType) + assert.Nil(t, gotBody["config_slug"], "POST body must not include config_slug") + assert.Equal(t, "My Setup", gotBody["name"]) + assert.Equal(t, "public", gotBody["visibility"]) +} + +func TestPostSnapshotToAPI_UpdateConfig_PUTSendsSlug(t *testing.T) { + withNoSnapshotBrowser(t) + + var gotMethod string + var gotBody map[string]interface{} + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + body, _ := io.ReadAll(r.Body) + json.Unmarshal(body, &gotBody) //nolint:errcheck // test helper + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{"slug": "existing-config"}) //nolint:errcheck // test helper + })) + defer ts.Close() + + snap := &snapshot.Snapshot{} + resultSlug, err := postSnapshotToAPI(snap, "", "", "", "obt_token", ts.URL, "existing-config") + require.NoError(t, err) + assert.Equal(t, "existing-config", resultSlug) + assert.Equal(t, http.MethodPut, gotMethod, "update must use PUT") + assert.Equal(t, "existing-config", gotBody["config_slug"]) +} + +func TestPostSnapshotToAPI_ConflictError(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusConflict) + json.NewEncoder(w).Encode(map[string]string{"message": "slug already exists"}) //nolint:errcheck // test helper + })) + defer ts.Close() + + snap := &snapshot.Snapshot{} + _, err := postSnapshotToAPI(snap, "name", "desc", "public", "tok", ts.URL, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "slug already exists") +} + +func TestPostSnapshotToAPI_ConflictMaxConfigs(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusConflict) + json.NewEncoder(w).Encode(map[string]string{"message": "maximum number of configs reached"}) //nolint:errcheck // test helper + })) + defer ts.Close() + + snap := &snapshot.Snapshot{} + _, err := postSnapshotToAPI(snap, "n", "d", "private", "tok", ts.URL, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "max 20") +} + +func TestPostSnapshotToAPI_ServerError(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("server meltdown")) //nolint:errcheck // test helper + })) + defer ts.Close() + + snap := &snapshot.Snapshot{} + _, err := postSnapshotToAPI(snap, "n", "d", "public", "tok", ts.URL, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "500") +} + +func TestPostSnapshotToAPI_SlugFallbackWhenResponseEmpty(t *testing.T) { + // When the server returns 200 but omits the slug field, fall back to the + // slug we passed in (subsequent update of an existing config). + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{}) //nolint:errcheck // test helper + })) + defer ts.Close() + + snap := &snapshot.Snapshot{} + resultSlug, err := postSnapshotToAPI(snap, "", "", "", "tok", ts.URL, "fallback-slug") + require.NoError(t, err) + assert.Equal(t, "fallback-slug", resultSlug, "must fall back to the passed-in slug") +} diff --git a/test/e2e/auth_e2e_test.go b/test/e2e/auth_e2e_test.go new file mode 100644 index 0000000..82e3aaa --- /dev/null +++ b/test/e2e/auth_e2e_test.go @@ -0,0 +1,260 @@ +//go:build e2e && vm + +// Package e2e contains VM-based E2E tests for the login/logout commands, +// exercising the full OAuth device flow via the compiled binary against a +// local mock HTTP server. +// +// Gap filled: the OAuth device flow was previously only tested at the unit +// level (internal/auth/login_test.go). These tests verify the compiled binary +// correctly reads/writes auth.json and surfaces meaningful errors to the user. + +package e2e + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/openbootdotdev/openboot/internal/auth" + "github.com/openbootdotdev/openboot/testutil" +) + +// ============================================================================= +// login +// ============================================================================= + +// TestE2E_Login_SuccessfulOAuthFlow runs `openboot login` against a local mock +// HTTP server that immediately approves the device-code request. +// +// 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() + + // Mock API: /start returns a code; /poll immediately approves. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/auth/cli/start": + json.NewEncoder(w).Encode(map[string]string{ //nolint:errcheck // test helper + "code_id": "e2e-code-id", + "code": "E2ETEST1", + }) + case "/api/auth/cli/poll": + json.NewEncoder(w).Encode(map[string]string{ //nolint:errcheck // test helper + "status": "approved", + "token": "obt_e2e_token", + "username": "e2etestuser", + "expires_at": time.Now().Add(24 * time.Hour).Format(time.RFC3339), + }) + default: + http.NotFound(w, r) + } + })) + defer 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) + 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") + data, readErr := os.ReadFile(authFile) + require.NoError(t, readErr, "auth.json should exist after successful login") + + var stored auth.StoredAuth + require.NoError(t, json.Unmarshal(data, &stored)) + assert.Equal(t, "obt_e2e_token", stored.Token) + assert.Equal(t, "e2etestuser", stored.Username) + assert.True(t, stored.ExpiresAt.After(time.Now()), "stored token must not be expired") +} + +// TestE2E_Login_AlreadyAuthenticated verifies that `openboot login` reports +// "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") + } + + 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) + 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") +} + +// 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() + + // 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) + 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) +} + +// TestE2E_Login_ExpiredCodeRejected verifies that the binary surfaces the +// "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() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/auth/cli/start": + json.NewEncoder(w).Encode(map[string]string{ //nolint:errcheck // test helper + "code_id": "expired-id", + "code": "EXPD1234", + }) + case "/api/auth/cli/poll": + json.NewEncoder(w).Encode(map[string]string{ //nolint:errcheck // test helper + "status": "expired", + }) + default: + http.NotFound(w, r) + } + })) + defer 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) + 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") + assert.NoFileExists(t, authFile, "auth.json must not be created after failed login") +} + +// ============================================================================= +// logout +// ============================================================================= + +// 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") + } + + 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) + 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") + 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() + + cmd := fmt.Sprintf( + "HOME=%q OPENBOOT_DISABLE_AUTOUPDATE=1 PATH=%q %s logout", + tmpHome, brewPath, bin, + ) + output, err := vm.Run(cmd) + 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) +} + +// ============================================================================= +// helpers +// ============================================================================= + +// writeTestAuthFile writes a non-expired auth.json under tmpHome/.openboot/. +func writeTestAuthFile(t *testing.T, tmpHome, token, username string) { + t.Helper() + authDir := filepath.Join(tmpHome, ".openboot") + require.NoError(t, os.MkdirAll(authDir, 0700)) + + stored := auth.StoredAuth{ + Token: token, + Username: username, + ExpiresAt: time.Now().Add(24 * time.Hour), + CreatedAt: time.Now(), + } + data, err := json.MarshalIndent(stored, "", " ") + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(authDir, "auth.json"), data, 0600)) +} diff --git a/test/e2e/dotfiles_e2e_test.go b/test/e2e/dotfiles_e2e_test.go new file mode 100644 index 0000000..26609b4 --- /dev/null +++ b/test/e2e/dotfiles_e2e_test.go @@ -0,0 +1,163 @@ +//go:build e2e && vm + +// Package e2e contains VM-based E2E tests for the dotfiles clone + stow +// feature. +// +// Gap filled: the VM journey included a `--dotfiles clone` flag in +// TestVM_Journey_FullSetupConfiguresEverything but only verified that +// ~/.dotfiles/.git exists (the clone step). Whether the dotfiles were actually +// linked (stowed / symlinked) into HOME was never asserted. + +package e2e + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/openbootdotdev/openboot/testutil" +) + +// TestVM_Journey_DotfilesClonedAndLinked runs +// +// openboot --preset minimal --silent --dotfiles clone --shell skip --macos skip +// +// and verifies that: +// 1. ~/.dotfiles is a valid git repository (clone succeeded). +// 2. At least one file from ~/.dotfiles appears as a symlink in HOME +// (link/stow step actually ran). +// +// This is the scenario a user experiences when they run openboot and choose +// to set up dotfiles: they expect their dotfiles to be cloned *and* linked, +// not merely downloaded. +// countDotfileSymlinksCmd counts symlinks in HOME that resolve into ~/.dotfiles. +const countDotfileSymlinksCmd = ` +count=0 +for f in ~/.*; do + if [ -L "$f" ]; then + target=$(readlink "$f" 2>/dev/null || true) + if echo "$target" | grep -q "\.dotfiles"; then + count=$((count + 1)) + fi + fi +done +echo "$count" +` + +func TestVM_Journey_DotfilesClonedAndLinked(t *testing.T) { + if testing.Short() { + t.Skip("skipping dotfiles journey test in short mode") + } + + vm := testutil.NewMacHost(t) + vmInstallHomebrew(t, vm) + bin := vmCopyDevBinary(t, vm) + + // Capture symlink count BEFORE install so we can detect new symlinks created + // by the link step (avoids false positives from pre-existing dotfile symlinks). + beforeOut, _ := vm.Run(countDotfileSymlinksCmd) + symsBefore := strings.TrimSpace(beforeOut) + + output, err := vmRunDevBinaryWithGit(t, vm, bin, + "--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") + + // ── 1. Clone verification ──────────────────────────────────────────────── + + t.Run("dotfiles_git_repo_exists", func(t *testing.T) { + out, err := vm.Run("test -d ~/.dotfiles/.git && echo git-repo || echo not-a-repo") + require.NoError(t, err) + assert.Contains(t, out, "git-repo", + "~/.dotfiles should be a git repository after clone") + }) + + t.Run("dotfiles_has_remote_origin", func(t *testing.T) { + out, _ := vm.Run("git -C ~/.dotfiles remote get-url origin 2>/dev/null") + assert.NotEmpty(t, strings.TrimSpace(out), + "dotfiles repo should have a remote origin URL") + }) + + // ── 2. Link / stow verification ────────────────────────────────────────── + // + // After the clone the installer calls dotfiles.Link() which either runs + // `stow` (if the repo has stow packages) or creates direct symlinks from + // files starting with "." in the repo root. We compare symlink counts + // BEFORE vs AFTER to avoid a false positive from pre-existing symlinks + // that were already present on the CI runner. + + t.Run("link_step_created_new_symlinks_in_home", func(t *testing.T) { + // The count was captured before the install above. + after, _ := vm.Run(countDotfileSymlinksCmd) + afterCount := strings.TrimSpace(after) + assert.NotEqual(t, symsBefore, afterCount, + "link step must create new symlinks pointing into ~/.dotfiles "+ + "(before=%s after=%s)", symsBefore, afterCount) + }) + + // ── 3. Re-run is idempotent ────────────────────────────────────────────── + // + // Running `--dotfiles clone` a second time should not fail (the installer + // detects the existing repo and syncs it instead of cloning fresh). + + t.Run("second_install_is_idempotent", func(t *testing.T) { + _, err := vmRunDevBinaryWithGit(t, vm, bin, + "--preset minimal --silent --dotfiles clone --shell skip --macos skip") + assert.NoError(t, err, + "running --dotfiles clone a second time should not fail") + }) +} + +// TestVM_Journey_DotfilesLink_OnlyLinks runs +// +// openboot --preset minimal --silent --dotfiles link --shell skip --macos skip +// +// when ~/.dotfiles already exists (from a previous clone), verifying that the +// link-only mode does not re-clone but still creates symlinks. +func TestVM_Journey_DotfilesLink_OnlyLinks(t *testing.T) { + if testing.Short() { + t.Skip("skipping dotfiles link-only journey test in short mode") + } + + vm := testutil.NewMacHost(t) + vmInstallHomebrew(t, vm) + bin := vmCopyDevBinary(t, vm) + + // Pre-clone so the dotfiles directory exists. + _, err := vmRunDevBinaryWithGit(t, vm, bin, + "--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") + require.NoError(t, err, "--dotfiles link should succeed when repo exists") + + t.Run("repo_commit_unchanged", func(t *testing.T) { + // The link-only path should not touch commits (no fetch/reset). + commitAfter, _ := vm.Run("git -C ~/.dotfiles rev-parse HEAD 2>/dev/null") + assert.Equal(t, strings.TrimSpace(commitBefore), strings.TrimSpace(commitAfter), + "--dotfiles link must not change the commit") + }) + + t.Run("symlinks_still_present", func(t *testing.T) { + out, _ := vm.Run(` + count=0 + for f in ~/.*; do + if [ -L "$f" ]; then + target=$(readlink "$f" 2>/dev/null || true) + if echo "$target" | grep -q "\.dotfiles"; then + count=$((count + 1)) + fi + fi + done + echo "$count" + `) + assert.NotEqual(t, "0", strings.TrimSpace(out), + "symlinks should still exist after --dotfiles link") + }) +} diff --git a/test/e2e/macos_defaults_e2e_test.go b/test/e2e/macos_defaults_e2e_test.go new file mode 100644 index 0000000..4de0f94 --- /dev/null +++ b/test/e2e/macos_defaults_e2e_test.go @@ -0,0 +1,201 @@ +//go:build e2e && vm + +// Package e2e contains VM-based E2E tests that verify macOS `defaults write` +// calls actually reach the system preference store. +// +// Gap filled: TestVM_Journey_FullSetupConfiguresEverything only spot-checked +// 3 defaults (AppleShowAllExtensions, FXPreferredViewStyle, show-recents). +// This file adds a focused test that exercises macOS configure in isolation +// and verifies representative preferences from every category defined in +// internal/macos/categories.go. + +package e2e + +import ( + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/openbootdotdev/openboot/testutil" +) + +// macOSPrefCheck describes a single `defaults read` assertion. +type macOSPrefCheck struct { + domain string + key string + expected string // exact value returned by `defaults read` +} + +// TestVM_Journey_MacOSDefaults_AllCategoriesWritten runs +// +// openboot --preset minimal --silent --shell skip --dotfiles skip --macos configure +// +// and verifies that representative preferences from each of the eight +// categories in internal/macos/categories.go are actually written to the +// macOS preference store — not just planned by the installer. +// +// User expectation: "I chose to configure macOS. Every setting I agreed to +// should actually take effect, not silently fail." +func TestVM_Journey_MacOSDefaults_AllCategoriesWritten(t *testing.T) { + if testing.Short() { + t.Skip("skipping macOS defaults test in short mode") + } + + vm := testutil.NewMacHost(t) + vmInstallHomebrew(t, vm) + bin := vmCopyDevBinary(t, vm) + + output, err := vmRunDevBinaryWithGit(t, vm, bin, + "--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") + + // Each entry maps a human-readable test name to the expected `defaults read` + // value. The expected value uses macOS output format: booleans are "0"/"1", + // strings are the raw string, ints are decimal. + checks := map[string]macOSPrefCheck{ + // ── System ──────────────────────────────────────────────────────────── + "system/show_all_extensions": { + "NSGlobalDomain", "AppleShowAllExtensions", "1", + }, + "system/show_scroll_bars": { + "NSGlobalDomain", "AppleShowScrollBars", "Always", + }, + "system/disable_autocorrect": { + "NSGlobalDomain", "NSAutomaticSpellingCorrectionEnabled", "0", + }, + "system/disable_autocapitalization": { + "NSGlobalDomain", "NSAutomaticCapitalizationEnabled", "0", + }, + "system/key_repeat": { + "NSGlobalDomain", "KeyRepeat", "2", + }, + + // ── Finder ──────────────────────────────────────────────────────────── + "finder/list_view": { + "com.apple.finder", "FXPreferredViewStyle", "Nlsv", + }, + "finder/show_path_bar": { + "com.apple.finder", "ShowPathbar", "1", + }, + "finder/show_status_bar": { + "com.apple.finder", "ShowStatusBar", "1", + }, + "finder/show_hidden_files": { + "com.apple.finder", "AppleShowAllFiles", "1", + }, + "finder/no_extension_change_warning": { + "com.apple.finder", "FXEnableExtensionChangeWarning", "0", + }, + + // ── Dock ────────────────────────────────────────────────────────────── + "dock/no_show_recents": { + "com.apple.dock", "show-recents", "0", + }, + "dock/tile_size": { + "com.apple.dock", "tilesize", "48", + }, + + // ── Screenshots ─────────────────────────────────────────────────────── + "screenshots/type_png": { + "com.apple.screencapture", "type", "png", + }, + "screenshots/disable_shadow": { + "com.apple.screencapture", "disable-shadow", "1", + }, + + // ── Mission Control ─────────────────────────────────────────────────── + "mission_control/no_auto_rearrange": { + "com.apple.dock", "mru-spaces", "0", + }, + + // ── Security ────────────────────────────────────────────────────────── + "security/require_password": { + "com.apple.screensaver", "askForPassword", "1", + }, + } + + for name, check := range checks { + name, check := name, check + t.Run(name, func(t *testing.T) { + readCmd := fmt.Sprintf( + "defaults read %q %q 2>/dev/null || echo NOT_SET", + check.domain, check.key, + ) + out, err := vm.Run(readCmd) + require.NoError(t, err, + "defaults read should exit 0 (the || echo NOT_SET handles missing keys)") + + actual := strings.TrimSpace(out) + assert.Equal(t, check.expected, actual, + "macOS pref %s.%s should be %q after configure", + check.domain, check.key, check.expected) + }) + } +} + +// TestVM_Journey_MacOSDefaults_ScreenshotsDirCreated verifies that the +// ~/Screenshots directory is created during a macOS configure run. +// +// Gap: the Screenshots directory creation (macos.CreateScreenshotsDir) was +// only checked in TestVM_Journey_FullSetupConfiguresEverything as part of a +// larger setup run. This test isolates that behaviour. +func TestVM_Journey_MacOSDefaults_ScreenshotsDirCreated(t *testing.T) { + if testing.Short() { + t.Skip("skipping macOS screenshots dir test in short mode") + } + + vm := testutil.NewMacHost(t) + vmInstallHomebrew(t, vm) + bin := vmCopyDevBinary(t, vm) + + // Remove ~/Screenshots if it already exists so the test is authoritative. + _, _ = vm.Run("rm -rf ~/Screenshots") + + _, err := vmRunDevBinaryWithGit(t, vm, bin, + "--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") + assert.Contains(t, out, "exists", + "~/Screenshots should be created by --macos configure") +} + +// TestVM_Journey_MacOSDefaults_DryRunWritesNothing verifies that +// --dry-run --macos configure does NOT modify any macOS preference. +// +// Regression guard: a bug in the Configure() dry-run branch could silently +// write preferences on dry-run. +func TestVM_Journey_MacOSDefaults_DryRunWritesNothing(t *testing.T) { + if testing.Short() { + t.Skip("skipping macOS dry-run defaults test in short mode") + } + + vm := testutil.NewMacHost(t) + vmInstallHomebrew(t, vm) + bin := vmCopyDevBinary(t, vm) + + // Force a known value that differs from what --macos configure would write + // ("Always"). This makes the assertion non-vacuous: if dry-run accidentally + // applies the preference, the value changes to "Always" and the test fails. + _, err := vm.Run(`defaults write "NSGlobalDomain" "AppleShowScrollBars" -string "WhenScrolling"`) + require.NoError(t, err, "should be able to force a known test value before dry-run") + + before, _ := vm.Run( + `defaults read "NSGlobalDomain" "AppleShowScrollBars" 2>/dev/null || echo UNSET`, + ) + + _, err = vmRunDevBinaryWithGit(t, vm, bin, + "--preset minimal --silent --shell skip --dotfiles skip --macos configure --dry-run") + require.NoError(t, err, "dry-run should succeed") + + after, _ := vm.Run( + `defaults read "NSGlobalDomain" "AppleShowScrollBars" 2>/dev/null || echo UNSET`, + ) + + assert.Equal(t, strings.TrimSpace(before), strings.TrimSpace(after), + "dry-run must not change NSGlobalDomain/AppleShowScrollBars") +} diff --git a/test/e2e/misc_e2e_test.go b/test/e2e/misc_e2e_test.go index 8815fa2..a6a7a34 100644 --- a/test/e2e/misc_e2e_test.go +++ b/test/e2e/misc_e2e_test.go @@ -5,8 +5,9 @@ package e2e import ( "testing" - "github.com/openbootdotdev/openboot/testutil" "github.com/stretchr/testify/assert" + + "github.com/openbootdotdev/openboot/testutil" ) func TestE2E_FullPreset_DryRun(t *testing.T) { diff --git a/test/e2e/openboot_e2e_test.go b/test/e2e/openboot_e2e_test.go index 3234fb0..598fe54 100644 --- a/test/e2e/openboot_e2e_test.go +++ b/test/e2e/openboot_e2e_test.go @@ -7,9 +7,10 @@ import ( "strings" "testing" - "github.com/openbootdotdev/openboot/testutil" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/openbootdotdev/openboot/testutil" ) func TestE2E_DryRunMinimal(t *testing.T) { diff --git a/test/e2e/snapshot_api_e2e_test.go b/test/e2e/snapshot_api_e2e_test.go new file mode 100644 index 0000000..ad07cb2 --- /dev/null +++ b/test/e2e/snapshot_api_e2e_test.go @@ -0,0 +1,280 @@ +//go:build e2e && vm + +// Package e2e contains VM-based E2E tests for the snapshot publish and import +// commands exercised via the compiled binary. +// +// Gaps filled: +// - `snapshot --publish`: HTTP POST/PUT path was never run end-to-end; +// slug conflicts and updates had no coverage. +// - `snapshot --import URL`: the http:// (insecure) rejection was only +// tested at the unit level; the binary error path was untested. + +package e2e + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/openbootdotdev/openboot/internal/auth" + syncpkg "github.com/openbootdotdev/openboot/internal/sync" + "github.com/openbootdotdev/openboot/testutil" +) + +// ============================================================================= +// snapshot --publish +// ============================================================================= + +// TestE2E_Snapshot_Publish_UpdatesExistingConfig runs +// +// openboot snapshot --publish +// +// when a sync source is already saved (simulating a second publish). +// The binary should issue a PUT request and report "updated successfully". +// +// 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() + + // 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") + + var receivedMethod string + var receivedAuth string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.URL.Path == "/api/configs/from-snapshot" && r.Method == http.MethodPut: + receivedMethod = r.Method + receivedAuth = r.Header.Get("Authorization") + 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() + + 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) + t.Logf("publish output:\n%s", output) + require.NoError(t, err, "snapshot --publish should succeed") + + t.Run("output_confirms_update", func(t *testing.T) { + assert.True(t, + strings.Contains(output, "updated") || strings.Contains(output, "Updated") || + strings.Contains(output, "successfully"), + "output should confirm successful update, got: %s", output) + }) + + t.Run("api_received_PUT_with_auth_header", func(t *testing.T) { + assert.Equal(t, http.MethodPut, receivedMethod, "update should send PUT") + assert.Equal(t, "Bearer obt_pub_token", receivedAuth, "should include Bearer token") + }) +} + +// TestE2E_Snapshot_Publish_ExplicitSlugUpdate runs +// +// openboot snapshot --publish --slug my-config +// +// 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") + } + + vm := testutil.NewMacHost(t) + vmInstallHomebrew(t, vm) + bin := vmCopyDevBinary(t, vm) + tmpHome := t.TempDir() + + writePublishAuthFile(t, tmpHome, "obt_slug_token", "sluguser") + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/api/configs/from-snapshot" && r.Method == http.MethodPut { + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{"slug": "my-config"}) //nolint:errcheck // test helper + return + } + json.NewEncoder(w).Encode(map[string]interface{}{"packages": []interface{}{}}) //nolint:errcheck // test helper + })) + defer 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) + t.Logf("publish --slug output:\n%s", output) + require.NoError(t, err, "snapshot --publish --slug should succeed") + assert.True(t, + strings.Contains(output, "updated") || strings.Contains(output, "Updated") || + strings.Contains(output, "successfully"), + "output should confirm update, got: %s", output) +} + +// TestE2E_Snapshot_Publish_ConflictError verifies that when the API returns a +// 409 conflict the binary surfaces the server's error message (not a generic +// "HTTP 409" string). +// +// 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") + } + + 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") + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/api/configs/from-snapshot" { + w.WriteHeader(http.StatusConflict) + json.NewEncoder(w).Encode(map[string]string{"message": "config slug already exists"}) //nolint:errcheck // test helper + return + } + json.NewEncoder(w).Encode(map[string]interface{}{"packages": []interface{}{}}) //nolint:errcheck // test helper + })) + defer 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) + t.Logf("conflict output:\n%s", output) + assert.Error(t, err, "snapshot --publish should fail on 409") + assert.True(t, + strings.Contains(output, "already exists") || strings.Contains(output, "conflict") || + strings.Contains(output, "slug"), + "output should describe the conflict, got: %s", output) +} + +// ============================================================================= +// snapshot --import URL +// ============================================================================= + +// TestE2E_Snapshot_Import_InsecureHTTP_Rejected verifies that the binary +// refuses to download a snapshot from a plain http:// URL and returns an +// actionable error message. +// +// 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() + + // 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) + t.Logf("insecure import output:\n%s", output) + assert.Error(t, err, "importing from http:// should fail") + assert.True(t, + strings.Contains(output, "insecure") || strings.Contains(output, "https") || + strings.Contains(output, "not allowed"), + "error should tell the user to use https://, got: %s", output) +} + +// 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() + + // 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) + t.Logf("download error output:\n%s", output) + assert.Error(t, err, "import from unreachable URL should fail") + assert.True(t, + strings.Contains(output, "download") || strings.Contains(output, "connect") || + strings.Contains(output, "failed") || strings.Contains(output, "refused"), + "error should indicate download failure, got: %s", output) +} + +// ============================================================================= +// helpers +// ============================================================================= + +// writePublishAuthFile writes a valid non-expired auth.json for publish tests. +func writePublishAuthFile(t *testing.T, tmpHome, token, username string) { + t.Helper() + authDir := filepath.Join(tmpHome, ".openboot") + require.NoError(t, os.MkdirAll(authDir, 0700)) + + stored := auth.StoredAuth{ + Token: token, + Username: username, + ExpiresAt: time.Now().Add(24 * time.Hour), + CreatedAt: time.Now(), + } + data, err := json.MarshalIndent(stored, "", " ") + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(authDir, "auth.json"), data, 0600)) +} + +// 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) { + t.Helper() + dir := filepath.Join(tmpHome, ".openboot") + require.NoError(t, os.MkdirAll(dir, 0700)) + + src := syncpkg.SyncSource{ + UserSlug: fmt.Sprintf("%s/%s", username, slug), + Username: username, + Slug: slug, + InstalledAt: time.Now(), + SyncedAt: time.Now(), + } + data, err := json.MarshalIndent(src, "", " ") + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, "sync_source.json"), data, 0600)) +} diff --git a/test/e2e/sync_shell_e2e_test.go b/test/e2e/sync_shell_e2e_test.go index 390de1a..c3ddaf2 100644 --- a/test/e2e/sync_shell_e2e_test.go +++ b/test/e2e/sync_shell_e2e_test.go @@ -6,9 +6,10 @@ import ( "strings" "testing" - "github.com/openbootdotdev/openboot/testutil" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/openbootdotdev/openboot/testutil" ) // TestE2E_Sync_Shell_CaptureShell verifies that CaptureShell works correctly diff --git a/test/e2e/vm_edge_cases_test.go b/test/e2e/vm_edge_cases_test.go index 171d954..3e68fce 100644 --- a/test/e2e/vm_edge_cases_test.go +++ b/test/e2e/vm_edge_cases_test.go @@ -8,9 +8,10 @@ import ( "strings" "testing" - "github.com/openbootdotdev/openboot/testutil" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/openbootdotdev/openboot/testutil" ) // ============================================================================= diff --git a/test/e2e/vm_helpers_test.go b/test/e2e/vm_helpers_test.go index 4733098..6a7f1cf 100644 --- a/test/e2e/vm_helpers_test.go +++ b/test/e2e/vm_helpers_test.go @@ -8,8 +8,9 @@ import ( "strings" "testing" - "github.com/openbootdotdev/openboot/testutil" "github.com/stretchr/testify/require" + + "github.com/openbootdotdev/openboot/testutil" ) const brewPath = "/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" @@ -76,13 +77,6 @@ func vmCopyDevBinary(t *testing.T, vm *testutil.MacHost) string { return remotePath } -// vmRunOpenboot runs an openboot command inside the VM with standard PATH and env. -func vmRunOpenboot(t *testing.T, vm *testutil.MacHost, args string) (string, error) { - t.Helper() - cmd := fmt.Sprintf("export PATH=%q && openboot %s", brewPath, args) - return vm.Run(cmd) -} - // vmRunDevBinary runs the dev binary inside the VM with standard PATH and env. func vmRunDevBinary(t *testing.T, vm *testutil.MacHost, binaryPath, args string) (string, error) { t.Helper() @@ -157,13 +151,6 @@ func vmBrewCaskList(t *testing.T, vm *testutil.MacHost) []string { return result } -// vmIsInstalled checks if a command is available in the VM's PATH. -func vmIsInstalled(t *testing.T, vm *testutil.MacHost, cmd string) bool { - t.Helper() - _, err := vm.Run(fmt.Sprintf("export PATH=%q && which %s", brewPath, cmd)) - return err == nil -} - // writeFile is a helper to write a string to a file. func writeFile(path, content string) error { return os.WriteFile(path, []byte(content), 0644) diff --git a/test/e2e/vm_infra_test.go b/test/e2e/vm_infra_test.go index e10bd27..f196c62 100644 --- a/test/e2e/vm_infra_test.go +++ b/test/e2e/vm_infra_test.go @@ -6,9 +6,10 @@ import ( "strings" "testing" - "github.com/openbootdotdev/openboot/testutil" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/openbootdotdev/openboot/testutil" ) // TestVM_Infra sanity-checks the host the E2E suite runs on: we can shell diff --git a/test/e2e/vm_interactive_test.go b/test/e2e/vm_interactive_test.go index cfa759a..7ddef6d 100644 --- a/test/e2e/vm_interactive_test.go +++ b/test/e2e/vm_interactive_test.go @@ -7,8 +7,9 @@ import ( "strings" "testing" - "github.com/openbootdotdev/openboot/testutil" "github.com/stretchr/testify/assert" + + "github.com/openbootdotdev/openboot/testutil" ) // TestVM_Interactive_InstallScript tests install.sh interactive prompts. diff --git a/test/e2e/vm_user_journey_test.go b/test/e2e/vm_user_journey_test.go index 28b30ba..eff502e 100644 --- a/test/e2e/vm_user_journey_test.go +++ b/test/e2e/vm_user_journey_test.go @@ -17,9 +17,10 @@ import ( "strings" "testing" - "github.com/openbootdotdev/openboot/testutil" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/openbootdotdev/openboot/testutil" ) // ============================================================================= @@ -62,14 +63,14 @@ func TestVM_Journey_FirstTimeUser(t *testing.T) { // User expectation: every tool should be USABLE, not just "in PATH" toolChecks := map[string]string{ - "jq": `echo '{"a":1}' | jq '.a'`, // Can it parse JSON? + "jq": `echo '{"a":1}' | jq '.a'`, // Can it parse JSON? "rg": `echo 'hello world' | rg 'hello'`, // Can it search? - "fd": `fd --version`, // Does it run? - "bat": `echo 'test' | bat --plain`, // Can it display? + "fd": `fd --version`, // Does it run? + "bat": `echo 'test' | bat --plain`, // Can it display? "fzf": `echo 'a\nb\nc' | fzf --filter 'b'`, // Can it filter? - "htop": `htop --version`, // Does it run? - "tree": `tree --version`, // Does it run? - "gh": `gh --version`, // Does it run? + "htop": `htop --version`, // Does it run? + "tree": `tree --version`, // Does it run? + "gh": `gh --version`, // Does it run? } for name, cmd := range toolChecks {