| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "net/http" |
| 8 | "net/http/httptest" |
| 9 | "os" |
| 10 | "path/filepath" |
| 11 | "strings" |
| 12 | "testing" |
| 13 | |
| 14 | "reasonix/internal/config" |
| 15 | "reasonix/internal/control" |
| 16 | ) |
| 17 | |
| 18 | func readPersistedTabsFile(t *testing.T) desktopTabsFile { |
| 19 | t.Helper() |
| 20 | data, err := os.ReadFile(filepath.Join(config.ReasonixHomeDir(), tabsFileName)) |
| 21 | if err != nil { |
| 22 | t.Fatalf("read tabs file: %v", err) |
| 23 | } |
| 24 | var f desktopTabsFile |
| 25 | if err := json.Unmarshal(data, &f); err != nil { |
| 26 | t.Fatalf("parse tabs file: %v", err) |
| 27 | } |
| 28 | return f |
| 29 | } |
| 30 | |
| 31 | func seedLocalTab(a *App, id string) { |
| 32 | a.mu.Lock() |
| 33 | if a.tabs == nil { |
| 34 | a.tabs = map[string]*WorkspaceTab{} |
| 35 | } |
| 36 | a.tabs[id] = &WorkspaceTab{ID: id, Scope: "global"} |
| 37 | a.tabOrder = append(a.tabOrder, id) |
| 38 | a.mu.Unlock() |
| 39 | } |
| 40 | |
| 41 | func TestRemoveRemoteHostReplacesSoleRemoteSurfaceWithLocalBlank(t *testing.T) { |
| 42 | seedBridgeTestHost(t, "box") |
| 43 | a := NewApp() |
| 44 | a.remoteRuntime = &fakeRemoteKernel{} |
| 45 | t.Cleanup(func() { a.shutdown(context.Background()) }) |
| 46 | a.remoteTabMu.Lock() |
| 47 | a.remoteTabs = map[string]*remoteTab{ |
| 48 | "remote-only": { |
| 49 | id: "remote-only", ref: RemoteTabRef{HostID: "box", Workspace: "~/app"}, |
| 50 | state: "disconnected", session: remoteTabSessionState{newSession: true}, |
| 51 | }, |
| 52 | } |
| 53 | a.remoteTabLayout.order = []string{"remote-only"} |
| 54 | a.remoteTabLayout.stripOrder = []string{"remote-only"} |
| 55 | a.remoteTabLayout.activeID = "remote-only" |
| 56 | a.remoteTabMu.Unlock() |
| 57 | |
| 58 | if err := a.RemoveRemoteHost("box"); err != nil { |
| 59 | t.Fatal(err) |
| 60 | } |
| 61 | tabs := a.ListTabs() |
| 62 | if len(tabs) != 1 || tabs[0].Remote != nil || !tabs[0].Active { |
| 63 | t.Fatalf("tabs after deleting sole remote host = %+v, want one active local blank", tabs) |
| 64 | } |
| 65 | a.remoteTabMu.Lock() |
| 66 | remoteCount := len(a.remoteTabs) |
| 67 | a.remoteTabMu.Unlock() |
| 68 | if remoteCount != 0 { |
| 69 | t.Fatalf("deleted host retained %d remote tabs", remoteCount) |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | // TestRemoteTabOpenPersistRoundTrip: an open remote tab lands in |
| 74 | // desktop-tabs.json; closing removes it again. |
| 75 | func TestRemoteTabOpenPersistRoundTrip(t *testing.T) { |
| 76 | fs := newFakeServe(t, "s3cret", []serveSessionEntry{{Name: "s1", Path: "/remote/sessions/s1.jsonl", Title: "Prior chat", Current: true}}) |
| 77 | kernel := &fakeRemoteKernel{ |
| 78 | statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}}, |
| 79 | ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL}, |
| 80 | ensureToken: "s3cret", |
| 81 | } |
| 82 | seedBridgeTestHost(t, "box") |
| 83 | a := &App{remoteRuntime: kernel} |
| 84 | cleanupRemoteTabPumps(t, a) |
| 85 | |
| 86 | meta, err := a.OpenRemoteProjectTab("box", "~/app", RemoteTabOpenOptions{SessionName: "s1", SessionPath: "/remote/sessions/s1.jsonl", SessionTitle: "Prior chat"}) |
| 87 | if err != nil { |
| 88 | t.Fatal(err) |
| 89 | } |
| 90 | waitForTabState(t, a, meta.ID, "ready") |
| 91 | |
| 92 | f := readPersistedTabsFile(t) |
| 93 | if len(f.RemoteTabs) != 1 || len(f.RemoteTabOrder) != 1 { |
| 94 | t.Fatalf("persisted remote section = %+v / %v, want one entry", f.RemoteTabs, f.RemoteTabOrder) |
| 95 | } |
| 96 | entry := f.RemoteTabs[0] |
| 97 | if entry.ID != meta.ID || entry.HostID != "box" || entry.Workspace != "~/app" { |
| 98 | t.Fatalf("persisted entry = %+v, want id/host/workspace for %s", entry, meta.ID) |
| 99 | } |
| 100 | if entry.SessionName != "s1" || entry.SessionPath != "/remote/sessions/s1.jsonl" { |
| 101 | t.Fatalf("persisted session = %q at %q, want s1", entry.SessionName, entry.SessionPath) |
| 102 | } |
| 103 | if f.RemoteTabOrder[0] != entry.ID { |
| 104 | t.Fatalf("persisted remote order = %v, want the entry id first", f.RemoteTabOrder) |
| 105 | } |
| 106 | if f.ActiveTab != meta.ID { |
| 107 | t.Fatalf("persisted active tab = %q, want the active remote id", f.ActiveTab) |
| 108 | } |
| 109 | |
| 110 | // The one-surface policy refuses a direct close of the sole visible |
| 111 | // surface, so the persisted entry survives it. A surface only leaves |
| 112 | // through host removal, which TestRemoveRemoteHost… covers. |
| 113 | if err := a.CloseRemoteTab(meta.ID); err == nil || !strings.Contains(err.Error(), "cannot close the last tab") { |
| 114 | t.Fatalf("closing the sole visible surface = %v, want the last-surface refusal", err) |
| 115 | } |
| 116 | if f = readPersistedTabsFile(t); len(f.RemoteTabs) != 1 { |
| 117 | t.Fatalf("a refused close must keep the persisted entry: %+v", f.RemoteTabs) |
| 118 | } |
| 119 | } |
| 120 | |
| 121 | // TestRemoteTabRestoreBuildsDisconnectedShells: restore rebuilds shells |
| 122 | // without connecting anything; invalid and local-colliding ids are skipped. |
| 123 | // Restored shells stay in the strip but must NOT become the startup active |
| 124 | // surface — first open would otherwise land on the disconnected placeholder. |
| 125 | func TestRemoteTabRestoreBuildsDisconnectedShells(t *testing.T) { |
| 126 | seedBridgeTestHost(t, "box") |
| 127 | a := &App{} |
| 128 | seedLocalTab(a, "local-1") |
| 129 | f := desktopTabsFile{ |
| 130 | RemoteTabs: []desktopRemoteTabEntry{ |
| 131 | {ID: "r-1", HostID: "box", Workspace: "~/app", TopicTitle: "Fix bug", SessionName: "s1", SessionPath: "/remote/sessions/s1.jsonl"}, |
| 132 | {ID: "r-2", HostID: "box", Workspace: "~/web", SessionPath: "/remote/sessions/blank.jsonl", SessionReset: true}, |
| 133 | {ID: "r-3", HostID: "box", Workspace: "~/canonical", TopicTitle: "canonical-session-id", SessionName: "canonical-session-id", SessionID: "canonical-session-id"}, |
| 134 | {ID: "", HostID: "box", Workspace: "~/skip"}, |
| 135 | {ID: "local-1", HostID: "box", Workspace: "~/dup"}, |
| 136 | }, |
| 137 | RemoteTabOrder: []string{"r-2", "r-1"}, |
| 138 | ActiveTab: "r-1", |
| 139 | } |
| 140 | a.restoreRemoteTabShells(f) |
| 141 | |
| 142 | a.remoteTabMu.Lock() |
| 143 | defer a.remoteTabMu.Unlock() |
| 144 | if len(a.remoteTabs) != 3 || a.remoteTabs["r-1"] == nil || a.remoteTabs["r-2"] == nil || a.remoteTabs["r-3"] == nil { |
| 145 | t.Fatalf("restored shells = %+v", a.remoteTabs) |
| 146 | } |
| 147 | for id, tab := range a.remoteTabs { |
| 148 | if tab.state != "disconnected" { |
| 149 | t.Fatalf("shell %s state = %q, want disconnected", id, tab.state) |
| 150 | } |
| 151 | if tab.client != nil || tab.cancel != nil { |
| 152 | t.Fatalf("shell %s connected during restore", id) |
| 153 | } |
| 154 | } |
| 155 | if got := a.remoteTabs["r-1"].topicTitle; got != "Fix bug" { |
| 156 | t.Fatalf("restored title = %q, want the persisted one", got) |
| 157 | } |
| 158 | if got := a.remoteTabs["r-3"].topicTitle; got != remoteWorkspaceName("~/canonical") { |
| 159 | t.Fatalf("canonical identity leaked into restored title = %q, want workspace fallback", got) |
| 160 | } |
| 161 | if tab := a.remoteTabs["r-1"]; tab.session.newSession || tab.session.name != "s1" || tab.session.path != "/remote/sessions/s1.jsonl" { |
| 162 | t.Fatalf("restored session identity = %+v", tab.session) |
| 163 | } |
| 164 | if tab := a.remoteTabs["r-2"]; !tab.session.newSession || !tab.session.reset || tab.session.path != "/remote/sessions/blank.jsonl" { |
| 165 | t.Fatalf("restored blank session identity = %+v", tab.session) |
| 166 | } |
| 167 | if got := strings.Join(a.remoteTabLayout.order, ","); got != "r-2,r-1,r-3" { |
| 168 | t.Fatalf("restored remote order = %q, want r-2,r-1,r-3", got) |
| 169 | } |
| 170 | if a.remoteTabLayout.activeID != "" { |
| 171 | t.Fatalf("remoteActiveTabID = %q, want local startup surface", a.remoteTabLayout.activeID) |
| 172 | } |
| 173 | } |
| 174 | |
| 175 | func TestRemoteTabBlankSessionPersistsResetState(t *testing.T) { |
| 176 | tab := &remoteTab{ |
| 177 | id: "blank", ref: RemoteTabRef{HostID: "box", Workspace: "~/app"}, |
| 178 | session: remoteTabSessionState{newSession: true, path: "/remote/sessions/blank.jsonl", reset: true}, |
| 179 | routing: remoteTabSessionRouting{currentPath: "/remote/sessions/blank.jsonl", running: map[string]bool{}}, |
| 180 | } |
| 181 | a := &App{remoteTabs: map[string]*remoteTab{tab.id: tab}, remoteTabLayout: remoteTabLayoutState{order: []string{tab.id}}} |
| 182 | entries, _, _, _ := a.remoteTabsFileEntries(nil) |
| 183 | if len(entries) != 1 || !entries[0].SessionReset || entries[0].SessionPath != tab.session.path { |
| 184 | t.Fatalf("persisted blank entry = %+v", entries) |
| 185 | } |
| 186 | } |
| 187 | |
| 188 | // TestActivateDisconnectedShellReconnects resumes the persisted session in |
| 189 | // the existing shell instead of replacing it with a blank conversation. |
| 190 | func TestActivateDisconnectedShellReconnects(t *testing.T) { |
| 191 | fs := newFakeServe(t, "s3cret", []serveSessionEntry{{Name: "s1", Path: "/remote/sessions/s1.jsonl"}}) |
| 192 | kernel := &fakeRemoteKernel{ |
| 193 | statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}}, |
| 194 | ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL}, |
| 195 | ensureToken: "s3cret", |
| 196 | } |
| 197 | seedBridgeTestHost(t, "box") |
| 198 | a := &App{remoteRuntime: kernel} |
| 199 | cleanupRemoteTabPumps(t, a) |
| 200 | a.remoteTabMu.Lock() |
| 201 | a.remoteTabs = map[string]*remoteTab{ |
| 202 | "shell-1": {id: "shell-1", ref: RemoteTabRef{HostID: "box", Workspace: "~/app"}, state: "disconnected", session: remoteTabSessionState{name: "s1", path: "/remote/sessions/s1.jsonl"}, hostLabel: "box", topicTitle: "Prior chat"}, |
| 203 | } |
| 204 | a.remoteTabLayout.order = []string{"shell-1"} |
| 205 | a.remoteTabMu.Unlock() |
| 206 | |
| 207 | if err := a.SetActiveTab("shell-1"); err != nil { |
| 208 | t.Fatal(err) |
| 209 | } |
| 210 | waitForTabState(t, a, "shell-1", "ready") |
| 211 | newCalled, resumePath, _ := fs.snapshot() |
| 212 | if newCalled != 0 || resumePath != "/remote/sessions/s1.jsonl" { |
| 213 | t.Fatalf("revive called new=%d resume=%q, want persisted s1", newCalled, resumePath) |
| 214 | } |
| 215 | a.remoteTabMu.Lock() |
| 216 | active := a.remoteTabLayout.activeID |
| 217 | a.remoteTabMu.Unlock() |
| 218 | if active != "shell-1" { |
| 219 | t.Fatalf("remoteActiveTabID = %q, want shell-1", active) |
| 220 | } |
| 221 | } |
| 222 | |
| 223 | func TestSetActiveRemoteTabPersistsAndUnknownKeepsSelection(t *testing.T) { |
| 224 | home := t.TempDir() |
| 225 | t.Setenv("REASONIX_HOME", home) |
| 226 | t.Setenv("HOME", home) |
| 227 | a := &App{} |
| 228 | seedLocalTab(a, "local-1") |
| 229 | a.mu.Lock() |
| 230 | a.activeTabID = "local-1" |
| 231 | a.mu.Unlock() |
| 232 | a.remoteTabMu.Lock() |
| 233 | a.remoteTabs = map[string]*remoteTab{ |
| 234 | "remote-1": {id: "remote-1", ref: RemoteTabRef{HostID: "box", Workspace: "~/app"}, state: "ready"}, |
| 235 | } |
| 236 | a.remoteTabLayout.order = []string{"remote-1"} |
| 237 | a.remoteTabMu.Unlock() |
| 238 | |
| 239 | if err := a.SetActiveTab("remote-1"); err != nil { |
| 240 | t.Fatal(err) |
| 241 | } |
| 242 | if got := readPersistedTabsFile(t).ActiveTab; got != "remote-1" { |
| 243 | t.Fatalf("persisted active tab = %q, want remote-1", got) |
| 244 | } |
| 245 | if err := a.SetActiveTab("missing"); err == nil { |
| 246 | t.Fatal("unknown tab activation succeeded") |
| 247 | } |
| 248 | a.remoteTabMu.Lock() |
| 249 | active := a.remoteTabLayout.activeID |
| 250 | a.remoteTabMu.Unlock() |
| 251 | if active != "remote-1" { |
| 252 | t.Fatalf("unknown activation cleared remote selection: %q", active) |
| 253 | } |
| 254 | } |
| 255 | |
| 256 | func TestSetActiveRemoteTabBlocksWhenCurrentSessionCannotPersist(t *testing.T) { |
| 257 | path := filepath.Join(t.TempDir(), "blocked.jsonl") |
| 258 | if err := os.Mkdir(path, 0o755); err != nil { |
| 259 | t.Fatalf("mkdir blocked path: %v", err) |
| 260 | } |
| 261 | a, _ := appWithTab(t, path) |
| 262 | a.remoteTabMu.Lock() |
| 263 | a.remoteTabs = map[string]*remoteTab{ |
| 264 | "remote-1": {id: "remote-1", ref: RemoteTabRef{HostID: "box", Workspace: "~/app"}, state: "ready"}, |
| 265 | } |
| 266 | a.remoteTabLayout.order = []string{"remote-1"} |
| 267 | a.remoteTabMu.Unlock() |
| 268 | |
| 269 | err := a.SetActiveTab("remote-1") |
| 270 | if err == nil || !strings.Contains(err.Error(), "save current session before switching tabs") { |
| 271 | t.Fatalf("SetActiveTab(remote) error = %v, want persistence failure", err) |
| 272 | } |
| 273 | a.remoteTabMu.Lock() |
| 274 | active := a.remoteTabLayout.activeID |
| 275 | a.remoteTabMu.Unlock() |
| 276 | if active != "" { |
| 277 | t.Fatalf("remote active tab = %q, want local selection preserved", active) |
| 278 | } |
| 279 | } |
| 280 | |
| 281 | func TestSetActiveLocalTabKeepsRemoteSelectionWhenSessionCannotPersist(t *testing.T) { |
| 282 | path := filepath.Join(t.TempDir(), "blocked.jsonl") |
| 283 | if err := os.Mkdir(path, 0o755); err != nil { |
| 284 | t.Fatalf("mkdir blocked path: %v", err) |
| 285 | } |
| 286 | a, _ := appWithTab(t, path) |
| 287 | seedLocalTab(a, "target") |
| 288 | a.remoteTabMu.Lock() |
| 289 | a.remoteTabs = map[string]*remoteTab{ |
| 290 | "remote-1": {id: "remote-1", ref: RemoteTabRef{HostID: "box", Workspace: "~/app"}, state: "ready"}, |
| 291 | } |
| 292 | a.remoteTabLayout.activeID = "remote-1" |
| 293 | a.remoteTabLayout.order = []string{"remote-1"} |
| 294 | a.remoteTabMu.Unlock() |
| 295 | |
| 296 | err := a.SetActiveTab("target") |
| 297 | if err == nil || !strings.Contains(err.Error(), "save current session before switching tabs") { |
| 298 | t.Fatalf("SetActiveTab(local) error = %v, want persistence failure", err) |
| 299 | } |
| 300 | a.remoteTabMu.Lock() |
| 301 | active := a.remoteTabLayout.activeID |
| 302 | a.remoteTabMu.Unlock() |
| 303 | if active != "remote-1" { |
| 304 | t.Fatalf("remote active tab = %q, want original remote selection", active) |
| 305 | } |
| 306 | if a.activeTabID != "test_tab" { |
| 307 | t.Fatalf("local active tab = %q, want original local tab", a.activeTabID) |
| 308 | } |
| 309 | } |
| 310 | |
| 311 | func TestOpenRemoteProjectTabBlocksBeforeMutationWhenLocalSessionCannotPersist(t *testing.T) { |
| 312 | home := t.TempDir() |
| 313 | t.Setenv("REASONIX_HOME", home) |
| 314 | t.Setenv("HOME", home) |
| 315 | seedBridgeTestHost(t, "box") |
| 316 | path := filepath.Join(t.TempDir(), "blocked.jsonl") |
| 317 | if err := os.Mkdir(path, 0o755); err != nil { |
| 318 | t.Fatalf("mkdir blocked path: %v", err) |
| 319 | } |
| 320 | a, _ := appWithTab(t, path) |
| 321 | |
| 322 | _, err := a.OpenRemoteProjectTab("box", "~/app", RemoteTabOpenOptions{NewSession: true}) |
| 323 | if err == nil || !strings.Contains(err.Error(), "save current session before switching tabs") { |
| 324 | t.Fatalf("OpenRemoteProjectTab error = %v, want persistence failure", err) |
| 325 | } |
| 326 | a.remoteTabMu.Lock() |
| 327 | remoteCount := len(a.remoteTabs) |
| 328 | a.remoteTabMu.Unlock() |
| 329 | if remoteCount != 0 { |
| 330 | t.Fatalf("remote tab count = %d, want no mutation after failed save", remoteCount) |
| 331 | } |
| 332 | } |
| 333 | |
| 334 | // TestOpenRemoteProjectTabRevivesShell: the tree-group path (ensure-open) |
| 335 | // reconnects a disconnected shell in place instead of only activating it. |
| 336 | func TestOpenRemoteProjectTabRevivesShell(t *testing.T) { |
| 337 | fs := newFakeServe(t, "s3cret", nil) |
| 338 | kernel := &fakeRemoteKernel{ |
| 339 | statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}}, |
| 340 | ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL}, |
| 341 | ensureToken: "s3cret", |
| 342 | } |
| 343 | seedBridgeTestHost(t, "box") |
| 344 | a := &App{remoteRuntime: kernel} |
| 345 | cleanupRemoteTabPumps(t, a) |
| 346 | a.remoteTabMu.Lock() |
| 347 | a.remoteTabs = map[string]*remoteTab{ |
| 348 | "shell-1": {id: "shell-1", ref: RemoteTabRef{HostID: "box", Workspace: "~/app"}, state: "disconnected", session: remoteTabSessionState{newSession: true}, hostLabel: "box", topicTitle: "app"}, |
| 349 | } |
| 350 | a.remoteTabLayout.order = []string{"shell-1"} |
| 351 | a.remoteTabMu.Unlock() |
| 352 | |
| 353 | meta, err := a.OpenRemoteProjectTab("box", "~/app", RemoteTabOpenOptions{NewSession: true}) |
| 354 | if err != nil { |
| 355 | t.Fatal(err) |
| 356 | } |
| 357 | if meta.ID != "shell-1" { |
| 358 | t.Fatalf("revived tab id = %q, want the shell id shell-1", meta.ID) |
| 359 | } |
| 360 | waitForTabState(t, a, "shell-1", "ready") |
| 361 | } |
| 362 | |
| 363 | func TestOpenRemoteProjectTabAppliesSingleSurfacePolicy(t *testing.T) { |
| 364 | fs := newFakeServe(t, "s3cret", nil) |
| 365 | kernel := &fakeRemoteKernel{statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}, {HostID: "box-two", State: "connected"}}, ensureView: RemoteServerView{State: "ready", LocalURL: fs.server.URL}, ensureToken: "s3cret"} |
| 366 | seedBridgeTestHost(t, "box") |
| 367 | if err := editUserConfig(func(c *config.Config) error { |
| 368 | if err := c.SetDesktopLayoutStyle("workbench"); err != nil { |
| 369 | return err |
| 370 | } |
| 371 | return c.UpsertRemoteHost(config.RemoteHostEntry{Name: "box-two", Host: "127.0.0.1", Port: 22, User: "dev"}) |
| 372 | }); err != nil { |
| 373 | t.Fatal(err) |
| 374 | } |
| 375 | a := &App{remoteRuntime: kernel} |
| 376 | cleanupRemoteTabPumps(t, a) |
| 377 | seedLocalTab(a, "local") |
| 378 | a.mu.Lock() |
| 379 | a.activeTabID = "local" |
| 380 | a.mu.Unlock() |
| 381 | |
| 382 | first, err := a.OpenRemoteProjectTab("box", "~/app", RemoteTabOpenOptions{NewSession: true}) |
| 383 | if err != nil { |
| 384 | t.Fatal(err) |
| 385 | } |
| 386 | waitForTabState(t, a, first.ID, "ready") |
| 387 | second, err := a.OpenRemoteProjectTab("box-two", "~/other", RemoteTabOpenOptions{NewSession: true}) |
| 388 | if err != nil { |
| 389 | t.Fatal(err) |
| 390 | } |
| 391 | waitForTabState(t, a, second.ID, "ready") |
| 392 | |
| 393 | tabs := a.ListTabs() |
| 394 | if len(tabs) != 1 || tabs[0].ID != second.ID || !tabs[0].Active { |
| 395 | t.Fatalf("single-surface tabs = %+v, want only active remote %q", tabs, second.ID) |
| 396 | } |
| 397 | a.mu.RLock() |
| 398 | localCount := len(a.tabs) |
| 399 | a.mu.RUnlock() |
| 400 | a.remoteTabMu.Lock() |
| 401 | remoteCount := len(a.remoteTabs) |
| 402 | a.remoteTabMu.Unlock() |
| 403 | if localCount != 0 || remoteCount != 1 { |
| 404 | t.Fatalf("single-surface registry counts local=%d remote=%d", localCount, remoteCount) |
| 405 | } |
| 406 | if err := a.CloseRemoteTab(second.ID); err == nil || !strings.Contains(err.Error(), "cannot close the last tab") { |
| 407 | t.Fatalf("close final remote surface error = %v", err) |
| 408 | } |
| 409 | a.remoteTabMu.Lock() |
| 410 | _, stillVisible := a.remoteTabs[second.ID] |
| 411 | a.remoteTabMu.Unlock() |
| 412 | if !stillVisible { |
| 413 | t.Fatal("failed final-surface close removed the remote tab") |
| 414 | } |
| 415 | } |
| 416 | |
| 417 | // TestReorderTabsMixedPersistsBothOrders: the full strip order partitions into |
| 418 | // local and remote orders; both persist; unknown remote ids reject the whole |
| 419 | // reorder without mutating either side. |
| 420 | func TestReorderTabsMixedPersistsBothOrders(t *testing.T) { |
| 421 | seedBridgeTestHost(t, "box") |
| 422 | a := &App{} |
| 423 | seedLocalTab(a, "l1") |
| 424 | seedLocalTab(a, "l2") |
| 425 | a.remoteTabMu.Lock() |
| 426 | a.remoteTabs = map[string]*remoteTab{"r1": {id: "r1", ref: RemoteTabRef{HostID: "box", Workspace: "~/app"}}} |
| 427 | a.remoteTabLayout.order = []string{"r1"} |
| 428 | a.remoteTabMu.Unlock() |
| 429 | |
| 430 | if err := a.ReorderTabs([]string{"r1", "l2", "l1"}); err != nil { |
| 431 | t.Fatal(err) |
| 432 | } |
| 433 | a.mu.RLock() |
| 434 | order := append([]string(nil), a.tabOrder...) |
| 435 | a.mu.RUnlock() |
| 436 | if len(order) != 2 || order[0] != "l2" || order[1] != "l1" { |
| 437 | t.Fatalf("local order = %v, want [l2 l1]", order) |
| 438 | } |
| 439 | f := readPersistedTabsFile(t) |
| 440 | if len(f.RemoteTabOrder) != 1 || f.RemoteTabOrder[0] != "r1" { |
| 441 | t.Fatalf("persisted remote order = %v, want [r1]", f.RemoteTabOrder) |
| 442 | } |
| 443 | if got := strings.Join(f.TabOrder, ","); got != "r1,l2,l1" { |
| 444 | t.Fatalf("persisted mixed strip order = %q, want r1,l2,l1", got) |
| 445 | } |
| 446 | |
| 447 | if err := a.ReorderTabs([]string{"l1", "l2", "ghost"}); err == nil { |
| 448 | t.Fatal("reorder accepted an unknown remote id") |
| 449 | } |
| 450 | a.mu.RLock() |
| 451 | order = append([]string(nil), a.tabOrder...) |
| 452 | a.mu.RUnlock() |
| 453 | if len(order) != 2 || order[0] != "l2" || order[1] != "l1" { |
| 454 | t.Fatalf("local order after rejected reorder = %v, want unchanged [l2 l1]", order) |
| 455 | } |
| 456 | } |
| 457 | |
| 458 | func TestReconcileTabStripOrderPreservesMixedOrderAndRepairsMembership(t *testing.T) { |
| 459 | got := reconcileTabStripOrder( |
| 460 | []string{"remote-1", "gone", "local-2", "remote-1"}, |
| 461 | []string{"local-1", "local-2"}, |
| 462 | []string{"remote-1", "remote-2"}, |
| 463 | ) |
| 464 | if joined := strings.Join(got, ","); joined != "remote-1,local-2,local-1,remote-2" { |
| 465 | t.Fatalf("reconciled strip order = %q", joined) |
| 466 | } |
| 467 | } |
| 468 | |
| 469 | func TestRemoteTabMetasMarksOnlySelectedTabActive(t *testing.T) { |
| 470 | a := &App{ |
| 471 | remoteTabs: map[string]*remoteTab{ |
| 472 | "remote-1": { |
| 473 | id: "remote-1", ref: RemoteTabRef{HostID: "box", Workspace: "~/one"}, |
| 474 | runtime: remoteTabRuntimeState{ |
| 475 | running: true, turnStartedAt: 123, pendingPrompt: true, |
| 476 | backgroundJobs: 2, cancelRequested: true, cancellable: true, |
| 477 | }, |
| 478 | }, |
| 479 | "remote-2": {id: "remote-2", ref: RemoteTabRef{HostID: "box", Workspace: "~/two"}}, |
| 480 | }, |
| 481 | remoteTabLayout: remoteTabLayoutState{ |
| 482 | order: []string{"remote-1", "remote-2"}, |
| 483 | activeID: "remote-2", |
| 484 | stripOrder: []string{"remote-1", "local-1", "remote-2"}, |
| 485 | }, |
| 486 | } |
| 487 | metas, active, order := a.remoteTabMetas([]string{"local-1"}) |
| 488 | if active != "remote-2" || strings.Join(order, ",") != "remote-1,local-1,remote-2" { |
| 489 | t.Fatalf("active/order = %q / %v", active, order) |
| 490 | } |
| 491 | for _, meta := range metas { |
| 492 | if meta.Active != (meta.ID == "remote-2") { |
| 493 | t.Fatalf("meta %s active = %v", meta.ID, meta.Active) |
| 494 | } |
| 495 | if meta.ID == "remote-1" && (!meta.Running || meta.TurnStartedAt != 123 || !meta.PendingPrompt || meta.BackgroundJobs != 2 || !meta.CancelRequested || !meta.Cancellable) { |
| 496 | t.Fatalf("inactive remote runtime meta = %+v", meta) |
| 497 | } |
| 498 | } |
| 499 | } |
| 500 | |
| 501 | func TestCloseFinalLocalTabAllowsRemainingRemoteSurface(t *testing.T) { |
| 502 | a := &App{} |
| 503 | seedLocalTab(a, "local") |
| 504 | a.remoteTabs = map[string]*remoteTab{ |
| 505 | "remote": {id: "remote", ref: RemoteTabRef{HostID: "box", Workspace: "~/app"}, state: "disconnected"}, |
| 506 | } |
| 507 | a.remoteTabLayout.order = []string{"remote"} |
| 508 | a.remoteTabLayout.stripOrder = []string{"local", "remote"} |
| 509 | a.remoteTabLayout.activeID = "remote" |
| 510 | if err := a.CloseTab("local"); err != nil { |
| 511 | t.Fatalf("CloseTab(local) with remote survivor: %v", err) |
| 512 | } |
| 513 | a.mu.RLock() |
| 514 | localCount := len(a.tabs) |
| 515 | a.mu.RUnlock() |
| 516 | if localCount != 0 { |
| 517 | t.Fatalf("local tab count = %d, want 0", localCount) |
| 518 | } |
| 519 | if tabs := a.ListTabs(); len(tabs) != 1 || tabs[0].ID != "remote" || !tabs[0].Active { |
| 520 | t.Fatalf("remaining surfaces = %+v, want active remote", tabs) |
| 521 | } |
| 522 | } |
| 523 | |
| 524 | func TestRemoteStatusRefreshPublishesInactiveRuntimeMeta(t *testing.T) { |
| 525 | client := &http.Client{} |
| 526 | log := &eventLog{} |
| 527 | a := &App{ |
| 528 | remoteEventHook: log.add, |
| 529 | remoteTabs: map[string]*remoteTab{ |
| 530 | "remote-1": {id: "remote-1", ref: RemoteTabRef{HostID: "box", Workspace: "~/one"}, client: client, gen: 4}, |
| 531 | "remote-2": {id: "remote-2", ref: RemoteTabRef{HostID: "box", Workspace: "~/two"}}, |
| 532 | }, |
| 533 | remoteTabLayout: remoteTabLayoutState{activeID: "remote-2"}, |
| 534 | } |
| 535 | statusSeq := a.reserveRemoteTabStatusSequence("remote-1", client, 4) |
| 536 | a.recordRemoteTabSessionStatus("remote-1", client, 4, statusSeq, json.RawMessage(`{"running":true,"pendingPrompt":true,"backgroundJobs":3,"cancelRequested":true,"cancellable":true}`)) |
| 537 | metas, _, _ := a.remoteTabMetas(nil) |
| 538 | var got TabMeta |
| 539 | for _, meta := range metas { |
| 540 | if meta.ID == "remote-1" { |
| 541 | got = meta |
| 542 | } |
| 543 | } |
| 544 | if got.Active || !got.Running || !got.PendingPrompt || got.BackgroundJobs != 3 || !got.CancelRequested || !got.Cancellable || got.TurnStartedAt <= 0 { |
| 545 | t.Fatalf("inactive status projection = %+v", got) |
| 546 | } |
| 547 | if log.count("remote-tab:updated ") != 1 { |
| 548 | t.Fatalf("runtime status update events = %v", log.recorded()) |
| 549 | } |
| 550 | } |
| 551 | |
| 552 | func TestRemoteStatusRefreshRejectsOutOfOrderSnapshot(t *testing.T) { |
| 553 | client := &http.Client{} |
| 554 | a := &App{remoteTabs: map[string]*remoteTab{ |
| 555 | "remote-1": {id: "remote-1", client: client, gen: 4}, |
| 556 | }} |
| 557 | older := a.reserveRemoteTabStatusSequence("remote-1", client, 4) |
| 558 | newer := a.reserveRemoteTabStatusSequence("remote-1", client, 4) |
| 559 | a.recordRemoteTabSessionStatus("remote-1", client, 4, newer, json.RawMessage(`{"running":false,"pendingPrompt":false}`)) |
| 560 | a.recordRemoteTabSessionStatus("remote-1", client, 4, older, json.RawMessage(`{"running":true,"pendingPrompt":true}`)) |
| 561 | a.remoteTabMu.Lock() |
| 562 | runtime := a.remoteTabs["remote-1"].runtime |
| 563 | a.remoteTabMu.Unlock() |
| 564 | if runtime.running || runtime.pendingPrompt { |
| 565 | t.Fatalf("older status overwrote newer settled state: %+v", runtime) |
| 566 | } |
| 567 | } |
| 568 | |
| 569 | // TestRemoteTabStatusSupersededRaceReturnsSentinel: when an SSE-derived frame |
| 570 | // advances the tab revision while a /status poll is in flight, RemoteTabStatus |
| 571 | // must fail with the superseded sentinel — a benign stale snapshot, distinct |
| 572 | // from transport failures — instead of an opaque error that surfaces as a |
| 573 | // crash report. |
| 574 | func TestRemoteTabStatusSupersededRaceReturnsSentinel(t *testing.T) { |
| 575 | client := &http.Client{} |
| 576 | a := &App{remoteTabs: map[string]*remoteTab{ |
| 577 | "remote-1": {id: "remote-1", client: client, gen: 4, state: "ready"}, |
| 578 | }} |
| 579 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { |
| 580 | // The serve streams a turn_started between reservation and recording: |
| 581 | // the frame handler advances the revision mid-poll. |
| 582 | a.reserveRemoteTabStatusSequence("remote-1", client, 4) |
| 583 | w.Header().Set("Content-Type", "application/json") |
| 584 | _, _ = w.Write([]byte(`{"running":false,"pendingPrompt":false}`)) |
| 585 | })) |
| 586 | t.Cleanup(server.Close) |
| 587 | a.remoteTabMu.Lock() |
| 588 | a.remoteTabs["remote-1"].base = server.URL |
| 589 | a.remoteTabMu.Unlock() |
| 590 | |
| 591 | _, err := a.RemoteTabStatus("remote-1") |
| 592 | if err == nil { |
| 593 | t.Fatal("superseded status poll returned nil error") |
| 594 | } |
| 595 | if !errors.Is(err, errRemoteTabStatusSuperseded) { |
| 596 | t.Fatalf("superseded race error = %v, want errRemoteTabStatusSuperseded", err) |
| 597 | } |
| 598 | if want := `remote tab "remote-1" status was superseded by newer runtime state`; err.Error() != want { |
| 599 | t.Fatalf("superseded race message = %q, want %q", err.Error(), want) |
| 600 | } |
| 601 | } |
| 602 | |
| 603 | func TestRemoteStatusRefreshRejectsSnapshotOlderThanTurnDone(t *testing.T) { |
| 604 | client := &http.Client{} |
| 605 | a := &App{remoteTabs: map[string]*remoteTab{ |
| 606 | "remote-1": {id: "remote-1", client: client, gen: 4, runtime: remoteTabRuntimeState{running: true}}, |
| 607 | }} |
| 608 | stale := a.reserveRemoteTabStatusSequence("remote-1", client, 4) |
| 609 | a.completeRemoteTabTurn("remote-1", 4) |
| 610 | a.recordRemoteTabSessionStatus("remote-1", client, 4, stale, json.RawMessage(`{"running":true,"pendingPrompt":true}`)) |
| 611 | a.remoteTabMu.Lock() |
| 612 | runtime := a.remoteTabs["remote-1"].runtime |
| 613 | a.remoteTabMu.Unlock() |
| 614 | if runtime.running || runtime.pendingPrompt { |
| 615 | t.Fatalf("pre-turn_done status revived settled runtime: %+v", runtime) |
| 616 | } |
| 617 | } |
| 618 | |
| 619 | // TestSingleSurfaceTabsFileCollapsesRemote: workbench/creation layouts keep |
| 620 | // exactly one surface across local and remote tabs, preferring the active one. |
| 621 | // One local entry survives even when the remote surface is active: local |
| 622 | // commands need a workspace tab to target, and the kept entry restores without |
| 623 | // a runtime so it adds no hidden startup work. |
| 624 | func TestSingleSurfaceTabsFileCollapsesRemote(t *testing.T) { |
| 625 | f := desktopTabsFile{ |
| 626 | Tabs: []desktopTabEntry{{ID: "l1"}, {ID: "l2"}}, |
| 627 | RemoteTabs: []desktopRemoteTabEntry{{ID: "r1", HostID: "h", Workspace: "~/a"}, {ID: "r2", HostID: "h", Workspace: "~/b"}}, |
| 628 | ActiveTab: "r1", |
| 629 | } |
| 630 | out := singleSurfaceTabsFile(f) |
| 631 | if len(out.RemoteTabs) != 1 || out.RemoteTabs[0].ID != "r1" || out.ActiveTab != "r1" || !remoteSurfaceIsActiveTab(out) { |
| 632 | t.Fatalf("single-surface collapse = %+v", out) |
| 633 | } |
| 634 | if len(out.Tabs) != 1 || out.Tabs[0].ID != "l1" { |
| 635 | t.Fatalf("collapsed local entries = %+v, want the dormant l1", out.Tabs) |
| 636 | } |
| 637 | } |
| 638 | |
| 639 | func TestSingleSurfaceTabsFilePrefersActiveLocalOverRemote(t *testing.T) { |
| 640 | f := desktopTabsFile{ |
| 641 | Tabs: []desktopTabEntry{{ID: "l1"}, {ID: "l2"}}, |
| 642 | RemoteTabs: []desktopRemoteTabEntry{{ID: "r1", HostID: "h", Workspace: "~/a"}}, |
| 643 | ActiveTab: "l2", |
| 644 | } |
| 645 | out := singleSurfaceTabsFile(f) |
| 646 | if len(out.Tabs) != 1 || out.Tabs[0].ID != "l2" || len(out.RemoteTabs) != 0 || out.ActiveTab != "l2" { |
| 647 | t.Fatalf("single-surface local collapse = %+v", out) |
| 648 | } |
| 649 | } |
| 650 | |
| 651 | // TestSuspendSkipsDisconnectedShells: host status transitions never flip a |
| 652 | // restored shell into a runtime state. |
| 653 | func TestSuspendSkipsDisconnectedShells(t *testing.T) { |
| 654 | a := &App{} |
| 655 | a.remoteTabMu.Lock() |
| 656 | a.remoteTabs = map[string]*remoteTab{ |
| 657 | "shell": {id: "shell", ref: RemoteTabRef{HostID: "box", Workspace: "~/app"}, state: "disconnected"}, |
| 658 | "live": {id: "live", ref: RemoteTabRef{HostID: "box", Workspace: "~/web"}, state: "ready"}, |
| 659 | } |
| 660 | a.remoteTabMu.Unlock() |
| 661 | a.suspendRemoteTabPumps("box", "reconnecting", "") |
| 662 | a.remoteTabMu.Lock() |
| 663 | defer a.remoteTabMu.Unlock() |
| 664 | if a.remoteTabs["shell"].state != "disconnected" { |
| 665 | t.Fatalf("shell state = %q, want disconnected", a.remoteTabs["shell"].state) |
| 666 | } |
| 667 | if a.remoteTabs["live"].state != "reconnecting" { |
| 668 | t.Fatalf("live state = %q, want reconnecting", a.remoteTabs["live"].state) |
| 669 | } |
| 670 | } |
| 671 | |
| 672 | // TestTabsFileWithoutRemoteTabsKeepsLegacyShape: with no remote tabs open the |
| 673 | // persisted file carries no remote keys, so local-only usage stays |
| 674 | // byte-compatible with the pre-remote format. |
| 675 | func TestTabsFileWithoutRemoteTabsKeepsLegacyShape(t *testing.T) { |
| 676 | seedBridgeTestHost(t, "box") |
| 677 | a := &App{} |
| 678 | seedLocalTab(a, "l1") |
| 679 | a.mu.Lock() |
| 680 | a.activeTabID = "l1" |
| 681 | a.mu.Unlock() |
| 682 | a.saveTabsFromRemote() |
| 683 | data, err := os.ReadFile(filepath.Join(config.ReasonixHomeDir(), tabsFileName)) |
| 684 | if err != nil { |
| 685 | t.Fatal(err) |
| 686 | } |
| 687 | if strings.Contains(string(data), "remoteTabs") || strings.Contains(string(data), "remoteTabOrder") { |
| 688 | t.Fatalf("tabs file mentions remote keys with none open:\n%s", data) |
| 689 | } |
| 690 | } |
| 691 | |
| 692 | // TestRemoteOnlyLayoutRestoresOneDormantLocalTab: a remote-only layout must not |
| 693 | // leave the app without a local workspace tab — local session opens and folder |
| 694 | // drops target one — while the remote shell stays the visible surface and no |
| 695 | // hidden startup work is added. |
| 696 | func TestRemoteOnlyLayoutRestoresOneDormantLocalTab(t *testing.T) { |
| 697 | isolateDesktopUserDirs(t) |
| 698 | seedBridgeTestHost(t, "box") |
| 699 | dir := desktopConfigDir() |
| 700 | if err := os.MkdirAll(dir, 0o755); err != nil { |
| 701 | t.Fatal(err) |
| 702 | } |
| 703 | body := `{"tabs":null,"activeTab":"r1","remoteTabs":[{"id":"r1","hostId":"box","workspace":"~/app"}],"remoteTabOrder":["r1"],"tabOrder":["r1"]}` |
| 704 | if err := os.WriteFile(filepath.Join(dir, tabsFileName), []byte(body), 0o644); err != nil { |
| 705 | t.Fatal(err) |
| 706 | } |
| 707 | a := NewApp() |
| 708 | a.ctx = t.Context() |
| 709 | a.remoteRuntime = &fakeRemoteKernel{} |
| 710 | t.Cleanup(func() { a.shutdown(context.Background()) }) |
| 711 | |
| 712 | a.restoreOrBuildTabs() |
| 713 | |
| 714 | local := a.singleLocalTab() |
| 715 | a.mu.RLock() |
| 716 | tabCount, activeID := len(a.tabs), a.activeTabID |
| 717 | a.mu.RUnlock() |
| 718 | if tabCount != 1 || local == nil { |
| 719 | t.Fatalf("restored local tabs = %d, want exactly one dormant tab", tabCount) |
| 720 | } |
| 721 | if local.Ctrl != nil { |
| 722 | t.Fatal("dormant local tab built a runtime at startup") |
| 723 | } |
| 724 | if activeID != "" { |
| 725 | t.Fatalf("activeTabID = %q, want the remote surface to stay active", activeID) |
| 726 | } |
| 727 | // Activating the restored remote shell (what the frontend does once the |
| 728 | // layout is up) must keep it the visible surface: the dormant local tab |
| 729 | // never claims it. |
| 730 | a.remoteTabMu.Lock() |
| 731 | a.remoteTabLayout.activeID = "r1" |
| 732 | a.remoteTabMu.Unlock() |
| 733 | remoteSeen := false |
| 734 | for _, meta := range a.ListTabs() { |
| 735 | if meta.Remote != nil { |
| 736 | remoteSeen = true |
| 737 | if !meta.Active { |
| 738 | t.Fatalf("remote surface lost the visible surface: %+v", meta) |
| 739 | } |
| 740 | continue |
| 741 | } |
| 742 | if meta.Active { |
| 743 | t.Fatalf("dormant local tab claims the visible surface: %+v", meta) |
| 744 | } |
| 745 | } |
| 746 | if !remoteSeen { |
| 747 | t.Fatal("restored remote shell disappeared") |
| 748 | } |
| 749 | } |
| 750 | |
| 751 | // TestOpenSessionUsesDormantLocalTabWithoutAnActiveTab: the state a remote-only |
| 752 | // layout restores (no active local tab, one dormant tab) must open a local |
| 753 | // canonical session instead of reporting "workspace is not ready". |
| 754 | func TestOpenSessionUsesDormantLocalTabWithoutAnActiveTab(t *testing.T) { |
| 755 | app, ref := lifecycleFixture(t) |
| 756 | app.mu.Lock() |
| 757 | app.tabs = map[string]*WorkspaceTab{} |
| 758 | app.tabOrder = nil |
| 759 | app.activeTabID = "" |
| 760 | app.mu.Unlock() |
| 761 | |
| 762 | release, err := app.beginProjectRuntimeAdmission("global", globalTabWorkspaceRoot()) |
| 763 | if err != nil { |
| 764 | t.Fatal(err) |
| 765 | } |
| 766 | dormant := app.createTabEntry("global", globalTabWorkspaceRoot(), "") |
| 767 | dormant.sink = &tabEventSink{tabID: dormant.ID, app: app} |
| 768 | app.publishRestoredTab(dormant, release) |
| 769 | |
| 770 | if _, err := app.OpenSession(ref); err != nil { |
| 771 | t.Fatalf("open session with only a dormant local tab: %v", err) |
| 772 | } |
| 773 | app.mu.RLock() |
| 774 | got := app.tabs[dormant.ID] |
| 775 | var boundCtrl control.SessionAPI |
| 776 | var boundSessionID string |
| 777 | if got != nil { |
| 778 | boundCtrl, boundSessionID = got.Ctrl, got.SessionID |
| 779 | } |
| 780 | app.mu.RUnlock() |
| 781 | if got == nil || boundCtrl == nil || boundSessionID != ref.SessionID { |
| 782 | t.Fatalf("dormant tab after open = %+v (ctrl nil=%v sessionID=%q)", got, boundCtrl == nil, boundSessionID) |
| 783 | } |
| 784 | } |
| 785 | |
| 786 | // TestResumeAdoptsTabRuntimeWhenControllerIsNil: a caller that resolved a tab |
| 787 | // before its runtime existed (a dormant tab restored for a remote-only layout) |
| 788 | // must not fail the open when a concurrent activation already built one. |
| 789 | func TestResumeAdoptsTabRuntimeWhenControllerIsNil(t *testing.T) { |
| 790 | app, tab, ctrl, _ := auditMigratedTab(t) |
| 791 | ref, ok := ctrl.SessionRef() |
| 792 | if !ok { |
| 793 | t.Fatal("fixture controller has no session ref") |
| 794 | } |
| 795 | if _, err := app.resumeCanonicalSessionForTranscript(tab, nil, sessionRoute(ref.SessionID), defaultHistoryPageTurns, false); err != nil { |
| 796 | t.Fatalf("resume with a nil controller: %v", err) |
| 797 | } |
| 798 | } |
| 799 |