| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "path/filepath" |
| 8 | "reflect" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | "time" |
| 12 | |
| 13 | "reasonix/desktop/internal/workspacestate" |
| 14 | "reasonix/internal/session" |
| 15 | "reasonix/internal/sessioncatalog" |
| 16 | ) |
| 17 | |
| 18 | func TestMetadataTopicTextFilterUsesLazyUnicodeSnapshot(t *testing.T) { |
| 19 | isolateDesktopUserDirs(t) |
| 20 | root := t.TempDir() |
| 21 | if err := addProject(root, "Text filter"); err != nil { |
| 22 | t.Fatal(err) |
| 23 | } |
| 24 | app := NewApp() |
| 25 | catalog, err := sessioncatalog.Open(t.Context(), sessioncatalog.Options{Path: filepath.Join(t.TempDir(), "catalog.sqlite"), MetadataOnly: true}) |
| 26 | if err != nil { |
| 27 | t.Fatal(err) |
| 28 | } |
| 29 | app.sessionCatalog.Store(catalog) |
| 30 | t.Cleanup(func() { app.desktopSessions.readSnapshots.close(); app.stopSessionCatalog(time.Second) }) |
| 31 | rows := make([]sessioncatalog.SessionRecord, 123) |
| 32 | for i := range rows { |
| 33 | title, preview := "unrelated", "" |
| 34 | if i == 121 || i == 3 { |
| 35 | title = "ÜBER 历史" |
| 36 | } |
| 37 | if i == 60 { |
| 38 | preview = "über preview" |
| 39 | } |
| 40 | rows[i] = sessioncatalog.SessionRecord{Path: filepath.Join(root, fmt.Sprintf("unread-%03d.jsonl", i)), Directory: root, |
| 41 | Scope: "project", WorkspaceRoot: root, TopicID: fmt.Sprint(i), TopicTitle: title, Preview: preview, |
| 42 | CreatedAt: int64(i + 1), LastActivityAt: int64(i + 1), OrdinaryVisible: true, Health: sessioncatalog.HealthOK} |
| 43 | if err := catalog.UpsertSession(t.Context(), rows[i]); err != nil { |
| 44 | t.Fatal(err) |
| 45 | } |
| 46 | } |
| 47 | req := ProjectTopicPageRequest{Scope: "project", WorkspaceRoot: root, Query: " üBeR ", Limit: 1} |
| 48 | first, err := app.ListProjectTopics(req) |
| 49 | if err != nil || len(first.Items) != 1 || first.Items[0].SessionPath != rows[121].Path || first.NextCursor == "" { |
| 50 | t.Fatalf("Unicode first page: %+v %v", first, err) |
| 51 | } |
| 52 | store := &app.desktopSessions.readSnapshots |
| 53 | store.mu.Lock() |
| 54 | snapshot := store.entries[first.SnapshotID].data |
| 55 | store.mu.Unlock() |
| 56 | if snapshot.readPage == nil || snapshot.count != 0 || len(snapshot.rows) != 0 || snapshot.db != nil { |
| 57 | t.Fatal("text filter materialized the history library") |
| 58 | } |
| 59 | rows[120].TopicTitle = "ÜBER newly matching" |
| 60 | if err := catalog.UpsertSession(t.Context(), rows[120]); err != nil { |
| 61 | t.Fatal(err) |
| 62 | } |
| 63 | page := first |
| 64 | for _, index := range []int{60, 3} { |
| 65 | req.Cursor = page.NextCursor |
| 66 | page, err = app.ListProjectTopics(req) |
| 67 | if err != nil || len(page.Items) != 1 || page.Items[0].SessionPath != rows[index].Path { |
| 68 | t.Fatalf("fixed text snapshot continuation: %+v %v", page, err) |
| 69 | } |
| 70 | } |
| 71 | if page.NextCursor != "" { |
| 72 | t.Fatal("filtered tail advertised more matches") |
| 73 | } |
| 74 | app.ReleaseReadSnapshot(first.SnapshotID) |
| 75 | req.Cursor, req.Query = "", strings.ToUpper(desktopSourceKey(rows[50].Path, "")) |
| 76 | identity, err := app.ListProjectTopics(req) |
| 77 | if err != nil || len(identity.Items) != 1 || identity.Items[0].SessionPath != rows[50].Path { |
| 78 | t.Fatalf("physical source key search changed: %+v %v", identity, err) |
| 79 | } |
| 80 | app.ReleaseReadSnapshot(identity.SnapshotID) |
| 81 | req.Query = "missing term" |
| 82 | empty, err := app.ListProjectTopics(req) |
| 83 | if err != nil || len(empty.Items) != 0 || empty.NextCursor != "" { |
| 84 | t.Fatalf("empty text result: %+v %v", empty, err) |
| 85 | } |
| 86 | app.ReleaseReadSnapshot(empty.SnapshotID) |
| 87 | app.setDesktopLocale("en") |
| 88 | if err := catalog.SyncMetadata(t.Context(), nil, []sessioncatalog.TopicMetadata{{Scope: "project", WorkspaceRoot: root, |
| 89 | TopicID: rows[0].TopicID, Title: defaultTopicTitle, TitleSource: topicTitleSourceAuto}}); err != nil { |
| 90 | t.Fatal(err) |
| 91 | } |
| 92 | req.Query = defaultTopicTitleEn |
| 93 | localized, err := app.ListProjectTopics(req) |
| 94 | if err != nil || len(localized.Items) != 1 || localized.Items[0].SessionPath != rows[0].Path || localized.Items[0].Label != defaultTopicTitleEn { |
| 95 | t.Fatalf("localized auto-title filter changed: %+v %v", localized, err) |
| 96 | } |
| 97 | app.ReleaseReadSnapshot(localized.SnapshotID) |
| 98 | } |
| 99 | |
| 100 | func TestMetadataTopicSnapshotReadsPagesWithoutMaterializingHistory(t *testing.T) { |
| 101 | isolateDesktopUserDirs(t) |
| 102 | root := t.TempDir() |
| 103 | if err := addProject(root, "Large history"); err != nil { |
| 104 | t.Fatal(err) |
| 105 | } |
| 106 | app := NewApp() |
| 107 | catalog, err := sessioncatalog.Open(t.Context(), sessioncatalog.Options{Path: filepath.Join(t.TempDir(), "catalog.sqlite"), MetadataOnly: true}) |
| 108 | if err != nil { |
| 109 | t.Fatal(err) |
| 110 | } |
| 111 | app.sessionCatalog.Store(catalog) |
| 112 | t.Cleanup(func() { app.desktopSessions.readSnapshots.close(); app.stopSessionCatalog(time.Second) }) |
| 113 | rows := make([]sessioncatalog.SessionRecord, 203) |
| 114 | for i := range rows { |
| 115 | // No source files exist: this list can only succeed by reading metadata. |
| 116 | rows[i] = sessioncatalog.SessionRecord{Path: filepath.Join(root, fmt.Sprintf("old-%04d.jsonl", i)), Directory: root, Scope: "project", WorkspaceRoot: root, TopicID: "shared-topic", TopicTitle: "Shared", CreatedAt: int64(i + 1), LastActivityAt: int64(i + 1), OrdinaryVisible: true, TurnsState: sessioncatalog.TurnsUnknown, Health: sessioncatalog.HealthOK} |
| 117 | if err := catalog.UpsertSession(t.Context(), rows[i]); err != nil { |
| 118 | t.Fatal(err) |
| 119 | } |
| 120 | } |
| 121 | req := ProjectTopicPageRequest{Scope: "project", WorkspaceRoot: root, Limit: 200} |
| 122 | first, err := app.ListProjectTopics(req) |
| 123 | if err != nil || len(first.Items) != 200 || first.NextCursor == "" { |
| 124 | t.Fatalf("first page: %d %q %v", len(first.Items), first.NextCursor, err) |
| 125 | } |
| 126 | store := &app.desktopSessions.readSnapshots |
| 127 | store.mu.Lock() |
| 128 | snapshot := store.entries[first.SnapshotID].data |
| 129 | store.mu.Unlock() |
| 130 | if snapshot.readPage == nil || snapshot.count != 0 || len(snapshot.rows) != 0 || snapshot.db != nil { |
| 131 | t.Fatal("first page materialized history instead of retaining a catalog view") |
| 132 | } |
| 133 | // Concurrent activity changes ordering for a new view only. |
| 134 | rows[0].LastActivityAt = 10000 |
| 135 | if err := catalog.UpsertSession(context.Background(), rows[0]); err != nil { |
| 136 | t.Fatal(err) |
| 137 | } |
| 138 | req.Cursor = first.NextCursor |
| 139 | second, err := app.ListProjectTopics(req) |
| 140 | if err != nil || len(second.Items) != 3 || second.NextCursor != "" || second.Items[2].SessionPath != rows[0].Path { |
| 141 | t.Fatalf("snapshot continuation: %+v %v", second, err) |
| 142 | } |
| 143 | req.Cursor = "" |
| 144 | fresh, err := app.ListProjectTopics(req) |
| 145 | if err != nil || fresh.Items[0].SessionPath != rows[0].Path { |
| 146 | t.Fatalf("refresh: %+v %v", fresh, err) |
| 147 | } |
| 148 | app.ReleaseReadSnapshot(first.SnapshotID) |
| 149 | if !snapshot.released || snapshot.closeRead != nil { |
| 150 | t.Fatal("released cursor retained the WAL view") |
| 151 | } |
| 152 | } |
| 153 | |
| 154 | func TestMetadataTopicGroupSnapshotKeepsMembershipAndPagesLazy(t *testing.T) { |
| 155 | app, root, _ := canonicalOrganizationFixture(t, "inside", "outside") |
| 156 | catalog, err := sessioncatalog.Open(t.Context(), sessioncatalog.Options{Path: filepath.Join(t.TempDir(), "catalog.sqlite"), MetadataOnly: true}) |
| 157 | if err != nil { |
| 158 | t.Fatal(err) |
| 159 | } |
| 160 | app.sessionCatalog.Store(catalog) |
| 161 | t.Cleanup(func() { app.desktopSessions.readSnapshots.close(); app.stopSessionCatalog(time.Second) }) |
| 162 | workspaceID, _, err := app.ensureSessionOrganization("project", root) |
| 163 | if err != nil { |
| 164 | t.Fatal(err) |
| 165 | } |
| 166 | paths := []string{} |
| 167 | for i := range 9 { |
| 168 | path := filepath.Join(root, fmt.Sprintf("unread-%d.jsonl", i)) |
| 169 | paths = append(paths, path) |
| 170 | if err := catalog.UpsertSession(t.Context(), sessioncatalog.SessionRecord{Path: path, Directory: root, |
| 171 | Scope: "project", WorkspaceRoot: root, TopicID: "shared", TopicTitle: "Shared", |
| 172 | CreatedAt: int64(i + 1), LastActivityAt: int64(i + 1), OrdinaryVisible: true, Health: sessioncatalog.HealthOK}); err != nil { |
| 173 | t.Fatal(err) |
| 174 | } |
| 175 | } |
| 176 | sourceKey := func(i int) string { return "source\x00local\x00" + desktopSourceKey(paths[i], "") } |
| 177 | _, _, err = app.workspaceRegistry().UpdateOrganization(t.Context(), workspaceID, nil, func(org *workspacestate.Organization) error { |
| 178 | org.Groups = []workspacestate.OrganizationGroup{ |
| 179 | {ID: "selected", Members: []string{workspacestate.SessionKey("inside"), sourceKey(1), sourceKey(8)}}, |
| 180 | {ID: "empty", Members: []string{}}, |
| 181 | } |
| 182 | return nil |
| 183 | }) |
| 184 | if err != nil { |
| 185 | t.Fatal(err) |
| 186 | } |
| 187 | reader := &recordingTopicInfoReader{} |
| 188 | req := ProjectTopicPageRequest{Scope: "project", WorkspaceRoot: root, GroupFilter: "group", GroupID: "selected", Limit: 1} |
| 189 | page, err := app.readProjectTopicPage(req, reader) |
| 190 | if err != nil || len(page.Items) != 1 || page.NextCursor == "" { |
| 191 | t.Fatalf("first grouped page: %+v %v", page, err) |
| 192 | } |
| 193 | if !reflect.DeepEqual(reader.ids, []string{"inside"}) { |
| 194 | t.Fatalf("group read unrelated canonical headers: %v", reader.ids) |
| 195 | } |
| 196 | store := &app.desktopSessions.readSnapshots |
| 197 | store.mu.Lock() |
| 198 | snapshot := store.entries[page.SnapshotID].data |
| 199 | store.mu.Unlock() |
| 200 | if snapshot.readPage == nil || snapshot.count != 0 || len(snapshot.rows) != 0 || snapshot.db != nil { |
| 201 | t.Fatal("group materialized the history library") |
| 202 | } |
| 203 | // A later membership change belongs to a fresh snapshot. The retained |
| 204 | // cursor must keep all three original members and their fixed order. |
| 205 | _, _, err = app.workspaceRegistry().UpdateOrganization(t.Context(), workspaceID, nil, func(org *workspacestate.Organization) error { |
| 206 | org.Groups[0].Members = []string{workspacestate.SessionKey("outside"), sourceKey(0)} |
| 207 | return nil |
| 208 | }) |
| 209 | if err != nil { |
| 210 | t.Fatal(err) |
| 211 | } |
| 212 | seen := []string{projectNodeSessionKey(page.Items[0])} |
| 213 | for page.NextCursor != "" && len(seen) <= 9 { |
| 214 | req.Cursor = page.NextCursor |
| 215 | page, err = app.readProjectTopicPage(req, reader) |
| 216 | if err != nil { |
| 217 | t.Fatal(err) |
| 218 | } |
| 219 | for _, node := range page.Items { |
| 220 | seen = append(seen, projectNodeSessionKey(node)) |
| 221 | } |
| 222 | } |
| 223 | want := []string{sourceKey(8), sourceKey(1), workspacestate.SessionKey("inside")} |
| 224 | if !reflect.DeepEqual(seen, want) { |
| 225 | t.Fatalf("retained membership: %v, want %v", seen, want) |
| 226 | } |
| 227 | app.ReleaseReadSnapshot(page.SnapshotID) |
| 228 | reader.ids = nil |
| 229 | req.Cursor, req.Limit = "", 50 |
| 230 | fresh, err := app.readProjectTopicPage(req, reader) |
| 231 | if err != nil || len(fresh.Items) != 2 || !reflect.DeepEqual(reader.ids, []string{"outside"}) { |
| 232 | t.Fatalf("fresh group: %+v headers=%v %v", fresh, reader.ids, err) |
| 233 | } |
| 234 | app.ReleaseReadSnapshot(fresh.SnapshotID) |
| 235 | reader.ids = nil |
| 236 | req.GroupID = "empty" |
| 237 | empty, err := app.readProjectTopicPage(req, reader) |
| 238 | if err != nil || len(empty.Items) != 0 || len(reader.ids) != 0 { |
| 239 | t.Fatalf("empty group: %+v headers=%v %v", empty, reader.ids, err) |
| 240 | } |
| 241 | app.ReleaseReadSnapshot(empty.SnapshotID) |
| 242 | req.GroupFilter, req.GroupID = "ungrouped", "" |
| 243 | ungrouped, err := app.readProjectTopicPage(req, reader) |
| 244 | if err != nil || len(ungrouped.Items) != 9 || !reflect.DeepEqual(reader.ids, []string{"inside"}) { |
| 245 | t.Fatalf("ungrouped: %+v headers=%v %v", ungrouped, reader.ids, err) |
| 246 | } |
| 247 | for _, node := range ungrouped.Items { |
| 248 | if key := projectNodeSessionKey(node); key == sourceKey(0) || key == workspacestate.SessionKey("outside") { |
| 249 | t.Fatalf("grouped member appeared ungrouped: %s", key) |
| 250 | } |
| 251 | } |
| 252 | app.ReleaseReadSnapshot(ungrouped.SnapshotID) |
| 253 | req.GroupFilter, req.GroupID = "group", "deleted" |
| 254 | if _, err := app.readProjectTopicPage(req, reader); err == nil { |
| 255 | t.Fatal("deleted group silently returned all history") |
| 256 | } |
| 257 | } |
| 258 | |
| 259 | func TestMetadataTopicSnapshotAllViewWithGroupsStaysLazy(t *testing.T) { |
| 260 | app, root, _ := canonicalOrganizationFixture(t, "pinned", "not-pinned") |
| 261 | catalog, err := sessioncatalog.Open(t.Context(), sessioncatalog.Options{Path: filepath.Join(t.TempDir(), "catalog.sqlite"), MetadataOnly: true}) |
| 262 | if err != nil { |
| 263 | t.Fatal(err) |
| 264 | } |
| 265 | app.sessionCatalog.Store(catalog) |
| 266 | t.Cleanup(func() { app.desktopSessions.readSnapshots.close(); app.stopSessionCatalog(time.Second) }) |
| 267 | workspaceID, _, err := app.ensureSessionOrganization("project", root) |
| 268 | if err != nil { |
| 269 | t.Fatal(err) |
| 270 | } |
| 271 | _, _, err = app.workspaceRegistry().UpdateOrganization(t.Context(), workspaceID, nil, func(org *workspacestate.Organization) error { |
| 272 | org.Groups = []workspacestate.OrganizationGroup{{ID: "g", Title: "Group", Members: []string{workspacestate.SessionKey("pinned")}}} |
| 273 | return nil |
| 274 | }) |
| 275 | if err != nil { |
| 276 | t.Fatal(err) |
| 277 | } |
| 278 | pin := true |
| 279 | if err := app.workspaceRegistry().UpdatePresentation(t.Context(), []string{"pinned"}, nil, &pin); err != nil { |
| 280 | t.Fatal(err) |
| 281 | } |
| 282 | for i := range 20 { |
| 283 | if err := catalog.UpsertSession(t.Context(), sessioncatalog.SessionRecord{ |
| 284 | Path: filepath.Join(root, fmt.Sprintf("unread-%d.jsonl", i)), Directory: root, |
| 285 | Scope: "project", WorkspaceRoot: root, TopicID: fmt.Sprintf("topic-%d", i), TopicTitle: "Legacy", |
| 286 | CreatedAt: 1, LastActivityAt: 1, OrdinaryVisible: true, Health: sessioncatalog.HealthOK, |
| 287 | }); err != nil { |
| 288 | t.Fatal(err) |
| 289 | } |
| 290 | } |
| 291 | req := ProjectTopicPageRequest{Scope: "project", WorkspaceRoot: root, GroupFilter: "all", Limit: 2} |
| 292 | page, err := app.ListProjectTopics(req) |
| 293 | if err != nil || len(page.Items) != 2 || page.NextCursor == "" { |
| 294 | t.Fatalf("all view: %+v %v", page, err) |
| 295 | } |
| 296 | store := &app.desktopSessions.readSnapshots |
| 297 | store.mu.Lock() |
| 298 | lazy := store.entries[page.SnapshotID].data.readPage != nil |
| 299 | store.mu.Unlock() |
| 300 | if !lazy { |
| 301 | t.Fatal("an unrelated group forced the all view to materialize legacy history") |
| 302 | } |
| 303 | app.ReleaseReadSnapshot(page.SnapshotID) |
| 304 | timed := req |
| 305 | timed.TimeFilter, timed.Limit = "24h", 1 |
| 306 | recent, err := app.ListProjectTopics(timed) |
| 307 | if err != nil || len(recent.Items) != 1 || recent.NextCursor == "" { |
| 308 | t.Fatalf("time-filtered first page: %+v %v", recent, err) |
| 309 | } |
| 310 | store.mu.Lock() |
| 311 | lazy = store.entries[recent.SnapshotID].data.readPage != nil |
| 312 | store.mu.Unlock() |
| 313 | if !lazy { |
| 314 | t.Fatal("time filter materialized the history library") |
| 315 | } |
| 316 | timed.Cursor = recent.NextCursor |
| 317 | last, err := app.ListProjectTopics(timed) |
| 318 | if err != nil || len(last.Items) != 1 || last.NextCursor != "" || last.Items[0].Session == nil { |
| 319 | t.Fatalf("time filter admitted old legacy metadata: %+v %v", last, err) |
| 320 | } |
| 321 | app.ReleaseReadSnapshot(recent.SnapshotID) |
| 322 | state, err := app.workspaceRegistry().LoadProjection(t.Context()) |
| 323 | if err != nil { |
| 324 | t.Fatal(err) |
| 325 | } |
| 326 | // Shell construction uses the same lazy adapter, including an unmigrated |
| 327 | // automatic-order preference. It must not enumerate unpinned histories. |
| 328 | pins, err := app.projectTopicsFromProjection(ProjectTopicPageRequest{Scope: "project", WorkspaceRoot: root, Limit: 2, pinnedOnly: true}, state, workspacestate.NewWorkspaceIndex(state), workspaceID, workspacestate.Organization{}, &desktopProject{}) |
| 329 | if err != nil || len(pins.Items) != 1 || pins.Items[0].Session.SessionID != "pinned" { |
| 330 | t.Fatalf("pinned shell: %+v %v", pins, err) |
| 331 | } |
| 332 | store.mu.Lock() |
| 333 | lazy = store.entries[pins.SnapshotID].data.readPage != nil |
| 334 | store.mu.Unlock() |
| 335 | if !lazy { |
| 336 | t.Fatal("pinned shell bypassed the lazy catalog adapter") |
| 337 | } |
| 338 | app.ReleaseReadSnapshot(pins.SnapshotID) |
| 339 | // Independently assert which canonical headers are admitted to this read. |
| 340 | reader := &recordingTopicInfoReader{} |
| 341 | admitted, err := app.readProjectTopicPage(ProjectTopicPageRequest{Scope: "project", WorkspaceRoot: root, pinnedOnly: true}, reader) |
| 342 | if err != nil || len(reader.ids) != 1 || reader.ids[0] != "pinned" { |
| 343 | t.Fatalf("pinned metadata admission: %v err=%v", reader.ids, err) |
| 344 | } |
| 345 | app.ReleaseReadSnapshot(admitted.SnapshotID) |
| 346 | } |
| 347 | |
| 348 | type recordingTopicInfoReader struct { |
| 349 | ids []string |
| 350 | onStat func() |
| 351 | } |
| 352 | |
| 353 | func (r *recordingTopicInfoReader) Stat(_ context.Context, ref session.SessionRef) (session.SessionInfo, error) { |
| 354 | r.ids = append(r.ids, ref.SessionID) |
| 355 | if r.onStat != nil { |
| 356 | r.onStat() |
| 357 | } |
| 358 | return session.SessionInfo{SessionID: ref.SessionID}, nil |
| 359 | } |
| 360 | |
| 361 | func TestMetadataTopicSnapshotFencesTopicChangeAfterWALCapture(t *testing.T) { |
| 362 | app, root, _ := canonicalOrganizationFixture(t, "current") |
| 363 | catalog, err := sessioncatalog.Open(t.Context(), sessioncatalog.Options{Path: filepath.Join(t.TempDir(), "catalog.sqlite"), MetadataOnly: true}) |
| 364 | if err != nil { |
| 365 | t.Fatal(err) |
| 366 | } |
| 367 | app.sessionCatalog.Store(catalog) |
| 368 | t.Cleanup(func() { app.desktopSessions.readSnapshots.close(); app.stopSessionCatalog(time.Second) }) |
| 369 | record := sessioncatalog.SessionRecord{Path: filepath.Join(root, "old.jsonl"), Directory: root, Scope: "project", WorkspaceRoot: root, TopicID: "before", TopicTitle: "Before", CreatedAt: 1, LastActivityAt: 1, Health: sessioncatalog.HealthOK, OrdinaryVisible: true} |
| 370 | if err := catalog.UpsertSession(t.Context(), record); err != nil { |
| 371 | t.Fatal(err) |
| 372 | } |
| 373 | if err := catalog.SyncMetadata(t.Context(), nil, []sessioncatalog.TopicMetadata{{Scope: "project", WorkspaceRoot: root, TopicID: "before", Title: "Before", Pinned: true}}); err != nil { |
| 374 | t.Fatal(err) |
| 375 | } |
| 376 | reader := &recordingTopicInfoReader{onStat: func() { |
| 377 | // Header reads happen after the catalog read transaction is captured. |
| 378 | // Move the physical source while that older view is still retained. |
| 379 | record.TopicID, record.TopicTitle = "after", "After" |
| 380 | if err := catalog.UpsertSession(t.Context(), record); err != nil { |
| 381 | t.Fatal(err) |
| 382 | } |
| 383 | }} |
| 384 | req := ProjectTopicPageRequest{Scope: "project", WorkspaceRoot: root, Limit: 1} |
| 385 | first, err := app.readProjectTopicPage(req, reader) |
| 386 | if err != nil || len(first.Items) != 1 || first.Items[0].TopicID != "before" || first.NextCursor == "" { |
| 387 | t.Fatalf("captured first page: %+v %v", first, err) |
| 388 | } |
| 389 | defer app.ReleaseReadSnapshot(first.SnapshotID) |
| 390 | req.Cursor = first.NextCursor |
| 391 | _, err = app.readProjectTopicPage(req, reader) |
| 392 | var stale *SessionOperationError |
| 393 | if !errors.As(err, &stale) || stale.Code != "stale_cursor" { |
| 394 | t.Fatalf("live identity replaced the frozen source fence: %v", err) |
| 395 | } |
| 396 | } |
| 397 |