返回 DeepSeek-Reasonix
remote_projects_test.go
根目录 / desktop / remote_projects_test.go
1 package main
2
3 import (
4 "strings"
5 "testing"
6 "time"
7
8 "reasonix/internal/config"
9 )
10
11 // TestSnapshotIncludesRemoteProjectGroups pins that pinned remote workspaces
12 // surface in the tree snapshot as ordinary project groups whose Remote ref
13 // marks them for the cloud icon, and that a config read failure degrades to
14 // "no remote groups" instead of failing the whole snapshot.
15 func TestSnapshotIncludesRemoteProjectGroups(t *testing.T) {
16 home := t.TempDir()
17 t.Setenv("REASONIX_HOME", home)
18 t.Setenv("HOME", home)
19 if err := editUserConfig(func(c *config.Config) error {
20 if err := c.UpsertRemoteHost(config.RemoteHostEntry{Name: "gpu-box", Host: "192.168.1.10", User: "dev"}); err != nil {
21 return err
22 }
23 return c.UpsertRemoteProject(config.RemoteProjectEntry{HostID: "gpu-box", Workspace: "/home/dev/app"})
24 }); err != nil {
25 t.Fatal(err)
26 }
27
28 a := &App{}
29 found := false
30 for _, node := range a.GetProjectTreeSnapshot().Projects {
31 if node.Remote == nil {
32 continue
33 }
34 if node.Remote.HostID != "gpu-box" || node.Remote.Workspace != "/home/dev/app" {
35 t.Fatalf("unexpected remote node: %+v", node)
36 }
37 found = true
38 if node.Kind != "project" {
39 t.Fatalf("remote group kind = %q, want project", node.Kind)
40 }
41 if !strings.HasPrefix(node.Key, "project_remote_") {
42 t.Fatalf("remote group key = %q", node.Key)
43 }
44 if node.Root != "remote-project:gpu-box:/home/dev/app" {
45 t.Fatalf("remote group root = %q, want host-qualified tree identity", node.Root)
46 }
47 if node.Label != "app" {
48 t.Fatalf("remote group label = %q, want workspace base name", node.Label)
49 }
50 }
51 if !found {
52 t.Fatal("snapshot missing the remote project group")
53 }
54 }
55
56 func TestRemoteProjectNodeKeysDoNotCollide(t *testing.T) {
57 home := t.TempDir()
58 t.Setenv("REASONIX_HOME", home)
59 t.Setenv("HOME", home)
60 if err := editUserConfig(func(c *config.Config) error {
61 for _, host := range []string{"a_b", "a"} {
62 if err := c.UpsertRemoteHost(config.RemoteHostEntry{Name: host, Host: "127.0.0.1"}); err != nil {
63 return err
64 }
65 }
66 if err := c.UpsertRemoteProject(config.RemoteProjectEntry{HostID: "a_b", Workspace: "c"}); err != nil {
67 return err
68 }
69 return c.UpsertRemoteProject(config.RemoteProjectEntry{HostID: "a", Workspace: "b_c"})
70 }); err != nil {
71 t.Fatal(err)
72 }
73 nodes, err := (&App{}).remoteProjectNodes()
74 if err != nil {
75 t.Fatal(err)
76 }
77 if len(nodes) != 2 || nodes[0].Key == nodes[1].Key {
78 t.Fatalf("remote project keys collided: %+v", nodes)
79 }
80 }
81
82 func TestRemoteProjectTreeIdentityIncludesHost(t *testing.T) {
83 home := t.TempDir()
84 t.Setenv("REASONIX_HOME", home)
85 t.Setenv("HOME", home)
86 if err := editUserConfig(func(c *config.Config) error {
87 for _, host := range []string{"host-a", "host-b"} {
88 if err := c.UpsertRemoteHost(config.RemoteHostEntry{Name: host, Host: "127.0.0.1"}); err != nil {
89 return err
90 }
91 if err := c.UpsertRemoteProject(config.RemoteProjectEntry{HostID: host, Workspace: "/srv/app"}); err != nil {
92 return err
93 }
94 }
95 return nil
96 }); err != nil {
97 t.Fatal(err)
98 }
99 nodes, err := (&App{}).remoteProjectNodes()
100 if err != nil {
101 t.Fatal(err)
102 }
103 if len(nodes) != 2 || nodes[0].Root == nodes[1].Root {
104 t.Fatalf("same-path remote roots collided: %+v", nodes)
105 }
106 }
107
108 func TestRemoteRootWorkspaceContainsAbsoluteDescendants(t *testing.T) {
109 if !isRemoteSubpath("/", "/home/dev/app") {
110 t.Fatal("POSIX root must contain every other absolute workspace")
111 }
112 if isRemoteSubpath("/", "/") || isRemoteSubpath("/", "relative/path") {
113 t.Fatal("root containment must stay strict and absolute")
114 }
115 }
116
117 // TestBootstrapNewSessionPublishesTabUpdateForSidebarBlankRow pins the
118 // sidebar's only signal for a brand-new remote session: the fresh session has
119 // no transcript on the serve, so the session listing synthesizes a blank row
120 // from the tab's reset flag — but only after the sidebar re-pulls, which it
121 // does on remote-tab:updated. A bootstrap that reaches ready without that
122 // update leaves the new project group empty.
123 func TestBootstrapNewSessionPublishesTabUpdateForSidebarBlankRow(t *testing.T) {
124 fs := newFakeServe(t, "s3cret", nil)
125 const freshPath = "/remote/sessions/fresh.jsonl"
126 fs.mu.Lock()
127 fs.newSessionPath = freshPath
128 fs.mu.Unlock()
129 kernel := &fakeRemoteKernel{
130 statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}},
131 ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL}, ensureToken: "s3cret",
132 }
133 seedBridgeTestHost(t, "box")
134 log := &eventLog{}
135 updates := make(chan TabMeta, 4)
136 a := &App{remoteRuntime: kernel, remoteEventHook: func(name string, payload any) {
137 log.add(name, payload)
138 if name != "remote-tab:updated" {
139 return
140 }
141 meta, ok := payload.(TabMeta)
142 if !ok {
143 return
144 }
145 select {
146 case updates <- meta:
147 default:
148 }
149 }}
150 cleanupRemoteTabPumps(t, a)
151 meta, err := a.OpenRemoteProjectTab("box", "~/app", RemoteTabOpenOptions{NewSession: true})
152 if err != nil {
153 t.Fatal(err)
154 }
155 timer := time.NewTimer(2 * time.Second)
156 defer timer.Stop()
157 var update TabMeta
158 for update.ID != meta.ID {
159 select {
160 case update = <-updates:
161 case <-timer.C:
162 t.Fatalf("bootstrap emitted no remote-tab:updated for tab %q, events: %v", meta.ID, log.recorded())
163 }
164 }
165 if !update.Ready || update.RemoteState != "ready" {
166 t.Fatalf("bootstrap update state = ready:%v remote:%q, want ready/ready", update.Ready, update.RemoteState)
167 }
168
169 a.remoteTabMu.Lock()
170 reset := a.remoteTabs[meta.ID].session.reset
171 a.remoteTabMu.Unlock()
172 if !reset {
173 t.Fatal("bootstrap did not mark the fresh session as reset")
174 }
175 sessions, err := a.RemoteProjectSessions("box", "~/app")
176 if err != nil {
177 t.Fatal(err)
178 }
179 if len(sessions) != 1 || sessions[0].Name != "" || sessions[0].Path != freshPath || !sessions[0].Current {
180 t.Fatalf("unlisted fresh session = %+v, want one synthetic current blank", sessions)
181 }
182
183 fs.mu.Lock()
184 fs.statusPayload = `{"sessionName":"fresh","sessionPath":"/remote/sessions/fresh.jsonl","running":false}`
185 fs.mu.Unlock()
186 if _, err := a.RemoteTabStatus(meta.ID); err != nil {
187 t.Fatal(err)
188 }
189 a.remoteTabMu.Lock()
190 reset = a.remoteTabs[meta.ID].session.reset
191 a.remoteTabMu.Unlock()
192 if reset {
193 t.Fatal("named status did not clear the fresh-session reset marker")
194 }
195 sessions, err = a.RemoteProjectSessions("box", "~/app")
196 if err != nil {
197 t.Fatal(err)
198 }
199 if len(sessions) != 1 || sessions[0].Name != "" || sessions[0].Path != freshPath || !sessions[0].Current {
200 t.Fatalf("named but unlisted fresh session = %+v, want the synthetic current blank", sessions)
201 }
202
203 fs.mu.Lock()
204 fs.sessions = []serveSessionEntry{{Name: "fresh", Path: freshPath, Title: "Fresh", Current: true}}
205 fs.mu.Unlock()
206 sessions, err = a.RemoteProjectSessions("box", "~/app")
207 if err != nil {
208 t.Fatal(err)
209 }
210 if len(sessions) != 1 || sessions[0].Name != "fresh" || sessions[0].Path != freshPath || !sessions[0].Current {
211 t.Fatalf("materialized fresh session = %+v, want the real current row", sessions)
212 }
213 }
214
214 lines GO