返回 DeepSeek-Reasonix
export_test.go
根目录 / desktop / export_test.go
1 package main
2
3 import (
4 "bytes"
5 "encoding/base64"
6 "fmt"
7 "os"
8 "path/filepath"
9 "runtime"
10 "strings"
11 "sync"
12 "testing"
13 )
14
15 func TestSaveExportFileWritesTextAndBinaryPayloads(t *testing.T) {
16 t.Parallel()
17 app := &App{}
18 dir := t.TempDir()
19
20 textPath := filepath.Join(dir, "session.md")
21 if err := app.SaveExportFile(textPath, "# 会话\n", false); err != nil {
22 t.Fatalf("save text export: %v", err)
23 }
24 text, err := os.ReadFile(textPath)
25 if err != nil {
26 t.Fatalf("read text export: %v", err)
27 }
28 if got, want := string(text), "# 会话\n"; got != want {
29 t.Fatalf("text export = %q, want %q", got, want)
30 }
31
32 binaryPath := filepath.Join(dir, "session.png")
33 binary := []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n', 0x00, 0xff}
34 if err := app.SaveExportFile(binaryPath, base64.StdEncoding.EncodeToString(binary), true); err != nil {
35 t.Fatalf("save binary export: %v", err)
36 }
37 written, err := os.ReadFile(binaryPath)
38 if err != nil {
39 t.Fatalf("read binary export: %v", err)
40 }
41 if string(written) != string(binary) {
42 t.Fatalf("binary export = %v, want %v", written, binary)
43 }
44 }
45
46 func TestSaveExportFileRejectsInvalidBase64(t *testing.T) {
47 t.Parallel()
48 path := filepath.Join(t.TempDir(), "broken.pdf")
49 err := (&App{}).SaveExportFile(path, "not base64!", true)
50 if err == nil {
51 t.Fatal("expected invalid base64 error")
52 }
53 if _, statErr := os.Stat(path); !os.IsNotExist(statErr) {
54 t.Fatalf("invalid payload should not create a file, stat error = %v", statErr)
55 }
56 }
57
58 func TestExportErrorsDoNotExposeSelectedDirectory(t *testing.T) {
59 t.Parallel()
60 dir := t.TempDir()
61 missingDir := filepath.Join(dir, "private-export-directory")
62 payload := base64.StdEncoding.EncodeToString([]byte("image"))
63 tests := []struct {
64 name string
65 path string
66 run func(string) error
67 }{
68 {
69 name: "single file",
70 path: filepath.Join(missingDir, "session.pdf"),
71 run: func(path string) error {
72 return (&App{}).SaveExportFile(path, payload, true)
73 },
74 },
75 {
76 name: "multipart image",
77 path: filepath.Join(missingDir, "session.png"),
78 run: func(path string) error {
79 return (&App{}).SaveExportImageFiles(path, []string{payload, payload})
80 },
81 },
82 }
83 for _, test := range tests {
84 t.Run(test.name, func(t *testing.T) {
85 err := test.run(test.path)
86 if err == nil {
87 t.Fatal("expected missing export directory to fail")
88 }
89 if strings.Contains(err.Error(), dir) {
90 t.Fatalf("export error exposed selected directory: %q", err)
91 }
92 if !strings.Contains(err.Error(), "session") {
93 t.Fatalf("export error should retain a safe file name: %q", err)
94 }
95 })
96 }
97 }
98
99 func TestSaveExportImageFilesWritesNumberedParts(t *testing.T) {
100 t.Parallel()
101 dir := t.TempDir()
102 path := filepath.Join(dir, "session.archive.png")
103 payloads := [][]byte{{0x01, 0x02}, {0x03, 0x04}, {0x05, 0x06}}
104 encoded := make([]string, len(payloads))
105 for i, payload := range payloads {
106 encoded[i] = base64.StdEncoding.EncodeToString(payload)
107 }
108
109 if err := (&App{}).SaveExportImageFiles(path, encoded); err != nil {
110 t.Fatalf("save image parts: %v", err)
111 }
112 for i, want := range payloads {
113 partPath := filepath.Join(dir, fmt.Sprintf("session.archive-%d-of-3.png", i+1))
114 got, err := os.ReadFile(partPath)
115 if err != nil {
116 t.Fatalf("read image part %d: %v", i+1, err)
117 }
118 if !bytes.Equal(got, want) {
119 t.Fatalf("image part %d = %v, want %v", i+1, got, want)
120 }
121 }
122 if _, err := os.Stat(path); !os.IsNotExist(err) {
123 t.Fatalf("multi-part export should not write the selected base path, stat error = %v", err)
124 }
125 }
126
127 func TestSaveExportImageFilesPreservesSelectedPath(t *testing.T) {
128 t.Parallel()
129 if runtime.GOOS == "windows" {
130 t.Skip("Windows normalizes trailing spaces in file names")
131 }
132 dir := t.TempDir()
133 selectedPath := filepath.Join(dir, "session.png ")
134 neighborPath := filepath.Join(dir, "session.png")
135 if err := os.WriteFile(neighborPath, []byte("keep me"), 0o644); err != nil {
136 t.Fatalf("seed neighboring file: %v", err)
137 }
138 payload := base64.StdEncoding.EncodeToString([]byte("new image"))
139
140 if err := (&App{}).SaveExportImageFiles(selectedPath, []string{payload}); err != nil {
141 t.Fatalf("save exact selected path: %v", err)
142 }
143 if got, err := os.ReadFile(selectedPath); err != nil || string(got) != "new image" {
144 t.Fatalf("selected path data = %q, err = %v", got, err)
145 }
146 if got, err := os.ReadFile(neighborPath); err != nil || string(got) != "keep me" {
147 t.Fatalf("neighboring file changed: data=%q err=%v", got, err)
148 }
149 }
150
151 func TestSaveExportImageFilesMatchesNormalExportPermissions(t *testing.T) {
152 t.Parallel()
153 dir := t.TempDir()
154 referencePath := filepath.Join(dir, "reference.png")
155 if err := (&App{}).SaveExportFile(referencePath, base64.StdEncoding.EncodeToString([]byte("reference")), true); err != nil {
156 t.Fatalf("save reference export: %v", err)
157 }
158 payload := base64.StdEncoding.EncodeToString([]byte("image"))
159 if err := (&App{}).SaveExportImageFiles(filepath.Join(dir, "session.png"), []string{payload, payload}); err != nil {
160 t.Fatalf("save multipart export: %v", err)
161 }
162
163 referenceInfo, err := os.Stat(referencePath)
164 if err != nil {
165 t.Fatalf("stat reference export: %v", err)
166 }
167 partInfo, err := os.Stat(filepath.Join(dir, "session-1-of-2.png"))
168 if err != nil {
169 t.Fatalf("stat multipart export: %v", err)
170 }
171 if got, want := partInfo.Mode().Perm(), referenceInfo.Mode().Perm(); got != want {
172 t.Fatalf("multipart permissions = %v, want normal export permissions %v", got, want)
173 }
174 if matches, err := filepath.Glob(filepath.Join(dir, ".reasonix-export-*")); err != nil || len(matches) != 0 {
175 t.Fatalf("staged files remain after successful export: matches=%v err=%v", matches, err)
176 }
177 }
178
179 func TestSaveExportImageFilesRejectsCollisionWithoutPartialOutput(t *testing.T) {
180 t.Parallel()
181 dir := t.TempDir()
182 path := filepath.Join(dir, "session.png")
183 collisionPath := filepath.Join(dir, "session-2-of-3.png")
184 if err := os.WriteFile(collisionPath, []byte("keep me"), 0o644); err != nil {
185 t.Fatalf("seed collision: %v", err)
186 }
187 payload := base64.StdEncoding.EncodeToString([]byte("new image"))
188
189 err := (&App{}).SaveExportImageFiles(path, []string{payload, payload, payload})
190 if err == nil {
191 t.Fatal("expected existing numbered export to reject the batch")
192 }
193 if got, readErr := os.ReadFile(collisionPath); readErr != nil || string(got) != "keep me" {
194 t.Fatalf("existing image part changed: data=%q err=%v", got, readErr)
195 }
196 for _, name := range []string{"session-1-of-3.png", "session-3-of-3.png"} {
197 if _, statErr := os.Stat(filepath.Join(dir, name)); !os.IsNotExist(statErr) {
198 t.Fatalf("collision should leave no partial output %s, stat error = %v", name, statErr)
199 }
200 }
201 }
202
203 func TestSaveExportImageFilesDecodesAllPartsBeforeWriting(t *testing.T) {
204 t.Parallel()
205 dir := t.TempDir()
206 path := filepath.Join(dir, "session.png")
207 valid := base64.StdEncoding.EncodeToString([]byte("image"))
208
209 err := (&App{}).SaveExportImageFiles(path, []string{valid, "not base64!", valid})
210 if err == nil {
211 t.Fatal("expected invalid image payload to reject the batch")
212 }
213 for i := 1; i <= 3; i++ {
214 partPath := filepath.Join(dir, fmt.Sprintf("session-%d-of-3.png", i))
215 if _, statErr := os.Stat(partPath); !os.IsNotExist(statErr) {
216 t.Fatalf("invalid payload should leave no image part %d, stat error = %v", i, statErr)
217 }
218 }
219 if matches, globErr := filepath.Glob(filepath.Join(dir, ".reasonix-export-*")); globErr != nil || len(matches) != 0 {
220 t.Fatalf("invalid payload left staged files: matches=%v err=%v", matches, globErr)
221 }
222 }
223
224 func TestSaveExclusiveExportFilesRollsBackCommittedTargets(t *testing.T) {
225 t.Parallel()
226 target := filepath.Join(t.TempDir(), "duplicate.png")
227
228 err := saveExclusiveExportFiles(
229 []string{target, target},
230 [][]byte{[]byte("first"), []byte("second")},
231 )
232 if err == nil {
233 t.Fatal("expected duplicate exclusive target to fail")
234 }
235 if _, statErr := os.Stat(target); !os.IsNotExist(statErr) {
236 t.Fatalf("failed batch should roll back its committed target, stat error = %v", statErr)
237 }
238 }
239
240 func TestRollbackDoesNotRemoveReplacedExportTarget(t *testing.T) {
241 t.Parallel()
242 dir := t.TempDir()
243 tempPath := filepath.Join(dir, ".staged.png")
244 targetPath := filepath.Join(dir, "session.png")
245 if err := os.WriteFile(tempPath, []byte("staged"), 0o644); err != nil {
246 t.Fatalf("write staged file: %v", err)
247 }
248 created, err := commitStagedExportFile(tempPath, targetPath)
249 if err != nil {
250 t.Fatalf("commit staged file: %v", err)
251 }
252 tempInfo, err := os.Lstat(tempPath)
253 if err != nil {
254 t.Fatalf("stat staged file: %v", err)
255 }
256 if !os.SameFile(created, tempInfo) {
257 t.Fatal("commit must return the staged inode identity")
258 }
259 if err := os.Remove(targetPath); err != nil {
260 t.Fatalf("replace committed target: %v", err)
261 }
262 if err := os.WriteFile(targetPath, []byte("replacement"), 0o644); err != nil {
263 t.Fatalf("write replacement target: %v", err)
264 }
265
266 rollbackCommittedExportFiles([]committedExportFile{{path: targetPath, info: created}})
267 if got, err := os.ReadFile(targetPath); err != nil || string(got) != "replacement" {
268 t.Fatalf("rollback removed replacement: data=%q err=%v", got, err)
269 }
270 }
271
272 func TestConcurrentMultipartExportsHaveSingleCompleteWinner(t *testing.T) {
273 t.Parallel()
274 dir := t.TempDir()
275 path := filepath.Join(dir, "session.png")
276 encode := func(values ...string) []string {
277 encoded := make([]string, len(values))
278 for i, value := range values {
279 encoded[i] = base64.StdEncoding.EncodeToString([]byte(value))
280 }
281 return encoded
282 }
283 batches := [][]string{
284 encode("a-1", "a-2", "a-3"),
285 encode("b-1", "b-2", "b-3"),
286 }
287 start := make(chan struct{})
288 errs := make(chan error, len(batches))
289 var ready sync.WaitGroup
290 ready.Add(len(batches))
291 for _, batch := range batches {
292 go func() {
293 ready.Done()
294 <-start
295 errs <- (&App{}).SaveExportImageFiles(path, batch)
296 }()
297 }
298 ready.Wait()
299 close(start)
300
301 successes := 0
302 for range batches {
303 if err := <-errs; err == nil {
304 successes++
305 }
306 }
307 if successes != 1 {
308 t.Fatalf("successful concurrent exports = %d, want exactly one", successes)
309 }
310 first, err := os.ReadFile(filepath.Join(dir, "session-1-of-3.png"))
311 if err != nil {
312 t.Fatalf("read winning first part: %v", err)
313 }
314 winner := string(first[:1])
315 for i := 1; i <= 3; i++ {
316 got, err := os.ReadFile(filepath.Join(dir, fmt.Sprintf("session-%d-of-3.png", i)))
317 if err != nil {
318 t.Fatalf("read winning part %d: %v", i, err)
319 }
320 if want := fmt.Sprintf("%s-%d", winner, i); string(got) != want {
321 t.Fatalf("winning part %d = %q, want %q from one batch", i, got, want)
322 }
323 }
324 if matches, globErr := filepath.Glob(filepath.Join(dir, ".reasonix-export-*")); globErr != nil || len(matches) != 0 {
325 t.Fatalf("concurrent export left staged files: matches=%v err=%v", matches, globErr)
326 }
327 }
328
329 func TestExportFileFiltersSelectExpectedNativePattern(t *testing.T) {
330 t.Parallel()
331 tests := []struct {
332 mime string
333 ext string
334 want string
335 }{
336 {mime: "application/pdf", ext: ".pdf", want: "*.pdf"},
337 {mime: "image/png", ext: ".png", want: "*.png"},
338 {mime: "application/octet-stream", ext: ".bin", want: "*.bin"},
339 }
340 for _, test := range tests {
341 filters := exportFileFilters(test.mime, test.ext)
342 if len(filters) != 1 || filters[0].Pattern != test.want {
343 t.Fatalf("filters for %s = %#v, want pattern %q", test.mime, filters, test.want)
344 }
345 }
346 }
347
347 lines GO