返回 DeepSeek-Reasonix
workspace_watch_test.go
根目录 / desktop / workspace_watch_test.go
1 package main
2
3 import (
4 "os"
5 "os/exec"
6 "path/filepath"
7 "runtime"
8 "strings"
9 "testing"
10 "time"
11
12 "github.com/fsnotify/fsnotify"
13 "reasonix/internal/event"
14 )
15
16 func contentWorkspaceMutation(paths []string, allPaths bool) event.WorkspaceMutation {
17 return event.WorkspaceMutation{Paths: paths, AllPaths: allPaths, Content: true, Tree: true, WorkingTree: true}
18 }
19
20 func TestWorkspaceChangeHubSharesRootRevisionsAndIsolatesSessions(t *testing.T) {
21 root := t.TempDir()
22 app := &App{tabs: map[string]*WorkspaceTab{}}
23 app.workspaceHub = newWorkspaceChangeHub(app)
24 t.Cleanup(func() { app.workspaceHub.close() })
25 app.tabs["a"] = &WorkspaceTab{ID: "a", WorkspaceRoot: root}
26 app.tabs["b"] = &WorkspaceTab{ID: "b", WorkspaceRoot: root}
27 // This tests revision routing, not OS watcher delivery. Seed the shared
28 // root so startup events cannot race the two independent reads.
29 key := canonicalWorkspaceRoot(root)
30 app.workspaceHub.roots[key] = &workspaceWatchRoot{key: key, root: key,
31 state: event.WorkspaceWatchActive, pending: make(map[string]event.WorkspacePathChange)}
32
33 beforeA := app.WorkspaceRevisionForTab("a")
34 beforeB := app.WorkspaceRevisionForTab("b")
35 app.workspaceHub.observeAgentMutation("a", contentWorkspaceMutation([]string{"pkg/main.go"}, false))
36 afterA := app.WorkspaceRevisionForTab("a")
37 afterB := app.WorkspaceRevisionForTab("b")
38 if afterA.Revisions.Content <= beforeA.Revisions.Content || afterB.Revisions.Content != afterA.Revisions.Content {
39 t.Fatalf("root content revision not shared: before=%+v afterA=%+v afterB=%+v", beforeA, afterA, afterB)
40 }
41 if afterA.Revisions.Session <= beforeA.Revisions.Session || afterB.Revisions.Session != beforeB.Revisions.Session {
42 t.Fatalf("session revision leaked across tabs: beforeA=%+v beforeB=%+v afterA=%+v afterB=%+v", beforeA, beforeB, afterA, afterB)
43 }
44 }
45
46 func TestWorkspaceChangeHubCapsOpaqueMutation(t *testing.T) {
47 root := t.TempDir()
48 app := &App{tabs: map[string]*WorkspaceTab{"a": {ID: "a", WorkspaceRoot: root}}}
49 app.workspaceHub = newWorkspaceChangeHub(app)
50 t.Cleanup(func() { app.workspaceHub.close() })
51 app.workspaceHub.observeAgentMutation("a", contentWorkspaceMutation(nil, true))
52 key := canonicalWorkspaceRoot(root)
53 app.workspaceHub.mu.Lock()
54 r := app.workspaceHub.roots[key]
55 allPaths := r != nil && r.allPaths
56 app.workspaceHub.mu.Unlock()
57 if !allPaths {
58 t.Fatal("opaque mutation did not become allPaths invalidation")
59 }
60 }
61
62 func TestWorkspaceChangeHubFilesystemWritePublishesContentRevision(t *testing.T) {
63 root := t.TempDir()
64 path := filepath.Join(root, "file.txt")
65 if err := os.WriteFile(path, []byte("before"), 0o600); err != nil {
66 t.Fatal(err)
67 }
68 app := &App{tabs: map[string]*WorkspaceTab{"a": {ID: "a", WorkspaceRoot: root}}}
69 app.workspaceHub = newWorkspaceChangeHub(app)
70 t.Cleanup(func() { app.workspaceHub.close() })
71 initial := app.WorkspaceRevisionForTab("a").Revisions.Content
72 if err := os.WriteFile(path, []byte("after"), 0o600); err != nil {
73 t.Fatal(err)
74 }
75 // The watcher callback is asynchronous; wait without imposing a fixed
76 // sleep so slow CI filesystems get the same bounded opportunity.
77 deadline := time.Now().Add(2 * time.Second)
78 for time.Now().Before(deadline) {
79 if app.WorkspaceRevisionForTab("a").Revisions.Content > initial {
80 return
81 }
82 time.Sleep(10 * time.Millisecond)
83 }
84 t.Fatal("filesystem write did not advance content revision")
85 }
86
87 func TestWorkspaceChangeHubDoesNotDropFilesystemWriteAfterAgentMutation(t *testing.T) {
88 root := t.TempDir()
89 path := filepath.Join(root, "file.txt")
90 if err := os.WriteFile(path, []byte("before"), 0o600); err != nil {
91 t.Fatal(err)
92 }
93 app := &App{tabs: map[string]*WorkspaceTab{"a": {ID: "a", WorkspaceRoot: root}}}
94 app.workspaceHub = newWorkspaceChangeHub(app)
95 t.Cleanup(func() { app.workspaceHub.close() })
96
97 waitForWorkspaceHubStartupToSettle(t, app, "a")
98 before := app.WorkspaceRevisionForTab("a").Revisions.Content
99 app.workspaceHub.observeAgentMutation("a", contentWorkspaceMutation([]string{"file.txt"}, false))
100 key := canonicalWorkspaceRoot(root)
101 app.workspaceHub.observeFilesystem(key, fsnotify.Event{Name: path, Op: fsnotify.Write})
102 after := app.WorkspaceRevisionForTab("a").Revisions.Content
103 if after != before+2 {
104 t.Fatalf("content revision = %d, want %d (agent and filesystem writes are independently observable)", after, before+2)
105 }
106 }
107
108 func TestWorkspaceChangeHubRejectsRelativeTraversalMetadata(t *testing.T) {
109 root := t.TempDir()
110 app := &App{tabs: map[string]*WorkspaceTab{"a": {ID: "a", WorkspaceRoot: root}}}
111 app.workspaceHub = newWorkspaceChangeHub(app)
112 t.Cleanup(func() { app.workspaceHub.close() })
113 app.workspaceHub.observeAgentMutation("a", contentWorkspaceMutation([]string{"../outside.txt"}, false))
114
115 key := canonicalWorkspaceRoot(root)
116 app.workspaceHub.mu.Lock()
117 r := app.workspaceHub.roots[key]
118 allPaths := r != nil && r.allPaths
119 _, leaked := r.pending["../outside.txt"]
120 app.workspaceHub.mu.Unlock()
121 if !allPaths || leaked {
122 t.Fatalf("relative traversal was not safely degraded: allPaths=%v leaked=%v", allPaths, leaked)
123 }
124 }
125
126 func TestWorkspaceChangeHubAdvancesOnlyDeclaredAgentResources(t *testing.T) {
127 root := t.TempDir()
128 app := &App{tabs: map[string]*WorkspaceTab{"a": {ID: "a", WorkspaceRoot: root}}}
129 app.workspaceHub = newWorkspaceChangeHub(app)
130 t.Cleanup(func() { app.workspaceHub.close() })
131 waitForWorkspaceHubStartupToSettle(t, app, "a")
132 before := app.WorkspaceRevisionForTab("a").Revisions
133
134 app.workspaceHub.observeAgentMutation("a", event.WorkspaceMutation{WorkingTree: true, GitMeta: true})
135 after := app.WorkspaceRevisionForTab("a").Revisions
136 if after.Content != before.Content || after.Tree != before.Tree {
137 t.Fatalf("git-only invalidation advanced content/tree: before=%+v after=%+v", before, after)
138 }
139 if after.WorkingTree != before.WorkingTree+1 || after.GitMeta != before.GitMeta+1 || after.Session != before.Session+1 {
140 t.Fatalf("git-only revisions not advanced independently: before=%+v after=%+v", before, after)
141 }
142 }
143
144 func waitForWorkspaceHubStartupToSettle(t *testing.T, app *App, tabID string) {
145 t.Helper()
146 if runtime.GOOS != "darwin" {
147 return
148 }
149 last := app.WorkspaceRevisionForTab(tabID).Revisions
150 stableSince := time.Now()
151 deadline := stableSince.Add(3 * time.Second)
152 for time.Now().Before(deadline) {
153 time.Sleep(10 * time.Millisecond)
154 current := app.WorkspaceRevisionForTab(tabID).Revisions
155 if current != last {
156 last = current
157 stableSince = time.Now()
158 continue
159 }
160 if time.Since(stableSince) >= 250*time.Millisecond {
161 return
162 }
163 }
164 t.Fatal("workspace watcher startup events did not settle")
165 }
166
167 func TestTabEventSinkForwardsImmediateWorkspaceMutation(t *testing.T) {
168 root := t.TempDir()
169 app := &App{tabs: map[string]*WorkspaceTab{"a": {ID: "a", WorkspaceRoot: root}}}
170 app.workspaceHub = newWorkspaceChangeHub(app)
171 t.Cleanup(func() { app.workspaceHub.close() })
172 waitForWorkspaceHubStartupToSettle(t, app, "a")
173 sink := event.Sync(&tabEventSink{tabID: "a", app: app})
174 before := app.WorkspaceRevisionForTab("a").Revisions
175
176 event.RecordWorkspaceMutation(sink, event.WorkspaceMutation{
177 ToolID: "write", ToolName: "write_file", Paths: []string{"file.go"}, Content: true, Tree: true, WorkingTree: true,
178 })
179 after := app.WorkspaceRevisionForTab("a").Revisions
180 if after.Content != before.Content+1 || after.Tree != before.Tree+1 || after.WorkingTree != before.WorkingTree+1 || after.Session != before.Session+1 {
181 t.Fatalf("tab sink did not forward immediate workspace mutation: before=%+v after=%+v", before, after)
182 }
183 }
184
185 func TestWorkspaceChangeHubUsesTrailingPublishGeneration(t *testing.T) {
186 root := t.TempDir()
187 app := &App{tabs: map[string]*WorkspaceTab{"a": {ID: "a", WorkspaceRoot: root}}}
188 app.workspaceHub = newWorkspaceChangeHub(app)
189 t.Cleanup(func() { app.workspaceHub.close() })
190 app.WorkspaceRevisionForTab("a")
191
192 key := canonicalWorkspaceRoot(root)
193 app.workspaceHub.mu.Lock()
194 r := app.workspaceHub.roots[key]
195 app.workspaceHub.schedulePublishLocked(r)
196 firstTimer, firstGeneration := r.timer, r.publishGen
197 app.workspaceHub.schedulePublishLocked(r)
198 secondTimer, secondGeneration := r.timer, r.publishGen
199 app.workspaceHub.mu.Unlock()
200 if firstTimer == secondTimer || secondGeneration != firstGeneration+1 {
201 t.Fatalf("quiet window was not reset: timersSame=%v generations=%d/%d", firstTimer == secondTimer, firstGeneration, secondGeneration)
202 }
203 }
204
205 func TestWorkspaceChangeHubReleasesRootAfterTabWorkspaceSwitch(t *testing.T) {
206 rootA, rootB := t.TempDir(), t.TempDir()
207 app := &App{tabs: map[string]*WorkspaceTab{"a": {ID: "a", WorkspaceRoot: rootA}}}
208 app.workspaceHub = newWorkspaceChangeHub(app)
209 t.Cleanup(func() { app.workspaceHub.close() })
210 app.WorkspaceRevisionForTab("a")
211 app.mu.Lock()
212 app.tabs["a"].WorkspaceRoot = rootB
213 app.mu.Unlock()
214 app.WorkspaceRevisionForTab("a")
215 app.workspaceHub.reconcileRoots()
216
217 app.workspaceHub.mu.Lock()
218 _, oldExists := app.workspaceHub.roots[canonicalWorkspaceRoot(rootA)]
219 _, newExists := app.workspaceHub.roots[canonicalWorkspaceRoot(rootB)]
220 app.workspaceHub.mu.Unlock()
221 if oldExists || !newExists {
222 t.Fatalf("root lifecycle after switch: oldExists=%v newExists=%v", oldExists, newExists)
223 }
224 }
225
226 func TestGitMetadataDirsForWorkspaceUsesHardenedGitCommand(t *testing.T) {
227 // Source-level contract: both startup rev-parse probes must go through
228 // gitcmd.Command so Windows gets HideWindow + CREATE_NO_WINDOW without
229 // forking a second unhardened path.
230 source, err := os.ReadFile("workspace_watch.go")
231 if err != nil {
232 t.Fatal(err)
233 }
234 text := string(source)
235 if !strings.Contains(text, `gitcmd.Command(ctx, root, "rev-parse", flag)`) {
236 t.Fatal("gitMetadataDirsForWorkspace must call gitcmd.Command for rev-parse probes")
237 }
238 if strings.Contains(text, `exec.CommandContext(ctx, "git"`) || strings.Contains(text, `exec.Command("git"`) {
239 t.Fatal("workspace_watch must not invoke raw git exec for metadata probes")
240 }
241 }
242
243 func TestWorkspaceChangeHubRecursivelyWatchesGitMetadataOnly(t *testing.T) {
244 root := t.TempDir()
245 if out, err := exec.Command("git", "-C", root, "init").CombinedOutput(); err != nil {
246 t.Fatalf("git init: %v: %s", err, out)
247 }
248 gitDir := filepath.Join(root, ".git")
249 for _, rel := range []string{"refs/heads", "logs/refs/heads", "worktrees/linked", "objects/pack"} {
250 if err := os.MkdirAll(filepath.Join(gitDir, rel), 0o700); err != nil {
251 t.Fatal(err)
252 }
253 }
254 app := &App{tabs: map[string]*WorkspaceTab{"a": {ID: "a", WorkspaceRoot: root}}}
255 app.workspaceHub = newWorkspaceChangeHub(app)
256 t.Cleanup(func() { app.workspaceHub.close() })
257 view := app.WorkspaceRevisionForTab("a")
258 if view.WatchState == "unavailable" {
259 t.Fatalf("watcher unavailable: %+v", view)
260 }
261
262 key := canonicalWorkspaceRoot(root)
263 gitDir = canonicalWorkspaceRoot(gitDir)
264 app.workspaceHub.mu.Lock()
265 r := app.workspaceHub.roots[key]
266 recursive := r != nil && r.watcher != nil && r.watcher.SupportsRecursive()
267 _, rootWatched := r.watched[key]
268 _, refsWatched := r.watched[filepath.Join(gitDir, "refs", "heads")]
269 _, logsWatched := r.watched[filepath.Join(gitDir, "logs", "refs", "heads")]
270 _, worktreeWatched := r.watched[filepath.Join(gitDir, "worktrees", "linked")]
271 _, objectsWatched := r.watched[filepath.Join(gitDir, "objects")]
272 app.workspaceHub.mu.Unlock()
273 if recursive {
274 if !rootWatched || refsWatched || logsWatched || worktreeWatched || objectsWatched {
275 t.Fatalf("recursive workspace watch root=%v refs=%v logs=%v worktrees=%v objects=%v", rootWatched, refsWatched, logsWatched, worktreeWatched, objectsWatched)
276 }
277 return
278 }
279 if !refsWatched || !logsWatched || !worktreeWatched || objectsWatched {
280 t.Fatalf("git watches refs=%v logs=%v worktrees=%v objects=%v", refsWatched, logsWatched, worktreeWatched, objectsWatched)
281 }
282 }
283
283 lines GO