| 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 |