| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "os" |
| 5 | "reflect" |
| 6 | "runtime" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | |
| 10 | "reasonix/internal/config" |
| 11 | "reasonix/internal/sandbox" |
| 12 | ) |
| 13 | |
| 14 | // assertConfigUntouched proves the repair API contract on every branch: the |
| 15 | // user config file is byte-identical to before, so no branch can smuggle in |
| 16 | // prefer="bash" or rebuild the controller. |
| 17 | func assertConfigUntouched(t *testing.T, before string) { |
| 18 | t.Helper() |
| 19 | if after := readUserConfigOrEmpty(t); after != before { |
| 20 | t.Fatalf("install branch rewrote config:\nbefore:\n%s\nafter:\n%s", before, after) |
| 21 | } |
| 22 | } |
| 23 | |
| 24 | func readUserConfigOrEmpty(t *testing.T) string { |
| 25 | t.Helper() |
| 26 | path := config.UserConfigPath() |
| 27 | if path == "" { |
| 28 | t.Fatal("user config path unavailable in test env") |
| 29 | } |
| 30 | raw, err := os.ReadFile(path) |
| 31 | if err != nil { |
| 32 | return "" // no file yet: absence is also a comparable state |
| 33 | } |
| 34 | return string(raw) |
| 35 | } |
| 36 | |
| 37 | func TestInstallShellSupportRejectsUnknownAction(t *testing.T) { |
| 38 | isolateDesktopUserDirs(t) |
| 39 | app := NewApp() |
| 40 | if _, err := app.InstallShellSupport("homebrew"); err == nil { |
| 41 | t.Fatal("unknown action id must be a Go error, not a structured result") |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | func TestInstallShellSupportPolicy(t *testing.T) { |
| 46 | isolateDesktopUserDirs(t) |
| 47 | before := readUserConfigOrEmpty(t) |
| 48 | |
| 49 | manual, err := installShellSupportForGOOS("windows", shellInstallActionGitForWindows) |
| 50 | if err != nil { |
| 51 | t.Fatalf("Windows manual fallback must be structured, not an error: %v", err) |
| 52 | } |
| 53 | if manual.Status != shellInstallStatusManualRequired || manual.ManualURL != GitForWindowsManualURL { |
| 54 | t.Fatalf("Windows result = %+v, want manual_required with the official link", manual) |
| 55 | } |
| 56 | if !strings.Contains(manual.Reason, "automatic installation is disabled") { |
| 57 | t.Fatalf("Windows manual reason = %q, want the safety policy", manual.Reason) |
| 58 | } |
| 59 | |
| 60 | for _, goos := range []string{"darwin", "linux"} { |
| 61 | unsupported, err := installShellSupportForGOOS(goos, shellInstallActionGitForWindows) |
| 62 | if err != nil { |
| 63 | t.Fatalf("%s unsupported result must be structured: %v", goos, err) |
| 64 | } |
| 65 | if unsupported.Status != shellInstallStatusUnsupported { |
| 66 | t.Fatalf("%s status = %q, want %q", goos, unsupported.Status, shellInstallStatusUnsupported) |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | if _, err := installShellSupportForGOOS("windows", "homebrew"); err == nil { |
| 71 | t.Fatal("unknown action id must be rejected before platform policy") |
| 72 | } |
| 73 | assertConfigUntouched(t, before) |
| 74 | } |
| 75 | |
| 76 | func TestSetShellPreferencePreservesOtherFields(t *testing.T) { |
| 77 | isolateDesktopUserDirs(t) |
| 78 | cfg := config.Default() |
| 79 | cfg.Tools.Shell.Prefer = "auto" |
| 80 | cfg.Tools.Shell.Path = `C:\Custom\bin\bash.exe` |
| 81 | cfg.Sandbox.Network = true |
| 82 | cfg.Sandbox.WorkspaceRoot = `D:\work` |
| 83 | cfg.Sandbox.AllowWrite = []string{`E:\extra`} |
| 84 | if err := cfg.SaveTo(config.UserConfigPath()); err != nil { |
| 85 | t.Fatalf("save initial config: %v", err) |
| 86 | } |
| 87 | |
| 88 | app := NewApp() |
| 89 | if err := app.SetShellPreference("powershell"); err != nil { |
| 90 | t.Fatalf("SetShellPreference: %v", err) |
| 91 | } |
| 92 | |
| 93 | got := config.LoadForEditWithoutCredentials(config.UserConfigPath()) |
| 94 | if got.Tools.Shell.Prefer != "powershell" { |
| 95 | t.Fatalf("prefer = %q, want powershell", got.Tools.Shell.Prefer) |
| 96 | } |
| 97 | if got.Tools.Shell.Path != `C:\Custom\bin\bash.exe` { |
| 98 | t.Fatalf("shell path = %q, want preserved", got.Tools.Shell.Path) |
| 99 | } |
| 100 | if !got.Sandbox.Network || got.Sandbox.WorkspaceRoot != `D:\work` || !reflect.DeepEqual(got.Sandbox.AllowWrite, []string{`E:\extra`}) { |
| 101 | t.Fatalf("sandbox fields were rewritten: %+v", got.Sandbox) |
| 102 | } |
| 103 | |
| 104 | if err := app.SetShellPreference("fish"); err == nil { |
| 105 | t.Fatal("invalid preference must be rejected as a Go error") |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | func TestShellInstallActionViewPerPlatform(t *testing.T) { |
| 110 | if got := shellInstallActionViewForGOOS("darwin"); got != nil { |
| 111 | t.Fatalf("darwin action = %+v, want nil", got) |
| 112 | } |
| 113 | if got := shellInstallActionViewForGOOS("linux"); got != nil { |
| 114 | t.Fatalf("linux action = %+v, want nil", got) |
| 115 | } |
| 116 | manual := shellInstallActionViewForGOOS("windows") |
| 117 | if manual == nil || manual.Mode != "manual" || manual.Available || manual.ManualURL != GitForWindowsManualURL { |
| 118 | t.Fatalf("windows manual action = %+v, want unavailable manual with the official link", manual) |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | func TestSettingsSandboxViewShellContract(t *testing.T) { |
| 123 | isolateDesktopUserDirs(t) |
| 124 | cfg := config.Default() |
| 125 | cfg.Tools.Shell.Prefer = "powershell" |
| 126 | if err := cfg.SaveTo(config.UserConfigPath()); err != nil { |
| 127 | t.Fatalf("save initial config: %v", err) |
| 128 | } |
| 129 | app := NewApp() |
| 130 | view := app.Settings() |
| 131 | sb := view.Sandbox |
| 132 | if sb.Shell != "powershell" { |
| 133 | t.Fatalf("shell = %q, want powershell", sb.Shell) |
| 134 | } |
| 135 | // Without a live controller the session shell is the fresh resolution, so |
| 136 | // the two views agree and no reload is implied. |
| 137 | if sb.EffectiveShell == "" || sb.EffectiveShell != sb.ResolvedShell { |
| 138 | t.Fatalf("effective = %q resolved = %q, want equal fallbacks", sb.EffectiveShell, sb.ResolvedShell) |
| 139 | } |
| 140 | if sb.ShellReloadRequired { |
| 141 | t.Fatal("no controller divergence without a controller") |
| 142 | } |
| 143 | // The bridge must encode an array, never null. |
| 144 | if sb.ShellCapabilities == nil { |
| 145 | t.Fatal("shellCapabilities must never be nil") |
| 146 | } |
| 147 | if len(sb.ShellCapabilities) == 0 { |
| 148 | t.Fatal("shellCapabilities should report at least one interpreter") |
| 149 | } |
| 150 | if sb.GitCapability == nil || sb.GitCapability.ID != sandbox.HostCapabilityGit { |
| 151 | t.Fatalf("Git capability = %+v, want independent Git report", sb.GitCapability) |
| 152 | } |
| 153 | for _, cap := range sb.ShellCapabilities { |
| 154 | if cap.ID == "" { |
| 155 | t.Fatalf("capability without id: %+v", cap) |
| 156 | } |
| 157 | if cap.Available && cap.Path == "" { |
| 158 | t.Fatalf("available capability %q without a path", cap.ID) |
| 159 | } |
| 160 | } |
| 161 | if sb.ShellInstallAction != nil { |
| 162 | t.Fatalf("install action on %s = %+v, want nil in current settings views", runtime.GOOS, sb.ShellInstallAction) |
| 163 | } |
| 164 | switch runtime.GOOS { |
| 165 | case "windows": |
| 166 | for _, cap := range sb.ShellCapabilities { |
| 167 | if cap.ID == sandbox.ShellCapabilityGitBash || cap.ID == sandbox.ShellCapabilityBash { |
| 168 | t.Fatalf("Windows settings advertised a Bash runtime: %+v", sb.ShellCapabilities) |
| 169 | } |
| 170 | } |
| 171 | if sb.ShellRepairGuidance != nil { |
| 172 | t.Fatalf("repair guidance on Windows = %+v, want native PowerShell discovery only", sb.ShellRepairGuidance) |
| 173 | } |
| 174 | if sb.GitRepairGuidance != nil { |
| 175 | t.Fatalf("Git repair guidance on Windows = %+v, want independent dependency status only", sb.GitRepairGuidance) |
| 176 | } |
| 177 | case "darwin": |
| 178 | if sb.ShellRepairGuidance != nil { |
| 179 | t.Fatalf("macOS shell guidance = %+v, want native zsh/sh fallback", sb.ShellRepairGuidance) |
| 180 | } |
| 181 | if sb.GitRepairGuidance == nil || sb.GitRepairGuidance.Command != "brew install git" { |
| 182 | t.Fatalf("macOS Git guidance = %+v, want Homebrew Git command", sb.GitRepairGuidance) |
| 183 | } |
| 184 | default: |
| 185 | if sb.ShellRepairGuidance == nil { |
| 186 | t.Fatalf("repair guidance on %s is nil", runtime.GOOS) |
| 187 | } |
| 188 | if strings.Contains(strings.ToLower(sb.ShellRepairGuidance.Command), "sudo") { |
| 189 | t.Fatalf("repair guidance must never prescribe sudo: %+v", sb.ShellRepairGuidance) |
| 190 | } |
| 191 | if sb.GitRepairGuidance == nil || strings.Contains(strings.ToLower(sb.GitRepairGuidance.Command), "sudo") { |
| 192 | t.Fatalf("Git repair guidance must exist without sudo: %+v", sb.GitRepairGuidance) |
| 193 | } |
| 194 | } |
| 195 | } |
| 196 |