返回 DeepSeek-Reasonix
workspace_root_test.go
根目录 / desktop / internal / workspacestate / workspace_root_test.go
1 package workspacestate
2
3 import (
4 "encoding/json"
5 "errors"
6 "os"
7 "path/filepath"
8 "runtime"
9 "slices"
10 "strings"
11 "testing"
12 "time"
13 )
14
15 func TestEnsureWorkspaceResolvedReturnsExistingPhysicalOwner(t *testing.T) {
16 realRoot := t.TempDir()
17 alias := filepath.Join(t.TempDir(), "alias")
18 if err := os.Symlink(realRoot, alias); err != nil {
19 t.Skipf("symlink unavailable: %v", err)
20 }
21 store := NewStore(filepath.Join(t.TempDir(), "workspace-state-v1.json"))
22 if err := store.EnsureWorkspace(t.Context(), Workspace{ID: "legacy-project", Root: realRoot, Title: "kept"}); err != nil {
23 t.Fatal(err)
24 }
25 id, err := store.EnsureWorkspaceResolved(t.Context(), Workspace{ID: "new-candidate", Root: alias, Title: "ignored"})
26 if err != nil || id != "legacy-project" {
27 t.Fatalf("resolved id = %q, err = %v", id, err)
28 }
29 state, err := store.Load(t.Context())
30 if err != nil {
31 t.Fatal(err)
32 }
33 if len(state.Workspaces) != 1 || state.Workspaces[id].Root != realRoot || state.Workspaces[id].Title != "kept" {
34 t.Fatalf("state was rewritten: %+v", state.Workspaces)
35 }
36 }
37
38 func writeRegistryState(t *testing.T, path string, state State) []byte {
39 t.Helper()
40 state.Initialized = true
41 body, err := json.Marshal(state)
42 if err != nil {
43 t.Fatal(err)
44 }
45 if err := os.WriteFile(path, append(body, '\n'), 0o600); err != nil {
46 t.Fatal(err)
47 }
48 return body
49 }
50
51 // A state written before physical identity could record one directory twice.
52 // The registry must repair that on contact: refusing leaves every projection
53 // over the directory ambiguous, which strands both records in the sidebar.
54 func TestEnsureWorkspaceResolvedRepairsAmbiguousLegacyOwners(t *testing.T) {
55 root := t.TempDir()
56 alias := filepath.Join(t.TempDir(), "alias")
57 if err := os.Symlink(root, alias); err != nil {
58 t.Skipf("symlink unavailable: %v", err)
59 }
60 path := filepath.Join(t.TempDir(), "workspace-state-v1.json")
61 state := newState()
62 state.WorkspaceIDs = []string{"old-a", "old-b"}
63 state.Workspaces["old-a"] = Workspace{ID: "old-a", Root: root, Title: "kept", SessionIDs: []string{"session-a"}}
64 state.Workspaces["old-b"] = Workspace{
65 ID: "old-b", Root: alias, Visible: true, SessionIDs: []string{"session-b"},
66 Organization: &Organization{Groups: []OrganizationGroup{{ID: "group-b", Title: "B", Members: []string{SessionKey("session-b")}}}},
67 }
68 state.SessionStates = map[string]SessionState{
69 "session-a": {Lifecycle: Active}, "session-b": {Lifecycle: Active},
70 }
71 state.SourceMappings["source-b"] = SourceMapping{SourceKey: "source-b", SessionID: "session-b", WorkspaceID: "old-b", Fingerprint: "fp-b"}
72 writeRegistryState(t, path, state)
73
74 store := NewStore(path)
75 if id, found, err := ResolveWorkspaceID(state, alias); err != nil || !found || id != "old-a" {
76 t.Fatalf("read-only resolution = %q (found=%v): %v", id, found, err)
77 }
78 index := NewWorkspaceIndex(state)
79 for _, candidate := range []string{root, alias} {
80 if id, found, err := index.Resolve(candidate); err != nil || !found || id != "old-a" {
81 t.Fatalf("indexed resolution for %q = %q (found=%v): %v", candidate, id, found, err)
82 }
83 }
84 id, err := store.EnsureWorkspaceResolved(t.Context(), Workspace{ID: "candidate", Root: root})
85 if err != nil || id != "old-a" {
86 t.Fatalf("resolved id = %q, err = %v", id, err)
87 }
88 repaired, err := store.Load(t.Context())
89 if err != nil {
90 t.Fatal(err)
91 }
92 owner, ok := repaired.Workspaces["old-a"]
93 if !ok || len(repaired.Workspaces) != 1 || !slices.Equal(repaired.WorkspaceIDs, []string{"old-a"}) {
94 t.Fatalf("duplicate survived the repair: %+v", repaired.Workspaces)
95 }
96 if owner.Root != root || owner.Title != "kept" || !owner.Visible {
97 t.Fatalf("owner lost its identity or presentation: %+v", owner)
98 }
99 if !slices.Equal(owner.SessionIDs, []string{"session-a", "session-b"}) {
100 t.Fatalf("sessions = %v", owner.SessionIDs)
101 }
102 if owner.Organization == nil || len(owner.Organization.Groups) != 1 || owner.Organization.Groups[0].ID != "group-b" {
103 t.Fatalf("organization was dropped: %+v", owner.Organization)
104 }
105 if repaired.SourceMappings["source-b"].WorkspaceID != "old-a" {
106 t.Fatalf("source mapping still names the absorbed workspace: %+v", repaired.SourceMappings)
107 }
108 }
109
110 // The Global folder lives at a fixed directory. A user who also added that
111 // directory as an ordinary project owns it twice, and Global must survive:
112 // its ID is addressed across the app and cannot be removed or hidden.
113 func TestEnsureWorkspaceResolvedKeepsGlobalOverOlderProjectDuplicate(t *testing.T) {
114 root := t.TempDir()
115 path := filepath.Join(t.TempDir(), "workspace-state-v1.json")
116 state := newState()
117 state.WorkspaceIDs = []string{"project-legacy", GlobalWorkspaceID}
118 state.Workspaces["project-legacy"] = Workspace{
119 ID: "project-legacy", Root: root, Title: "global-workspace", Visible: true,
120 SessionIDs: []string{}, CreatedAt: time.Unix(0, 0).UTC(),
121 }
122 state.Workspaces[GlobalWorkspaceID] = Workspace{
123 ID: GlobalWorkspaceID, Root: root, Title: "Global", Visible: true,
124 SessionIDs: []string{}, CreatedAt: time.Unix(1, 0).UTC(),
125 }
126 writeRegistryState(t, path, state)
127
128 store := NewStore(path)
129 id, err := store.EnsureWorkspaceResolved(t.Context(), Workspace{ID: "project-legacy", Root: root, Title: "global-workspace"})
130 if err != nil || id != GlobalWorkspaceID {
131 t.Fatalf("resolved id = %q, err = %v", id, err)
132 }
133 repaired, err := store.Load(t.Context())
134 if err != nil {
135 t.Fatal(err)
136 }
137 if len(repaired.Workspaces) != 1 || repaired.Workspaces[GlobalWorkspaceID].Title != "Global" {
138 t.Fatalf("global did not keep the directory: %+v", repaired.Workspaces)
139 }
140 }
141
142 // A path that does not exist resolves through whichever ancestor does, so two
143 // distinct directories on an unreachable case-sensitive volume can share a key
144 // on a case-insensitive host. Folding records is not reversible: while the
145 // directory cannot be verified, a lookup answers but changes nothing.
146 func TestEnsureWorkspaceResolvedLeavesUnverifiableDirectoriesUnmerged(t *testing.T) {
147 missing := filepath.Join(t.TempDir(), "unmounted", "project")
148 path := filepath.Join(t.TempDir(), "workspace-state-v1.json")
149 state := newState()
150 state.WorkspaceIDs = []string{"project-a", "project-b"}
151 state.Workspaces["project-a"] = Workspace{ID: "project-a", Root: missing, SessionIDs: []string{"session-a"}}
152 state.Workspaces["project-b"] = Workspace{ID: "project-b", Root: missing + string(os.PathSeparator) + ".", SessionIDs: []string{"session-b"}}
153 state.SessionStates = map[string]SessionState{
154 "session-a": {Lifecycle: Active}, "session-b": {Lifecycle: Active},
155 }
156 before := writeRegistryState(t, path, state)
157
158 id, err := NewStore(path).EnsureWorkspaceResolved(t.Context(), Workspace{ID: "candidate", Root: missing})
159 if err != nil || id != "project-a" {
160 t.Fatalf("resolved id = %q, err = %v", id, err)
161 }
162 after, err := os.ReadFile(path)
163 if err != nil || strings.TrimSpace(string(after)) != string(before) {
164 t.Fatalf("lookup rewrote records for a directory it could not verify: %v", err)
165 }
166 }
167
168 // One record this host cannot resolve must not fail every other workspace.
169 func TestUnresolvableWorkspaceRootDoesNotBlockOtherWorkspaces(t *testing.T) {
170 loop := filepath.Join(t.TempDir(), "loop")
171 if err := os.Symlink(loop, loop); err != nil {
172 t.Skipf("symlink unavailable: %v", err)
173 }
174 root := t.TempDir()
175 path := filepath.Join(t.TempDir(), "workspace-state-v1.json")
176 state := newState()
177 state.WorkspaceIDs = []string{"project-broken", "project-ok"}
178 state.Workspaces["project-broken"] = Workspace{ID: "project-broken", Root: loop, SessionIDs: []string{}}
179 state.Workspaces["project-ok"] = Workspace{ID: "project-ok", Root: root, SessionIDs: []string{}}
180 writeRegistryState(t, path, state)
181
182 id, err := NewStore(path).EnsureWorkspaceResolved(t.Context(), Workspace{ID: "candidate", Root: root})
183 if err != nil || id != "project-ok" {
184 t.Fatalf("resolved id = %q, err = %v", id, err)
185 }
186 }
187
188 func TestEnsureWorkspaceResolvedUsesVersionedIDForRealCollision(t *testing.T) {
189 store := NewStore(filepath.Join(t.TempDir(), "workspace-state-v1.json"))
190 if err := store.EnsureWorkspace(t.Context(), Workspace{ID: "project-collision", Root: filepath.Join(t.TempDir(), "one")}); err != nil {
191 t.Fatal(err)
192 }
193 id, err := store.EnsureWorkspaceResolved(t.Context(), Workspace{ID: "project-collision", Root: filepath.Join(t.TempDir(), "two")})
194 if err != nil {
195 t.Fatal(err)
196 }
197 if !strings.HasPrefix(id, "project-v2-") {
198 t.Fatalf("fallback id = %q", id)
199 }
200 }
201
202 func TestEnsureWorkspaceAcceptsEquivalentRootWithoutRewritingState(t *testing.T) {
203 for _, id := range []string{GlobalWorkspaceID, "project-a"} {
204 t.Run(id, func(t *testing.T) {
205 root := filepath.Join(t.TempDir(), "Workspace")
206 path := filepath.Join(t.TempDir(), "workspace-state-v1.json")
207 store := NewStore(path)
208 if err := store.EnsureWorkspace(t.Context(), Workspace{ID: id, Root: root, Title: "My workspace", Visible: false}); err != nil {
209 t.Fatal(err)
210 }
211 if err := store.AttachSession(t.Context(), "", id, "saved-session", ""); err != nil {
212 t.Fatal(err)
213 }
214 if err := store.ArchiveSession(t.Context(), "saved-session"); err != nil {
215 t.Fatal(err)
216 }
217 before, err := os.ReadFile(path)
218 if err != nil {
219 t.Fatal(err)
220 }
221 aliases := []string{root + string(os.PathSeparator) + ".", root + string(os.PathSeparator)}
222 if runtime.GOOS == "windows" {
223 aliases = append(aliases, strings.ToUpper(root), strings.ReplaceAll(root, `\`, "/"))
224 }
225 for _, alias := range aliases {
226 // Reopen the registry just as a later process would. An equivalent
227 // spelling must preserve the old root, presentation and lifecycle.
228 if err := NewStore(path).EnsureWorkspace(t.Context(), Workspace{ID: id, Root: alias, Title: "Default", Visible: true}); err != nil {
229 t.Fatalf("equivalent root %q: %v", alias, err)
230 }
231 after, err := os.ReadFile(path)
232 if err != nil || string(after) != string(before) {
233 t.Fatalf("equivalent root rewrote persisted state: %v", err)
234 }
235 }
236 })
237 }
238 }
239
240 func TestEnsureWorkspaceRejectsDifferentRootWithoutRewritingState(t *testing.T) {
241 root := filepath.Join(t.TempDir(), "Workspace")
242 store := NewStore(filepath.Join(t.TempDir(), "workspace-state-v1.json"))
243 if err := store.EnsureWorkspace(t.Context(), Workspace{ID: GlobalWorkspaceID, Root: root}); err != nil {
244 t.Fatal(err)
245 }
246 before, err := os.ReadFile(store.Path())
247 if err != nil {
248 t.Fatal(err)
249 }
250 if err := store.EnsureWorkspace(t.Context(), Workspace{ID: GlobalWorkspaceID, Root: ""}); !errors.Is(err, ErrMutationConflict) {
251 t.Fatalf("empty root: %v", err)
252 }
253 after, err := os.ReadFile(store.Path())
254 if err != nil || string(after) != string(before) {
255 t.Fatalf("conflicting root rewrote persisted state: %v", err)
256 }
257 }
258
259 // Global is derived from the data directory, so a stored root elsewhere is
260 // where that directory used to be. Refusing it left Global unloadable after a
261 // portable build or REASONIX_HOME moved (#10638, #10659).
262 func TestEnsureWorkspaceResolvedRebindsGlobalAfterDataDirectoryMoves(t *testing.T) {
263 oldRoot := filepath.Join(t.TempDir(), "old-home", "global-workspace")
264 newRoot := filepath.Join(t.TempDir(), "new-home", "global-workspace")
265 path := filepath.Join(t.TempDir(), "workspace-state-v1.json")
266 state := newState()
267 state.WorkspaceIDs = []string{GlobalWorkspaceID}
268 state.Workspaces[GlobalWorkspaceID] = Workspace{ID: GlobalWorkspaceID, Root: oldRoot, Title: "Global", Visible: true, SessionIDs: []string{"kept"}}
269 state.SessionStates = map[string]SessionState{"kept": {Lifecycle: Active}}
270 writeRegistryState(t, path, state)
271
272 id, err := NewStore(path).EnsureWorkspaceResolved(t.Context(), Workspace{ID: GlobalWorkspaceID, Root: newRoot, Title: "Global", Visible: true})
273 if err != nil || id != GlobalWorkspaceID {
274 t.Fatalf("resolved id = %q, err = %v", id, err)
275 }
276 loaded, err := NewStore(path).Load(t.Context())
277 if err != nil {
278 t.Fatal(err)
279 }
280 global := loaded.Workspaces[GlobalWorkspaceID]
281 if global.Root != newRoot || !slices.Equal(global.FormerRoots, []string{oldRoot}) || !slices.Equal(global.SessionIDs, []string{"kept"}) {
282 t.Fatalf("global after rebind = %+v", global)
283 }
284 if len(loaded.WorkspaceIDs) != 1 {
285 t.Fatalf("rebind registered another workspace: %v", loaded.WorkspaceIDs)
286 }
287 if _, err := NewStore(path).EnsureWorkspaceResolved(t.Context(), Workspace{ID: GlobalWorkspaceID, Root: oldRoot}); err != nil {
288 t.Fatalf("moving back: %v", err)
289 }
290 if back, _ := NewStore(path).Load(t.Context()); back.Workspaces[GlobalWorkspaceID].Root != oldRoot || !slices.Equal(back.Workspaces[GlobalWorkspaceID].FormerRoots, []string{newRoot}) {
291 t.Fatalf("global after moving back = %+v", back.Workspaces[GlobalWorkspaceID])
292 }
293 }
294
295 func TestReconcileDiscoveredSessionRebindsGlobalAfterDataDirectoryMoves(t *testing.T) {
296 oldRoot := filepath.Join(t.TempDir(), "old-home", "global-workspace")
297 newRoot := filepath.Join(t.TempDir(), "new-home", "global-workspace")
298 path := filepath.Join(t.TempDir(), "workspace-state-v1.json")
299 state := newState()
300 state.WorkspaceIDs = []string{GlobalWorkspaceID}
301 state.Workspaces[GlobalWorkspaceID] = Workspace{ID: GlobalWorkspaceID, Root: oldRoot, Visible: true}
302 writeRegistryState(t, path, state)
303
304 err := NewStore(path).ReconcileDiscoveredSession(t.Context(),
305 RecoveryEntry{ID: "canonical-s", SourceKey: "canonical:s", SessionID: "s", Format: "canonical", Status: "pending"},
306 &Workspace{ID: GlobalWorkspaceID, Root: newRoot, Visible: true})
307 if err != nil {
308 t.Fatal(err)
309 }
310 loaded, err := NewStore(path).Load(t.Context())
311 if err != nil {
312 t.Fatal(err)
313 }
314 if global := loaded.Workspaces[GlobalWorkspaceID]; global.Root != newRoot || !slices.Equal(global.SessionIDs, []string{"s"}) {
315 t.Fatalf("global after discovery = %+v", global)
316 }
317 }
318
318 lines GO