返回 DeepSeek-Reasonix
catalog_watch_service_test.go
根目录 / internal / skill / catalog_watch_service_test.go
1 package skill
2
3 import (
4 "io"
5 "os"
6 "path/filepath"
7 "slices"
8 "sort"
9 "testing"
10 "time"
11
12 "reasonix/internal/skill/skillwatch"
13 )
14
15 func catalogNames(snapshot CatalogSnapshot) []string {
16 names := make([]string, 0, len(snapshot.Candidates))
17 for _, candidate := range snapshot.Candidates {
18 names = append(names, candidate.Name)
19 }
20 sort.Strings(names)
21 return names
22 }
23
24 func sameCatalogNames(a, b CatalogSnapshot) bool {
25 return slices.Equal(catalogNames(a), catalogNames(b))
26 }
27
28 func waitForCatalog(t *testing.T, what string, cond func() bool) {
29 t.Helper()
30 deadline := time.Now().Add(5 * time.Second)
31 for time.Now().Before(deadline) {
32 if cond() {
33 return
34 }
35 time.Sleep(20 * time.Millisecond)
36 }
37 t.Fatalf("timed out waiting for %s", what)
38 }
39
40 // Two stores over the same roots must share the service's physical watches,
41 // and a discovery-relevant change must invalidate both catalogs through the
42 // single coalesced notification.
43 func TestHostWatchServiceSharedAcrossStores(t *testing.T) {
44 root := t.TempDir()
45 skillsDir := filepath.Join(root, ".reasonix", "skills")
46 if err := os.MkdirAll(filepath.Join(skillsDir, "alpha"), 0o755); err != nil {
47 t.Fatal(err)
48 }
49 if err := os.WriteFile(filepath.Join(skillsDir, "alpha", "SKILL.md"), []byte("---\nname: alpha\ndescription: first\n---\nbody"), 0o644); err != nil {
50 t.Fatal(err)
51 }
52 svc := skillwatch.NewService(skillwatch.Options{Stderr: io.Discard})
53 defer svc.Close()
54
55 storeA := New(Options{HomeDir: t.TempDir(), ReasonixHomeDir: t.TempDir(), ProjectRoot: root, Stderr: io.Discard, Watch: true, WatchService: svc})
56 storeB := New(Options{HomeDir: t.TempDir(), ReasonixHomeDir: t.TempDir(), ProjectRoot: root, Stderr: io.Discard, Watch: true, WatchService: svc})
57 defer storeA.Close()
58 defer storeB.Close()
59
60 firstA, err := storeA.Snapshot(t.Context())
61 if err != nil {
62 t.Fatal(err)
63 }
64 firstB, err := storeB.Snapshot(t.Context())
65 if err != nil {
66 t.Fatal(err)
67 }
68 // Version is each store's own rebuild counter, not a shared clock. Sharing
69 // guarantees identical discovered content, so compare that.
70 if !sameCatalogNames(firstA, firstB) {
71 t.Fatalf("shared roots produced different catalogs: %v vs %v", catalogNames(firstA), catalogNames(firstB))
72 }
73 diag, ok := storeA.WatchDiagnostics()
74 if !ok || diag.PhysicalWatches == 0 {
75 t.Fatalf("service watches not armed: %+v active=%v", diag, ok)
76 }
77 if diag.LogicalSubscriptions < 2 {
78 t.Fatalf("expected two logical subscriptions, got %+v", diag)
79 }
80
81 // Installing a new skill directory must invalidate both stores.
82 beta := filepath.Join(skillsDir, "beta")
83 if err := os.MkdirAll(beta, 0o755); err != nil {
84 t.Fatal(err)
85 }
86 if err := os.WriteFile(filepath.Join(beta, "SKILL.md"), []byte("---\nname: beta\ndescription: second\n---\nbody"), 0o644); err != nil {
87 t.Fatal(err)
88 }
89 waitForCatalog(t, "storeA invalidation", func() bool {
90 snap, err := storeA.Snapshot(t.Context())
91 return err == nil && snap.Version > firstA.Version
92 })
93 waitForCatalog(t, "storeB invalidation", func() bool {
94 snap, err := storeB.Snapshot(t.Context())
95 return err == nil && snap.Version > firstB.Version
96 })
97 snap, err := storeA.Snapshot(t.Context())
98 if err != nil {
99 t.Fatal(err)
100 }
101 found := false
102 for _, sk := range snap.Candidates {
103 if sk.Name == "beta" {
104 found = true
105 }
106 }
107 if !found {
108 t.Fatal("beta skill not discovered after invalidation")
109 }
110
111 // Closing both stores must release the shared physical watches.
112 storeA.Close()
113 storeB.Close()
114 waitForCatalog(t, "physical watch teardown", func() bool {
115 d := svc.Diagnostics()
116 return d.PhysicalWatches == 0 && d.LogicalSubscriptions == 0
117 })
118 }
119
120 // Changes confined to discovery-skipped bodies (scripts/, assets/, ...)
121 // must not invalidate the catalog through the watch service.
122 func TestHostWatchServiceIgnoresSkippedBodies(t *testing.T) {
123 root := t.TempDir()
124 skillsDir := filepath.Join(root, ".reasonix", "skills")
125 if err := os.MkdirAll(filepath.Join(skillsDir, "alpha", "scripts"), 0o755); err != nil {
126 t.Fatal(err)
127 }
128 if err := os.WriteFile(filepath.Join(skillsDir, "alpha", "SKILL.md"), []byte("---\nname: alpha\ndescription: first\n---\nbody"), 0o644); err != nil {
129 t.Fatal(err)
130 }
131 svc := skillwatch.NewService(skillwatch.Options{Stderr: io.Discard})
132 defer svc.Close()
133
134 store := New(Options{HomeDir: t.TempDir(), ReasonixHomeDir: t.TempDir(), ProjectRoot: root, Stderr: io.Discard, Watch: true, WatchService: svc})
135 defer store.Close()
136 snap, err := store.Snapshot(t.Context())
137 if err != nil {
138 t.Fatal(err)
139 }
140 if err := os.WriteFile(filepath.Join(skillsDir, "alpha", "scripts", "tool.sh"), []byte("echo hi"), 0o644); err != nil {
141 t.Fatal(err)
142 }
143 time.Sleep(600 * time.Millisecond)
144 after, err := store.Snapshot(t.Context())
145 if err != nil {
146 t.Fatal(err)
147 }
148 if after.Version != snap.Version {
149 t.Fatalf("scripts/ body change bumped catalog version %d -> %d", snap.Version, after.Version)
150 }
151 }
152
152 lines GO