返回 DeepSeek-Reasonix
attach_dropped_test.go
根目录 / desktop / attach_dropped_test.go
1 package main
2
3 import (
4 "context"
5 "encoding/base64"
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10 "time"
11
12 "reasonix/internal/config"
13 "reasonix/internal/control"
14 "reasonix/internal/event"
15 )
16
17 const desktopTinyPNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
18
19 func TestWorkspaceRelativeIn(t *testing.T) {
20 root := t.TempDir()
21
22 if rel, ok := workspaceRelativeIn(filepath.Join(root, "sub", "file.go"), root); !ok || rel != "sub/file.go" {
23 t.Fatalf("in-tree = (%q, %v), want (sub/file.go, true)", rel, ok)
24 }
25 if _, ok := workspaceRelativeIn(filepath.Join(filepath.Dir(root), "sibling.txt"), root); ok {
26 t.Fatal("a path above the workspace must not resolve as in-tree")
27 }
28 }
29
30 func TestIsImageExt(t *testing.T) {
31 for _, p := range []string{"a.png", "A.PNG", "b.jpeg", "c.webp"} {
32 if !isImageExt(p) {
33 t.Errorf("%q should be an image extension", p)
34 }
35 }
36 for _, p := range []string{"notes.pdf", "main.go", "noext"} {
37 if isImageExt(p) {
38 t.Errorf("%q should not be an image extension", p)
39 }
40 }
41 }
42
43 func TestSavePastedImageUsesActiveWorkspaceRoot(t *testing.T) {
44 orig, _ := os.Getwd()
45 defer os.Chdir(orig)
46
47 launchRoot := t.TempDir()
48 projectRoot := t.TempDir()
49 if err := os.Chdir(projectRoot); err != nil {
50 t.Fatal(err)
51 }
52 projectRoot, _ = os.Getwd()
53 if err := os.Chdir(launchRoot); err != nil {
54 t.Fatal(err)
55 }
56 app := &App{
57 tabs: map[string]*WorkspaceTab{
58 "project": {ID: "project", WorkspaceRoot: projectRoot},
59 },
60 activeTabID: "project",
61 }
62
63 got, err := app.SavePastedImage("data:image/png;base64," + desktopTinyPNG)
64 if err != nil {
65 t.Fatalf("SavePastedImage: %v", err)
66 }
67 if _, err := os.Stat(filepath.Join(projectRoot, filepath.FromSlash(got))); err != nil {
68 t.Fatalf("pasted image should be saved under active workspace: %v", err)
69 }
70 if _, err := os.Stat(filepath.Join(launchRoot, filepath.FromSlash(got))); !os.IsNotExist(err) {
71 t.Fatalf("pasted image should not be saved under launch root, stat err=%v", err)
72 }
73 preview, err := app.AttachmentDataURL(got)
74 if err != nil {
75 t.Fatalf("AttachmentDataURL: %v", err)
76 }
77 if !strings.HasPrefix(preview, "data:image/png;base64,") {
78 t.Fatalf("preview = %q, want png data URL", preview)
79 }
80 }
81
82 func TestSavePastedImageUsesPinnedSessionOwnerBeforeStaleWorkspaceRoot(t *testing.T) {
83 isolateDesktopUserDirs(t)
84 orig, _ := os.Getwd()
85 defer os.Chdir(orig)
86
87 launchRoot := t.TempDir()
88 projectA := t.TempDir()
89 projectB := t.TempDir()
90 if err := addProject(projectA, "Project A"); err != nil {
91 t.Fatalf("add project A: %v", err)
92 }
93 if err := addProject(projectB, "Project B"); err != nil {
94 t.Fatalf("add project B: %v", err)
95 }
96 sessionDirA := desktopSessionDir(projectA)
97 if err := os.MkdirAll(sessionDirA, 0o755); err != nil {
98 t.Fatalf("mkdir project A sessions: %v", err)
99 }
100 sessionPathA := writeTopicSessionWithPrompt(t, sessionDirA, "project-a.jsonl", "topic_attach_owner", "Attach owner", projectA, "project A prompt", time.Now())
101 if err := os.Chdir(launchRoot); err != nil {
102 t.Fatal(err)
103 }
104
105 app := &App{
106 tabs: map[string]*WorkspaceTab{
107 "project": {ID: "project", Scope: "project", WorkspaceRoot: projectB, SessionPath: sessionPathA},
108 },
109 activeTabID: "project",
110 }
111
112 got, err := app.SavePastedImage("data:image/png;base64," + desktopTinyPNG)
113 if err != nil {
114 t.Fatalf("SavePastedImage: %v", err)
115 }
116 if _, err := os.Stat(filepath.Join(projectA, filepath.FromSlash(got))); err != nil {
117 t.Fatalf("pasted image should be saved under pinned session owner project A: %v", err)
118 }
119 if _, err := os.Stat(filepath.Join(projectB, filepath.FromSlash(got))); !os.IsNotExist(err) {
120 t.Fatalf("pasted image should not be saved under stale project B, stat err=%v", err)
121 }
122 if gotRoot := normalizeProjectRoot(app.tabs["project"].WorkspaceRoot); gotRoot != normalizeProjectRoot(projectA) {
123 t.Fatalf("tab workspace root = %q, want project A %q", gotRoot, normalizeProjectRoot(projectA))
124 }
125 }
126
127 func TestAttachDroppedUsesActiveWorkspaceRoot(t *testing.T) {
128 orig, _ := os.Getwd()
129 defer os.Chdir(orig)
130
131 launchRoot := t.TempDir()
132 projectRoot := t.TempDir()
133 if err := os.Chdir(projectRoot); err != nil {
134 t.Fatal(err)
135 }
136 projectRoot, _ = os.Getwd()
137 if err := os.Chdir(launchRoot); err != nil {
138 t.Fatal(err)
139 }
140 app := &App{
141 tabs: map[string]*WorkspaceTab{
142 "project": {ID: "project", WorkspaceRoot: projectRoot},
143 },
144 activeTabID: "project",
145 }
146 if err := os.MkdirAll(filepath.Join(projectRoot, "sub"), 0o755); err != nil {
147 t.Fatal(err)
148 }
149 target := filepath.Join(projectRoot, "sub", "notes.txt")
150 if err := os.WriteFile(target, []byte("body"), 0o644); err != nil {
151 t.Fatal(err)
152 }
153
154 got, err := app.AttachDropped(target)
155 if err != nil {
156 t.Fatalf("AttachDropped: %v", err)
157 }
158 if got.Kind != "workspace" || got.Path != "sub/notes.txt" {
159 t.Fatalf("got %+v, want workspace ref sub/notes.txt", got)
160 }
161 }
162
163 func TestAttachDroppedImageUsesActiveWorkspaceRoot(t *testing.T) {
164 orig, _ := os.Getwd()
165 defer os.Chdir(orig)
166
167 launchRoot := t.TempDir()
168 projectRoot := t.TempDir()
169 if err := os.Chdir(launchRoot); err != nil {
170 t.Fatal(err)
171 }
172 app := &App{
173 tabs: map[string]*WorkspaceTab{
174 "project": {ID: "project", WorkspaceRoot: projectRoot},
175 },
176 activeTabID: "project",
177 }
178 raw, err := base64.StdEncoding.DecodeString(desktopTinyPNG)
179 if err != nil {
180 t.Fatal(err)
181 }
182 outside := filepath.Join(t.TempDir(), "shot.png")
183 if err := os.WriteFile(outside, raw, 0o644); err != nil {
184 t.Fatal(err)
185 }
186
187 got, err := app.AttachDropped(outside)
188 if err != nil {
189 t.Fatalf("AttachDropped: %v", err)
190 }
191 if got.Kind != "attachment" || !strings.HasSuffix(got.Path, ".png") {
192 t.Fatalf("got %+v, want png attachment", got)
193 }
194 if _, err := os.Stat(filepath.Join(projectRoot, filepath.FromSlash(got.Path))); err != nil {
195 t.Fatalf("dropped image should be saved under active workspace: %v", err)
196 }
197 if _, err := os.Stat(filepath.Join(launchRoot, filepath.FromSlash(got.Path))); !os.IsNotExist(err) {
198 t.Fatalf("dropped image should not be saved under launch root, stat err=%v", err)
199 }
200 if !strings.HasPrefix(got.PreviewURL, "data:image/png;base64,") {
201 t.Fatalf("preview = %q, want png data URL", got.PreviewURL)
202 }
203 }
204
205 func TestAttachDroppedInWorkspaceReferencesInPlace(t *testing.T) {
206 orig, _ := os.Getwd()
207 defer os.Chdir(orig)
208
209 root := t.TempDir()
210 if err := os.Chdir(root); err != nil {
211 t.Fatal(err)
212 }
213 cwd, _ := os.Getwd()
214 if err := os.MkdirAll(filepath.Join(cwd, "sub"), 0o755); err != nil {
215 t.Fatal(err)
216 }
217 target := filepath.Join(cwd, "sub", "notes.txt")
218 if err := os.WriteFile(target, []byte("body"), 0o644); err != nil {
219 t.Fatal(err)
220 }
221
222 got, err := (&App{}).AttachDropped(target)
223 if err != nil {
224 t.Fatalf("AttachDropped: %v", err)
225 }
226 if got.Kind != "workspace" || got.Path != "sub/notes.txt" {
227 t.Fatalf("got %+v, want workspace ref sub/notes.txt", got)
228 }
229 }
230
231 func TestAttachDroppedOutsideWorkspaceCopiesToAttachments(t *testing.T) {
232 orig, _ := os.Getwd()
233 defer os.Chdir(orig)
234
235 outside := filepath.Join(t.TempDir(), "report.pdf")
236 if err := os.WriteFile(outside, []byte("%PDF body"), 0o644); err != nil {
237 t.Fatal(err)
238 }
239
240 root := t.TempDir()
241 if err := os.Chdir(root); err != nil {
242 t.Fatal(err)
243 }
244
245 got, err := (&App{}).AttachDropped(outside)
246 if err != nil {
247 t.Fatalf("AttachDropped: %v", err)
248 }
249 if got.Kind != "attachment" || !strings.HasPrefix(got.Path, ".reasonix/attachments/") || !strings.HasSuffix(got.Path, ".pdf") {
250 t.Fatalf("got %+v, want copied pdf attachment", got)
251 }
252 }
253
254 func TestAttachDroppedImageStoresThumbnail(t *testing.T) {
255 orig, _ := os.Getwd()
256 defer os.Chdir(orig)
257
258 root := t.TempDir()
259 if err := os.Chdir(root); err != nil {
260 t.Fatal(err)
261 }
262 cwd, _ := os.Getwd()
263 png := append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 64)...)
264 if err := os.WriteFile(filepath.Join(cwd, "shot.png"), png, 0o644); err != nil {
265 t.Fatal(err)
266 }
267
268 got, err := (&App{}).AttachDropped(filepath.Join(cwd, "shot.png"))
269 if err != nil {
270 t.Fatalf("AttachDropped: %v", err)
271 }
272 if got.Kind != "attachment" || !strings.HasSuffix(got.Path, ".png") {
273 t.Fatalf("got %+v, want png attachment", got)
274 }
275 if !strings.HasPrefix(got.PreviewURL, "data:image/png;base64,") {
276 t.Fatalf("preview = %q, want png data URL", got.PreviewURL)
277 }
278 }
279
280 func TestAttachDroppedOutsideWorkspaceDirRegistersWorkspaceRef(t *testing.T) {
281 orig, _ := os.Getwd()
282 defer os.Chdir(orig)
283
284 workspace := t.TempDir()
285 outside := filepath.Join(t.TempDir(), "Folder With Spaces")
286 if err := os.MkdirAll(filepath.Join(outside, "sub"), 0o755); err != nil {
287 t.Fatal(err)
288 }
289 if err := os.WriteFile(filepath.Join(outside, "sub", "notes.txt"), []byte("notes"), 0o644); err != nil {
290 t.Fatal(err)
291 }
292 expectedOutside := outside
293 if resolved, err := filepath.EvalSymlinks(outside); err == nil {
294 expectedOutside = resolved
295 }
296 expectedDisplayPath := filepath.ToSlash(expectedOutside)
297 if err := os.Chdir(workspace); err != nil {
298 t.Fatal(err)
299 }
300
301 ctrl := control.New(control.Options{WorkspaceRoot: workspace})
302 app := &App{
303 tabs: map[string]*WorkspaceTab{
304 "project": {ID: "project", WorkspaceRoot: workspace, Ctrl: ctrl},
305 },
306 activeTabID: "project",
307 }
308
309 got, err := app.AttachDropped(outside)
310 if err != nil {
311 t.Fatalf("AttachDropped: %v", err)
312 }
313 if got.Kind != "workspace" || !got.IsDir {
314 t.Fatalf("got %+v, want workspace directory ref", got)
315 }
316 if !strings.HasPrefix(got.Path, "__reasonix_external_folder/") || strings.ContainsAny(got.Path, " \t\r\n") {
317 t.Fatalf("external folder path token = %q, want whitespace-free external token", got.Path)
318 }
319 if got.DisplayPath != expectedDisplayPath {
320 t.Fatalf("display path = %q, want %q", got.DisplayPath, expectedDisplayPath)
321 }
322
323 block, errs := ctrl.ResolveScopedRefs(context.Background(), "inspect @"+got.Path+"/")
324 if len(errs) != 0 {
325 t.Fatalf("ResolveScopedRefs errors = %v", errs)
326 }
327 if !strings.Contains(block, `<dir path="`+expectedDisplayPath+`">`) ||
328 !strings.Contains(block, "sub/") ||
329 !strings.Contains(block, "sub/notes.txt") {
330 t.Fatalf("external dropped folder should resolve as dir context:\n%s", block)
331 }
332 }
333
334 func TestAttachDroppedOutsideWorkspaceDirRegistersAfterPinnedOwnerRebuild(t *testing.T) {
335 isolateDesktopUserDirs(t)
336 setDesktopTestCredential(t, "TEST_MODEL_KEY", "sk-test")
337 cfg := config.Default()
338 cfg.DefaultModel = "test/test-model"
339 cfg.Desktop.ProviderAccess = []string{"test"}
340 cfg.Providers = []config.ProviderEntry{
341 {Name: "test", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "test-model", APIKeyEnv: "TEST_MODEL_KEY"},
342 }
343 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
344 t.Fatalf("save config: %v", err)
345 }
346 orig, _ := os.Getwd()
347 defer os.Chdir(orig)
348
349 projectA := t.TempDir()
350 projectB := t.TempDir()
351 outside := filepath.Join(t.TempDir(), "External")
352 if err := os.MkdirAll(filepath.Join(outside, "sub"), 0o755); err != nil {
353 t.Fatal(err)
354 }
355 if err := os.WriteFile(filepath.Join(outside, "sub", "notes.txt"), []byte("notes"), 0o644); err != nil {
356 t.Fatal(err)
357 }
358 if err := addProject(projectA, "Project A"); err != nil {
359 t.Fatalf("add project A: %v", err)
360 }
361 if err := addProject(projectB, "Project B"); err != nil {
362 t.Fatalf("add project B: %v", err)
363 }
364 sessionDirA := desktopSessionDir(projectA)
365 sessionDirB := desktopSessionDir(projectB)
366 if err := os.MkdirAll(sessionDirA, 0o755); err != nil {
367 t.Fatalf("mkdir project A sessions: %v", err)
368 }
369 if err := os.MkdirAll(sessionDirB, 0o755); err != nil {
370 t.Fatalf("mkdir project B sessions: %v", err)
371 }
372 sessionPathA := writeTopicSessionWithPrompt(t, sessionDirA, "project-a.jsonl", "topic_external_ref", "External ref", projectA, "project A prompt", time.Now())
373 sessionPathB := filepath.Join(sessionDirB, "wrong.jsonl")
374 oldCtrl := control.New(control.Options{
375 SessionDir: sessionDirB,
376 SessionPath: sessionPathB,
377 WorkspaceRoot: projectB,
378 Sink: event.Discard,
379 })
380 app := NewApp()
381 app.readyHook = func() {}
382 tab := &WorkspaceTab{
383 ID: "project",
384 Scope: "project",
385 WorkspaceRoot: projectB,
386 TopicID: "topic_external_ref",
387 TopicTitle: "External ref",
388 SessionPath: sessionPathA,
389 Ready: true,
390 model: "test/test-model",
391 Ctrl: oldCtrl,
392 sink: &tabEventSink{tabID: "project", app: app},
393 disabledMCP: map[string]ServerView{},
394 }
395 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
396 app.tabOrder = []string{tab.ID}
397 app.activeTabID = tab.ID
398 t.Cleanup(func() {
399 if tab.Ctrl != nil {
400 tab.Ctrl.Close()
401 }
402 })
403
404 got, err := app.AttachDropped(outside)
405 if err != nil {
406 t.Fatalf("AttachDropped: %v", err)
407 }
408 if tab.Ctrl == oldCtrl {
409 t.Fatal("stale controller was reused for external folder ref")
410 }
411 if gotRoot := normalizeProjectRoot(tab.Ctrl.WorkspaceRoot()); gotRoot != normalizeProjectRoot(projectA) {
412 t.Fatalf("controller workspace root = %q, want project A %q", gotRoot, normalizeProjectRoot(projectA))
413 }
414 resolver, ok := tab.Ctrl.(interface {
415 ResolveScopedRefs(context.Context, string) (string, []string)
416 })
417 if !ok {
418 t.Fatalf("rebuilt controller does not resolve scoped refs: %T", tab.Ctrl)
419 }
420 block, errs := resolver.ResolveScopedRefs(context.Background(), "inspect @"+got.Path+"/")
421 if len(errs) != 0 {
422 t.Fatalf("ResolveScopedRefs errors = %v", errs)
423 }
424 if !strings.Contains(block, "sub/notes.txt") {
425 t.Fatalf("external dropped folder should resolve on rebuilt controller:\n%s", block)
426 }
427 }
428
428 lines GO