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