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