返回 DeepSeek-Reasonix
remote_tab_reattach_selection_test.go
根目录 / desktop / remote_tab_reattach_selection_test.go
1 package main
2
3 import (
4 "testing"
5 )
6
7 func reattachSelectionFixture(t *testing.T, sessions []serveSessionEntry) (*fakeServe, *App) {
8 t.Helper()
9 fs := newFakeServe(t, "s3cret", sessions)
10 kernel := &fakeRemoteKernel{
11 statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}},
12 ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL, InstanceID: "serve-1"}, ensureToken: "s3cret",
13 }
14 seedBridgeTestHost(t, "box")
15 a := &App{remoteRuntime: kernel}
16 cleanupRemoteTabPumps(t, a)
17 previousDelays := remoteTabReattachDelays
18 remoteTabReattachDelays = nil
19 t.Cleanup(func() { remoteTabReattachDelays = previousDelays })
20 return fs, a
21 }
22
23 // dropRemoteTabPump retires the live pump the way a dead stream does, leaving
24 // the tab reconnecting on the same Serve instance.
25 func dropRemoteTabPump(a *App, tabID string) {
26 a.remoteTabMu.Lock()
27 defer a.remoteTabMu.Unlock()
28 tab := a.remoteTabs[tabID]
29 if tab.cancel != nil {
30 tab.cancel()
31 }
32 tab.gen++
33 tab.cancel, tab.client, tab.base, tab.token = nil, nil, "", ""
34 tab.state = "reconnecting"
35 }
36
37 // A reattach on a surviving Serve must land on the session the tab was opened
38 // for. Another client can move Serve's foreground while the stream is down;
39 // publishing ready without re-entering would let the next /status adopt that
40 // foreign session and silently swap the user's conversation.
41 func TestRemoteTabReattachReentersSelectedSessionWhenServeMoved(t *testing.T) {
42 const selected = "/sessions/s2.jsonl"
43 fs, a := reattachSelectionFixture(t, []serveSessionEntry{
44 {Name: "s1", Path: "/sessions/s1.jsonl", Current: true},
45 {Name: "s2", Path: selected},
46 })
47 meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{SessionName: "s2", SessionPath: selected})
48
49 fs.mu.Lock()
50 for i := range fs.sessions {
51 fs.sessions[i].Current = fs.sessions[i].Name == "s1"
52 }
53 fs.resumePath = ""
54 fs.mu.Unlock()
55 dropRemoteTabPump(a, meta.ID)
56
57 if !a.reattachRemoteTabOnce(meta.ID) {
58 t.Fatal("reattach on the surviving serve failed")
59 }
60 _, resumed, _ := fs.snapshot()
61 if resumed != selected {
62 t.Fatalf("reattach resumed %q, want the tab's selected session %q re-entered", resumed, selected)
63 }
64 a.remoteTabMu.Lock()
65 tab := a.remoteTabs[meta.ID]
66 state, route, name := tab.state, tab.routing.currentPath, tab.session.name
67 a.remoteTabMu.Unlock()
68 if state != "ready" || route != selected || name != "s2" {
69 t.Fatalf("reattached state/route/name = %q/%q/%q, want ready on %q", state, route, name, selected)
70 }
71 sessions, err := a.RemoteProjectSessions("box", "~/app")
72 if err != nil {
73 t.Fatal(err)
74 }
75 for _, session := range sessions {
76 if session.Current && session.Path != selected {
77 t.Fatalf("serve foreground after reattach = %+v, want %q", session, selected)
78 }
79 }
80 }
81
82 // When Serve still runs the selected session the reattach only rebuilds the
83 // stream: no redundant /resume transition is issued.
84 func TestRemoteTabReattachKeepsStreamWhenServeStillRunsSelection(t *testing.T) {
85 const selected = "/sessions/s2.jsonl"
86 fs, a := reattachSelectionFixture(t, []serveSessionEntry{
87 {Name: "s1", Path: "/sessions/s1.jsonl", Current: true},
88 {Name: "s2", Path: selected},
89 })
90 meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{SessionName: "s2", SessionPath: selected})
91 fs.mu.Lock()
92 fs.resumePath = ""
93 fs.mu.Unlock()
94 dropRemoteTabPump(a, meta.ID)
95
96 if !a.reattachRemoteTabOnce(meta.ID) {
97 t.Fatal("reattach on the surviving serve failed")
98 }
99 if _, resumed, _ := fs.snapshot(); resumed != "" {
100 t.Fatalf("reattach re-entered %q although serve still ran the selection", resumed)
101 }
102 a.remoteTabMu.Lock()
103 state, route := a.remoteTabs[meta.ID].state, a.remoteTabs[meta.ID].routing.currentPath
104 a.remoteTabMu.Unlock()
105 if state != "ready" || route != selected {
106 t.Fatalf("reattached state/route = %q/%q, want ready on %q", state, route, selected)
107 }
108 }
109
110 // A first-open New Topic whose /events stream is refused never sent /new. The
111 // reattach loop that recovers it must still create the requested blank instead
112 // of publishing ready on whatever session Serve happens to run.
113 func TestRemoteTabFirstOpenReattachEntersRequestedNewSession(t *testing.T) {
114 const fresh = "/sessions/fresh.jsonl"
115 fs, a := reattachSelectionFixture(t, []serveSessionEntry{{Name: "s1", Path: "/sessions/s1.jsonl", Current: true}})
116 fs.mu.Lock()
117 fs.eventsFailCount = 1
118 fs.newSessionPath = fresh
119 fs.mu.Unlock()
120
121 meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{NewSession: true})
122 newCalled, _, _ := fs.snapshot()
123 if newCalled != 1 {
124 t.Fatalf("POST /new called %d times, want exactly one from the recovering reattach", newCalled)
125 }
126 a.remoteTabMu.Lock()
127 tab := a.remoteTabs[meta.ID]
128 route, reset, name := tab.routing.currentPath, tab.session.reset, tab.session.name
129 a.remoteTabMu.Unlock()
130 if route != fresh || !reset || name != "" {
131 t.Fatalf("recovered New Topic route/reset/name = %q/%v/%q, want the fresh blank %q", route, reset, name, fresh)
132 }
133 if err := a.SubmitRemoteTab(meta.ID, "hi"); err != nil {
134 t.Fatalf("submit after recovered first open: %v", err)
135 }
136 if fenced := fs.recordedExpectedPaths(); len(fenced) == 0 || fenced[len(fenced)-1] != fresh {
137 t.Fatalf("submit fenced against %v, want the recovered blank %q", fenced, fresh)
138 }
139 }
140
140 lines GO