forked from gastownhall/gastown
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathhooks_sync_check_test.go
More file actions
305 lines (260 loc) · 8.94 KB
/
hooks_sync_check_test.go
File metadata and controls
305 lines (260 loc) · 8.94 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
package doctor
import (
"os"
"path/filepath"
"testing"
"github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/hooks"
)
// scaffoldWorkspace creates a minimal town workspace in a temp directory with
// the given role agents configured. Returns the town root path.
func scaffoldWorkspace(t *testing.T, roleAgents map[string]string) string {
t.Helper()
tmpDir := t.TempDir()
t.Setenv("HOME", tmpDir)
// Put dummy binaries for non-Claude role agents on PATH so agent
// resolution doesn't fall back to claude when the binary is missing.
if len(roleAgents) > 0 {
binDir := filepath.Join(tmpDir, "bin")
if err := os.MkdirAll(binDir, 0755); err != nil {
t.Fatal(err)
}
for _, agent := range roleAgents {
if agent != "" && agent != "claude" {
if err := os.WriteFile(filepath.Join(binDir, agent), []byte("#!/bin/sh\n"), 0755); err != nil {
t.Fatal(err)
}
}
}
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
}
townRoot := filepath.Join(tmpDir, "town")
// Required workspace structure
for _, dir := range []string{"mayor", "deacon"} {
if err := os.MkdirAll(filepath.Join(townRoot, dir), 0755); err != nil {
t.Fatal(err)
}
}
// Workspace marker
if err := os.WriteFile(
filepath.Join(townRoot, "mayor", "town.json"),
[]byte(`{"type":"town","version":1,"name":"test"}`),
0644,
); err != nil {
t.Fatal(err)
}
// Town settings with role agents
townSettings := config.NewTownSettings()
townSettings.RoleAgents = roleAgents
if err := os.MkdirAll(filepath.Join(townRoot, "settings"), 0755); err != nil {
t.Fatal(err)
}
if err := config.SaveTownSettings(config.TownSettingsPath(townRoot), townSettings); err != nil {
t.Fatal(err)
}
// Base hooks config (required for Claude targets)
base := &hooks.HooksConfig{
SessionStart: []hooks.HookEntry{
{Matcher: "", Hooks: []hooks.Hook{{Type: "command", Command: "echo test"}}},
},
}
if err := hooks.SaveBase(base); err != nil {
t.Fatalf("SaveBase: %v", err)
}
return townRoot
}
// syncAllClaudeTargets creates in-sync .claude/settings.json for every
// Claude target that DiscoverTargets would find. This prevents false
// positives from unrelated Claude targets in template agent tests.
func syncAllClaudeTargets(t *testing.T, townRoot string) {
t.Helper()
targets, err := hooks.DiscoverTargets(townRoot)
if err != nil {
t.Fatalf("DiscoverTargets: %v", err)
}
for _, target := range targets {
if target.Provider != "" && target.Provider != "claude" {
continue
}
expected, err := hooks.ComputeExpected(target.Key)
if err != nil {
t.Fatalf("ComputeExpected(%s): %v", target.Key, err)
}
settings := &hooks.SettingsJSON{Hooks: *expected}
data, err := hooks.MarshalSettings(settings)
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(target.Path), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(target.Path, append(data, '\n'), 0644); err != nil {
t.Fatal(err)
}
}
}
func TestHooksSyncCheck_ClaudeTargetInSync(t *testing.T) {
townRoot := scaffoldWorkspace(t, nil)
// Create a rig with a crew worktree
worktree := filepath.Join(townRoot, "myrig", "crew", "alice")
if err := os.MkdirAll(worktree, 0755); err != nil {
t.Fatal(err)
}
// Sync ALL Claude targets (mayor, deacon, crew worktree)
syncAllClaudeTargets(t, townRoot)
check := NewHooksSyncCheck()
ctx := &CheckContext{TownRoot: townRoot}
result := check.Run(ctx)
if result.Status != StatusOK {
t.Errorf("expected StatusOK for in-sync Claude targets, got %v: %s", result.Status, result.Message)
for _, d := range result.Details {
t.Logf(" detail: %s", d)
}
}
}
func TestHooksSyncCheck_TemplateAgent_InSync(t *testing.T) {
townRoot := scaffoldWorkspace(t, map[string]string{"crew": "opencode"})
// Create a crew worktree
worktree := filepath.Join(townRoot, "myrig", "crew", "alice")
if err := os.MkdirAll(worktree, 0755); err != nil {
t.Fatal(err)
}
// Sync Claude targets first
syncAllClaudeTargets(t, townRoot)
// Install the correct OpenCode template file
expectedContent, err := hooks.ComputeExpectedTemplate("opencode", "gastown.js", "crew")
if err != nil {
t.Fatalf("ComputeExpectedTemplate: %v", err)
}
pluginDir := filepath.Join(worktree, ".opencode", "plugins")
if err := os.MkdirAll(pluginDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(pluginDir, "gastown.js"), expectedContent, 0644); err != nil {
t.Fatal(err)
}
check := NewHooksSyncCheck()
ctx := &CheckContext{TownRoot: townRoot}
result := check.Run(ctx)
if result.Status != StatusOK {
t.Errorf("expected StatusOK for in-sync template agent, got %v: %s", result.Status, result.Message)
for _, d := range result.Details {
t.Logf(" detail: %s", d)
}
}
}
func TestHooksSyncCheck_TemplateAgent_OutOfSync(t *testing.T) {
townRoot := scaffoldWorkspace(t, map[string]string{"crew": "opencode"})
// Create a crew worktree with stale content
worktree := filepath.Join(townRoot, "myrig", "crew", "alice")
pluginDir := filepath.Join(worktree, ".opencode", "plugins")
if err := os.MkdirAll(pluginDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(pluginDir, "gastown.js"), []byte("// old stale content"), 0644); err != nil {
t.Fatal(err)
}
// Sync Claude targets so any Warning comes from the template agent
syncAllClaudeTargets(t, townRoot)
check := NewHooksSyncCheck()
ctx := &CheckContext{TownRoot: townRoot}
result := check.Run(ctx)
if result.Status != StatusWarning {
t.Errorf("expected StatusWarning for out-of-sync template agent, got %v: %s", result.Status, result.Message)
}
}
func TestHooksSyncCheck_TemplateAgent_Missing(t *testing.T) {
townRoot := scaffoldWorkspace(t, map[string]string{"crew": "opencode"})
// Create a crew worktree but DON'T install the plugin
worktree := filepath.Join(townRoot, "myrig", "crew", "alice")
if err := os.MkdirAll(worktree, 0755); err != nil {
t.Fatal(err)
}
// Sync Claude targets
syncAllClaudeTargets(t, townRoot)
check := NewHooksSyncCheck()
ctx := &CheckContext{TownRoot: townRoot}
result := check.Run(ctx)
if result.Status != StatusWarning {
t.Errorf("expected StatusWarning for missing template agent file, got %v: %s", result.Status, result.Message)
}
}
func TestHooksSyncCheck_Fix_TemplateAgent(t *testing.T) {
townRoot := scaffoldWorkspace(t, map[string]string{"crew": "opencode"})
// Create a crew worktree with stale content
worktree := filepath.Join(townRoot, "myrig", "crew", "alice")
pluginDir := filepath.Join(worktree, ".opencode", "plugins")
if err := os.MkdirAll(pluginDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(pluginDir, "gastown.js"), []byte("// stale"), 0644); err != nil {
t.Fatal(err)
}
// Sync Claude targets
syncAllClaudeTargets(t, townRoot)
check := NewHooksSyncCheck()
ctx := &CheckContext{TownRoot: townRoot}
// Run to detect out-of-sync
result := check.Run(ctx)
if result.Status != StatusWarning {
t.Fatalf("expected StatusWarning before fix, got %v", result.Status)
}
// Fix should write correct content
if err := check.Fix(ctx); err != nil {
t.Fatalf("Fix: %v", err)
}
// Verify file now matches expected template
pluginPath := filepath.Join(pluginDir, "gastown.js")
actual, err := os.ReadFile(pluginPath)
if err != nil {
t.Fatalf("reading fixed file: %v", err)
}
expected, err := hooks.ComputeExpectedTemplate("opencode", "gastown.js", "crew")
if err != nil {
t.Fatalf("ComputeExpectedTemplate: %v", err)
}
if string(actual) != string(expected) {
t.Error("fixed file does not match expected template")
}
}
func TestHooksSyncCheck_Fix_PreservesClaudePath(t *testing.T) {
townRoot := scaffoldWorkspace(t, nil)
// Sync all Claude targets first (creates in-sync settings for mayor, deacon)
syncAllClaudeTargets(t, townRoot)
// THEN overwrite mayor's settings with stale hooks but a custom editorMode
mayorClaudeDir := filepath.Join(townRoot, "mayor", ".claude")
stale := &hooks.SettingsJSON{
EditorMode: "vim",
Hooks: hooks.HooksConfig{
SessionStart: []hooks.HookEntry{
{Matcher: "", Hooks: []hooks.Hook{{Type: "command", Command: "old-cmd"}}},
},
},
}
data, err := hooks.MarshalSettings(stale)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(mayorClaudeDir, "settings.json"), append(data, '\n'), 0644); err != nil {
t.Fatal(err)
}
check := NewHooksSyncCheck()
ctx := &CheckContext{TownRoot: townRoot}
// Run to detect out-of-sync
result := check.Run(ctx)
if result.Status != StatusWarning {
t.Fatalf("expected StatusWarning before fix, got %v: %s", result.Status, result.Message)
}
// Fix
if err := check.Fix(ctx); err != nil {
t.Fatalf("Fix: %v", err)
}
// Verify editorMode was preserved (merge path, not overwrite)
settings, err := hooks.LoadSettings(filepath.Join(mayorClaudeDir, "settings.json"))
if err != nil {
t.Fatalf("LoadSettings: %v", err)
}
if settings.EditorMode != "vim" {
t.Errorf("editorMode not preserved: got %q, want %q", settings.EditorMode, "vim")
}
}