| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "os" |
| 6 | "testing" |
| 7 | "time" |
| 8 | |
| 9 | "reasonix/internal/config" |
| 10 | ) |
| 11 | |
| 12 | // OpenRemoteProjectTab adopts the clicked identity immediately; the Serve |
| 13 | // resume round trip runs in the background. |
| 14 | |
| 15 | func TestOpenRemoteProjectTabResumeReturnsBeforeServeRoundTrip(t *testing.T) { |
| 16 | fs := newFakeServe(t, "s3cret", []serveSessionEntry{ |
| 17 | {Name: "s1", Path: "/remote/sessions/s1.jsonl", Title: "First", Turns: 1, Current: true}, |
| 18 | {Name: "s2", Path: "/remote/sessions/s2.jsonl", Title: "Second", Turns: 1}, |
| 19 | }) |
| 20 | kernel := &fakeRemoteKernel{ |
| 21 | statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}}, |
| 22 | ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL}, |
| 23 | ensureToken: "s3cret", |
| 24 | } |
| 25 | seedBridgeTestHost(t, "box") |
| 26 | a := &App{remoteRuntime: kernel} |
| 27 | cleanupRemoteTabPumps(t, a) |
| 28 | meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{SessionName: "s1", SessionPath: "/remote/sessions/s1.jsonl"}) |
| 29 | |
| 30 | started, release := make(chan string, 1), make(chan struct{}) |
| 31 | fs.mu.Lock() |
| 32 | fs.resumeStarted, fs.resumeRelease = started, release |
| 33 | fs.mu.Unlock() |
| 34 | t.Cleanup(func() { |
| 35 | select { |
| 36 | case <-release: |
| 37 | default: |
| 38 | close(release) |
| 39 | } |
| 40 | }) |
| 41 | |
| 42 | type openResult struct { |
| 43 | meta TabMeta |
| 44 | err error |
| 45 | } |
| 46 | result := make(chan openResult, 1) |
| 47 | go func() { |
| 48 | meta, err := a.OpenRemoteProjectTab("box", "~/app", RemoteTabOpenOptions{ |
| 49 | SessionName: "s2", SessionPath: "/remote/sessions/s2.jsonl", SessionTitle: "Second", |
| 50 | }) |
| 51 | result <- openResult{meta: meta, err: err} |
| 52 | }() |
| 53 | var switched TabMeta |
| 54 | select { |
| 55 | case got := <-result: |
| 56 | if got.err != nil { |
| 57 | t.Fatal(got.err) |
| 58 | } |
| 59 | switched = got.meta |
| 60 | case <-time.After(5 * time.Second): |
| 61 | t.Fatal("open blocked on the held resume round trip") |
| 62 | } |
| 63 | if want := "box\x00~/app\x00s2"; switched.TopicID != want { |
| 64 | t.Fatalf("returned meta TopicID = %q, want the adopted s2 identity %q", switched.TopicID, want) |
| 65 | } |
| 66 | select { |
| 67 | case path := <-started: |
| 68 | if path != "/remote/sessions/s2.jsonl" { |
| 69 | t.Fatalf("resume path = %q, want s2", path) |
| 70 | } |
| 71 | case <-time.After(5 * time.Second): |
| 72 | t.Fatal("background resume did not reach Serve") |
| 73 | } |
| 74 | a.remoteTabMu.Lock() |
| 75 | tab := a.remoteTabs[switched.ID] |
| 76 | gen := tab.gen |
| 77 | route := tab.routing.currentPath |
| 78 | a.remoteTabMu.Unlock() |
| 79 | if route != "/remote/sessions/s2.jsonl" { |
| 80 | t.Fatalf("route while resume is held = %q, want target", route) |
| 81 | } |
| 82 | if !a.routeRemoteTabFrame(switched.ID, gen, route, "approval_request") { |
| 83 | t.Fatal("target frame was rejected while resume request was held") |
| 84 | } |
| 85 | a.cacheRemotePendingEvent(switched.ID, gen, "approval_request", json.RawMessage(`{"kind":"approval_request","callId":"during-resume"}`)) |
| 86 | close(release) |
| 87 | waitForRemoteSessionIdentity(t, a, meta.ID, "s2", "/remote/sessions/s2.jsonl") |
| 88 | a.remoteTabMu.Lock() |
| 89 | pending := len(a.remoteTabs[switched.ID].pendingEvents) |
| 90 | a.remoteTabMu.Unlock() |
| 91 | if pending != 1 { |
| 92 | t.Fatalf("pending target event count after resume = %d, want 1", pending) |
| 93 | } |
| 94 | cleanupRemoteTabPumps(t, a) |
| 95 | } |
| 96 | |
| 97 | // A slow resume that lands after a newer switch must not stomp the newer |
| 98 | // session's identity or re-emit ready out of order. |
| 99 | func TestOpenRemoteProjectTabLateResumeCannotStompNewerSwitch(t *testing.T) { |
| 100 | fs := newFakeServe(t, "s3cret", []serveSessionEntry{ |
| 101 | {Name: "s1", Path: "/remote/sessions/s1.jsonl", Title: "First", Turns: 1, Current: true}, |
| 102 | {Name: "s2", Path: "/remote/sessions/s2.jsonl", Title: "Second", Turns: 1}, |
| 103 | {Name: "s3", Path: "/remote/sessions/s3.jsonl", Title: "Third", Turns: 1}, |
| 104 | }) |
| 105 | kernel := &fakeRemoteKernel{ |
| 106 | statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}}, |
| 107 | ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL}, |
| 108 | ensureToken: "s3cret", |
| 109 | } |
| 110 | seedBridgeTestHost(t, "box") |
| 111 | a := &App{remoteRuntime: kernel} |
| 112 | cleanupRemoteTabPumps(t, a) |
| 113 | meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{SessionName: "s1", SessionPath: "/remote/sessions/s1.jsonl"}) |
| 114 | |
| 115 | started, release := make(chan string, 2), make(chan struct{}) |
| 116 | fs.mu.Lock() |
| 117 | fs.resumeStarted, fs.resumeRelease = started, release |
| 118 | fs.mu.Unlock() |
| 119 | t.Cleanup(func() { |
| 120 | select { |
| 121 | case <-release: |
| 122 | default: |
| 123 | close(release) |
| 124 | } |
| 125 | }) |
| 126 | |
| 127 | if _, err := a.OpenRemoteProjectTab("box", "~/app", RemoteTabOpenOptions{SessionName: "s2", SessionPath: "/remote/sessions/s2.jsonl"}); err != nil { |
| 128 | t.Fatal(err) |
| 129 | } |
| 130 | select { |
| 131 | case path := <-started: |
| 132 | if path != "/remote/sessions/s2.jsonl" { |
| 133 | t.Fatalf("first resume path = %q, want s2", path) |
| 134 | } |
| 135 | case <-time.After(5 * time.Second): |
| 136 | t.Fatal("first resume did not reach Serve") |
| 137 | } |
| 138 | // Switch again while the first Serve request is held: s3 must win. |
| 139 | if _, err := a.OpenRemoteProjectTab("box", "~/app", RemoteTabOpenOptions{SessionName: "s3", SessionPath: "/remote/sessions/s3.jsonl"}); err != nil { |
| 140 | t.Fatal(err) |
| 141 | } |
| 142 | close(release) |
| 143 | select { |
| 144 | case path := <-started: |
| 145 | if path != "/remote/sessions/s3.jsonl" { |
| 146 | t.Fatalf("second resume path = %q, want s3", path) |
| 147 | } |
| 148 | case <-time.After(5 * time.Second): |
| 149 | t.Fatal("newer resume did not reach Serve after the held request completed") |
| 150 | } |
| 151 | waitForRemoteSessionIdentity(t, a, meta.ID, "s3", "/remote/sessions/s3.jsonl") |
| 152 | cleanupRemoteTabPumps(t, a) |
| 153 | } |
| 154 | |
| 155 | func TestOpenRemoteProjectTabRejectedResumeRestoresPreviousIdentity(t *testing.T) { |
| 156 | const oldPath = "/remote/sessions/s1.jsonl" |
| 157 | const targetPath = "/remote/sessions/s2.jsonl" |
| 158 | fs := newFakeServe(t, "s3cret", []serveSessionEntry{ |
| 159 | {Name: "s1", Path: oldPath, Title: "First", Turns: 1, Current: true}, |
| 160 | {Name: "s2", Path: targetPath, Title: "Second", Turns: 1}, |
| 161 | }) |
| 162 | kernel := &fakeRemoteKernel{ |
| 163 | statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}}, |
| 164 | ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL}, |
| 165 | ensureToken: "s3cret", |
| 166 | } |
| 167 | seedBridgeTestHost(t, "box") |
| 168 | a := &App{remoteRuntime: kernel} |
| 169 | cleanupRemoteTabPumps(t, a) |
| 170 | meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{SessionName: "s1", SessionPath: oldPath, SessionTitle: "First"}) |
| 171 | |
| 172 | fs.mu.Lock() |
| 173 | fs.failEnter = "session is already leased by another process" |
| 174 | fs.mu.Unlock() |
| 175 | if _, err := a.OpenRemoteProjectTab("box", "~/app", RemoteTabOpenOptions{ |
| 176 | SessionName: "s2", SessionPath: targetPath, SessionTitle: "Second", |
| 177 | }); err != nil { |
| 178 | t.Fatal(err) |
| 179 | } |
| 180 | waitForRemoteTabError(t, a, meta.ID, "already leased") |
| 181 | |
| 182 | a.remoteTabMu.Lock() |
| 183 | tab := a.remoteTabs[meta.ID] |
| 184 | name, path, route, title := tab.session.name, tab.session.path, tab.routing.currentPath, tab.topicTitle |
| 185 | a.remoteTabMu.Unlock() |
| 186 | if name != "s1" || path != oldPath || route != oldPath || title != "First" { |
| 187 | t.Fatalf("rejected async resume kept target identity: name/path/route/title = %q/%q/%q/%q", name, path, route, title) |
| 188 | } |
| 189 | } |
| 190 | |
| 191 | func waitForRemoteSessionIdentity(t *testing.T, a *App, tabID, name, path string) { |
| 192 | t.Helper() |
| 193 | deadline := time.Now().Add(5 * time.Second) |
| 194 | for time.Now().Before(deadline) { |
| 195 | a.remoteTabMu.Lock() |
| 196 | tab := a.remoteTabs[tabID] |
| 197 | matches := tab != nil && tab.session.name == name && tab.session.path == path && tab.state == "ready" |
| 198 | a.remoteTabMu.Unlock() |
| 199 | if matches { |
| 200 | return |
| 201 | } |
| 202 | time.Sleep(time.Millisecond) |
| 203 | } |
| 204 | t.Fatalf("remote tab %q did not settle on %q at %q", tabID, name, path) |
| 205 | } |
| 206 | |
| 207 | // Re-registering an already-pinned remote project must not rewrite the user |
| 208 | // config file: OpenRemoteProjectTab re-adds on every click, and the repeated |
| 209 | // disk write shows up as switch latency. |
| 210 | func TestAddRemoteProjectSkipsConfigRewriteWhenPinned(t *testing.T) { |
| 211 | seedBridgeTestHost(t, "box") |
| 212 | if _, err := addRemoteProjectForTest(t, "box", "~/app"); err != nil { |
| 213 | t.Fatal(err) |
| 214 | } |
| 215 | path := config.UserConfigPath() |
| 216 | before, err := os.ReadFile(path) |
| 217 | if err != nil { |
| 218 | t.Fatal(err) |
| 219 | } |
| 220 | statBefore, err := os.Stat(path) |
| 221 | if err != nil { |
| 222 | t.Fatal(err) |
| 223 | } |
| 224 | time.Sleep(20 * time.Millisecond) |
| 225 | |
| 226 | view, err := addRemoteProjectForTest(t, "box", "~/app") |
| 227 | if err != nil { |
| 228 | t.Fatal(err) |
| 229 | } |
| 230 | if !view.Merged { |
| 231 | t.Fatalf("re-add did not merge into the existing pin: %+v", view) |
| 232 | } |
| 233 | after, err := os.ReadFile(path) |
| 234 | if err != nil { |
| 235 | t.Fatal(err) |
| 236 | } |
| 237 | statAfter, err := os.Stat(path) |
| 238 | if err != nil { |
| 239 | t.Fatal(err) |
| 240 | } |
| 241 | if statAfter.ModTime() != statBefore.ModTime() { |
| 242 | t.Fatalf("config rewritten on re-add: mtime %v -> %v", statBefore.ModTime(), statAfter.ModTime()) |
| 243 | } |
| 244 | if string(after) != string(before) { |
| 245 | t.Fatalf("config content changed on re-add") |
| 246 | } |
| 247 | } |
| 248 | |
| 249 | func TestAddRemoteProjectSerializesNoOpDecisionWithRemoval(t *testing.T) { |
| 250 | seedBridgeTestHost(t, "box") |
| 251 | if _, err := addRemoteProjectForTest(t, "box", "~/app"); err != nil { |
| 252 | t.Fatal(err) |
| 253 | } |
| 254 | |
| 255 | unlock := config.LockUserConfigEdits() |
| 256 | result := make(chan error, 1) |
| 257 | go func() { |
| 258 | _, err := addRemoteProjectForTest(t, "box", "~/app") |
| 259 | result <- err |
| 260 | }() |
| 261 | select { |
| 262 | case err := <-result: |
| 263 | unlock() |
| 264 | t.Fatalf("AddRemoteProject bypassed the edit lock: %v", err) |
| 265 | case <-time.After(20 * time.Millisecond): |
| 266 | } |
| 267 | |
| 268 | path := config.UserConfigPath() |
| 269 | cfg := config.LoadForEdit(path) |
| 270 | if cfg == nil || !cfg.RemoveRemoteProject("box", "~/app") { |
| 271 | unlock() |
| 272 | t.Fatal("failed to stage concurrent project removal") |
| 273 | } |
| 274 | if err := cfg.SaveTo(path); err != nil { |
| 275 | unlock() |
| 276 | t.Fatal(err) |
| 277 | } |
| 278 | unlock() |
| 279 | if err := <-result; err != nil { |
| 280 | t.Fatal(err) |
| 281 | } |
| 282 | loaded, err := config.Load() |
| 283 | if err != nil { |
| 284 | t.Fatal(err) |
| 285 | } |
| 286 | if _, ok := loaded.RemoteProject("box", "~/app"); !ok { |
| 287 | t.Fatal("successful AddRemoteProject was lost behind a concurrent removal") |
| 288 | } |
| 289 | } |
| 290 | |
| 291 | func addRemoteProjectForTest(t *testing.T, hostID, workspace string) (RemoteProjectView, error) { |
| 292 | t.Helper() |
| 293 | a := &App{} |
| 294 | return a.AddRemoteProject(hostID, workspace) |
| 295 | } |
| 296 |