返回 DeepSeek-Reasonix
shell_support_test.go
根目录 / desktop / shell_support_test.go
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
196 lines GO