返回 DeepSeek-Reasonix
fork_targets_test.go
根目录 / desktop / fork_targets_test.go
1 package main
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "testing"
8 "time"
9
10 "reasonix/internal/control"
11 "reasonix/internal/event"
12 "reasonix/internal/session"
13 )
14
15 // forkTargetsStubController is a control.SessionAPI fake that also satisfies
16 // forkTargetsController and records the creation request it received.
17 type forkTargetsStubController struct {
18 *tabScopedActionController
19 running bool
20 set session.ForkTargetSet
21 setErr error
22 childID string
23 createErr error
24 createTurn string
25 createBoundary uint64
26 createName string
27 createOp string
28 creates int
29 service *session.Service
30 cwd string
31 }
32
33 // RuntimeStatus reports a turn in flight when running is set, so a scenario can
34 // assert the binding answers while the source is busy.
35 func (c *forkTargetsStubController) RuntimeStatus() control.RuntimeStatus {
36 if !c.running {
37 return c.tabScopedActionController.RuntimeStatus()
38 }
39 return control.RuntimeStatus{Running: true, Status: event.TurnInProgress}
40 }
41
42 func (c *forkTargetsStubController) ForkTargets() (session.ForkTargetSet, error) {
43 return c.set, c.setErr
44 }
45
46 func (c *forkTargetsStubController) CreateForkSession(request session.ForkRequest, name string) (string, error) {
47 c.creates++
48 c.createTurn, c.createBoundary, c.createName, c.createOp = request.TurnID, request.BoundarySequence, name, request.OperationID
49 if c.createErr == nil && c.service != nil {
50 var runtime *session.Runtime
51 runtime, c.createErr = c.service.Create(context.Background(), session.CreateOptions{
52 SessionID: c.childID, CWD: c.cwd, ParentSessionID: request.Source.SessionID, Origin: session.SessionOriginFork,
53 })
54 if c.createErr == nil {
55 c.createErr = c.service.Close(context.Background(), runtime.Ref())
56 }
57 }
58 return c.childID, c.createErr
59 }
60
61 func enableForkTargetPersistence(t *testing.T, app *App, ctrl *forkTargetsStubController) {
62 t.Helper()
63 pinDesktopSessionRoot(t, app)
64 ctrl.service = app.desktopSessionService("")
65 ctrl.cwd = globalWorkspaceRoot()
66 }
67
68 func (c *forkTargetsStubController) UsesExclusiveSession() bool { return true }
69 func (c *forkTargetsStubController) SessionRef() (session.SessionRef, bool) {
70 return session.SessionRef{HostID: "host-1", SessionID: "source-1"}, true
71 }
72 func (c *forkTargetsStubController) SessionService() *session.Service { return nil }
73 func (c *forkTargetsStubController) BindFreshSession(context.Context, string) (session.SessionRef, error) {
74 return session.SessionRef{}, errors.New("not implemented")
75 }
76 func (c *forkTargetsStubController) OpenSession(context.Context, session.SessionRef) (session.SessionRef, error) {
77 return session.SessionRef{}, errors.New("not implemented")
78 }
79 func (c *forkTargetsStubController) ContinueLegacySession(context.Context, string, string) (session.SessionRef, error) {
80 return session.SessionRef{}, errors.New("not implemented")
81 }
82 func (c *forkTargetsStubController) ContinuePrototypeSession(context.Context, string) (session.SessionRef, error) {
83 return session.SessionRef{}, errors.New("not implemented")
84 }
85
86 // assertEmptyForkTargets checks the shared empty result: no error, a non-nil
87 // slice, and a "targets" field that JSON-marshals to [] instead of null.
88 func assertEmptyForkTargets(t *testing.T, name string, view ForkTargetSetView, err error) {
89 t.Helper()
90 if err != nil {
91 t.Fatalf("%s: err = %v, want nil", name, err)
92 }
93 if view.Targets == nil {
94 t.Fatalf("%s: Targets is nil; the renderer expects []", name)
95 }
96 if len(view.Targets) != 0 || view.Verifiable {
97 t.Fatalf("%s: view = %+v, want an empty unverifiable set", name, view)
98 }
99 raw, marshalErr := json.Marshal(view)
100 if marshalErr != nil {
101 t.Fatalf("%s: marshal: %v", name, marshalErr)
102 }
103 var decoded struct {
104 Targets *[]ForkTargetView `json:"targets"`
105 }
106 if unmarshalErr := json.Unmarshal(raw, &decoded); unmarshalErr != nil {
107 t.Fatalf("%s: unmarshal %s: %v", name, raw, unmarshalErr)
108 }
109 if decoded.Targets == nil || len(*decoded.Targets) != 0 {
110 t.Fatalf("%s: JSON = %s, want \"targets\":[]", name, raw)
111 }
112 }
113
114 func TestForkTargetsForTabReturnsEmptyNonNilTargets(t *testing.T) {
115 isolateDesktopUserDirs(t)
116
117 app := NewApp()
118 missing, missingErr := app.ForkTargetsForTab("missing")
119 assertEmptyForkTargets(t, "missing tab", missing, missingErr)
120 app.setTestCtrl(newTabScopedActionController(), "")
121 stubbed, stubbedErr := app.ForkTargetsForTab("test")
122 assertEmptyForkTargets(t, "controller without fork targets", stubbed, stubbedErr)
123 active, activeErr := app.ForkTargetsForTab("")
124 assertEmptyForkTargets(t, "active tab", active, activeErr)
125 }
126
127 func TestForkedSessionLocatorRejectsCatalogPseudoPaths(t *testing.T) {
128 source := &WorkspaceTab{ID: "source"}
129 for _, path := range []string{"", ".", "bare-session-id"} {
130 if _, err := normalizeForkedSessionLocator(source, forkedSessionLocator{SessionPath: path}); err == nil {
131 t.Fatalf("session path %q was accepted", path)
132 }
133 }
134 if got, err := normalizeForkedSessionLocator(source, forkedSessionLocator{SessionID: "child-session"}); err != nil || got.SessionID != "child-session" {
135 t.Fatalf("canonical session id = %+v, err=%v", got, err)
136 }
137 for _, path := range []string{"", ".", "child-session"} {
138 if got := sessionDirectoryForPath(path); got != "" {
139 t.Fatalf("sessionDirectoryForPath(%q) = %q, want no catalog target", path, got)
140 }
141 }
142 }
143
144 func TestForkTargetsForTabMapsTargetsForReadOnlyTab(t *testing.T) {
145 isolateDesktopUserDirs(t)
146
147 ctrl := &forkTargetsStubController{
148 tabScopedActionController: newTabScopedActionController(),
149 // A read-only channel tab whose turn is running still lists targets.
150 running: true,
151 set: session.ForkTargetSet{
152 Source: session.SessionRef{HostID: "host-1", SessionID: "source-1"},
153 Targets: []session.ForkTarget{
154 {TurnID: "turn-1", BoundarySequence: 7, TurnNumber: 1, Status: event.TurnCompleted, MessageID: "msg-1", Available: true},
155 {TurnID: "turn-2", TurnNumber: 2, Status: event.TurnInProgress, Reason: session.ForkTurnOpen},
156 },
157 Verifiable: true,
158 },
159 }
160 app := NewApp()
161 app.setTestCtrl(ctrl, "")
162 app.tabs["test"].SessionID = "source-1"
163 app.tabs["test"].Scope = "global"
164 app.tabs["test"].ReadOnly = true
165
166 view, err := app.ForkTargetsForTab("test")
167 if err != nil {
168 t.Fatalf("ForkTargetsForTab: %v", err)
169 }
170 if !view.Verifiable {
171 t.Fatal("Verifiable = false, want the controller's value")
172 }
173 want := []ForkTargetView{
174 {SourceHostID: "host-1", SourceSessionID: "source-1", TurnID: "turn-1", BoundarySequence: 7, TurnNumber: 1, Status: string(event.TurnCompleted), MessageID: "msg-1", Available: true},
175 {SourceHostID: "host-1", SourceSessionID: "source-1", TurnID: "turn-2", TurnNumber: 2, Status: string(event.TurnInProgress), Reason: string(session.ForkTurnOpen)},
176 }
177 if len(view.Targets) != len(want) {
178 t.Fatalf("targets = %+v, want %+v", view.Targets, want)
179 }
180 for i, target := range view.Targets {
181 if target != want[i] {
182 t.Fatalf("target[%d] = %+v, want %+v", i, target, want[i])
183 }
184 }
185 raw, marshalErr := json.Marshal(view.Targets[1])
186 if marshalErr != nil {
187 t.Fatalf("marshal target: %v", marshalErr)
188 }
189 var fields map[string]any
190 if unmarshalErr := json.Unmarshal(raw, &fields); unmarshalErr != nil {
191 t.Fatalf("unmarshal target: %v", unmarshalErr)
192 }
193 if _, present := fields["messageId"]; present {
194 t.Fatalf("target without a message id = %s, want messageId omitted", raw)
195 }
196 }
197
198 func TestForkTargetsForTabReturnsControllerError(t *testing.T) {
199 isolateDesktopUserDirs(t)
200
201 ctrl := &forkTargetsStubController{
202 tabScopedActionController: newTabScopedActionController(),
203 setErr: errors.New("fork targets unavailable"),
204 }
205 app := NewApp()
206 app.setTestCtrl(ctrl, "")
207
208 view, err := app.ForkTargetsForTab("test")
209 if err == nil {
210 t.Fatal("ForkTargetsForTab: err = nil, want the controller's failure")
211 }
212 // The error is asserted above; the view still satisfies the empty-set contract.
213 assertEmptyForkTargets(t, "failed targets", view, nil)
214 }
215
216 func TestCreateForkForTabOpensChildInNewTab(t *testing.T) {
217 isolateDesktopUserDirs(t)
218
219 childID := "created-fork"
220 ctrl := &forkTargetsStubController{
221 tabScopedActionController: newTabScopedActionController(),
222 // Creating a child neither stops the running turn nor takes a rotation gate.
223 running: true,
224 childID: childID,
225 }
226 app := NewApp()
227 app.setTestCtrl(ctrl, "")
228 enableForkTargetPersistence(t, app, ctrl)
229 app.tabs["test"].SessionID = "source-1"
230 app.tabs["test"].Scope = "global"
231 app.tabs["test"].TopicTitle = "Source topic"
232 // A read-only channel tab is a legitimate fork source: the child is written
233 // from the source, never into it.
234 app.tabs["test"].ReadOnly = true
235
236 view, err := app.CreateForkForTab("test", ForkAnchorView{SourceHostID: "host-1", SourceSessionID: "source-1", TurnID: "turn-7", BoundarySequence: 9})
237 if err != nil {
238 t.Fatalf("CreateForkForTab: %v", err)
239 }
240 if !view.Opened || view.Error != "" {
241 t.Fatalf("view = %+v, want an opened tab without an error", view)
242 }
243 if view.SessionID != childID {
244 t.Fatalf("sessionId = %q, want %q", view.SessionID, childID)
245 }
246 if view.TabID == "" || view.TabID == "test" {
247 t.Fatalf("tabId = %q, want a fresh tab", view.TabID)
248 }
249 if ctrl.createTurn != "turn-7" || ctrl.createBoundary != 9 || ctrl.createName != "" || ctrl.createOp == "" || view.OperationID != ctrl.createOp {
250 t.Fatalf("create request = (%q, %d, %q, %q), want the anchored turn and host operation",
251 ctrl.createTurn, ctrl.createBoundary, ctrl.createName, ctrl.createOp)
252 }
253 if app.tabs["test"] == nil || app.tabs["test"].Ctrl != ctrl {
254 t.Fatal("source tab lost its controller")
255 }
256 if app.activeTabID != view.TabID {
257 t.Fatalf("active tab = %q, want the focused source's child %q", app.activeTabID, view.TabID)
258 }
259 child := app.tabs[view.TabID]
260 if child == nil {
261 t.Fatalf("child tab %q is missing", view.TabID)
262 }
263 if child.TopicID == "" || child.SessionID != childID || child.SessionPath != "" {
264 t.Fatalf("child tab = %+v, want canonical session id %q", child, childID)
265 }
266 }
267
268 func TestRepeatedForkPublishesSidebarMembershipWithoutRestart(t *testing.T) {
269 for _, scope := range []string{"global", "project"} {
270 t.Run(scope, func(t *testing.T) {
271 isolateDesktopUserDirs(t)
272 ctrl := &forkTargetsStubController{tabScopedActionController: newTabScopedActionController(), childID: "fork-one"}
273 app := NewApp()
274 app.ctx = context.Background()
275 app.setTestCtrl(ctrl, "")
276 enableForkTargetPersistence(t, app, ctrl)
277 root := ""
278 if scope == "project" {
279 root = t.TempDir()
280 ctrl.cwd = root
281 }
282 app.tabs["test"].SessionID = "source-1"
283 app.tabs["test"].Scope = scope
284 app.tabs["test"].WorkspaceRoot = root
285 app.tabs["test"].TopicTitle = "Source topic"
286 events := make(chan ProjectTreeChangedV2, 4)
287 app.runtimeEvents.emit = func(_ context.Context, name string, payload ...any) {
288 if name == "project-tree:changed-v2" && len(payload) == 1 {
289 events <- payload[0].(ProjectTreeChangedV2)
290 }
291 }
292 lastRevision := uint64(0)
293 checkEvent := func(child string) {
294 t.Helper()
295 select {
296 case event := <-events:
297 if event.Revision <= lastRevision || len(event.Roots) != 1 || event.Roots[0] != root {
298 t.Fatalf("fork %q sidebar event = %+v, want root %q", child, event, root)
299 }
300 lastRevision = event.Revision
301 case <-time.After(5 * time.Second):
302 t.Fatalf("fork %q did not invalidate the sidebar topic page", child)
303 }
304 }
305
306 anchor := ForkAnchorView{SourceHostID: "host-1", SourceSessionID: "source-1", TurnID: "turn-7", BoundarySequence: 9}
307 first, err := app.CreateForkForTab("test", anchor)
308 if err != nil || !first.Opened {
309 t.Fatalf("first fork = %+v, err = %v", first, err)
310 }
311 checkEvent(first.SessionID)
312 checkEvent(first.SessionID)
313 if err := app.AcknowledgeForkOperation("test", first.OperationID); err != nil {
314 t.Fatal(err)
315 }
316 // The user returns to the original conversation before forking again.
317 app.mu.Lock()
318 app.activeTabID = "test"
319 app.mu.Unlock()
320 ctrl.childID = "fork-two"
321 second, err := app.CreateForkForTab("test", anchor)
322 if err != nil || !second.Opened || second.SessionID == first.SessionID {
323 t.Fatalf("second fork = %+v, err = %v", second, err)
324 }
325 checkEvent(second.SessionID)
326 checkEvent(second.SessionID)
327 page, err := app.ListProjectTopics(ProjectTopicPageRequest{Scope: scope, WorkspaceRoot: root, Limit: 20})
328 if err != nil {
329 t.Fatal(err)
330 }
331 seen := map[string]bool{}
332 wantTitle := app.forkTopicTitle("Source topic")
333 for _, item := range page.Items {
334 if item.Session != nil {
335 seen[item.Session.SessionID] = true
336 if (item.Session.SessionID == first.SessionID || item.Session.SessionID == second.SessionID) && item.Label != wantTitle {
337 t.Fatalf("fork %q label = %q, want %q", item.Session.SessionID, item.Label, wantTitle)
338 }
339 }
340 }
341 if !seen[first.SessionID] || !seen[second.SessionID] {
342 t.Fatalf("sidebar page lacks repeated forks: %+v", page.Items)
343 }
344 })
345 }
346 }
347
348 func TestCreateForkForTabKeepsChildWhenTabAttachFails(t *testing.T) {
349 isolateDesktopUserDirs(t)
350
351 childID := "orphan-fork"
352 ctrl := &forkTargetsStubController{
353 tabScopedActionController: newTabScopedActionController(),
354 childID: childID,
355 }
356 app := NewApp()
357 app.ctx = context.Background()
358 app.setTestCtrl(ctrl, "")
359 enableForkTargetPersistence(t, app, ctrl)
360 app.tabs["test"].SessionID = "source-1"
361 app.tabs["test"].Scope = "global"
362 app.tabs["test"].TopicTitle = "Source topic"
363 changed := make(chan ProjectTreeChangedV2, 4)
364 app.runtimeEvents.emit = func(_ context.Context, name string, payload ...any) {
365 if name == "project-tree:changed-v2" && len(payload) == 1 {
366 changed <- payload[0].(ProjectTreeChangedV2)
367 }
368 }
369 t.Cleanup(func() { forkTabBeforePublishHookForTest.Store(nil) })
370 // Closing the source tab mid-flight makes the attach a no-op, which is the
371 // same outcome as an attach that fails outright.
372 hook := func() {
373 app.mu.Lock()
374 delete(app.tabs, "test")
375 app.removeTabOrderLocked("test")
376 if app.activeTabID == "test" {
377 app.activeTabID = ""
378 }
379 app.mu.Unlock()
380 }
381 forkTabBeforePublishHookForTest.Store(&hook)
382
383 view, err := app.CreateForkForTab("test", ForkAnchorView{SourceHostID: "host-1", SourceSessionID: "source-1", TurnID: "turn-7", BoundarySequence: 9})
384 if err != nil {
385 t.Fatalf("CreateForkForTab: %v", err)
386 }
387 if view.Opened {
388 t.Fatal("opened = true, want false when the new tab was not created")
389 }
390 // The invalidation must include the persisted title, even if an earlier
391 // membership event already caused the frontend to cache a placeholder row.
392 wantRevision := app.unifiedProjectRevision(app.currentSessionCatalogStatus().Revision)
393 deadline := time.After(5 * time.Second)
394 for refreshed := false; !refreshed; {
395 select {
396 case event := <-changed:
397 refreshed = event.Revision >= wantRevision
398 case <-deadline:
399 t.Fatal("durable fork presentation did not invalidate the sidebar after tab attach failed")
400 }
401 }
402 if view.TabID != "" {
403 t.Fatalf("tabId = %q, want empty", view.TabID)
404 }
405 if view.SessionID != childID {
406 t.Fatalf("sessionId = %q, want the created child %q", view.SessionID, childID)
407 }
408 if view.Error == "" {
409 t.Fatal("error is empty; the caller cannot offer a recovery entry")
410 }
411 if ctrl.creates != 1 {
412 t.Fatalf("creates = %d, want exactly one child", ctrl.creates)
413 }
414 if ctrl.createOp == "" || view.OperationID != ctrl.createOp {
415 t.Fatalf("operationId = %q view=%q, want one host-owned id", ctrl.createOp, view.OperationID)
416 }
417 }
418
419 func TestCreateForkForTabReturnsCreateFailure(t *testing.T) {
420 isolateDesktopUserDirs(t)
421
422 ctrl := &forkTargetsStubController{
423 tabScopedActionController: newTabScopedActionController(),
424 createErr: errors.New("turn is not forkable"),
425 }
426 app := NewApp()
427 app.setTestCtrl(ctrl, "")
428 app.tabs["test"].SessionID = "source-1"
429 app.tabs["test"].Scope = "global"
430
431 view, err := app.CreateForkForTab("test", ForkAnchorView{SourceHostID: "host-1", SourceSessionID: "source-1", TurnID: "turn-7", BoundarySequence: 9})
432 if err == nil {
433 t.Fatal("CreateForkForTab: err = nil, want the controller's failure")
434 }
435 if view.SessionID != "" || view.TabID != "" || view.Opened || view.Error != "" {
436 t.Fatalf("view = %+v, want the zero view alongside the error", view)
437 }
438 if len(app.tabs) != 1 || app.tabs["test"] == nil || app.activeTabID != "test" {
439 t.Fatalf("tabs = %d, active = %q, want only the unchanged source tab", len(app.tabs), app.activeTabID)
440 }
441 journal, loadErr := loadForkOperations(forkOperationsPath())
442 if loadErr != nil || len(journal.Operations) != 1 || journal.Operations[0].State != "pending" {
443 t.Fatalf("uncertain failure journal = %+v, err=%v", journal, loadErr)
444 }
445 }
446
447 func TestCreateForkForTabDiscardsExplicitRefusal(t *testing.T) {
448 isolateDesktopUserDirs(t)
449 ctrl := &forkTargetsStubController{tabScopedActionController: newTabScopedActionController(),
450 createErr: &session.ForkUnavailableError{TurnID: "turn-7", Reason: session.ForkActiveAuthority}}
451 app := NewApp()
452 app.setTestCtrl(ctrl, "")
453 app.tabs["test"].SessionID = "source-1"
454 view, err := app.CreateForkForTab("test", ForkAnchorView{SourceHostID: "host-1", SourceSessionID: "source-1",
455 TurnID: "turn-7", BoundarySequence: 9})
456 if err != nil || view.Reason != string(session.ForkActiveAuthority) {
457 t.Fatalf("explicit refusal = %+v, err=%v", view, err)
458 }
459 journal, loadErr := loadForkOperations(forkOperationsPath())
460 if loadErr != nil || len(journal.Operations) != 0 {
461 t.Fatalf("explicit refusal journal = %+v, err=%v", journal, loadErr)
462 }
463 }
464
465 func TestCreateForkForTabRejectsStaleSourceIdentity(t *testing.T) {
466 isolateDesktopUserDirs(t)
467 ctrl := &forkTargetsStubController{tabScopedActionController: newTabScopedActionController(), childID: "must-not-exist"}
468 app := NewApp()
469 app.setTestCtrl(ctrl, "")
470 app.tabs["test"].Scope = "global"
471 app.tabs["test"].SessionID = "source-b"
472
473 view, err := app.CreateForkForTab("test", ForkAnchorView{SourceHostID: "host-1", SourceSessionID: "source-a",
474 TurnID: "shared-turn", BoundarySequence: 9})
475 if err != nil || view.Reason != string(session.ForkStaleSource) || ctrl.creates != 0 {
476 t.Fatalf("stale create = %+v, err=%v creates=%d", view, err, ctrl.creates)
477 }
478 journal, loadErr := loadForkOperations(forkOperationsPath())
479 if loadErr != nil || len(journal.Operations) != 0 {
480 t.Fatalf("stale create journal = %+v, err=%v", journal, loadErr)
481 }
482 }
483
484 func TestForkOperationJournalSurvivesRestartAndAcknowledgement(t *testing.T) {
485 isolateDesktopUserDirs(t)
486 template := forkOperation{Surface: "remote", TabID: "tab", SourceHostID: "host", SourceSessionID: "source",
487 TurnID: "turn", BoundarySequence: 12}
488 first, err := (&App{}).beginForkOperation(template)
489 if err != nil {
490 t.Fatal(err)
491 }
492 if err := (&App{}).AcknowledgeForkOperation("tab", first.OperationID); err != nil {
493 t.Fatal(err)
494 }
495 second, err := (&App{}).beginForkOperation(template)
496 if err != nil || second.OperationID != first.OperationID || second.State != "pending" {
497 t.Fatalf("reloaded pending = %+v, err=%v; want %+v", second, err, first)
498 }
499 if err := (&App{}).completeForkOperation(first.OperationID, "child"); err != nil {
500 t.Fatal(err)
501 }
502 restartedTemplate := template
503 restartedTemplate.TabID = "tab-after-restart"
504 completed, err := (&App{}).beginForkOperation(restartedTemplate)
505 if err != nil || completed.OperationID != first.OperationID || completed.State != "completed" || completed.ChildSessionID != "child" {
506 t.Fatalf("reloaded completion = %+v, err=%v", completed, err)
507 }
508 if err := (&App{}).AcknowledgeForkOperation("tab-after-restart", first.OperationID); err != nil {
509 t.Fatal(err)
510 }
511 fresh, err := (&App{}).beginForkOperation(template)
512 if err != nil || fresh.OperationID == first.OperationID {
513 t.Fatalf("fresh operation after acknowledgement = %+v, err=%v", fresh, err)
514 }
515 }
516
517 func TestCreateForkForTabReopensCompletedOperationAfterAttachFailure(t *testing.T) {
518 isolateDesktopUserDirs(t)
519 ctrl := &forkTargetsStubController{tabScopedActionController: newTabScopedActionController(), childID: "recovered-child"}
520 app := NewApp()
521 app.setTestCtrl(ctrl, "")
522 enableForkTargetPersistence(t, app, ctrl)
523 app.tabs["test"].Scope = "global"
524 app.tabs["test"].SessionID = "source-1"
525 anchor := ForkAnchorView{SourceHostID: "host-1", SourceSessionID: "source-1", TurnID: "turn", BoundarySequence: 9}
526 t.Cleanup(func() { forkTabBeforePublishHookForTest.Store(nil) })
527 hook := func() {
528 app.mu.Lock()
529 app.tabs["test"] = &WorkspaceTab{ID: "test", Scope: "global", TopicTitle: "Source",
530 SessionID: "source-1", Ctrl: ctrl}
531 app.mu.Unlock()
532 forkTabBeforePublishHookForTest.Store(nil)
533 }
534 forkTabBeforePublishHookForTest.Store(&hook)
535 first, err := app.CreateForkForTab("test", anchor)
536 if err != nil || first.Opened || first.SessionID != "recovered-child" || ctrl.creates != 1 {
537 t.Fatalf("first attach = %+v err=%v creates=%d", first, err, ctrl.creates)
538 }
539 // The durable row is usable even when opening its tab failed. A retry
540 // must preserve a title chosen through that row in the meantime.
541 chosenTitle := "Chosen fork title"
542 if err := app.workspaceRegistry().UpdatePresentation(context.Background(), []string{first.SessionID}, &chosenTitle, nil); err != nil {
543 t.Fatal(err)
544 }
545 second, err := app.CreateForkForTab("test", anchor)
546 if err != nil || !second.Opened || second.SessionID != first.SessionID || second.OperationID != first.OperationID || ctrl.creates != 1 {
547 t.Fatalf("recovered attach = %+v err=%v creates=%d; first=%+v", second, err, ctrl.creates, first)
548 }
549 state, err := app.workspaceRegistry().Load(context.Background())
550 if err != nil {
551 t.Fatal(err)
552 }
553 presentation := state.Presentation[second.SessionID]
554 app.mu.RLock()
555 tab := app.tabs[second.TabID]
556 topicID, title := tab.TopicID, tab.TopicTitle
557 app.mu.RUnlock()
558 if topicID != presentation.TopicID || title != presentation.Title {
559 t.Fatalf("retried tab presentation = %q / %q, durable sidebar = %q / %q", topicID, title, presentation.TopicID, presentation.Title)
560 }
561 }
562
562 lines GO