返回 DeepSeek-Reasonix
remote_tab_sessions_test.go
根目录 / desktop / remote_tab_sessions_test.go
1 package main
2
3 import (
4 "bytes"
5 "encoding/json"
6 "errors"
7 "log"
8 "slices"
9 "strings"
10 "testing"
11 "time"
12
13 "reasonix/internal/config"
14 )
15
16 func TestRemoteTabReconnectDoesNotLogEnsureServerSecrets(t *testing.T) {
17 const secret = "sk-sensitive-reconnect-error"
18 kernel := &fakeRemoteKernel{ensureErr: errors.New("remote bootstrap failed: " + secret)}
19 a := &App{
20 remoteRuntime: kernel,
21 remoteTabs: map[string]*remoteTab{
22 "remote-1": {
23 id: "remote-1",
24 ref: RemoteTabRef{HostID: "box", Workspace: "~/app"},
25 state: "reconnecting",
26 },
27 },
28 }
29
30 var logs bytes.Buffer
31 previousWriter := log.Writer()
32 log.SetOutput(&logs)
33 t.Cleanup(func() { log.SetOutput(previousWriter) })
34
35 previousDelays := remoteTabReattachDelays
36 remoteTabReattachDelays = nil
37 t.Cleanup(func() { remoteTabReattachDelays = previousDelays })
38
39 a.reattachRemoteTab("remote-1")
40 if strings.Contains(logs.String(), secret) {
41 t.Fatalf("reconnect log exposed EnsureServer error: %q", logs.String())
42 }
43 if !strings.Contains(logs.String(), "EnsureServer NOT-READY") {
44 t.Fatalf("reconnect failure was not logged structurally: %q", logs.String())
45 }
46 a.remoteTabMu.Lock()
47 state := a.remoteTabs["remote-1"].state
48 a.remoteTabMu.Unlock()
49 if kernel.ensureCalls != len(remoteTabReattachDelays)+1 || state != "serve_down" {
50 t.Fatalf("reattach exhaustion calls/state = %d/%q", kernel.ensureCalls, state)
51 }
52 }
53
54 func TestCompleteRemoteTabTurnMakesFreshSessionNonReusable(t *testing.T) {
55 a := &App{remoteTabs: map[string]*remoteTab{
56 "remote": {
57 id: "remote",
58 gen: 7,
59 session: remoteTabSessionState{reset: true},
60 pendingEvents: map[string]json.RawMessage{"approval_request:1": json.RawMessage(`{"kind":"approval_request"}`)},
61 },
62 }}
63 a.completeRemoteTabTurn("remote", 6)
64 if !a.remoteTabs["remote"].session.reset {
65 t.Fatal("a stale stream generation cleared the reusable blank marker")
66 }
67 a.completeRemoteTabTurn("remote", 7)
68 if a.remoteTabs["remote"].session.reset {
69 t.Fatal("a completed turn remained eligible for blank-session reuse")
70 }
71 if len(a.remoteTabs["remote"].pendingEvents) != 0 {
72 t.Fatal("turn completion did not clear pending prompt replay")
73 }
74 }
75
76 func TestMigrateBlankRemoteSessionTitleOverride(t *testing.T) {
77 isolateDesktopUserDirs(t)
78 if err := setRemoteSessionTitleOverride("box", "~/app", "", "My first topic"); err != nil {
79 t.Fatal(err)
80 }
81 if err := setRemoteSessionPinned("box", "~/app", "", true); err != nil {
82 t.Fatal(err)
83 }
84 got, err := migrateRemoteSessionTitleOverride("box", "~/app", "session-1")
85 if err != nil {
86 t.Fatal(err)
87 }
88 if got != "My first topic" || remoteSessionTitleOverride("box", "~/app", "session-1") != got {
89 t.Fatalf("migrated title = %q, named preference = %q", got, remoteSessionTitleOverride("box", "~/app", "session-1"))
90 }
91 if blank := remoteSessionTitleOverride("box", "~/app", ""); blank != "" {
92 t.Fatalf("blank preference survived migration: %q", blank)
93 }
94 if !remoteSessionPinned("box", "~/app", "session-1") || remoteSessionPinned("box", "~/app", "") {
95 t.Fatal("blank-session pin did not move to the durable session identity")
96 }
97 }
98
99 func TestRemoteProxyModelCatalogOffersProtocolsForSnapshotSwitch(t *testing.T) {
100 isolateDesktopUserDirs(t)
101 setDesktopTestCredential(t, "OPENAI_TEST_KEY", "sk-openai")
102 setDesktopTestCredential(t, "ANTHROPIC_TEST_KEY", "sk-anthropic")
103 cfg := config.Default()
104 cfg.DefaultModel = "chat/gpt-test"
105 cfg.Providers = []config.ProviderEntry{
106 {Name: "chat", Kind: "openai", BaseURL: "https://chat.example/v1", Models: []string{"gpt-test", "gpt-next"}, Default: "gpt-test", APIKeyEnv: "OPENAI_TEST_KEY"},
107 {Name: "claude", Kind: "anthropic", BaseURL: "https://claude.example", Models: []string{"claude-test"}, Default: "claude-test", APIKeyEnv: "ANTHROPIC_TEST_KEY"},
108 }
109 if err := cfg.UpsertRemoteHost(config.RemoteHostEntry{Name: "box", Host: "127.0.0.1", CredentialMode: "local-proxy"}); err != nil {
110 t.Fatal(err)
111 }
112 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
113 t.Fatal(err)
114 }
115 a := &App{remoteTabs: map[string]*remoteTab{
116 "remote": {id: "remote", ref: RemoteTabRef{HostID: "box", Workspace: "~/app"}, state: "ready", model: "chat/gpt-test"},
117 }}
118 models := a.ModelsForTab("remote")
119 if len(models) != 3 || !slices.ContainsFunc(models, func(model ModelInfo) bool { return model.Provider == "claude" }) {
120 t.Fatalf("local-proxy catalog omitted an available protocol: %+v", models)
121 }
122 err := a.SetRemoteTabModel("remote", "claude/claude-test")
123 if err == nil || !strings.Contains(err.Error(), "not connected") {
124 t.Fatalf("cross-protocol switch error = %v", err)
125 }
126 }
127
128 // TestRemoteTabSnapshotMergesServeMembers: all six GETs merge in parallel;
129 // only /history is required — its failure errors, optional members degrade.
130 func TestRemoteTabSnapshotMergesServeMembers(t *testing.T) {
131 fs := newFakeServe(t, "s3cret", nil)
132 kernel := &fakeRemoteKernel{
133 statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}},
134 ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL},
135 ensureToken: "s3cret",
136 }
137 seedBridgeTestHost(t, "box")
138 a := &App{remoteRuntime: kernel}
139 cleanupRemoteTabPumps(t, a)
140 meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{NewSession: true})
141
142 snap, err := a.RemoteTabSnapshot(meta.ID)
143 if err != nil {
144 t.Fatal(err)
145 }
146 for name, raw := range map[string]json.RawMessage{
147 "history": snap.History, "context": snap.Context, "todos": snap.Todos,
148 "checkpoints": snap.Checkpoints, "models": snap.Models, "status": snap.Status,
149 } {
150 if len(raw) == 0 {
151 t.Fatalf("snapshot member %s is empty", name)
152 }
153 }
154 before := len(fs.recorded())
155 if _, err := a.RemoteTabStatus(meta.ID); err != nil {
156 t.Fatal(err)
157 }
158 statusCalls := fs.recorded()[before:]
159 if len(statusCalls) != 1 || !strings.HasPrefix(statusCalls[0], "GET /status") {
160 t.Fatalf("status-only binding fetched extra snapshot members: %v", statusCalls)
161 }
162
163 fs.mu.Lock()
164 fs.failHistory = true
165 fs.mu.Unlock()
166 if _, err := a.RemoteTabSnapshot(meta.ID); err == nil {
167 t.Fatal("snapshot with failing /history must error")
168 }
169 }
170
171 // TestRemoteProjectSessionsWithoutOpenTab pins the read-only one-shot path:
172 // listing sessions for a workspace with no live tab reuses the registry's
173 // ready registration, handshakes, and maps entries to the frontend view —
174 // without ever ensuring a serve.
175 func TestRemoteProjectSessionsWithoutOpenTab(t *testing.T) {
176 fs := newFakeServe(t, "s3cret", []serveSessionEntry{
177 {Name: "s1", Path: "/x.jsonl", Title: "First", Turns: 2},
178 })
179 kernel := &fakeRemoteKernel{
180 statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}},
181 ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL},
182 ensureToken: "s3cret",
183 }
184 seedBridgeTestHost(t, "box")
185 a := &App{remoteRuntime: kernel}
186
187 sessions, err := a.RemoteProjectSessions("box", "~/app")
188 if err != nil {
189 t.Fatal(err)
190 }
191 if len(sessions) != 1 || sessions[0].Name != "s1" || sessions[0].Title != "First" || sessions[0].Turns != 2 {
192 t.Fatalf("sessions = %+v, want the mapped s1 entry", sessions)
193 }
194 found := false
195 for _, c := range fs.recorded() {
196 if c == "GET /sessions " {
197 found = true
198 }
199 }
200 if !found {
201 t.Fatalf("GET /sessions not reached: %v", fs.recorded())
202 }
203 if kernel.ensureCalls != 0 {
204 t.Fatalf("listing woke the serve: %d EnsureServer calls", kernel.ensureCalls)
205 }
206 }
207
208 // TestRemoteProjectSessionsNeverWakesServe: a query path must never
209 // cold-start a serve — no ready registration means an error, and EnsureServer
210 // must not even be attempted (the old behavior here starved tab bootstraps
211 // on the per-host serve lock).
212 func TestRemoteProjectSessionsNeverWakesServe(t *testing.T) {
213 kernel := &fakeRemoteKernel{
214 statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}},
215 // No ready registration: ServeSnapshot reports nothing.
216 }
217 seedBridgeTestHost(t, "box")
218 a := &App{remoteRuntime: kernel}
219
220 if _, err := a.RemoteProjectSessions("box", "~/app"); err == nil {
221 t.Fatal("listing must report the serve as not running")
222 }
223 if kernel.ensureCalls != 0 {
224 t.Fatalf("listing woke the serve: %d EnsureServer calls", kernel.ensureCalls)
225 }
226 }
227
228 // TestResolveOverlappingWorkspace pins the merge rules for overlapping pins:
229 // exact match wins, then the nearest ancestor, then the shallowest
230 // descendant; disjoint paths never merge.
231 func TestResolveOverlappingWorkspace(t *testing.T) {
232 entries := []config.RemoteProjectEntry{
233 {HostID: "box", Workspace: "/srv/app"},
234 {HostID: "box", Workspace: "/srv/app/sub"},
235 {HostID: "other", Workspace: "/srv/app"},
236 }
237 for _, tc := range []struct {
238 ws string
239 want string
240 ok bool
241 }{
242 {ws: "/srv/app", want: "/srv/app", ok: true}, // exact
243 {ws: "/srv/app/", want: "/srv/app", ok: true}, // trailing slash normalizes to exact
244 {ws: "/srv/app/sub/deep", want: "/srv/app/sub", ok: true}, // nearest ancestor
245 {ws: "/srv", want: "/srv/app", ok: true}, // ancestor request merges into shallowest descendant
246 {ws: "/srv/other", want: "", ok: false}, // sibling never merges
247 {ws: "", want: "", ok: false}, // empty never merges
248 } {
249 got, ok := resolveOverlappingWorkspace(entries, "box", tc.ws)
250 if ok != tc.ok || got != tc.want {
251 t.Fatalf("resolveOverlappingWorkspace(%q) = (%q, %v), want (%q, %v)", tc.ws, got, ok, tc.want, tc.ok)
252 }
253 }
254 // Host scoping: the same path on another host must not capture the merge.
255 if got, ok := resolveOverlappingWorkspace(entries, "other", "/srv/app/sub/x"); !ok || got != "/srv/app" {
256 t.Fatalf("cross-host overlap merged: (%q, %v)", got, ok)
257 }
258 }
259
260 // TestRemoteTabCommandSurfacesServeErrorBody: the serve's error text (the
261 // session-in-use close hint) rides through to the caller.
262 func TestRemoteTabCommandSurfacesServeErrorBody(t *testing.T) {
263 fs := newFakeServe(t, "s3cret", nil)
264 kernel := &fakeRemoteKernel{
265 statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}},
266 ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL},
267 ensureToken: "s3cret",
268 }
269 seedBridgeTestHost(t, "box")
270 a := &App{remoteRuntime: kernel}
271 cleanupRemoteTabPumps(t, a)
272 meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{NewSession: true})
273
274 fs.mu.Lock()
275 fs.failNext = "session in use; close the remote tab first"
276 fs.mu.Unlock()
277 err := a.SubmitRemoteTab(meta.ID, "hello")
278 if err == nil || !strings.Contains(err.Error(), "close the remote tab first") {
279 t.Fatalf("err = %v, want the serve error body surfaced", err)
280 }
281 }
282
283 // TestCloseRemoteTabRefusesTheSoleSurface: the one-surface policy keeps the
284 // last visible surface occupied, so closing it is refused — and a repeated
285 // attempt must change nothing, rather than tearing the tab down half way.
286 // Closing a surface that is not the last one is covered by the host-removal
287 // path, which closes the final surface and hands the slot to a blank local.
288 func TestCloseRemoteTabRefusesTheSoleSurface(t *testing.T) {
289 fs := newFakeServe(t, "s3cret", nil)
290 kernel := &fakeRemoteKernel{
291 statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}},
292 ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL},
293 ensureToken: "s3cret",
294 }
295 seedBridgeTestHost(t, "box")
296 a := &App{remoteRuntime: kernel}
297 cleanupRemoteTabPumps(t, a)
298 meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{NewSession: true})
299
300 for attempt := 1; attempt <= 2; attempt += 1 {
301 err := a.CloseRemoteTab(meta.ID)
302 if err == nil || !strings.Contains(err.Error(), "cannot close the last tab") {
303 t.Fatalf("close attempt %d = %v, want the last-surface refusal", attempt, err)
304 }
305 }
306 a.remoteTabMu.Lock()
307 _, present := a.remoteTabs[meta.ID]
308 a.remoteTabMu.Unlock()
309 if !present {
310 t.Fatal("a refused close must leave the tab in the registry")
311 }
312 if err := a.SubmitRemoteTab(meta.ID, "hi"); err != nil {
313 t.Fatalf("a refused close must leave the tab usable: %v", err)
314 }
315 }
316
317 // TestRemoteTabFollowsHostReconnect pins the SSH-driven lifecycle: a
318 // transient drop suspends the pump and flags reconnecting, the regained
319 // connection re-attaches a fresh pump to the still-running serve, and a
320 // terminal failure parks the tab in error.
321 func TestRemoteTabFollowsHostReconnect(t *testing.T) {
322 fs := newFakeServe(t, "s3cret", nil)
323 kernel := &fakeRemoteKernel{
324 statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}},
325 ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL},
326 ensureToken: "s3cret",
327 }
328 seedBridgeTestHost(t, "box")
329 events := &eventLog{}
330 a := &App{remoteRuntime: kernel, remoteEventHook: events.add}
331 cleanupRemoteTabPumps(t, a)
332 meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{NewSession: true})
333 firstConns := fs.eventsCount()
334
335 a.remoteTabsHostStatus("box", "reconnecting", "")
336 waitForTabState(t, a, meta.ID, "reconnecting")
337 if events.count("remote-tab:"+meta.ID+":state ") == 0 {
338 t.Fatal("host disconnect changed backend state without emitting a tab-state event")
339 }
340 if err := a.SubmitRemoteTab(meta.ID, "hi"); err == nil {
341 t.Fatal("commands during reconnecting must fail")
342 }
343
344 a.remoteTabsHostStatus("box", "connected", "")
345 waitForTabState(t, a, meta.ID, "ready")
346 if fs.eventsCount() <= firstConns {
347 t.Fatalf("re-attach did not open a new event stream: %d then %d", firstConns, fs.eventsCount())
348 }
349 if err := a.SubmitRemoteTab(meta.ID, "back online"); err != nil {
350 t.Fatalf("submit after reconnect: %v", err)
351 }
352
353 a.remoteTabsHostStatus("box", "stopped", "ssh: auth failed")
354 waitForTabState(t, a, meta.ID, "error")
355 a.remoteTabMu.Lock()
356 tabErr := a.remoteTabs[meta.ID].err
357 a.remoteTabMu.Unlock()
358 if !strings.Contains(tabErr, "ssh: auth failed") {
359 t.Fatalf("tab error = %q, want the host failure text", tabErr)
360 }
361 }
362
363 func TestCloseActiveRemoteTabSelectsAdjacentLocalTab(t *testing.T) {
364 a := &App{}
365 seedLocalTab(a, "local-a")
366 seedLocalTab(a, "local-b")
367 a.mu.Lock()
368 a.activeTabID = "local-a"
369 a.mu.Unlock()
370 a.remoteTabMu.Lock()
371 a.remoteTabs = map[string]*remoteTab{"remote": {id: "remote", state: "ready"}}
372 a.remoteTabLayout = remoteTabLayoutState{activeID: "remote", order: []string{"remote"}, stripOrder: []string{"local-a", "remote", "local-b"}}
373 a.remoteTabMu.Unlock()
374 if err := a.CloseRemoteTab("remote"); err != nil {
375 t.Fatal(err)
376 }
377 a.mu.RLock()
378 active := a.activeTabID
379 a.mu.RUnlock()
380 if active != "local-b" {
381 t.Fatalf("active local tab = %q, want adjacent local-b", active)
382 }
383 }
384
385 func TestStopRemoteServerParksTabsWithoutRestart(t *testing.T) {
386 fs := newFakeServe(t, "s3cret", nil)
387 kernel := &fakeRemoteKernel{
388 statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}},
389 ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL}, ensureToken: "s3cret",
390 }
391 seedBridgeTestHost(t, "box")
392 a := &App{remoteRuntime: kernel}
393 cleanupRemoteTabPumps(t, a)
394 meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{NewSession: true})
395 before := kernel.ensureCalls
396 if err := a.StopRemoteServer("box", "~/app"); err != nil {
397 t.Fatal(err)
398 }
399 time.Sleep(100 * time.Millisecond)
400 a.remoteTabMu.Lock()
401 state, client := a.remoteTabs[meta.ID].state, a.remoteTabs[meta.ID].client
402 a.remoteTabMu.Unlock()
403 if state != "serve_down" || client != nil {
404 t.Fatalf("stopped tab state/client = %q/%v", state, client)
405 }
406 if kernel.ensureCalls != before {
407 t.Fatalf("explicit stop restarted Serve: EnsureServer calls %d -> %d", before, kernel.ensureCalls)
408 }
409 }
410
411 // TestListTabsIncludesRemoteEntries pins the strip integration: open remote
412 // tabs appear in ListTabs, a highlighted remote tab deactivates the local
413 // entries, and SetActiveTab routes by registry membership.
414 func TestListTabsIncludesRemoteEntries(t *testing.T) {
415 fs := newFakeServe(t, "s3cret", nil)
416 kernel := &fakeRemoteKernel{
417 statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}},
418 ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL},
419 ensureToken: "s3cret",
420 }
421 seedBridgeTestHost(t, "box")
422 a := &App{remoteRuntime: kernel}
423 cleanupRemoteTabPumps(t, a)
424 meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{NewSession: true})
425
426 tabs := a.ListTabs()
427 var remote TabMeta
428 found := false
429 for _, tab := range tabs {
430 if tab.ID == meta.ID {
431 remote, found = tab, true
432 }
433 }
434 if !found {
435 t.Fatalf("remote tab missing from ListTabs: %+v", tabs)
436 }
437 if remote.Remote == nil || remote.Remote.HostID != "box" {
438 t.Fatalf("remote meta ref = %+v", remote.Remote)
439 }
440 if !remote.Active {
441 t.Fatal("freshly opened remote tab must carry the strip highlight")
442 }
443
444 if err := a.SetActiveTab(meta.ID); err != nil {
445 t.Fatalf("SetActiveTab(remote): %v", err)
446 }
447 a.remoteTabMu.Lock()
448 active := a.remoteTabLayout.activeID
449 a.remoteTabMu.Unlock()
450 if active != meta.ID {
451 t.Fatalf("remoteActiveTabID = %q, want %q", active, meta.ID)
452 }
453 // It is the sole visible surface, so the one-surface policy refuses to
454 // remove it and the entry stays listed.
455 err := a.CloseTabWithPolicy(meta.ID, "keep_running")
456 if err == nil || !strings.Contains(err.Error(), "cannot close the last tab") {
457 t.Fatalf("CloseTabWithPolicy(remote) = %v, want the last-surface refusal", err)
458 }
459 a.remoteTabMu.Lock()
460 _, present := a.remoteTabs[meta.ID]
461 a.remoteTabMu.Unlock()
462 if !present {
463 t.Fatal("a refused CloseTabWithPolicy must leave the remote tab registered")
464 }
465 }
466
467 // TestRemoteTabTitleAdoptsServeSession pins the title pipeline: the serve's
468 // LLM-generated title for the current session replaces the workspace-name
469 // default and reaches the chrome through the metadata-only update channel.
470 func TestRemoteTabTitleAdoptsServeSession(t *testing.T) {
471 fs := newFakeServe(t, "s3cret", []serveSessionEntry{
472 {Name: "s1", Path: "/x.jsonl", Title: "Fix the login bug", Turns: 1, Current: true},
473 })
474 kernel := &fakeRemoteKernel{
475 statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}},
476 ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL},
477 ensureToken: "s3cret",
478 }
479 seedBridgeTestHost(t, "box")
480 log := &eventLog{}
481 a := &App{remoteRuntime: kernel, remoteEventHook: log.add}
482 cleanupRemoteTabPumps(t, a)
483 // Resume the seeded session so it stays current; /new would abandon it.
484 meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{SessionName: "s1"})
485 log.mu.Lock()
486 log.events = nil
487 log.mu.Unlock()
488 a.remoteTabMu.Lock()
489 a.remoteTabs[meta.ID].topicTitle = remoteWorkspaceName("~/app")
490 a.remoteTabMu.Unlock()
491
492 a.refreshRemoteTabTitle(meta.ID)
493
494 a.remoteTabMu.Lock()
495 title := a.remoteTabs[meta.ID].topicTitle
496 a.remoteTabMu.Unlock()
497 if title != "Fix the login bug" {
498 t.Fatalf("topicTitle = %q, want the serve title", title)
499 }
500 found := false
501 for _, e := range log.recorded() {
502 if strings.HasPrefix(e, "remote-tab:updated ") && strings.Contains(e, meta.ID) && strings.Contains(e, "Fix the login bug") {
503 found = true
504 }
505 }
506 if !found {
507 t.Fatalf("title refresh not pushed to the chrome: %v", log.recorded())
508 }
509 for _, tab := range a.ListTabs() {
510 if tab.ID == meta.ID && tab.TopicTitle != "Fix the login bug" {
511 t.Fatalf("ListTabs title = %q", tab.TopicTitle)
512 }
513 }
514 }
515
516 func TestRemoteTabTitleRefreshRejectsRotatedSession(t *testing.T) {
517 fs := newFakeServe(t, "s3cret", []serveSessionEntry{{Name: "a", Path: "/a.jsonl", Title: "Title A", Current: true}})
518 kernel := &fakeRemoteKernel{
519 statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}},
520 ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL}, ensureToken: "s3cret",
521 }
522 seedBridgeTestHost(t, "box")
523 a := &App{remoteRuntime: kernel}
524 cleanupRemoteTabPumps(t, a)
525 meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{SessionName: "a"})
526 fs.mu.Lock()
527 fs.sessionsStarted = make(chan struct{}, 1)
528 fs.sessionsRelease = make(chan struct{})
529 started, release := fs.sessionsStarted, fs.sessionsRelease
530 fs.mu.Unlock()
531 done := make(chan struct{})
532 go func() { a.refreshRemoteTabTitle(meta.ID); close(done) }()
533 select {
534 case <-started:
535 case <-time.After(time.Second):
536 t.Fatal("title refresh did not start")
537 }
538 a.remoteTabMu.Lock()
539 tab := a.remoteTabs[meta.ID]
540 tab.session.name, tab.session.path = "b", "/b.jsonl"
541 tab.routing.currentPath, tab.topicTitle = "/b.jsonl", "Selected B"
542 a.remoteTabMu.Unlock()
543 close(release)
544 select {
545 case <-done:
546 case <-time.After(time.Second):
547 t.Fatal("title refresh did not return")
548 }
549 a.remoteTabMu.Lock()
550 name, path, route, title := tab.session.name, tab.session.path, tab.routing.currentPath, tab.topicTitle
551 a.remoteTabMu.Unlock()
552 if name != "b" || path != "/b.jsonl" || route != "/b.jsonl" || title != "Selected B" {
553 t.Fatalf("stale title refresh replaced rotated session: name=%q path=%q route=%q title=%q", name, path, route, title)
554 }
555 }
556
557 // TestRemoteTabNewSessionResetsServeSession: a NewSession open on an
558 // existing tab POSTs /new (the old session stays in the history list) and
559 // re-emits ready so the frontend re-syncs its snapshot.
560 func TestRemoteTabNewSessionResetsServeSession(t *testing.T) {
561 fs := newFakeServe(t, "s3cret", []serveSessionEntry{
562 {Name: "s1", Path: "/remote/sessions/s1.jsonl", Title: "Serve title", Turns: 1, Current: true},
563 })
564 kernel := &fakeRemoteKernel{
565 statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}},
566 ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL},
567 ensureToken: "s3cret",
568 }
569 seedBridgeTestHost(t, "box")
570 log := &eventLog{}
571 a := &App{remoteRuntime: kernel, remoteEventHook: log.add}
572 cleanupRemoteTabPumps(t, a)
573 meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{NewSession: true})
574
575 // The bootstrap already entered a fresh session; the listing carries the
576 // desktop-view blank (the serve abandoned s1 and lists no current row).
577 sessions, err := a.RemoteProjectSessions("box", "~/app")
578 if err != nil {
579 t.Fatal(err)
580 }
581 if len(sessions) == 0 || sessions[0].Name != "" || !sessions[0].Current || sessions[0].Title != "新的会话" {
582 t.Fatalf("sessions = %+v, want the synthetic blank leading the listing", sessions)
583 }
584 if err := a.RenameRemoteProjectSession("box", "~/app", "", "空白会话标题"); err != nil {
585 t.Fatal(err)
586 }
587 sessions, err = a.RemoteProjectSessions("box", "~/app")
588 if err != nil || len(sessions) == 0 || sessions[0].Title != "空白会话标题" {
589 t.Fatalf("renamed blank session = %+v, err=%v", sessions, err)
590 }
591
592 // A further new-session open reuses the blank: no extra POST /new — the
593 // same contract as the local reusable-blank tab.
594 newBefore, _, _ := fs.snapshot()
595 if _, err := a.OpenRemoteProjectTab("box", "~/app", RemoteTabOpenOptions{NewSession: true}); err != nil {
596 t.Fatal(err)
597 }
598 if newAfter, _, _ := fs.snapshot(); newAfter != newBefore {
599 t.Fatalf("POST /new called %d times after reuse, want %d", newAfter, newBefore)
600 }
601
602 // Resuming a listed session clears the blank and restores it as current.
603 if _, err := a.OpenRemoteProjectTab("box", "~/app", RemoteTabOpenOptions{SessionName: "s1"}); err != nil {
604 t.Fatal(err)
605 }
606 deadline := time.Now().Add(2 * time.Second)
607 for {
608 sessions, err = a.RemoteProjectSessions("box", "~/app")
609 if err != nil {
610 t.Fatal(err)
611 }
612 if len(sessions) > 0 && sessions[0].Name == "s1" && sessions[0].Current {
613 break
614 }
615 if time.Now().After(deadline) {
616 t.Fatalf("resume did not restore s1 as current: %+v", sessions)
617 }
618 time.Sleep(10 * time.Millisecond)
619 }
620 a.remoteTabMu.Lock()
621 reset := a.remoteTabs[meta.ID].session.reset
622 title := a.remoteTabs[meta.ID].topicTitle
623 a.remoteTabMu.Unlock()
624 if reset {
625 waitForRemoteSessionIdentity(t, a, meta.ID, "s1", sessions[0].Path)
626 a.remoteTabMu.Lock()
627 reset = a.remoteTabs[meta.ID].session.reset
628 title = a.remoteTabs[meta.ID].topicTitle
629 a.remoteTabMu.Unlock()
630 }
631 if reset {
632 t.Fatal("sessionReset must clear after a resume")
633 }
634 if title != "Serve title" {
635 t.Fatalf("topicTitle after resume = %q, want the serve title", title)
636 }
637 }
638
639 // TestRenameRemoteProjectSession pins the desktop-owned title chain: the
640 // override wins in the session listing, and a live tab holding that session
641 // adopts the new title immediately; clearing falls back to the serve title.
642 func TestRenameRemoteProjectSession(t *testing.T) {
643 home := t.TempDir()
644 t.Setenv("REASONIX_HOME", home)
645 t.Setenv("HOME", home)
646 if err := editUserConfig(func(c *config.Config) error {
647 return c.UpsertRemoteHost(config.RemoteHostEntry{Name: "box", Host: "127.0.0.1", Port: 22, User: "dev"})
648 }); err != nil {
649 t.Fatal(err)
650 }
651 fs := newFakeServe(t, "s3cret", []serveSessionEntry{
652 {Name: "s1", Path: "/x.jsonl", Title: "Serve title", Turns: 1, Current: true, MtimeMilli: 1700000000000},
653 })
654 kernel := &fakeRemoteKernel{
655 statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}},
656 ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL},
657 ensureToken: "s3cret",
658 }
659 log := &eventLog{}
660 a := &App{remoteRuntime: kernel, remoteEventHook: log.add}
661 cleanupRemoteTabPumps(t, a)
662 meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{SessionName: "s1"})
663
664 sessions, err := a.RemoteProjectSessions("box", "~/app")
665 if err != nil {
666 t.Fatal(err)
667 }
668 if len(sessions) != 1 || sessions[0].LastActivityAt != 1700000000000 || sessions[0].Title != "Serve title" {
669 t.Fatalf("sessions = %+v, want serve title + mtime passthrough", sessions)
670 }
671
672 if err := a.RenameRemoteProjectSession("box", "~/app", "s1", "我的新标题"); err != nil {
673 t.Fatal(err)
674 }
675 sessions, err = a.RemoteProjectSessions("box", "~/app")
676 if err != nil {
677 t.Fatal(err)
678 }
679 if sessions[0].Title != "我的新标题" {
680 t.Fatalf("override title = %q", sessions[0].Title)
681 }
682 a.remoteTabMu.Lock()
683 title := a.remoteTabs[meta.ID].topicTitle
684 a.remoteTabMu.Unlock()
685 if title != "我的新标题" {
686 t.Fatalf("live tab title = %q, want the override", title)
687 }
688 a.refreshRemoteTabTitle(meta.ID)
689 a.remoteTabMu.Lock()
690 title = a.remoteTabs[meta.ID].topicTitle
691 a.remoteTabMu.Unlock()
692 if title != "我的新标题" {
693 t.Fatalf("automatic refresh replaced the manual title with %q", title)
694 }
695
696 if err := a.RenameRemoteProjectSession("box", "~/app", "s1", ""); err != nil {
697 t.Fatal(err)
698 }
699 sessions, _ = a.RemoteProjectSessions("box", "~/app")
700 if sessions[0].Title != "Serve title" {
701 t.Fatalf("cleared override title = %q, want the serve title", sessions[0].Title)
702 }
703 }
704
705 // TestRemoteSessionPinnedOrderingAndProjectTitle pins the desktop-owned
706 // row pin (pinned-first listing) and the registry-backed project rename.
707 func TestRemoteSessionPinnedOrderingAndProjectTitle(t *testing.T) {
708 home := t.TempDir()
709 t.Setenv("REASONIX_HOME", home)
710 t.Setenv("HOME", home)
711 if err := editUserConfig(func(c *config.Config) error {
712 return c.UpsertRemoteHost(config.RemoteHostEntry{Name: "box", Host: "127.0.0.1", Port: 22, User: "dev"})
713 }); err != nil {
714 t.Fatal(err)
715 }
716 fs := newFakeServe(t, "s3cret", []serveSessionEntry{
717 {Name: "a", Path: "/a.jsonl", Title: "First", Current: false, MtimeMilli: 1},
718 {Name: "b", Path: "/b.jsonl", Title: "Second", Current: true, MtimeMilli: 2},
719 })
720 kernel := &fakeRemoteKernel{
721 statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}},
722 ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL},
723 ensureToken: "s3cret",
724 }
725 a := &App{remoteRuntime: kernel}
726 cleanupRemoteTabPumps(t, a)
727
728 if err := a.SetRemoteSessionPinned("box", "~/app", "a", true); err != nil {
729 t.Fatal(err)
730 }
731 sessions, err := a.RemoteProjectSessions("box", "~/app")
732 if err != nil {
733 t.Fatal(err)
734 }
735 if len(sessions) != 2 || sessions[0].Name != "a" || !sessions[0].Pinned || sessions[1].Pinned {
736 t.Fatalf("sessions = %+v, want pinned a first", sessions)
737 }
738
739 meta, err := a.OpenRemoteProjectTab("box", "~/app", RemoteTabOpenOptions{NewSession: true})
740 if err != nil {
741 t.Fatal(err)
742 }
743 _ = meta
744 if err := a.SetRemoteProjectTitle("box", "~/app", "云端演示"); err != nil {
745 t.Fatal(err)
746 }
747 for _, node := range a.ListTabs() {
748 _ = node
749 }
750 found := false
751 for _, node := range a.GetProjectTreeSnapshot().Projects {
752 if node.Remote != nil && node.Remote.HostID == "box" {
753 found = true
754 if node.Label != "云端演示" {
755 t.Fatalf("group label = %q, want the renamed title", node.Label)
756 }
757 }
758 }
759 if !found {
760 t.Fatal("remote group missing from the snapshot")
761 }
762 }
763
763 lines GO