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