返回 DeepSeek-Reasonix
doctor_test.go
根目录 / internal / cli / doctor_test.go
1 package cli
2
3 import (
4 "encoding/json"
5 "io"
6 "os"
7 "path/filepath"
8 "strconv"
9 "strings"
10 "testing"
11
12 "reasonix/internal/config"
13 "reasonix/internal/i18n"
14 )
15
16 func TestDoctorCommandPrintsJSON(t *testing.T) {
17 out := captureStdout(t, func() {
18 if rc := doctorCommand([]string{"--json"}, "test-version"); rc != 0 {
19 t.Fatalf("doctorCommand rc = %d, want 0", rc)
20 }
21 })
22
23 var decoded map[string]any
24 if err := json.Unmarshal([]byte(out), &decoded); err != nil {
25 t.Fatalf("doctor --json output is not JSON: %v\n%s", err, out)
26 }
27 if decoded["version"] != "test-version" {
28 t.Fatalf("version = %v, want test-version", decoded["version"])
29 }
30 }
31
32 func TestRunDispatchesDoctor(t *testing.T) {
33 out := captureStdout(t, func() {
34 if rc := Run([]string{"doctor"}, "dispatch-version"); rc != 0 {
35 t.Fatalf("Run doctor rc = %d, want 0", rc)
36 }
37 })
38 if !strings.Contains(out, "reasonix dispatch-version doctor") {
39 t.Fatalf("doctor output missing header:\n%s", out)
40 }
41 }
42
43 func TestDoctorUsesConfiguredLanguage(t *testing.T) {
44 home := t.TempDir()
45 t.Setenv("REASONIX_HOME", home)
46 t.Chdir(t.TempDir())
47 previous := i18n.CurrentLanguage()
48 t.Cleanup(func() { i18n.DetectLanguage(previous) })
49 if err := os.WriteFile(filepath.Join(home, "config.toml"), []byte("language='zh'\n"), 0600); err != nil {
50 t.Fatal(err)
51 }
52 captureStdout(t, func() {
53 if rc := Run([]string{"doctor", "--json"}, "test"); rc != 0 {
54 t.Fatalf("doctor rc=%d", rc)
55 }
56 })
57 if i18n.CurrentLanguage() != "zh" {
58 t.Fatalf("configured language lost: %s", i18n.CurrentLanguage())
59 }
60 }
61
62 func TestDoctorSessionsIsReadOnly(t *testing.T) {
63 cache := t.TempDir()
64 t.Setenv("REASONIX_CACHE_HOME", cache)
65 out := captureStdout(t, func() {
66 if rc := doctorCommand([]string{"sessions", "--json"}, "test-version"); rc != 1 {
67 t.Fatalf("doctor sessions rc = %d, want 1 for missing catalog", rc)
68 }
69 })
70 var decoded map[string]any
71 if err := json.Unmarshal([]byte(out), &decoded); err != nil {
72 t.Fatalf("doctor sessions output: %v\n%s", err, out)
73 }
74 if _, err := os.Stat(filepath.Join(cache, "session-catalog", "v1.sqlite")); !os.IsNotExist(err) {
75 t.Fatalf("doctor sessions created or changed catalog: %v", err)
76 }
77 }
78
79 func TestSessionsReindexRebuildsOnlyCatalog(t *testing.T) {
80 cache := t.TempDir()
81 home := t.TempDir()
82 sessions := filepath.Join(home, "sessions")
83 t.Setenv("REASONIX_CACHE_HOME", cache)
84 t.Setenv("REASONIX_HOME", home)
85 if err := os.MkdirAll(sessions, 0o755); err != nil {
86 t.Fatal(err)
87 }
88 path := filepath.Join(sessions, "keep.jsonl")
89 body := []byte(`{"role":"user","content":"keep me"}` + "\n")
90 if err := os.WriteFile(path, body, 0o600); err != nil {
91 t.Fatal(err)
92 }
93 out := captureStdout(t, func() {
94 if rc := sessionsCommand([]string{"reindex", "--dir", sessions, "--json"}); rc != 0 {
95 t.Fatalf("sessions reindex rc = %d, want 0", rc)
96 }
97 })
98 if !json.Valid([]byte(out)) {
99 t.Fatalf("sessions reindex output is not JSON: %s", out)
100 }
101 got, err := os.ReadFile(path)
102 if err != nil || string(got) != string(body) {
103 t.Fatalf("authoritative transcript changed: err=%v got=%q", err, got)
104 }
105 }
106
107 func TestDefaultSessionCatalogTargetsIncludeDesktopProjects(t *testing.T) {
108 home := t.TempDir()
109 t.Setenv("REASONIX_HOME", home)
110 projectRoot := filepath.Join(t.TempDir(), "project")
111 projects := `{"projects":[{"root":` + strconv.Quote(projectRoot) + `}]}`
112 if err := os.WriteFile(filepath.Join(home, "desktop-projects.json"), []byte(projects), 0o600); err != nil {
113 t.Fatal(err)
114 }
115 targets := defaultSessionCatalogTargets()
116 want := map[string]string{
117 filepath.Clean(filepath.Join(home, "sessions")): "global",
118 filepath.Clean(config.ProjectSessionDir(filepath.Join(home, "global-workspace"))): "global",
119 filepath.Clean(config.ProjectSessionDir(projectRoot)): "project",
120 }
121 if len(targets) != len(want) {
122 t.Fatalf("targets = %#v, want %d entries", targets, len(want))
123 }
124 for _, target := range targets {
125 if scope, ok := want[target.Path]; !ok || target.Scope != scope {
126 t.Fatalf("unexpected target %#v (want %#v)", target, want)
127 }
128 if target.Scope == "project" && target.WorkspaceRoot != projectRoot {
129 t.Fatalf("project target lost workspace root: %#v", target)
130 }
131 }
132 }
133
134 func TestDoctorSessionCommandWritesBundle(t *testing.T) {
135 home := t.TempDir()
136 t.Setenv("REASONIX_HOME", home)
137 sessionDir := filepath.Join(home, "sessions")
138 if err := os.MkdirAll(sessionDir, 0o755); err != nil {
139 t.Fatal(err)
140 }
141 if err := os.WriteFile(filepath.Join(sessionDir, "abc.jsonl"), []byte(`{"role":"user","content":"hi"}`+"\n"), 0o644); err != nil {
142 t.Fatal(err)
143 }
144 outPath := filepath.Join(t.TempDir(), "abc-diag.zip")
145 out := captureStdout(t, func() {
146 if rc := doctorCommand([]string{"session", "abc", "--zip", "--out", outPath}, "test-version"); rc != 0 {
147 t.Fatalf("doctor session rc = %d, want 0", rc)
148 }
149 })
150 if strings.TrimSpace(out) != outPath {
151 t.Fatalf("doctor session output = %q, want %q", strings.TrimSpace(out), outPath)
152 }
153 if info, err := os.Stat(outPath); err != nil || info.Size() == 0 {
154 t.Fatalf("bundle stat = %v, %v", info, err)
155 }
156 }
157
158 func TestDoctorQualityCommandPrintsPublicSafeJSON(t *testing.T) {
159 home := t.TempDir()
160 t.Setenv("REASONIX_HOME", home)
161 sessionDir := filepath.Join(home, "sessions")
162 if err := os.MkdirAll(sessionDir, 0o755); err != nil {
163 t.Fatal(err)
164 }
165 path := filepath.Join(sessionDir, "abc.jsonl")
166 const secret = "quality-command-secret"
167 if err := os.WriteFile(path, []byte(`{"role":"user","content":"`+secret+`"}`+"\n"), 0o644); err != nil {
168 t.Fatal(err)
169 }
170 out := captureStdout(t, func() {
171 if rc := doctorCommand([]string{"quality", "abc", "--json"}, "test-version"); rc != 0 {
172 t.Fatalf("doctor quality rc = %d, want 0", rc)
173 }
174 })
175 var decoded map[string]any
176 if err := json.Unmarshal([]byte(out), &decoded); err != nil {
177 t.Fatalf("doctor quality output is not JSON: %v\n%s", err, out)
178 }
179 if decoded["version"] != "test-version" {
180 t.Fatalf("version = %v", decoded["version"])
181 }
182 if strings.Contains(out, secret) || strings.Contains(out, home) || strings.Contains(out, "abc.jsonl") {
183 t.Fatalf("doctor quality leaked session data:\n%s", out)
184 }
185 }
186
187 func captureStdout(t *testing.T, fn func()) string {
188 t.Helper()
189 old := os.Stdout
190 r, w, err := os.Pipe()
191 if err != nil {
192 t.Fatal(err)
193 }
194 os.Stdout = w
195 defer func() { os.Stdout = old }()
196
197 fn()
198 if err := w.Close(); err != nil {
199 t.Fatal(err)
200 }
201 data, err := io.ReadAll(r)
202 if err != nil {
203 t.Fatal(err)
204 }
205 return string(data)
206 }
207
208 func TestDoctorSessionCommandOutEqualsForm(t *testing.T) {
209 home := t.TempDir()
210 t.Setenv("REASONIX_HOME", home)
211 sessionDir := filepath.Join(home, "sessions")
212 if err := os.MkdirAll(sessionDir, 0o755); err != nil {
213 t.Fatal(err)
214 }
215 if err := os.WriteFile(filepath.Join(sessionDir, "abc.jsonl"), []byte(`{"role":"user","content":"hi"}`+"\n"), 0o644); err != nil {
216 t.Fatal(err)
217 }
218 outPath := filepath.Join(t.TempDir(), "abc-diag.zip")
219 out := captureStdout(t, func() {
220 if rc := doctorCommand([]string{"session", "abc", "--out=" + outPath}, "test-version"); rc != 0 {
221 t.Fatalf("doctor session rc = %d, want 0", rc)
222 }
223 })
224 if strings.TrimSpace(out) != outPath {
225 t.Fatalf("doctor session output = %q, want %q", strings.TrimSpace(out), outPath)
226 }
227 if rc := doctorCommand([]string{"session", "abc", "--out="}, "test-version"); rc != 2 {
228 t.Fatalf("empty --out= rc = %d, want usage error 2", rc)
229 }
230 }
231
232 func TestDoctorRedactSessionsCommand(t *testing.T) {
233 dir := t.TempDir()
234 const secret = "sensitive-value-that-must-not-leak"
235 path := filepath.Join(dir, "abc.jsonl")
236 if err := os.WriteFile(path, []byte(`{"role":"tool","content":"DEEPSEEK_API_KEY=`+secret+`"}`+"\n"), 0o644); err != nil {
237 t.Fatal(err)
238 }
239
240 out := captureStdout(t, func() {
241 if rc := doctorCommand([]string{"redact-sessions", "--dir", dir}, "test-version"); rc != 0 {
242 t.Fatalf("doctor redact-sessions rc = %d, want 0", rc)
243 }
244 })
245 if !strings.Contains(out, "session secret cleanup redacted 1/1 files") {
246 t.Fatalf("unexpected output:\n%s", out)
247 }
248 data, err := os.ReadFile(path)
249 if err != nil {
250 t.Fatal(err)
251 }
252 if strings.Contains(string(data), secret) {
253 t.Fatalf("doctor redact-sessions leaked secret:\n%s", data)
254 }
255 }
256
256 lines GO