diff --git a/internal/diff/compare_test.go b/internal/diff/compare_test.go index c7b62c2..0f7209d 100644 --- a/internal/diff/compare_test.go +++ b/internal/diff/compare_test.go @@ -210,3 +210,66 @@ func TestCompareSnapshots_EmptySnapshots(t *testing.T) { assert.Equal(t, 0, result.TotalExtra()) assert.Equal(t, 0, result.TotalChanged()) } + +func TestCompareSnapshotToRemote_WithMacOSPrefs(t *testing.T) { + isolateHome(t) + system := &snapshot.Snapshot{ + MacOSPrefs: []snapshot.MacOSPref{ + {Domain: "com.apple.dock", Key: "autohide", Value: "false"}, + }, + } + remote := &config.RemoteConfig{ + MacOSPrefs: []config.RemoteMacOSPref{ + {Domain: "com.apple.dock", Key: "autohide", Value: "true"}, + {Domain: "com.apple.dock", Key: "tilesize", Value: "48"}, + }, + } + + result := CompareSnapshotToRemote(system, remote, Source{Kind: "remote", Path: "user/cfg"}) + + require.NotNil(t, result.MacOS) + assert.Len(t, result.MacOS.Changed, 1) + assert.Equal(t, "autohide", result.MacOS.Changed[0].Key) + assert.Equal(t, "false", result.MacOS.Changed[0].System) + assert.Equal(t, "true", result.MacOS.Changed[0].Reference) + assert.Len(t, result.MacOS.Missing, 1) + assert.Equal(t, "tilesize", result.MacOS.Missing[0].Key) +} + +func TestDiffDotfiles_GitSuffixNormalization(t *testing.T) { + isolateHome(t) + + // Same repo, one with .git suffix and one without — should not report a change. + result := diffDotfiles( + "https://github.com/user/dotfiles.git", + "https://github.com/user/dotfiles", + ) + assert.Nil(t, result.RepoChanged, "trailing .git suffix should be normalized away") +} + +func TestDiffDotfiles_DifferentRepos(t *testing.T) { + isolateHome(t) + + result := diffDotfiles( + "https://github.com/user/dotfiles-old", + "https://github.com/user/dotfiles-new", + ) + require.NotNil(t, result.RepoChanged) + assert.Equal(t, "https://github.com/user/dotfiles-old", result.RepoChanged.System) + assert.Equal(t, "https://github.com/user/dotfiles-new", result.RepoChanged.Reference) +} + +func TestDiffDotfiles_BothEmpty(t *testing.T) { + isolateHome(t) + result := diffDotfiles("", "") + assert.Nil(t, result.RepoChanged) + assert.False(t, result.Dirty) + assert.False(t, result.Unpushed) +} + +func TestDiffDotfiles_EmptyReference(t *testing.T) { + isolateHome(t) + // Reference is empty → no change reported even if system has one. + result := diffDotfiles("https://github.com/user/dotfiles", "") + assert.Nil(t, result.RepoChanged) +} diff --git a/internal/diff/diff_test.go b/internal/diff/diff_test.go index b4323ec..aee0a7c 100644 --- a/internal/diff/diff_test.go +++ b/internal/diff/diff_test.go @@ -200,3 +200,98 @@ func TestDiffResult_NilSections(t *testing.T) { assert.Equal(t, 0, r.TotalChanged()) assert.False(t, r.HasChanges()) } + +func TestDiffResult_HasChanges_DotfilesDirty(t *testing.T) { + r := &DiffResult{Dotfiles: &DotfilesDiff{Dirty: true}} + assert.True(t, r.HasChanges()) +} + +func TestDiffResult_HasChanges_DotfilesUnpushed(t *testing.T) { + r := &DiffResult{Dotfiles: &DotfilesDiff{Unpushed: true}} + assert.True(t, r.HasChanges()) +} + +func TestDiffResult_HasChanges_DotfilesRepoChanged(t *testing.T) { + r := &DiffResult{Dotfiles: &DotfilesDiff{RepoChanged: &ValueChange{System: "a", Reference: "b"}}} + assert.True(t, r.HasChanges()) +} + +func TestDiffResult_HasChanges_DotfilesEmptyNoChange(t *testing.T) { + r := &DiffResult{Dotfiles: &DotfilesDiff{}} + assert.False(t, r.HasChanges()) +} + +func TestDiffResult_HasChanges_Shell(t *testing.T) { + r := &DiffResult{Shell: &ShellDiff{ThemeChanged: true}} + assert.True(t, r.HasChanges()) +} + +func TestDiffResult_TotalChanged_DotfilesRepoChanged(t *testing.T) { + r := &DiffResult{Dotfiles: &DotfilesDiff{RepoChanged: &ValueChange{System: "old", Reference: "new"}}} + assert.Equal(t, 1, r.TotalChanged()) +} + +func TestDiffResult_TotalChanged_DotfilesNilRepoChanged(t *testing.T) { + r := &DiffResult{Dotfiles: &DotfilesDiff{Dirty: true}} + assert.Equal(t, 0, r.TotalChanged()) +} + +func TestDiffResult_TotalChanged_Shell(t *testing.T) { + r := &DiffResult{Shell: &ShellDiff{PluginsChanged: true}} + assert.Equal(t, 1, r.TotalChanged()) +} + +func TestDiffResult_TotalChanged_DotfilesAndShell(t *testing.T) { + r := &DiffResult{ + Dotfiles: &DotfilesDiff{RepoChanged: &ValueChange{System: "a", Reference: "b"}}, + Shell: &ShellDiff{ThemeChanged: true}, + } + assert.Equal(t, 2, r.TotalChanged()) +} + +func TestToSet(t *testing.T) { + tests := []struct { + name string + input []string + wantKeys []string + }{ + {"nil input", nil, nil}, + {"empty input", []string{}, nil}, + {"single item", []string{"a"}, []string{"a"}}, + {"multiple items", []string{"a", "b", "c"}, []string{"a", "b", "c"}}, + {"duplicates collapse", []string{"a", "a", "b"}, []string{"a", "b"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ToSet(tt.input) + assert.Equal(t, len(tt.wantKeys), len(got)) + for _, k := range tt.wantKeys { + assert.True(t, got[k], "expected key %q in set", k) + } + }) + } +} + +func TestPluginsEqual(t *testing.T) { + tests := []struct { + name string + a, b []string + want bool + }{ + {"both nil", nil, nil, true}, + {"both empty", []string{}, []string{}, true}, + {"identical order", []string{"git", "z"}, []string{"git", "z"}, true}, + {"different order", []string{"z", "git"}, []string{"git", "z"}, true}, + {"different length", []string{"a"}, []string{"a", "b"}, false}, + {"different elements", []string{"a", "b"}, []string{"a", "c"}, false}, + // len guard (2==2) passes, but the set built from ["a","a"] lacks "b" → false. + {"duplicates in a", []string{"a", "a"}, []string{"a", "b"}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, PluginsEqual(tt.a, tt.b)) + }) + } +} diff --git a/internal/diff/format_test.go b/internal/diff/format_test.go index e9561bc..1056a59 100644 --- a/internal/diff/format_test.go +++ b/internal/diff/format_test.go @@ -194,3 +194,170 @@ func TestFormatTerminal_PackagesOnly(t *testing.T) { FormatTerminal(result, true) }) } + +func TestFormatTerminal_DotfilesSection(t *testing.T) { + tests := []struct { + name string + result *DiffResult + }{ + { + name: "repo changed", + result: &DiffResult{ + Source: Source{Kind: "remote", Path: "user/slug"}, + Dotfiles: &DotfilesDiff{RepoChanged: &ValueChange{System: "old-repo", Reference: "new-repo"}}, + }, + }, + { + name: "dirty", + result: &DiffResult{ + Source: Source{Kind: "local", Path: "test.json"}, + Dotfiles: &DotfilesDiff{Dirty: true}, + }, + }, + { + name: "unpushed", + result: &DiffResult{ + Source: Source{Kind: "local", Path: "test.json"}, + Dotfiles: &DotfilesDiff{Unpushed: true}, + }, + }, + { + name: "all dotfiles conditions", + result: &DiffResult{ + Source: Source{Kind: "file", Path: "snap.json"}, + Dotfiles: &DotfilesDiff{ + RepoChanged: &ValueChange{System: "a", Reference: "b"}, + Dirty: true, + Unpushed: true, + }, + }, + }, + { + name: "empty dotfiles skips section", + result: &DiffResult{ + Source: Source{Kind: "local", Path: "test.json"}, + Dotfiles: &DotfilesDiff{}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.NotPanics(t, func() { + FormatTerminal(tt.result, false) + }) + }) + } +} + +func TestFormatTerminal_ShellSection(t *testing.T) { + tests := []struct { + name string + result *DiffResult + }{ + { + name: "theme changed", + result: &DiffResult{ + Source: Source{Kind: "remote", Path: "user/slug"}, + Shell: &ShellDiff{ + ThemeChanged: true, + LocalTheme: "robbyrussell", + ReferenceTheme: "agnoster", + }, + }, + }, + { + name: "plugins changed", + result: &DiffResult{ + Source: Source{Kind: "remote", Path: "user/slug"}, + Shell: &ShellDiff{ + PluginsChanged: true, + LocalPlugins: []string{"git"}, + ReferencePlugins: []string{"git", "z"}, + }, + }, + }, + { + name: "theme and plugins changed", + result: &DiffResult{ + Source: Source{Kind: "remote", Path: "user/slug"}, + Shell: &ShellDiff{ + ThemeChanged: true, + LocalTheme: "", + ReferenceTheme: "agnoster", + PluginsChanged: true, + LocalPlugins: nil, + ReferencePlugins: []string{"git"}, + }, + }, + }, + { + name: "shell diff with no changes skips section", + result: &DiffResult{ + Source: Source{Kind: "remote", Path: "user/slug"}, + Shell: &ShellDiff{ThemeChanged: false, PluginsChanged: false}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.NotPanics(t, func() { + FormatTerminal(tt.result, false) + }) + }) + } +} + +func TestFormatJSON_WithShellAndDotfiles(t *testing.T) { + result := &DiffResult{ + Source: Source{Kind: "remote", Path: "user/cfg"}, + Shell: &ShellDiff{ + ThemeChanged: true, + LocalTheme: "robbyrussell", + ReferenceTheme: "agnoster", + }, + Dotfiles: &DotfilesDiff{ + RepoChanged: &ValueChange{System: "old", Reference: "new"}, + }, + } + + data, err := FormatJSON(result) + require.NoError(t, err) + + var parsed map[string]interface{} + err = json.Unmarshal(data, &parsed) + require.NoError(t, err) + + assert.Contains(t, parsed, "shell") + assert.Contains(t, parsed, "dotfiles") + + // Shell adds 1 to TotalChanged; dotfiles.RepoChanged adds 1 → total 2 + summary := parsed["summary"].(map[string]interface{}) + assert.Equal(t, float64(2), summary["changed"]) +} + +func TestFormatJSON_WithDevToolsSection(t *testing.T) { + result := &DiffResult{ + Source: Source{Kind: "local", Path: "snap.json"}, + DevTools: &DevToolDiff{ + Missing: []string{"rust"}, + Extra: []string{"python"}, + Changed: []DevToolDelta{{Name: "go", System: "1.22", Reference: "1.24"}}, + Common: 2, + }, + } + + data, err := FormatJSON(result) + require.NoError(t, err) + + var parsed map[string]interface{} + err = json.Unmarshal(data, &parsed) + require.NoError(t, err) + + assert.Contains(t, parsed, "dev_tools") + summary := parsed["summary"].(map[string]interface{}) + assert.Equal(t, float64(1), summary["missing"]) + assert.Equal(t, float64(1), summary["extra"]) + assert.Equal(t, float64(1), summary["changed"]) +} diff --git a/internal/permissions/screen_recording_test.go b/internal/permissions/screen_recording_test.go index 33310a5..60536d6 100644 --- a/internal/permissions/screen_recording_test.go +++ b/internal/permissions/screen_recording_test.go @@ -6,11 +6,17 @@ import ( "github.com/stretchr/testify/assert" ) +// TestHasScreenRecordingPermission_Returns verifies the function returns a bool. +// On non-darwin/non-cgo builds the stub always returns false; on darwin+cgo +// the result depends on system permissions, so we only check the type. func TestHasScreenRecordingPermission_Returns(t *testing.T) { result := HasScreenRecordingPermission() assert.IsType(t, true, result) } -// TestOpenScreenRecordingSettings is intentionally omitted: the function's only -// effect is opening System Settings UI on macOS, which cannot be meaningfully -// unit-tested without side-effecting the developer's machine. +// TestOpenScreenRecordingSettings_Returns verifies the non-darwin/non-cgo stub +// is a no-op and returns nil. +func TestOpenScreenRecordingSettings_Returns(t *testing.T) { + err := OpenScreenRecordingSettings() + assert.NoError(t, err) +} diff --git a/internal/search/search_test.go b/internal/search/search_test.go index d2d5051..fda1de0 100644 --- a/internal/search/search_test.go +++ b/internal/search/search_test.go @@ -217,6 +217,35 @@ func TestQueryAPI_CaskAndNpmFlags(t *testing.T) { } } +// --------------------------------------------------------------------------- +// getAPIBase env var handling +// --------------------------------------------------------------------------- + +func TestGetAPIBase_DefaultURL(t *testing.T) { + t.Setenv("OPENBOOT_API_URL", "") + base := getAPIBase() + assert.Equal(t, "https://openboot.dev/api", base) +} + +func TestGetAPIBase_ValidHTTPSOverride(t *testing.T) { + t.Setenv("OPENBOOT_API_URL", "https://staging.openboot.dev") + base := getAPIBase() + assert.Equal(t, "https://staging.openboot.dev/api", base) +} + +func TestGetAPIBase_ValidLocalhostOverride(t *testing.T) { + t.Setenv("OPENBOOT_API_URL", "http://localhost:8080") + base := getAPIBase() + assert.Equal(t, "http://localhost:8080/api", base) +} + +func TestGetAPIBase_DisallowedURLFallsBack(t *testing.T) { + // Plain HTTP to a non-loopback host must not be accepted. + t.Setenv("OPENBOOT_API_URL", "http://evil.example.com") + base := getAPIBase() + assert.Equal(t, "https://openboot.dev/api", base) +} + // --------------------------------------------------------------------------- // Package field mapping // ---------------------------------------------------------------------------