| 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 |