| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "testing" |
| 8 | |
| 9 | "reasonix/internal/control" |
| 10 | "reasonix/internal/event" |
| 11 | "reasonix/internal/session" |
| 12 | ) |
| 13 | |
| 14 | // forkTargetsStubController is a control.SessionAPI fake that also satisfies |
| 15 | // forkTargetsController and records the creation request it received. |
| 16 | type forkTargetsStubController struct { |
| 17 | *tabScopedActionController |
| 18 | running bool |
| 19 | set session.ForkTargetSet |
| 20 | setErr error |
| 21 | childID string |
| 22 | createErr error |
| 23 | createTurn string |
| 24 | createBoundary uint64 |
| 25 | createName string |
| 26 | createOp string |
| 27 | creates int |
| 28 | service *session.Service |
| 29 | cwd string |
| 30 | } |
| 31 | |
| 32 | // RuntimeStatus reports a turn in flight when running is set, so a scenario can |
| 33 | // assert the binding answers while the source is busy. |
| 34 | func (c *forkTargetsStubController) RuntimeStatus() control.RuntimeStatus { |
| 35 | if !c.running { |
| 36 | return c.tabScopedActionController.RuntimeStatus() |
| 37 | } |
| 38 | return control.RuntimeStatus{Running: true, Status: event.TurnInProgress} |
| 39 | } |
| 40 | |
| 41 | func (c *forkTargetsStubController) ForkTargets() (session.ForkTargetSet, error) { |
| 42 | return c.set, c.setErr |
| 43 | } |
| 44 | |
| 45 | func (c *forkTargetsStubController) CreateForkSession(request session.ForkRequest, name string) (string, error) { |
| 46 | c.creates++ |
| 47 | c.createTurn, c.createBoundary, c.createName, c.createOp = request.TurnID, request.BoundarySequence, name, request.OperationID |
| 48 | if c.createErr == nil && c.service != nil { |
| 49 | var runtime *session.Runtime |
| 50 | runtime, c.createErr = c.service.Create(context.Background(), session.CreateOptions{ |
| 51 | SessionID: c.childID, CWD: c.cwd, ParentSessionID: request.Source.SessionID, Origin: session.SessionOriginFork, |
| 52 | }) |
| 53 | if c.createErr == nil { |
| 54 | c.createErr = c.service.Close(context.Background(), runtime.Ref()) |
| 55 | } |
| 56 | } |
| 57 | return c.childID, c.createErr |
| 58 | } |
| 59 | |
| 60 | func enableForkTargetPersistence(t *testing.T, app *App, ctrl *forkTargetsStubController) { |
| 61 | t.Helper() |
| 62 | pinDesktopSessionRoot(t, app) |
| 63 | ctrl.service = app.desktopSessionService("") |
| 64 | ctrl.cwd = globalWorkspaceRoot() |
| 65 | } |
| 66 | |
| 67 | func (c *forkTargetsStubController) UsesExclusiveSession() bool { return true } |
| 68 | func (c *forkTargetsStubController) SessionRef() (session.SessionRef, bool) { |
| 69 | return session.SessionRef{HostID: "host-1", SessionID: "source-1"}, true |
| 70 | } |
| 71 | func (c *forkTargetsStubController) SessionService() *session.Service { return nil } |
| 72 | func (c *forkTargetsStubController) BindFreshSession(context.Context, string) (session.SessionRef, error) { |
| 73 | return session.SessionRef{}, errors.New("not implemented") |
| 74 | } |
| 75 | func (c *forkTargetsStubController) OpenSession(context.Context, session.SessionRef) (session.SessionRef, error) { |
| 76 | return session.SessionRef{}, errors.New("not implemented") |
| 77 | } |
| 78 | func (c *forkTargetsStubController) ContinueLegacySession(context.Context, string, string) (session.SessionRef, error) { |
| 79 | return session.SessionRef{}, errors.New("not implemented") |
| 80 | } |
| 81 | func (c *forkTargetsStubController) ContinuePrototypeSession(context.Context, string) (session.SessionRef, error) { |
| 82 | return session.SessionRef{}, errors.New("not implemented") |
| 83 | } |
| 84 | |
| 85 | // assertEmptyForkTargets checks the shared empty result: no error, a non-nil |
| 86 | // slice, and a "targets" field that JSON-marshals to [] instead of null. |
| 87 | func assertEmptyForkTargets(t *testing.T, name string, view ForkTargetSetView, err error) { |
| 88 | t.Helper() |
| 89 | if err != nil { |
| 90 | t.Fatalf("%s: err = %v, want nil", name, err) |
| 91 | } |
| 92 | if view.Targets == nil { |
| 93 | t.Fatalf("%s: Targets is nil; the renderer expects []", name) |
| 94 | } |
| 95 | if len(view.Targets) != 0 || view.Verifiable { |
| 96 | t.Fatalf("%s: view = %+v, want an empty unverifiable set", name, view) |
| 97 | } |
| 98 | raw, marshalErr := json.Marshal(view) |
| 99 | if marshalErr != nil { |
| 100 | t.Fatalf("%s: marshal: %v", name, marshalErr) |
| 101 | } |
| 102 | var decoded struct { |
| 103 | Targets *[]ForkTargetView `json:"targets"` |
| 104 | } |
| 105 | if unmarshalErr := json.Unmarshal(raw, &decoded); unmarshalErr != nil { |
| 106 | t.Fatalf("%s: unmarshal %s: %v", name, raw, unmarshalErr) |
| 107 | } |
| 108 | if decoded.Targets == nil || len(*decoded.Targets) != 0 { |
| 109 | t.Fatalf("%s: JSON = %s, want \"targets\":[]", name, raw) |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | func TestForkTargetsForTabReturnsEmptyNonNilTargets(t *testing.T) { |
| 114 | isolateDesktopUserDirs(t) |
| 115 | |
| 116 | app := NewApp() |
| 117 | missing, missingErr := app.ForkTargetsForTab("missing") |
| 118 | assertEmptyForkTargets(t, "missing tab", missing, missingErr) |
| 119 | app.setTestCtrl(newTabScopedActionController(), "") |
| 120 | stubbed, stubbedErr := app.ForkTargetsForTab("test") |
| 121 | assertEmptyForkTargets(t, "controller without fork targets", stubbed, stubbedErr) |
| 122 | active, activeErr := app.ForkTargetsForTab("") |
| 123 | assertEmptyForkTargets(t, "active tab", active, activeErr) |
| 124 | } |
| 125 | |
| 126 | func TestForkedSessionLocatorRejectsCatalogPseudoPaths(t *testing.T) { |
| 127 | source := &WorkspaceTab{ID: "source"} |
| 128 | for _, path := range []string{"", ".", "bare-session-id"} { |
| 129 | if _, err := normalizeForkedSessionLocator(source, forkedSessionLocator{SessionPath: path}); err == nil { |
| 130 | t.Fatalf("session path %q was accepted", path) |
| 131 | } |
| 132 | } |
| 133 | if got, err := normalizeForkedSessionLocator(source, forkedSessionLocator{SessionID: "child-session"}); err != nil || got.SessionID != "child-session" { |
| 134 | t.Fatalf("canonical session id = %+v, err=%v", got, err) |
| 135 | } |
| 136 | for _, path := range []string{"", ".", "child-session"} { |
| 137 | if got := sessionDirectoryForPath(path); got != "" { |
| 138 | t.Fatalf("sessionDirectoryForPath(%q) = %q, want no catalog target", path, got) |
| 139 | } |
| 140 | } |
| 141 | } |
| 142 | |
| 143 | func TestForkTargetsForTabMapsTargetsForReadOnlyTab(t *testing.T) { |
| 144 | isolateDesktopUserDirs(t) |
| 145 | |
| 146 | ctrl := &forkTargetsStubController{ |
| 147 | tabScopedActionController: newTabScopedActionController(), |
| 148 | // A read-only channel tab whose turn is running still lists targets. |
| 149 | running: true, |
| 150 | set: session.ForkTargetSet{ |
| 151 | Source: session.SessionRef{HostID: "host-1", SessionID: "source-1"}, |
| 152 | Targets: []session.ForkTarget{ |
| 153 | {TurnID: "turn-1", BoundarySequence: 7, TurnNumber: 1, Status: event.TurnCompleted, MessageID: "msg-1", Available: true}, |
| 154 | {TurnID: "turn-2", TurnNumber: 2, Status: event.TurnInProgress, Reason: session.ForkTurnOpen}, |
| 155 | }, |
| 156 | Verifiable: true, |
| 157 | }, |
| 158 | } |
| 159 | app := NewApp() |
| 160 | app.setTestCtrl(ctrl, "") |
| 161 | app.tabs["test"].SessionID = "source-1" |
| 162 | app.tabs["test"].Scope = "global" |
| 163 | app.tabs["test"].ReadOnly = true |
| 164 | |
| 165 | view, err := app.ForkTargetsForTab("test") |
| 166 | if err != nil { |
| 167 | t.Fatalf("ForkTargetsForTab: %v", err) |
| 168 | } |
| 169 | if !view.Verifiable { |
| 170 | t.Fatal("Verifiable = false, want the controller's value") |
| 171 | } |
| 172 | want := []ForkTargetView{ |
| 173 | {SourceHostID: "host-1", SourceSessionID: "source-1", TurnID: "turn-1", BoundarySequence: 7, TurnNumber: 1, Status: string(event.TurnCompleted), MessageID: "msg-1", Available: true}, |
| 174 | {SourceHostID: "host-1", SourceSessionID: "source-1", TurnID: "turn-2", TurnNumber: 2, Status: string(event.TurnInProgress), Reason: string(session.ForkTurnOpen)}, |
| 175 | } |
| 176 | if len(view.Targets) != len(want) { |
| 177 | t.Fatalf("targets = %+v, want %+v", view.Targets, want) |
| 178 | } |
| 179 | for i, target := range view.Targets { |
| 180 | if target != want[i] { |
| 181 | t.Fatalf("target[%d] = %+v, want %+v", i, target, want[i]) |
| 182 | } |
| 183 | } |
| 184 | raw, marshalErr := json.Marshal(view.Targets[1]) |
| 185 | if marshalErr != nil { |
| 186 | t.Fatalf("marshal target: %v", marshalErr) |
| 187 | } |
| 188 | var fields map[string]any |
| 189 | if unmarshalErr := json.Unmarshal(raw, &fields); unmarshalErr != nil { |
| 190 | t.Fatalf("unmarshal target: %v", unmarshalErr) |
| 191 | } |
| 192 | if _, present := fields["messageId"]; present { |
| 193 | t.Fatalf("target without a message id = %s, want messageId omitted", raw) |
| 194 | } |
| 195 | } |
| 196 | |
| 197 | func TestForkTargetsForTabReturnsControllerError(t *testing.T) { |
| 198 | isolateDesktopUserDirs(t) |
| 199 | |
| 200 | ctrl := &forkTargetsStubController{ |
| 201 | tabScopedActionController: newTabScopedActionController(), |
| 202 | setErr: errors.New("fork targets unavailable"), |
| 203 | } |
| 204 | app := NewApp() |
| 205 | app.setTestCtrl(ctrl, "") |
| 206 | |
| 207 | view, err := app.ForkTargetsForTab("test") |
| 208 | if err == nil { |
| 209 | t.Fatal("ForkTargetsForTab: err = nil, want the controller's failure") |
| 210 | } |
| 211 | // The error is asserted above; the view still satisfies the empty-set contract. |
| 212 | assertEmptyForkTargets(t, "failed targets", view, nil) |
| 213 | } |
| 214 | |
| 215 | func TestCreateForkForTabOpensChildInNewTab(t *testing.T) { |
| 216 | isolateDesktopUserDirs(t) |
| 217 | |
| 218 | childID := "created-fork" |
| 219 | ctrl := &forkTargetsStubController{ |
| 220 | tabScopedActionController: newTabScopedActionController(), |
| 221 | // Creating a child neither stops the running turn nor takes a rotation gate. |
| 222 | running: true, |
| 223 | childID: childID, |
| 224 | } |
| 225 | app := NewApp() |
| 226 | app.setTestCtrl(ctrl, "") |
| 227 | enableForkTargetPersistence(t, app, ctrl) |
| 228 | app.tabs["test"].SessionID = "source-1" |
| 229 | app.tabs["test"].Scope = "global" |
| 230 | app.tabs["test"].TopicTitle = "Source topic" |
| 231 | // A read-only channel tab is a legitimate fork source: the child is written |
| 232 | // from the source, never into it. |
| 233 | app.tabs["test"].ReadOnly = true |
| 234 | |
| 235 | view, err := app.CreateForkForTab("test", ForkAnchorView{SourceHostID: "host-1", SourceSessionID: "source-1", TurnID: "turn-7", BoundarySequence: 9}) |
| 236 | if err != nil { |
| 237 | t.Fatalf("CreateForkForTab: %v", err) |
| 238 | } |
| 239 | if !view.Opened || view.Error != "" { |
| 240 | t.Fatalf("view = %+v, want an opened tab without an error", view) |
| 241 | } |
| 242 | if view.SessionID != childID { |
| 243 | t.Fatalf("sessionId = %q, want %q", view.SessionID, childID) |
| 244 | } |
| 245 | if view.TabID == "" || view.TabID == "test" { |
| 246 | t.Fatalf("tabId = %q, want a fresh tab", view.TabID) |
| 247 | } |
| 248 | if ctrl.createTurn != "turn-7" || ctrl.createBoundary != 9 || ctrl.createName != "" || ctrl.createOp == "" || view.OperationID != ctrl.createOp { |
| 249 | t.Fatalf("create request = (%q, %d, %q, %q), want the anchored turn and host operation", |
| 250 | ctrl.createTurn, ctrl.createBoundary, ctrl.createName, ctrl.createOp) |
| 251 | } |
| 252 | if app.tabs["test"] == nil || app.tabs["test"].Ctrl != ctrl { |
| 253 | t.Fatal("source tab lost its controller") |
| 254 | } |
| 255 | if app.activeTabID != view.TabID { |
| 256 | t.Fatalf("active tab = %q, want the focused source's child %q", app.activeTabID, view.TabID) |
| 257 | } |
| 258 | child := app.tabs[view.TabID] |
| 259 | if child == nil { |
| 260 | t.Fatalf("child tab %q is missing", view.TabID) |
| 261 | } |
| 262 | if child.TopicID == "" || child.SessionID != childID || child.SessionPath != "" { |
| 263 | t.Fatalf("child tab = %+v, want canonical session id %q", child, childID) |
| 264 | } |
| 265 | } |
| 266 | |
| 267 | func TestCreateForkForTabKeepsChildWhenTabAttachFails(t *testing.T) { |
| 268 | isolateDesktopUserDirs(t) |
| 269 | |
| 270 | childID := "orphan-fork" |
| 271 | ctrl := &forkTargetsStubController{ |
| 272 | tabScopedActionController: newTabScopedActionController(), |
| 273 | childID: childID, |
| 274 | } |
| 275 | app := NewApp() |
| 276 | app.setTestCtrl(ctrl, "") |
| 277 | enableForkTargetPersistence(t, app, ctrl) |
| 278 | app.tabs["test"].SessionID = "source-1" |
| 279 | app.tabs["test"].Scope = "global" |
| 280 | app.tabs["test"].TopicTitle = "Source topic" |
| 281 | t.Cleanup(func() { forkTabBeforePublishHookForTest.Store(nil) }) |
| 282 | // Closing the source tab mid-flight makes the attach a no-op, which is the |
| 283 | // same outcome as an attach that fails outright. |
| 284 | hook := func() { |
| 285 | app.mu.Lock() |
| 286 | delete(app.tabs, "test") |
| 287 | app.removeTabOrderLocked("test") |
| 288 | if app.activeTabID == "test" { |
| 289 | app.activeTabID = "" |
| 290 | } |
| 291 | app.mu.Unlock() |
| 292 | } |
| 293 | forkTabBeforePublishHookForTest.Store(&hook) |
| 294 | |
| 295 | view, err := app.CreateForkForTab("test", ForkAnchorView{SourceHostID: "host-1", SourceSessionID: "source-1", TurnID: "turn-7", BoundarySequence: 9}) |
| 296 | if err != nil { |
| 297 | t.Fatalf("CreateForkForTab: %v", err) |
| 298 | } |
| 299 | if view.Opened { |
| 300 | t.Fatal("opened = true, want false when the new tab was not created") |
| 301 | } |
| 302 | if view.TabID != "" { |
| 303 | t.Fatalf("tabId = %q, want empty", view.TabID) |
| 304 | } |
| 305 | if view.SessionID != childID { |
| 306 | t.Fatalf("sessionId = %q, want the created child %q", view.SessionID, childID) |
| 307 | } |
| 308 | if view.Error == "" { |
| 309 | t.Fatal("error is empty; the caller cannot offer a recovery entry") |
| 310 | } |
| 311 | if ctrl.creates != 1 { |
| 312 | t.Fatalf("creates = %d, want exactly one child", ctrl.creates) |
| 313 | } |
| 314 | if ctrl.createOp == "" || view.OperationID != ctrl.createOp { |
| 315 | t.Fatalf("operationId = %q view=%q, want one host-owned id", ctrl.createOp, view.OperationID) |
| 316 | } |
| 317 | } |
| 318 | |
| 319 | func TestCreateForkForTabReturnsCreateFailure(t *testing.T) { |
| 320 | isolateDesktopUserDirs(t) |
| 321 | |
| 322 | ctrl := &forkTargetsStubController{ |
| 323 | tabScopedActionController: newTabScopedActionController(), |
| 324 | createErr: errors.New("turn is not forkable"), |
| 325 | } |
| 326 | app := NewApp() |
| 327 | app.setTestCtrl(ctrl, "") |
| 328 | app.tabs["test"].SessionID = "source-1" |
| 329 | app.tabs["test"].Scope = "global" |
| 330 | |
| 331 | view, err := app.CreateForkForTab("test", ForkAnchorView{SourceHostID: "host-1", SourceSessionID: "source-1", TurnID: "turn-7", BoundarySequence: 9}) |
| 332 | if err == nil { |
| 333 | t.Fatal("CreateForkForTab: err = nil, want the controller's failure") |
| 334 | } |
| 335 | if view.SessionID != "" || view.TabID != "" || view.Opened || view.Error != "" { |
| 336 | t.Fatalf("view = %+v, want the zero view alongside the error", view) |
| 337 | } |
| 338 | if len(app.tabs) != 1 || app.tabs["test"] == nil || app.activeTabID != "test" { |
| 339 | t.Fatalf("tabs = %d, active = %q, want only the unchanged source tab", len(app.tabs), app.activeTabID) |
| 340 | } |
| 341 | journal, loadErr := loadForkOperations(forkOperationsPath()) |
| 342 | if loadErr != nil || len(journal.Operations) != 1 || journal.Operations[0].State != "pending" { |
| 343 | t.Fatalf("uncertain failure journal = %+v, err=%v", journal, loadErr) |
| 344 | } |
| 345 | } |
| 346 | |
| 347 | func TestCreateForkForTabDiscardsExplicitRefusal(t *testing.T) { |
| 348 | isolateDesktopUserDirs(t) |
| 349 | ctrl := &forkTargetsStubController{tabScopedActionController: newTabScopedActionController(), |
| 350 | createErr: &session.ForkUnavailableError{TurnID: "turn-7", Reason: session.ForkActiveAuthority}} |
| 351 | app := NewApp() |
| 352 | app.setTestCtrl(ctrl, "") |
| 353 | app.tabs["test"].SessionID = "source-1" |
| 354 | view, err := app.CreateForkForTab("test", ForkAnchorView{SourceHostID: "host-1", SourceSessionID: "source-1", |
| 355 | TurnID: "turn-7", BoundarySequence: 9}) |
| 356 | if err != nil || view.Reason != string(session.ForkActiveAuthority) { |
| 357 | t.Fatalf("explicit refusal = %+v, err=%v", view, err) |
| 358 | } |
| 359 | journal, loadErr := loadForkOperations(forkOperationsPath()) |
| 360 | if loadErr != nil || len(journal.Operations) != 0 { |
| 361 | t.Fatalf("explicit refusal journal = %+v, err=%v", journal, loadErr) |
| 362 | } |
| 363 | } |
| 364 | |
| 365 | func TestCreateForkForTabRejectsStaleSourceIdentity(t *testing.T) { |
| 366 | isolateDesktopUserDirs(t) |
| 367 | ctrl := &forkTargetsStubController{tabScopedActionController: newTabScopedActionController(), childID: "must-not-exist"} |
| 368 | app := NewApp() |
| 369 | app.setTestCtrl(ctrl, "") |
| 370 | app.tabs["test"].Scope = "global" |
| 371 | app.tabs["test"].SessionID = "source-b" |
| 372 | |
| 373 | view, err := app.CreateForkForTab("test", ForkAnchorView{SourceHostID: "host-1", SourceSessionID: "source-a", |
| 374 | TurnID: "shared-turn", BoundarySequence: 9}) |
| 375 | if err != nil || view.Reason != string(session.ForkStaleSource) || ctrl.creates != 0 { |
| 376 | t.Fatalf("stale create = %+v, err=%v creates=%d", view, err, ctrl.creates) |
| 377 | } |
| 378 | journal, loadErr := loadForkOperations(forkOperationsPath()) |
| 379 | if loadErr != nil || len(journal.Operations) != 0 { |
| 380 | t.Fatalf("stale create journal = %+v, err=%v", journal, loadErr) |
| 381 | } |
| 382 | } |
| 383 | |
| 384 | func TestForkOperationJournalSurvivesRestartAndAcknowledgement(t *testing.T) { |
| 385 | isolateDesktopUserDirs(t) |
| 386 | template := forkOperation{Surface: "remote", TabID: "tab", SourceHostID: "host", SourceSessionID: "source", |
| 387 | TurnID: "turn", BoundarySequence: 12} |
| 388 | first, err := (&App{}).beginForkOperation(template) |
| 389 | if err != nil { |
| 390 | t.Fatal(err) |
| 391 | } |
| 392 | if err := (&App{}).AcknowledgeForkOperation("tab", first.OperationID); err != nil { |
| 393 | t.Fatal(err) |
| 394 | } |
| 395 | second, err := (&App{}).beginForkOperation(template) |
| 396 | if err != nil || second.OperationID != first.OperationID || second.State != "pending" { |
| 397 | t.Fatalf("reloaded pending = %+v, err=%v; want %+v", second, err, first) |
| 398 | } |
| 399 | if err := (&App{}).completeForkOperation(first.OperationID, "child"); err != nil { |
| 400 | t.Fatal(err) |
| 401 | } |
| 402 | restartedTemplate := template |
| 403 | restartedTemplate.TabID = "tab-after-restart" |
| 404 | completed, err := (&App{}).beginForkOperation(restartedTemplate) |
| 405 | if err != nil || completed.OperationID != first.OperationID || completed.State != "completed" || completed.ChildSessionID != "child" { |
| 406 | t.Fatalf("reloaded completion = %+v, err=%v", completed, err) |
| 407 | } |
| 408 | if err := (&App{}).AcknowledgeForkOperation("tab-after-restart", first.OperationID); err != nil { |
| 409 | t.Fatal(err) |
| 410 | } |
| 411 | fresh, err := (&App{}).beginForkOperation(template) |
| 412 | if err != nil || fresh.OperationID == first.OperationID { |
| 413 | t.Fatalf("fresh operation after acknowledgement = %+v, err=%v", fresh, err) |
| 414 | } |
| 415 | } |
| 416 | |
| 417 | func TestCreateForkForTabReopensCompletedOperationAfterAttachFailure(t *testing.T) { |
| 418 | isolateDesktopUserDirs(t) |
| 419 | ctrl := &forkTargetsStubController{tabScopedActionController: newTabScopedActionController(), childID: "recovered-child"} |
| 420 | app := NewApp() |
| 421 | app.setTestCtrl(ctrl, "") |
| 422 | enableForkTargetPersistence(t, app, ctrl) |
| 423 | app.tabs["test"].Scope = "global" |
| 424 | app.tabs["test"].SessionID = "source-1" |
| 425 | anchor := ForkAnchorView{SourceHostID: "host-1", SourceSessionID: "source-1", TurnID: "turn", BoundarySequence: 9} |
| 426 | t.Cleanup(func() { forkTabBeforePublishHookForTest.Store(nil) }) |
| 427 | hook := func() { |
| 428 | app.mu.Lock() |
| 429 | app.tabs["test"] = &WorkspaceTab{ID: "test", Scope: "global", TopicTitle: "Source", |
| 430 | SessionID: "source-1", Ctrl: ctrl} |
| 431 | app.mu.Unlock() |
| 432 | forkTabBeforePublishHookForTest.Store(nil) |
| 433 | } |
| 434 | forkTabBeforePublishHookForTest.Store(&hook) |
| 435 | first, err := app.CreateForkForTab("test", anchor) |
| 436 | if err != nil || first.Opened || first.SessionID != "recovered-child" || ctrl.creates != 1 { |
| 437 | t.Fatalf("first attach = %+v err=%v creates=%d", first, err, ctrl.creates) |
| 438 | } |
| 439 | second, err := app.CreateForkForTab("test", anchor) |
| 440 | if err != nil || !second.Opened || second.SessionID != first.SessionID || second.OperationID != first.OperationID || ctrl.creates != 1 { |
| 441 | t.Fatalf("recovered attach = %+v err=%v creates=%d; first=%+v", second, err, ctrl.creates, first) |
| 442 | } |
| 443 | } |
| 444 |