返回 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 id, err := store.EnsureWorkspaceResolved(t.Context(), Workspace{ID: "candidate", Root: root})
79 if err != nil || id != "old-a" {
80 t.Fatalf("resolved id = %q, err = %v", id, err)
81 }
82 repaired, err := store.Load(t.Context())
83 if err != nil {
84 t.Fatal(err)
85 }
86 owner, ok := repaired.Workspaces["old-a"]
87 if !ok || len(repaired.Workspaces) != 1 || !slices.Equal(repaired.WorkspaceIDs, []string{"old-a"}) {
88 t.Fatalf("duplicate survived the repair: %+v", repaired.Workspaces)
89 }
90 if owner.Root != root || owner.Title != "kept" || !owner.Visible {
91 t.Fatalf("owner lost its identity or presentation: %+v", owner)
92 }
93 if !slices.Equal(owner.SessionIDs, []string{"session-a", "session-b"}) {
94 t.Fatalf("sessions = %v", owner.SessionIDs)
95 }
96 if owner.Organization == nil || len(owner.Organization.Groups) != 1 || owner.Organization.Groups[0].ID != "group-b" {
97 t.Fatalf("organization was dropped: %+v", owner.Organization)
98 }
99 if repaired.SourceMappings["source-b"].WorkspaceID != "old-a" {
100 t.Fatalf("source mapping still names the absorbed workspace: %+v", repaired.SourceMappings)
101 }
102 }
103
104 // The Global folder lives at a fixed directory. A user who also added that
105 // directory as an ordinary project owns it twice, and Global must survive:
106 // its ID is addressed across the app and cannot be removed or hidden.
107 func TestEnsureWorkspaceResolvedKeepsGlobalOverOlderProjectDuplicate(t *testing.T) {
108 root := t.TempDir()
109 path := filepath.Join(t.TempDir(), "workspace-state-v1.json")
110 state := newState()
111 state.WorkspaceIDs = []string{"project-legacy", GlobalWorkspaceID}
112 state.Workspaces["project-legacy"] = Workspace{
113 ID: "project-legacy", Root: root, Title: "global-workspace", Visible: true,
114 SessionIDs: []string{}, CreatedAt: time.Unix(0, 0).UTC(),
115 }
116 state.Workspaces[GlobalWorkspaceID] = Workspace{
117 ID: GlobalWorkspaceID, Root: root, Title: "Global", Visible: true,
118 SessionIDs: []string{}, CreatedAt: time.Unix(1, 0).UTC(),
119 }
120 writeRegistryState(t, path, state)
121
122 store := NewStore(path)
123 id, err := store.EnsureWorkspaceResolved(t.Context(), Workspace{ID: "project-legacy", Root: root, Title: "global-workspace"})
124 if err != nil || id != GlobalWorkspaceID {
125 t.Fatalf("resolved id = %q, err = %v", id, err)
126 }
127 repaired, err := store.Load(t.Context())
128 if err != nil {
129 t.Fatal(err)
130 }
131 if len(repaired.Workspaces) != 1 || repaired.Workspaces[GlobalWorkspaceID].Title != "Global" {
132 t.Fatalf("global did not keep the directory: %+v", repaired.Workspaces)
133 }
134 }
135
136 // A path that does not exist resolves through whichever ancestor does, so two
137 // distinct directories on an unreachable case-sensitive volume can share a key
138 // on a case-insensitive host. Folding records is not reversible: while the
139 // directory cannot be verified, a lookup answers but changes nothing.
140 func TestEnsureWorkspaceResolvedLeavesUnverifiableDirectoriesUnmerged(t *testing.T) {
141 missing := filepath.Join(t.TempDir(), "unmounted", "project")
142 path := filepath.Join(t.TempDir(), "workspace-state-v1.json")
143 state := newState()
144 state.WorkspaceIDs = []string{"project-a", "project-b"}
145 state.Workspaces["project-a"] = Workspace{ID: "project-a", Root: missing, SessionIDs: []string{"session-a"}}
146 state.Workspaces["project-b"] = Workspace{ID: "project-b", Root: missing + string(os.PathSeparator) + ".", SessionIDs: []string{"session-b"}}
147 state.SessionStates = map[string]SessionState{
148 "session-a": {Lifecycle: Active}, "session-b": {Lifecycle: Active},
149 }
150 before := writeRegistryState(t, path, state)
151
152 id, err := NewStore(path).EnsureWorkspaceResolved(t.Context(), Workspace{ID: "candidate", Root: missing})
153 if err != nil || id != "project-a" {
154 t.Fatalf("resolved id = %q, err = %v", id, err)
155 }
156 after, err := os.ReadFile(path)
157 if err != nil || strings.TrimSpace(string(after)) != string(before) {
158 t.Fatalf("lookup rewrote records for a directory it could not verify: %v", err)
159 }
160 }
161
162 // One record this host cannot resolve must not fail every other workspace.
163 func TestUnresolvableWorkspaceRootDoesNotBlockOtherWorkspaces(t *testing.T) {
164 loop := filepath.Join(t.TempDir(), "loop")
165 if err := os.Symlink(loop, loop); err != nil {
166 t.Skipf("symlink unavailable: %v", err)
167 }
168 root := t.TempDir()
169 path := filepath.Join(t.TempDir(), "workspace-state-v1.json")
170 state := newState()
171 state.WorkspaceIDs = []string{"project-broken", "project-ok"}
172 state.Workspaces["project-broken"] = Workspace{ID: "project-broken", Root: loop, SessionIDs: []string{}}
173 state.Workspaces["project-ok"] = Workspace{ID: "project-ok", Root: root, SessionIDs: []string{}}
174 writeRegistryState(t, path, state)
175
176 id, err := NewStore(path).EnsureWorkspaceResolved(t.Context(), Workspace{ID: "candidate", Root: root})
177 if err != nil || id != "project-ok" {
178 t.Fatalf("resolved id = %q, err = %v", id, err)
179 }
180 }
181
182 func TestEnsureWorkspaceResolvedUsesVersionedIDForRealCollision(t *testing.T) {
183 store := NewStore(filepath.Join(t.TempDir(), "workspace-state-v1.json"))
184 if err := store.EnsureWorkspace(t.Context(), Workspace{ID: "project-collision", Root: filepath.Join(t.TempDir(), "one")}); err != nil {
185 t.Fatal(err)
186 }
187 id, err := store.EnsureWorkspaceResolved(t.Context(), Workspace{ID: "project-collision", Root: filepath.Join(t.TempDir(), "two")})
188 if err != nil {
189 t.Fatal(err)
190 }
191 if !strings.HasPrefix(id, "project-v2-") {
192 t.Fatalf("fallback id = %q", id)
193 }
194 }
195
196 func TestEnsureWorkspaceAcceptsEquivalentRootWithoutRewritingState(t *testing.T) {
197 for _, id := range []string{GlobalWorkspaceID, "project-a"} {
198 t.Run(id, func(t *testing.T) {
199 root := filepath.Join(t.TempDir(), "Workspace")
200 path := filepath.Join(t.TempDir(), "workspace-state-v1.json")
201 store := NewStore(path)
202 if err := store.EnsureWorkspace(t.Context(), Workspace{ID: id, Root: root, Title: "My workspace", Visible: false}); err != nil {
203 t.Fatal(err)
204 }
205 if err := store.AttachSession(t.Context(), "", id, "saved-session", ""); err != nil {
206 t.Fatal(err)
207 }
208 if err := store.ArchiveSession(t.Context(), "saved-session"); err != nil {
209 t.Fatal(err)
210 }
211 before, err := os.ReadFile(path)
212 if err != nil {
213 t.Fatal(err)
214 }
215 aliases := []string{root + string(os.PathSeparator) + ".", root + string(os.PathSeparator)}
216 if runtime.GOOS == "windows" {
217 aliases = append(aliases, strings.ToUpper(root), strings.ReplaceAll(root, `\`, "/"))
218 }
219 for _, alias := range aliases {
220 // Reopen the registry just as a later process would. An equivalent
221 // spelling must preserve the old root, presentation and lifecycle.
222 if err := NewStore(path).EnsureWorkspace(t.Context(), Workspace{ID: id, Root: alias, Title: "Default", Visible: true}); err != nil {
223 t.Fatalf("equivalent root %q: %v", alias, err)
224 }
225 after, err := os.ReadFile(path)
226 if err != nil || string(after) != string(before) {
227 t.Fatalf("equivalent root rewrote persisted state: %v", err)
228 }
229 }
230 })
231 }
232 }
233
234 func TestEnsureWorkspaceRejectsDifferentRootWithoutRewritingState(t *testing.T) {
235 root := filepath.Join(t.TempDir(), "Workspace")
236 store := NewStore(filepath.Join(t.TempDir(), "workspace-state-v1.json"))
237 if err := store.EnsureWorkspace(t.Context(), Workspace{ID: GlobalWorkspaceID, Root: root}); err != nil {
238 t.Fatal(err)
239 }
240 before, err := os.ReadFile(store.Path())
241 if err != nil {
242 t.Fatal(err)
243 }
244 roots := []string{"", filepath.Join(root, "different")}
245 if runtime.GOOS != "windows" && runtime.GOOS != "darwin" {
246 roots = append(roots, strings.ToUpper(root))
247 }
248 for _, other := range roots {
249 if err := store.EnsureWorkspace(t.Context(), Workspace{ID: GlobalWorkspaceID, Root: other}); !errors.Is(err, ErrMutationConflict) {
250 t.Fatalf("different root %q: %v", other, err)
251 }
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
258 lines GO