返回 DeepSeek-Reasonix
service_test.go
根目录 / internal / skill / skillwatch / service_test.go
1 package skillwatch
2
3 import (
4 "bytes"
5 "context"
6 "crypto/sha256"
7 "io"
8 "os"
9 "path/filepath"
10 "sync"
11 "testing"
12 "time"
13 )
14
15 func TestFrameRoundTrip(t *testing.T) {
16 frames := []frame{
17 {Kind: wireRegister, ID: 7, RootGen: 3, Dirs: []string{"/a", "/b"}},
18 {Kind: wireCancel, ID: 9},
19 {Kind: wireEvent, ID: 7, RootGen: 3, Op: OpWrite},
20 {Kind: wireError, ID: 7, Msg: "boom"},
21 }
22 for _, want := range frames {
23 var buf bytes.Buffer
24 if err := writeFrame(&buf, want); err != nil {
25 t.Fatalf("writeFrame: %v", err)
26 }
27 got, err := readFrame(&buf)
28 if err != nil {
29 t.Fatalf("readFrame: %v", err)
30 }
31 if got.Kind != want.Kind || got.ID != want.ID || got.RootGen != want.RootGen || got.Op != want.Op || got.Msg != want.Msg || len(got.Dirs) != len(want.Dirs) {
32 t.Fatalf("round trip mismatch: got %+v want %+v", got, want)
33 }
34 }
35 }
36
37 func TestReadFrameRejectsBadSize(t *testing.T) {
38 if _, err := readFrame(bytes.NewReader([]byte{0, 0, 0, 0})); err == nil {
39 t.Fatal("zero-size frame accepted")
40 }
41 if _, err := readFrame(bytes.NewReader([]byte{0xff, 0xff, 0xff, 0xff})); err == nil {
42 t.Fatal("oversized frame accepted")
43 }
44 }
45
46 // countingScope reports the root itself plus one level of child directories,
47 // skipping the discovery-skipped bodies like the real scope does.
48 func countingScope(ctx context.Context, root string, maxDepth int) ([]string, bool) {
49 dirs := []string{root}
50 entries, err := os.ReadDir(root)
51 if err != nil {
52 return dirs, true
53 }
54 for _, e := range entries {
55 if !e.IsDir() || maxDepth < 2 {
56 continue
57 }
58 switch e.Name() {
59 case "assets", "node_modules", "references", "scripts":
60 continue
61 }
62 dirs = append(dirs, filepath.Join(root, e.Name()))
63 }
64 return dirs, true
65 }
66
67 func flatHash(ctx context.Context, root string, maxDepth int) ([sha256.Size]byte, int, bool) {
68 entries, err := os.ReadDir(root)
69 if err != nil {
70 return [sha256.Size]byte{}, 0, false
71 }
72 h := sha256.New()
73 count := 0
74 for _, e := range entries {
75 count++
76 info, err := e.Info()
77 if err != nil {
78 continue
79 }
80 _, _ = h.Write([]byte(e.Name()))
81 _, _ = h.Write([]byte{0})
82 _, _ = h.Write([]byte(info.ModTime().String()))
83 }
84 var sum [sha256.Size]byte
85 copy(sum[:], h.Sum(nil))
86 return sum, count, true
87 }
88
89 func waitFor(t *testing.T, what string, cond func() bool) {
90 t.Helper()
91 deadline := time.Now().Add(5 * time.Second)
92 for time.Now().Before(deadline) {
93 if cond() {
94 return
95 }
96 time.Sleep(10 * time.Millisecond)
97 }
98 t.Fatalf("timed out waiting for %s", what)
99 }
100
101 func TestSharedSubscriptionsCoalesceEvents(t *testing.T) {
102 dir := t.TempDir()
103 svc := NewService(Options{Stderr: io.Discard})
104 defer svc.Close()
105
106 var mu sync.Mutex
107 hits := map[string]int{}
108 sub1 := svc.Subscribe(dir, 2, countingScope, flatHash, func(string) { mu.Lock(); hits["one"]++; mu.Unlock() })
109 sub2 := svc.Subscribe(dir, 2, countingScope, flatHash, func(string) { mu.Lock(); hits["two"]++; mu.Unlock() })
110
111 // Helper registration is bounded rather than unconditionally blocking, so
112 // keep this assertion tolerant of either backend.
113 waitFor(t, "physical watch registration", func() bool {
114 return svc.Diagnostics().PhysicalWatches == 1
115 })
116 diag := svc.Diagnostics()
117 if diag.LogicalSubscriptions != 2 {
118 t.Fatalf("logical subscriptions = %d, want 2", diag.LogicalSubscriptions)
119 }
120 if diag.PhysicalWatches != 1 {
121 t.Fatalf("physical watches = %d, want 1 (shared root)", diag.PhysicalWatches)
122 }
123
124 if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("---\nname: x\n---\nbody"), 0o644); err != nil {
125 t.Fatal(err)
126 }
127 waitFor(t, "coalesced notifications", func() bool {
128 mu.Lock()
129 defer mu.Unlock()
130 return hits["one"] >= 1 && hits["two"] >= 1
131 })
132
133 // Both subscriptions share one root: one physical watch, one coalesced
134 // window, two notifications.
135 mu.Lock()
136 first, second := hits["one"], hits["two"]
137 mu.Unlock()
138 if first != 1 || second != 1 {
139 t.Fatalf("notifications one=%d two=%d, want 1/1 (coalesced)", first, second)
140 }
141 diag = svc.Diagnostics()
142 if diag.EventsReceived < 1 || diag.Notifications != 1 {
143 t.Fatalf("events=%d notifications=%d, want >=1/1", diag.EventsReceived, diag.Notifications)
144 }
145
146 sub1.Release()
147 sub1.Release() // idempotent
148 diag = svc.Diagnostics()
149 if diag.LogicalSubscriptions != 1 {
150 t.Fatalf("after release subscriptions = %d, want 1", diag.LogicalSubscriptions)
151 }
152 sub2.Release()
153 waitFor(t, "physical watch teardown", func() bool {
154 d := svc.Diagnostics()
155 return d.LogicalSubscriptions == 0 && d.PhysicalWatches == 0
156 })
157 if diag := svc.Diagnostics(); diag.LogicalSubscriptions != 0 || diag.PhysicalWatches != 0 {
158 t.Fatalf("after final release subscriptions=%d watches=%d, want 0/0", diag.LogicalSubscriptions, diag.PhysicalWatches)
159 }
160 }
161
162 func TestScopeSkippedBodyChangesDoNotNotify(t *testing.T) {
163 dir := t.TempDir()
164 if err := os.MkdirAll(filepath.Join(dir, "scripts"), 0o755); err != nil {
165 t.Fatal(err)
166 }
167 svc := NewService(Options{Stderr: io.Discard})
168 defer svc.Close()
169
170 var hits int
171 var mu sync.Mutex
172 svc.Subscribe(dir, 3, countingScope, flatHash, func(string) { mu.Lock(); hits++; mu.Unlock() })
173 // Helper registration is bounded rather than unconditionally blocking, so
174 // the count settles shortly after Subscribe returns on that backend.
175 waitFor(t, "physical watch registration", func() bool {
176 return svc.Diagnostics().PhysicalWatches == 1
177 })
178
179 if err := os.WriteFile(filepath.Join(dir, "scripts", "tool.sh"), []byte("echo hi"), 0o644); err != nil {
180 t.Fatal(err)
181 }
182 time.Sleep(600 * time.Millisecond)
183 mu.Lock()
184 defer mu.Unlock()
185 if hits != 0 {
186 t.Fatalf("scripts/ change notified %d times, want 0", hits)
187 }
188 }
189
190 func TestScanFallbackNotifiesOnHashDiff(t *testing.T) {
191 dir := t.TempDir()
192 svc := NewService(Options{Stderr: io.Discard, ForceHelper: true, HelperCommand: func(ctx context.Context) (helperProcess, error) {
193 return nil, errHelperStopped // helper never comes up
194 }})
195 defer svc.Close()
196
197 var hits int
198 var mu sync.Mutex
199 svc.Subscribe(dir, 2, countingScope, flatHash, func(string) { mu.Lock(); hits++; mu.Unlock() })
200
201 // First pass runs after the shortest backoff and establishes the baseline.
202 waitFor(t, "degraded root diagnostics", func() bool {
203 return svc.Diagnostics().DegradedRoots == 1
204 })
205 if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("changed"), 0o644); err != nil {
206 t.Fatal(err)
207 }
208 waitFor(t, "scan fallback notification", func() bool {
209 mu.Lock()
210 defer mu.Unlock()
211 return hits >= 1
212 })
213 diag := svc.Diagnostics()
214 if diag.Scans == 0 {
215 t.Fatal("scan fallback produced no scans")
216 }
217 }
218
218 lines GO