返回 DeepSeek-Reasonix
session_export_test.go
根目录 / desktop / session_export_test.go
1 package main
2
3 import (
4 "bytes"
5 "encoding/base64"
6 "encoding/json"
7 "image"
8 "image/jpeg"
9 "image/png"
10 "net/http"
11 "os"
12 "path/filepath"
13 "reasonix/internal/provider"
14 "reasonix/internal/session"
15 "slices"
16 "strings"
17 "testing"
18 )
19
20 func TestSessionExportCapturesSourceAndCompletePrefix(t *testing.T) {
21 app, ref := activityBaselineFixture(t, "export-source")
22 runtime, _ := app.desktopSessionService("").Runtime(ref)
23 appendMessage := func(id, content string) {
24 t.Helper()
25 payload, _ := json.Marshal(map[string]any{"message": provider.Message{ID: id, Role: provider.RoleUser, Content: content, Origin: provider.MessageOrigin("user")}})
26 if _, err := runtime.Session().Append(t.Context(), session.Batch{OperationID: id, Events: []session.Event{{Kind: "message/complete", Payload: payload}}}); err != nil {
27 t.Fatal(err)
28 }
29 }
30 appendMessage("first", "FIRST-QUESTION")
31 handle, err := app.BeginSessionExportForTarget(SessionSelector{Ref: &ref}, "", "clipboard", "Source A", `{"residentItems":0}`)
32 if err != nil {
33 t.Fatal(err)
34 }
35 defer app.CancelSessionExport(handle.ExportID)
36 appendMessage("later", "AFTER-CAPTURE")
37 app.activeTabID = "different-tab"
38 var body strings.Builder
39 var offset int64
40 for {
41 chunk, err := app.ReadSessionExportChunk(handle.ExportID, offset)
42 if err != nil {
43 t.Fatal(err)
44 }
45 bytes, err := base64.StdEncoding.DecodeString(chunk.Data)
46 if err != nil {
47 t.Fatal(err)
48 }
49 body.Write(bytes)
50 offset = chunk.NextOffset
51 if chunk.Done {
52 break
53 }
54 }
55 if !strings.Contains(body.String(), "FIRST-QUESTION") || strings.Contains(body.String(), "AFTER-CAPTURE") {
56 t.Fatalf("unexpected snapshot %q", body.String())
57 }
58 result, err := app.FinishSessionExport(handle.ExportID)
59 if err != nil {
60 t.Fatal(err)
61 }
62 if result.Paths == nil || result.Records != 1 {
63 t.Fatalf("result=%+v", result)
64 }
65 if _, err = app.exportJob(handle.ExportID); err == nil {
66 t.Fatal("finished export retained its resources")
67 }
68 }
69
70 func TestCancelExportDoesNotCloseSession(t *testing.T) {
71 app, ref := activityBaselineFixture(t, "export-cancel")
72 handle, err := app.BeginSessionExportForTarget(SessionSelector{Ref: &ref}, "", "clipboard", "Cancel", "")
73 if err != nil {
74 t.Fatal(err)
75 }
76 job, err := app.exportJob(handle.ExportID)
77 if err != nil {
78 t.Fatal(err)
79 }
80 if err = app.CancelSessionExport(handle.ExportID); err != nil {
81 t.Fatal(err)
82 }
83 if _, err = os.Stat(job.dir); !os.IsNotExist(err) {
84 t.Fatal("export staging survived cancellation")
85 }
86 if _, ok := app.desktopSessionService("").Runtime(ref); !ok {
87 t.Fatal("export cancelled the session runtime")
88 }
89 if err = app.CancelSessionExport(handle.ExportID); err != nil {
90 t.Fatal("cancel is not idempotent")
91 }
92 }
93
94 func TestColdSessionDiagnosticsRetainEvidence(t *testing.T) {
95 app, ref := activityBaselineFixture(t, "export-cold")
96 if err := app.desktopSessionService("").Close(t.Context(), ref); err != nil {
97 t.Fatal(err)
98 }
99 path := filepath.Join(t.TempDir(), "diagnostic.json")
100 host := &recordingNativeHost{dialogPath: path}
101 app.setNativeHost(host)
102 handle, err := app.BeginSessionExportForTarget(SessionSelector{Ref: &ref}, "", "diagnostic", "Cold", `{"residentItems":0}`)
103 if err != nil {
104 t.Fatal(err)
105 }
106 if _, err = app.FinishSessionExport(handle.ExportID); err != nil {
107 t.Fatal(err)
108 }
109 data, err := os.ReadFile(path)
110 if err != nil {
111 t.Fatal(err)
112 }
113 var value map[string]json.RawMessage
114 if err = json.Unmarshal(data, &value); err != nil {
115 t.Fatal(err)
116 }
117 for _, field := range []string{"metadata", "commits", "activationChanges", "unavailable", "frontendObservation", "sessionIdentity"} {
118 if _, ok := value[field]; !ok {
119 t.Fatalf("missing %s", field)
120 }
121 }
122 }
123
124 func TestSessionExportDialogSwitchKeepsSource(t *testing.T) {
125 app, ref := activityBaselineFixture(t, "dialog-export")
126 runtime, _ := app.desktopSessionService("").Runtime(ref)
127 appendMessage := func(id string) {
128 t.Helper()
129 data, _ := json.Marshal(map[string]any{"message": provider.Message{ID: id, Role: provider.RoleUser, Content: id, Origin: provider.MessageOrigin("user")}})
130 if _, err := runtime.Session().AppendBatch(t.Context(), id, []session.Event{{Kind: "message/complete", Payload: data}}); err != nil {
131 t.Fatal(err)
132 }
133 }
134 appendMessage("BEFORE-DIALOG")
135 path := filepath.Join(t.TempDir(), "export.json")
136 app.setNativeHost(&recordingNativeHost{dialogPath: path, onCall: func(name string) {
137 if strings.HasPrefix(name, "SaveFileDialog:") {
138 app.activeTabID = "B"
139 appendMessage("DURING-DIALOG")
140 }
141 }})
142 handle, err := app.BeginSessionExportForTarget(SessionSelector{Ref: &ref}, "", "json", "A", "")
143 if err != nil {
144 t.Fatal(err)
145 }
146 if _, err = app.FinishSessionExport(handle.ExportID); err != nil {
147 t.Fatal(err)
148 }
149 data, err := os.ReadFile(path)
150 if err != nil {
151 t.Fatal(err)
152 }
153 if !json.Valid(data) || !strings.Contains(string(data), "BEFORE-DIALOG") || strings.Contains(string(data), "DURING-DIALOG") {
154 t.Fatalf("wrong dialog snapshot: %s", data)
155 }
156 }
157
158 func TestSessionExportRejectsCorruptPageWithoutReplacingTarget(t *testing.T) {
159 app, ref := activityBaselineFixture(t, "invalid-page")
160 path := filepath.Join(t.TempDir(), "export.png")
161 if err := os.WriteFile(path, []byte("existing"), 0600); err != nil {
162 t.Fatal(err)
163 }
164 app.setNativeHost(&recordingNativeHost{dialogPath: path})
165 handle, err := app.BeginSessionExportForTarget(SessionSelector{Ref: &ref}, "", "image", "A", "")
166 if err != nil {
167 t.Fatal(err)
168 }
169 defer app.CancelSessionExport(handle.ExportID)
170 if err = app.AppendSessionExportPage(handle.ExportID, SessionExportPage{Data: base64.StdEncoding.EncodeToString([]byte("not an image")), Done: true, Width: 1, Height: 1}); err == nil {
171 t.Fatal("accepted corrupt page")
172 }
173 if _, err = app.FinishSessionExport(handle.ExportID); err == nil {
174 t.Fatal("published incomplete output")
175 }
176 data, err := os.ReadFile(path)
177 if err != nil || string(data) != "existing" {
178 t.Fatal("replaced target on failure")
179 }
180 }
181
182 func TestSessionExportRejectsOldRemoteBeforeSaveDialog(t *testing.T) {
183 app, tab := remoteRuntimeTestApp(&http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
184 t.Fatal("old peer must not receive export requests")
185 return nil, nil
186 })})
187 host := &recordingNativeHost{}
188 app.setNativeHost(host)
189 _, err := app.BeginSessionExportForTarget(SessionSelector{}, tab.id, "json", "Old peer", "")
190 if err == nil || !strings.Contains(err.Error(), "session-export-v1") {
191 t.Fatalf("missing explicit upgrade error: %v", err)
192 }
193 if len(host.callNames()) != 0 {
194 t.Fatal("opened save dialog for unsupported peer")
195 }
196 }
197
198 func TestRemoteSessionExportPinsExplicitIdentityAcrossTabSwitch(t *testing.T) {
199 const sourceID = "source-a"
200 var requests []string
201 app, tab := remoteRuntimeTestApp(&http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
202 if got := req.URL.Query().Get("sessionId"); got != sourceID {
203 t.Fatalf("%s sessionId = %q, want %q", req.URL.Path, got, sourceID)
204 }
205 if got := req.Header.Get(expectedSessionIDHeader); got != sourceID {
206 t.Fatalf("%s expected session = %q, want %q", req.URL.Path, got, sourceID)
207 }
208 requests = append(requests, req.URL.Path)
209 switch req.URL.Path {
210 case "/session-export/snapshot":
211 snapshot := session.ExportSnapshot{Ref: session.SessionRef{HostID: "fixture-host", SessionID: sourceID}, StorageGeneration: "generation-a", SnapshotSequence: 1, AcceptedThrough: 1, DurableThrough: 1, Title: "Source A"}
212 return remoteRuntimeTestResponse(req, http.StatusOK, remoteRuntimeTestJSON(t, snapshot)), nil
213 case "/session-export/document":
214 response := remoteRuntimeTestResponse(req, http.StatusOK, `[{"id":"SOURCE-A"}]`)
215 response.Header.Set("X-Reasonix-Export-Records", "1")
216 return response, nil
217 case "/session-export/validate":
218 return remoteRuntimeTestResponse(req, http.StatusNoContent, ""), nil
219 default:
220 t.Fatalf("unexpected remote export request %s", req.URL)
221 return nil, nil
222 }
223 })})
224 tab.capabilities["session-export-v1"] = true
225 tab.routing.currentPath = remoteSessionIDRoutePrefix + sourceID
226 tab.session.path = tab.routing.currentPath
227 path := filepath.Join(t.TempDir(), "remote.json")
228 app.setNativeHost(&recordingNativeHost{dialogPath: path, onCall: func(name string) {
229 if strings.HasPrefix(name, "SaveFileDialog:") {
230 tab.routing.currentPath = remoteSessionIDRoutePrefix + "source-b"
231 }
232 }})
233 ref := session.SessionRef{HostID: "fixture-host", SessionID: sourceID}
234 handle, err := app.BeginSessionExportForTarget(SessionSelector{Ref: &ref}, tab.id, "json", "Source A", "")
235 if err != nil {
236 t.Fatal(err)
237 }
238 if _, err = app.FinishSessionExport(handle.ExportID); err != nil {
239 t.Fatal(err)
240 }
241 data, err := os.ReadFile(path)
242 if err != nil || !bytes.Contains(data, []byte("SOURCE-A")) {
243 t.Fatalf("published remote export = %s err=%v", data, err)
244 }
245 want := []string{"/session-export/snapshot", "/session-export/validate", "/session-export/document", "/session-export/validate"}
246 if !slices.Equal(requests, want) {
247 t.Fatalf("remote requests = %v, want %v", requests, want)
248 }
249 }
250
251 func TestSessionExportPublishesValidatedRasterPages(t *testing.T) {
252 for _, format := range []string{"pdf", "image"} {
253 t.Run(format, func(t *testing.T) {
254 app, ref := activityBaselineFixture(t, "raster-"+format)
255 extension := ".png"
256 if format == "pdf" {
257 extension = ".pdf"
258 }
259 path := filepath.Join(t.TempDir(), "session"+extension)
260 app.setNativeHost(&recordingNativeHost{dialogPath: path})
261 handle, err := app.BeginSessionExportForTarget(SessionSelector{Ref: &ref}, "", format, "Raster 中文", "")
262 if err != nil {
263 t.Fatal(err)
264 }
265 defer app.CancelSessionExport(handle.ExportID)
266 var data bytes.Buffer
267 pixels := image.NewRGBA(image.Rect(0, 0, 320, 120))
268 if format == "pdf" {
269 err = jpeg.Encode(&data, pixels, nil)
270 } else {
271 err = png.Encode(&data, pixels)
272 }
273 if err != nil {
274 t.Fatal(err)
275 }
276 for index := range 2 {
277 if err = app.AppendSessionExportPage(handle.ExportID, SessionExportPage{Index: index, Data: base64.StdEncoding.EncodeToString(data.Bytes()), Done: true, Width: 320, Height: 120}); err != nil {
278 t.Fatal(err)
279 }
280 }
281 result, err := app.FinishSessionExport(handle.ExportID)
282 if err != nil {
283 t.Fatal(err)
284 }
285 if result.Pages != 2 {
286 t.Fatalf("pages=%d", result.Pages)
287 }
288 if format == "pdf" {
289 raw, err := os.ReadFile(path)
290 if err != nil {
291 t.Fatal(err)
292 }
293 if !bytes.HasPrefix(raw, []byte("%PDF-1.4")) || !bytes.Contains(raw, []byte("/Count 2")) || !bytes.HasSuffix(raw, []byte("%%EOF\n")) {
294 t.Fatal("invalid PDF structure")
295 }
296 } else {
297 if len(result.Paths) != 2 {
298 t.Fatal("wrong PNG count")
299 }
300 for _, path := range result.Paths {
301 file, err := os.Open(path)
302 if err != nil {
303 t.Fatal(err)
304 }
305 _, err = png.Decode(file)
306 file.Close()
307 if err != nil {
308 t.Fatal(err)
309 }
310 }
311 }
312 })
313 }
314 }
315
316 func TestRemoteExportRejectsReboundSelector(t *testing.T) {
317 app, tab := remoteRuntimeTestApp(&http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
318 t.Fatal("rebound source must not receive requests")
319 return nil, nil
320 })})
321 tab.capabilities = map[string]bool{"session-export-v1": true}
322 tab.ref.HostID = "host-B"
323 tab.routing.currentPath = "session-id:same-id"
324 host := &recordingNativeHost{}
325 app.setNativeHost(host)
326 _, err := app.BeginSessionExportForTarget(SessionSelector{Ref: &session.SessionRef{HostID: "host-A", SessionID: "same-id"}}, tab.id, "json", "A", "")
327 if err == nil {
328 t.Fatal("accepted another host with the same session id")
329 }
330 if len(host.callNames()) != 0 {
331 t.Fatal("opened save dialog for a rebound source")
332 }
333 }
334
334 lines GO