| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "encoding/base64" |
| 5 | "encoding/json" |
| 6 | "net/http" |
| 7 | "net/http/httptest" |
| 8 | "net/url" |
| 9 | "strings" |
| 10 | "sync/atomic" |
| 11 | "testing" |
| 12 | "time" |
| 13 | |
| 14 | "reasonix/internal/servecontract" |
| 15 | "reasonix/internal/session" |
| 16 | "reasonix/internal/sessioncontent" |
| 17 | "reasonix/internal/transcript" |
| 18 | ) |
| 19 | |
| 20 | func remoteTranscriptFixture(server *httptest.Server) (*App, *remoteTab) { |
| 21 | tab := &remoteTab{id: "remote", state: "ready", client: server.Client(), base: server.URL, gen: 1, |
| 22 | routing: remoteTabSessionRouting{currentPath: "/session.jsonl"}} |
| 23 | return &App{remoteTabs: map[string]*remoteTab{tab.id: tab}}, tab |
| 24 | } |
| 25 | |
| 26 | func TestRemoteFollowRequiresV2WithoutLegacyProbe(t *testing.T) { |
| 27 | var requests atomic.Int64 |
| 28 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 29 | requests.Add(1) |
| 30 | if r.URL.Path != "/transcript/follow" { |
| 31 | t.Errorf("unexpected legacy fallback: %s", r.URL.Path) |
| 32 | } |
| 33 | _ = json.NewEncoder(w).Encode(transcript.FollowResponse{ProtocolVersion: 2, Subscription: "sub", Changes: []transcript.Change{}}) |
| 34 | })) |
| 35 | defer server.Close() |
| 36 | app, tab := remoteTranscriptFixture(server) |
| 37 | if _, err := app.RemoteTranscriptFollowForTab(tab.id, transcript.FollowRequest{}); err == nil || !strings.Contains(err.Error(), "upgrade") { |
| 38 | t.Fatalf("old Serve must produce upgrade error: %v", err) |
| 39 | } |
| 40 | if requests.Load() != 0 { |
| 41 | t.Fatal("unnegotiated server was probed") |
| 42 | } |
| 43 | tab.capabilities = map[string]bool{servecontract.TranscriptV2: true} |
| 44 | response, err := app.RemoteTranscriptFollowForTab(tab.id, transcript.FollowRequest{Subscription: "sub"}) |
| 45 | if err != nil || response.ProtocolVersion != 2 || requests.Load() != 1 { |
| 46 | t.Fatalf("v2 follow: %+v, %v", response, err) |
| 47 | } |
| 48 | } |
| 49 | |
| 50 | func TestRemoteTranscriptNegotiatesOldServeWithoutMutation(t *testing.T) { |
| 51 | for _, status := range []int{http.StatusNotFound, http.StatusMethodNotAllowed, http.StatusNotImplemented, http.StatusOK} { |
| 52 | t.Run(http.StatusText(status), func(t *testing.T) { |
| 53 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 54 | if r.Method != http.MethodGet || r.URL.Path != "/transcript/snapshot" || r.URL.Query().Get("session") != "/session.jsonl" { |
| 55 | t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) |
| 56 | } |
| 57 | w.WriteHeader(status) |
| 58 | _, _ = w.Write([]byte("<html>old Serve index</html>")) |
| 59 | })) |
| 60 | defer server.Close() |
| 61 | app, tab := remoteTranscriptFixture(server) |
| 62 | result, err := app.RemoteTranscriptSnapshotForTab(tab.id, transcript.PageRequest{}) |
| 63 | if err != nil || result.Supported || result.Snapshot != nil { |
| 64 | t.Fatalf("negotiation = %+v, %v", result, err) |
| 65 | } |
| 66 | if tab.state != "ready" || tab.gen != 1 { |
| 67 | t.Fatal("capability probe changed the connection") |
| 68 | } |
| 69 | }) |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | // A Serve that does not advertise the outline capability must never be probed: |
| 74 | // the client keeps its loaded-turn rail instead of spending a round trip. |
| 75 | func TestRemoteTranscriptOutlineRequiresAdvertisedCapability(t *testing.T) { |
| 76 | var requests atomic.Int64 |
| 77 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { |
| 78 | requests.Add(1) |
| 79 | w.WriteHeader(http.StatusOK) |
| 80 | _, _ = w.Write([]byte("<html>old Serve index</html>")) |
| 81 | })) |
| 82 | defer server.Close() |
| 83 | app, tab := remoteTranscriptFixture(server) |
| 84 | |
| 85 | page, err := app.RemoteTranscriptOutlineForTab(tab.id, transcript.OutlineRequest{SnapshotID: "cut"}) |
| 86 | if err == nil || page.SnapshotID != "" { |
| 87 | t.Fatalf("unadvertised outline = %+v, %v", page, err) |
| 88 | } |
| 89 | if requests.Load() != 0 { |
| 90 | t.Fatalf("an unadvertised capability issued %d requests", requests.Load()) |
| 91 | } |
| 92 | |
| 93 | // An advertised capability that answers with something other than protocol |
| 94 | // data is a real error, not a silent downgrade to "unsupported". |
| 95 | tab.capabilities = map[string]bool{servecontract.TranscriptOutlineV1: true} |
| 96 | if _, err := app.RemoteTranscriptOutlineForTab(tab.id, transcript.OutlineRequest{SnapshotID: "cut"}); err == nil { |
| 97 | t.Fatal("an HTML homepage response was accepted as an outline") |
| 98 | } |
| 99 | if requests.Load() != 1 { |
| 100 | t.Fatalf("advertised capability issued %d requests, want 1", requests.Load()) |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | func TestRemoteTranscriptOutlineReadsAdvertisedEndpoint(t *testing.T) { |
| 105 | want := transcript.OutlinePage{ |
| 106 | Boundary: transcript.Boundary{ProtocolVersion: transcript.ProtocolVersion, SnapshotID: "cut"}, |
| 107 | Entries: []transcript.OutlineEntry{{ID: "m:2", MessageID: "2", Turn: 2, Order: 4, Prompt: "second", Answer: "answer"}}, |
| 108 | Total: 2, NextOffset: 2, Done: true, |
| 109 | } |
| 110 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 111 | if r.URL.Path != "/transcript/outline" || r.URL.Query().Get("session") != "/session.jsonl" { |
| 112 | t.Errorf("unexpected request %s", r.URL.String()) |
| 113 | } |
| 114 | var request transcript.OutlineRequest |
| 115 | if err := json.Unmarshal([]byte(r.URL.Query().Get("request")), &request); err != nil || request.SnapshotID != "cut" { |
| 116 | t.Errorf("request = %+v, %v", request, err) |
| 117 | } |
| 118 | _ = json.NewEncoder(w).Encode(want) |
| 119 | })) |
| 120 | defer server.Close() |
| 121 | app, tab := remoteTranscriptFixture(server) |
| 122 | tab.capabilities = map[string]bool{servecontract.TranscriptOutlineV1: true} |
| 123 | |
| 124 | page, err := app.RemoteTranscriptOutlineForTab(tab.id, transcript.OutlineRequest{SnapshotID: "cut"}) |
| 125 | if err != nil || page.Total != 2 || len(page.Entries) != 1 || page.Entries[0].ID != "m:2" { |
| 126 | t.Fatalf("outline = %+v, %v", page, err) |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | func TestRemoteTranscriptRejectsLateSessionResponse(t *testing.T) { |
| 131 | entered, release := make(chan struct{}), make(chan struct{}) |
| 132 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 133 | close(entered) |
| 134 | <-release |
| 135 | _ = json.NewEncoder(w).Encode(transcript.Snapshot{Boundary: transcript.Boundary{ProtocolVersion: 1, SnapshotID: "old"}}) |
| 136 | })) |
| 137 | defer server.Close() |
| 138 | app, tab := remoteTranscriptFixture(server) |
| 139 | done := make(chan error, 1) |
| 140 | go func() { _, err := app.RemoteTranscriptSnapshotForTab(tab.id, transcript.PageRequest{}); done <- err }() |
| 141 | select { |
| 142 | case <-entered: |
| 143 | case <-time.After(time.Second): |
| 144 | t.Fatal("request did not start") |
| 145 | } |
| 146 | app.remoteTabMu.Lock() |
| 147 | tab.routing.currentPath = "/new-session.jsonl" |
| 148 | app.remoteTabMu.Unlock() |
| 149 | close(release) |
| 150 | if err := <-done; err == nil || !strings.Contains(err.Error(), "replaced session") { |
| 151 | t.Fatalf("late response accepted: %v", err) |
| 152 | } |
| 153 | } |
| 154 | |
| 155 | func TestRemoteTranscriptRefreshesIdentityAfterConflict(t *testing.T) { |
| 156 | var snapshotReads atomic.Int32 |
| 157 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 158 | switch r.URL.Path { |
| 159 | case "/transcript/snapshot": |
| 160 | if n := snapshotReads.Add(1); n == 1 { |
| 161 | if got := r.URL.Query().Get("session"); got != "/session.jsonl" { |
| 162 | t.Errorf("first session route = %q", got) |
| 163 | } |
| 164 | w.WriteHeader(http.StatusConflict) |
| 165 | _, _ = w.Write([]byte("active session changed")) |
| 166 | return |
| 167 | } |
| 168 | if got := r.URL.Query().Get("session"); got != "session-id:current" { |
| 169 | t.Errorf("refreshed session route = %q", got) |
| 170 | } |
| 171 | _ = json.NewEncoder(w).Encode(transcript.Snapshot{Boundary: transcript.Boundary{ProtocolVersion: transcript.ProtocolVersion, SnapshotID: "current"}}) |
| 172 | case "/status": |
| 173 | _ = json.NewEncoder(w).Encode(map[string]any{"sessionPath": "", "sessionId": "current", "running": false}) |
| 174 | default: |
| 175 | http.NotFound(w, r) |
| 176 | } |
| 177 | })) |
| 178 | defer server.Close() |
| 179 | app, tab := remoteTranscriptFixture(server) |
| 180 | result, err := app.RemoteTranscriptSnapshotForTab(tab.id, transcript.PageRequest{}) |
| 181 | if err != nil || !result.Supported || result.Snapshot == nil || snapshotReads.Load() != 2 { |
| 182 | t.Fatalf("conflict retry = %+v, %v, reads=%d", result, err, snapshotReads.Load()) |
| 183 | } |
| 184 | if got := tab.routing.currentPath; got != "session-id:current" { |
| 185 | t.Fatalf("refreshed tab route = %q", got) |
| 186 | } |
| 187 | } |
| 188 | |
| 189 | func TestRemoteTabMetadataDoesNotRequestHistory(t *testing.T) { |
| 190 | var historyReads atomic.Int32 |
| 191 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 192 | if r.URL.Path == "/history" { |
| 193 | historyReads.Add(1) |
| 194 | } |
| 195 | w.Header().Set("Content-Type", "application/json") |
| 196 | if r.URL.Path == "/status" { |
| 197 | _, _ = w.Write([]byte(`{"sessionPath":"/session.jsonl","running":false}`)) |
| 198 | return |
| 199 | } |
| 200 | _, _ = w.Write([]byte(`[]`)) |
| 201 | })) |
| 202 | defer server.Close() |
| 203 | app, tab := remoteTranscriptFixture(server) |
| 204 | metadata, err := app.RemoteTabMetadata(tab.id) |
| 205 | if err != nil || historyReads.Load() != 0 || len(metadata.History) != 0 { |
| 206 | t.Fatalf("metadata requested history: count=%d error=%v", historyReads.Load(), err) |
| 207 | } |
| 208 | } |
| 209 | |
| 210 | func TestRemoteCanonicalSessionHistoryUsesNegotiatedIdentity(t *testing.T) { |
| 211 | ref := sessioncontent.Ref{Digest: strings.Repeat("a", 64), Bytes: 3, MediaType: "text/plain"} |
| 212 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 213 | if got := r.URL.Query().Get("sessionId"); got != "canonical" { |
| 214 | t.Errorf("sessionId = %q", got) |
| 215 | } |
| 216 | switch r.URL.Path { |
| 217 | case "/session/open": |
| 218 | _ = json.NewEncoder(w).Encode(session.SessionOpenView{SnapshotSequence: 9, Recent: session.RecentSnapshot{Entries: []session.PersistentMessage{{MessageID: "m1", Role: "user"}}}}) |
| 219 | case "/session-history/page": |
| 220 | if r.URL.Query().Get("cursor") != "next" || r.URL.Query().Get("limit") != "7" { |
| 221 | t.Errorf("page query = %q", r.URL.RawQuery) |
| 222 | } |
| 223 | _ = json.NewEncoder(w).Encode(session.MessageHistoryPage{Messages: []session.PersistentMessage{{MessageID: "m1", Role: "user", ContentRef: &ref}}, SnapshotSequence: 9}) |
| 224 | case "/session-history/locate": |
| 225 | if r.URL.Query().Get("messageId") != "m1" || r.URL.Query().Get("snapshot") != "9" { |
| 226 | t.Errorf("locate query = %q", r.URL.RawQuery) |
| 227 | } |
| 228 | _ = json.NewEncoder(w).Encode(session.MessageLocation{Status: "ready", MessageID: "m1", SnapshotSequence: 9, Cursor: "located"}) |
| 229 | case "/session-history/content": |
| 230 | var request struct { |
| 231 | Ref sessioncontent.Ref `json:"ref"` |
| 232 | Offset int64 `json:"offset"` |
| 233 | Length int64 `json:"length"` |
| 234 | } |
| 235 | if err := json.Unmarshal([]byte(r.URL.Query().Get("request")), &request); err != nil { |
| 236 | t.Fatal(err) |
| 237 | } |
| 238 | if request.Ref.Digest != ref.Digest || request.Offset != 0 || request.Length != 3 { |
| 239 | t.Errorf("content request = %+v", request) |
| 240 | } |
| 241 | _ = json.NewEncoder(w).Encode(SessionHistoryContentChunk{Data: base64.StdEncoding.EncodeToString([]byte("big")), NextOffset: 3, Done: true}) |
| 242 | case "/session-history/search": |
| 243 | if r.URL.Query().Get("q") != "needle" || r.URL.Query().Get("cursor") != "older" || r.URL.Query().Get("limit") != "5" { |
| 244 | t.Errorf("search query = %q", r.URL.RawQuery) |
| 245 | } |
| 246 | _ = json.NewEncoder(w).Encode(session.SearchHistoryPage{Hits: []session.SearchHistoryHit{{MessageID: "m1", Preview: "needle"}}, SnapshotSequence: 9}) |
| 247 | default: |
| 248 | http.NotFound(w, r) |
| 249 | } |
| 250 | })) |
| 251 | defer server.Close() |
| 252 | app, tab := remoteTranscriptFixture(server) |
| 253 | tab.capabilities = map[string]bool{serveCapabilitySessions: true, serveCapabilitySessionContentV1: true, serveCapabilitySessionReadV2: true} |
| 254 | tab.session.sessionID = "canonical" |
| 255 | view, err := app.RemoteSessionOpenForTab(tab.id) |
| 256 | if err != nil || view.SnapshotSequence != 9 || len(view.Recent.Entries) != 1 { |
| 257 | t.Fatalf("open = %+v, %v", view, err) |
| 258 | } |
| 259 | page, err := app.RemoteSessionHistoryPageForTab(tab.id, "next", 7) |
| 260 | if err != nil || page.SnapshotSequence != 9 || len(page.Messages) != 1 { |
| 261 | t.Fatalf("page = %+v, %v", page, err) |
| 262 | } |
| 263 | location, err := app.RemoteLocateSessionMessageForTab(tab.id, "m1", 9) |
| 264 | if err != nil || location.Status != "ready" || location.Cursor != "located" { |
| 265 | t.Fatalf("location = %+v, %v", location, err) |
| 266 | } |
| 267 | chunk, err := app.RemoteSessionHistoryContentForTab(tab.id, ref, 0) |
| 268 | if err != nil || chunk.Data != base64.StdEncoding.EncodeToString([]byte("big")) || !chunk.Done { |
| 269 | t.Fatalf("chunk = %+v, %v", chunk, err) |
| 270 | } |
| 271 | search, err := app.RemoteSearchSessionHistoryForTab(tab.id, "needle", "older", 5) |
| 272 | if err != nil || len(search.Hits) != 1 || search.Hits[0].MessageID != "m1" { |
| 273 | t.Fatalf("search = %+v, %v", search, err) |
| 274 | } |
| 275 | } |
| 276 | |
| 277 | func TestRemoteCanonicalSessionHistoryRequiresCapability(t *testing.T) { |
| 278 | var reads atomic.Int32 |
| 279 | server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reads.Add(1) })) |
| 280 | defer server.Close() |
| 281 | app, tab := remoteTranscriptFixture(server) |
| 282 | if _, err := app.RemoteSessionHistoryPageForTab(tab.id, "", 0); err == nil { |
| 283 | t.Fatal("canonical history unexpectedly enabled") |
| 284 | } |
| 285 | if reads.Load() != 0 { |
| 286 | t.Fatalf("network reads = %d", reads.Load()) |
| 287 | } |
| 288 | } |
| 289 | |
| 290 | // historyWindowFixture builds a remote tab that advertises exactly the |
| 291 | // capabilities the window protocol needs. |
| 292 | func historyWindowFixture(server *httptest.Server) (*App, *remoteTab) { |
| 293 | app, tab := remoteTranscriptFixture(server) |
| 294 | tab.session.sessionID = "canonical" |
| 295 | tab.capabilities = map[string]bool{ |
| 296 | serveCapabilitySessionContentV1: true, |
| 297 | serveCapabilityHistoryWindowV1: true, |
| 298 | } |
| 299 | return app, tab |
| 300 | } |
| 301 | |
| 302 | func TestRemoteSessionHistoryWindowRequiresCapability(t *testing.T) { |
| 303 | var reads atomic.Int32 |
| 304 | server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reads.Add(1) })) |
| 305 | defer server.Close() |
| 306 | |
| 307 | // Without session-content-v1 the canonical routes are not negotiated at |
| 308 | // all, so a window request must not reach the network. |
| 309 | app, tab := remoteTranscriptFixture(server) |
| 310 | tab.session.sessionID = "canonical" |
| 311 | if page, err := app.RemoteSessionHistoryWindowForTab(tab.id, session.HistoryWindowRequest{Anchor: "newest"}); err != nil || page.Status != session.HistoryWindowUnsupported { |
| 312 | t.Fatalf("window read without canonical history = %+v, %v", page, err) |
| 313 | } |
| 314 | |
| 315 | // With content but without history-window-v1 the service is an older Serve: |
| 316 | // a capability answer, not a failure. The caller gets a typed unsupported |
| 317 | // status and still no round trip, so no service fakes a bounded window. |
| 318 | tab.capabilities = map[string]bool{serveCapabilitySessionContentV1: true} |
| 319 | page, err := app.RemoteSessionHistoryWindowForTab(tab.id, session.HistoryWindowRequest{Anchor: "newest"}) |
| 320 | if err != nil || page.Status != session.HistoryWindowUnsupported { |
| 321 | t.Fatalf("unadvertised window = %+v, %v", page, err) |
| 322 | } |
| 323 | field, err := app.RemoteSessionMessageFieldForTab(tab.id, "m1", 0, "content", 0, 64) |
| 324 | if err != nil || field.Status != session.HistoryWindowUnsupported || field.MessageID != "m1" { |
| 325 | t.Fatalf("unadvertised field read = %+v, %v", field, err) |
| 326 | } |
| 327 | if reads.Load() != 0 { |
| 328 | t.Fatalf("unadvertised capabilities issued %d requests", reads.Load()) |
| 329 | } |
| 330 | } |
| 331 | |
| 332 | func TestRemoteSessionHistoryWindowSendsAnchorsAndReturnsTypedStatus(t *testing.T) { |
| 333 | var seen []url.Values |
| 334 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 335 | if r.URL.Path == "/session-history/window" { |
| 336 | seen = append(seen, r.URL.Query()) |
| 337 | w.Header().Set("Content-Type", "application/json") |
| 338 | page := session.HistoryWindowPage{Status: "ready", SnapshotSequence: 9, TotalTurns: 3, |
| 339 | HasOlder: true, HasNewer: true, OlderCursor: "older", NewerCursor: "newer", |
| 340 | AnchorMessageID: r.URL.Query().Get("messageId"), |
| 341 | Messages: []session.PersistentMessage{{MessageID: "m7", Position: 7, Version: 1, Role: "user"}}, |
| 342 | } |
| 343 | _ = json.NewEncoder(w).Encode(page) |
| 344 | return |
| 345 | } |
| 346 | if r.URL.Path == "/session-message-field" { |
| 347 | seen = append(seen, r.URL.Query()) |
| 348 | w.Header().Set("Content-Type", "application/json") |
| 349 | _ = json.NewEncoder(w).Encode(session.MessageFieldPage{Status: "ready", MessageID: "m7", Field: "content", |
| 350 | TotalBytes: 100, Offset: 0, NextOffset: 64, Encoding: "utf-8", Data: []byte("fragment")}) |
| 351 | return |
| 352 | } |
| 353 | t.Errorf("unexpected request %s", r.URL.Path) |
| 354 | })) |
| 355 | defer server.Close() |
| 356 | app, tab := historyWindowFixture(server) |
| 357 | |
| 358 | page, err := app.RemoteSessionHistoryWindowForTab(tab.id, session.HistoryWindowRequest{ |
| 359 | Anchor: "message", MessageID: "m7", Direction: "older", Limit: 32, |
| 360 | }) |
| 361 | if err != nil || page.Status != "ready" || page.SnapshotSequence != 9 || len(page.Messages) != 1 { |
| 362 | t.Fatalf("window = %+v, %v", page, err) |
| 363 | } |
| 364 | if page.AnchorMessageID != "m7" || !page.HasOlder || !page.HasNewer || page.OlderCursor != "older" || page.NewerCursor != "newer" { |
| 365 | t.Fatalf("window metadata = %+v", page) |
| 366 | } |
| 367 | field, err := app.RemoteSessionMessageFieldForTab(tab.id, "m7", 3, "content", 0, 64) |
| 368 | if err != nil || field.Status != "ready" || field.NextOffset != 64 || string(field.Data) != "fragment" { |
| 369 | t.Fatalf("field = %+v, %v", field, err) |
| 370 | } |
| 371 | if len(seen) != 2 { |
| 372 | t.Fatalf("requests = %d", len(seen)) |
| 373 | } |
| 374 | // The tab's binding identity is stamped by the host, never by the caller: |
| 375 | // a window read cannot name another session. |
| 376 | for index, query := range seen { |
| 377 | if query.Get("sessionId") != "canonical" { |
| 378 | t.Fatalf("request %d session=%q", index, query.Get("sessionId")) |
| 379 | } |
| 380 | } |
| 381 | if got := seen[0]; got.Get("anchor") != "message" || got.Get("messageId") != "m7" || got.Get("direction") != "older" || got.Get("limit") != "32" { |
| 382 | t.Fatalf("window query = %v", got) |
| 383 | } |
| 384 | if got := seen[1]; got.Get("messageId") != "m7" || got.Get("field") != "content" || got.Get("version") != "3" || got.Get("offset") != "0" || got.Get("length") != "64" { |
| 385 | t.Fatalf("field query = %v", got) |
| 386 | } |
| 387 | } |
| 388 | |
| 389 | // TestRemoteSessionHistoryWindowKeepsTypedStatuses pins the transport's |
| 390 | // contract with the reader: a stale cursor and a not-found anchor are answers |
| 391 | // the caller reasons about, not transport failures. |
| 392 | func TestRemoteSessionHistoryWindowKeepsTypedStatuses(t *testing.T) { |
| 393 | for _, status := range []string{"stale_cursor", "not_found", "preparing", "failed"} { |
| 394 | t.Run(status, func(t *testing.T) { |
| 395 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { |
| 396 | w.Header().Set("Content-Type", "application/json") |
| 397 | _ = json.NewEncoder(w).Encode(session.HistoryWindowPage{Status: status}) |
| 398 | })) |
| 399 | defer server.Close() |
| 400 | app, tab := historyWindowFixture(server) |
| 401 | page, err := app.RemoteSessionHistoryWindowForTab(tab.id, session.HistoryWindowRequest{Anchor: "newest"}) |
| 402 | if err != nil || page.Status != status { |
| 403 | t.Fatalf("status %q surfaced as %+v, %v", status, page, err) |
| 404 | } |
| 405 | }) |
| 406 | } |
| 407 | } |
| 408 | |
| 409 | // TestSessionHistoryWindowRequiresCanonicalBinding keeps the local command off |
| 410 | // every path that has no exclusive canonical session behind it. |
| 411 | func TestSessionHistoryWindowRequiresCanonicalBinding(t *testing.T) { |
| 412 | app := &App{} |
| 413 | if _, err := app.SessionHistoryWindowForTab("missing", session.HistoryWindowRequest{Anchor: "newest"}); err == nil { |
| 414 | t.Fatal("window read without a bound session") |
| 415 | } |
| 416 | if _, err := app.SessionMessageFieldForTab("missing", "m1", 0, "content", 0, 64); err == nil { |
| 417 | t.Fatal("field read without a bound session") |
| 418 | } |
| 419 | } |
| 420 | |
| 421 | func TestRemoteCanonicalSessionHistoryPageDoesNotRequireContentCapability(t *testing.T) { |
| 422 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 423 | if r.URL.Path != "/session-history/page" { |
| 424 | t.Fatalf("unexpected route %s", r.URL.Path) |
| 425 | } |
| 426 | _ = json.NewEncoder(w).Encode(session.MessageHistoryPage{SnapshotSequence: 4}) |
| 427 | })) |
| 428 | defer server.Close() |
| 429 | app, tab := remoteTranscriptFixture(server) |
| 430 | tab.capabilities = map[string]bool{serveCapabilitySessions: true} |
| 431 | tab.session.sessionID = "canonical" |
| 432 | page, err := app.RemoteSessionHistoryPageForTab(tab.id, "", 0) |
| 433 | if err != nil || page.SnapshotSequence != 4 { |
| 434 | t.Fatalf("page = %+v, %v", page, err) |
| 435 | } |
| 436 | } |
| 437 | |
| 438 | func TestRemoteCanonicalSessionHistoryContentRequiresContentCapability(t *testing.T) { |
| 439 | var reads atomic.Int32 |
| 440 | server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reads.Add(1) })) |
| 441 | defer server.Close() |
| 442 | app, tab := remoteTranscriptFixture(server) |
| 443 | tab.capabilities = map[string]bool{serveCapabilitySessions: true} |
| 444 | tab.session.sessionID = "canonical" |
| 445 | ref := sessioncontent.Ref{Digest: strings.Repeat("b", 64), Bytes: 1, MediaType: "text/plain"} |
| 446 | if _, err := app.RemoteSessionHistoryContentForTab(tab.id, ref, 0); err == nil { |
| 447 | t.Fatal("content unexpectedly enabled without content capability") |
| 448 | } |
| 449 | if reads.Load() != 0 { |
| 450 | t.Fatalf("network reads = %d", reads.Load()) |
| 451 | } |
| 452 | } |
| 453 |