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