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