1package backend_test
2
3import (
4 "context"
5 "fmt"
6 "os"
7 "path/filepath"
8 "testing"
9 "time"
10
11 tea "charm.land/bubbletea/v2"
12 "git.secluded.site/crush/internal/backend"
13 "git.secluded.site/crush/internal/config"
14 "git.secluded.site/crush/internal/proto"
15 "git.secluded.site/crush/internal/pubsub"
16 "git.secluded.site/crush/internal/skills"
17 "github.com/stretchr/testify/require"
18)
19
20// TestBackend_WorkspaceSkillsIsolation verifies that skill discovery
21// state and SSE events are per-workspace, not process-global. This
22// guards the structural change in ยง5 of the plan: two workspaces in the
23// same backend process must not see each other's discoveries (either in
24// their initial snapshot or in subsequent PublishStates events).
25//
26// Before that change landed, the package-level latestStates cache and
27// the package-level broker meant that:
28// - workspace A's PublishStates would arrive on workspace B's SSE
29// stream, and
30// - the most recent SetLatestStates would silently overwrite the
31// other workspace's cache.
32//
33// This test fails on either scenario.
34func TestBackend_WorkspaceSkillsIsolation(t *testing.T) {
35 // Isolate all of config.Init's filesystem reads from the host. The
36 // project-local .agents/skills/<name>/SKILL.md per working dir is
37 // what we actually want each workspace to see; everything else
38 // (global skills, XDG dirs, etc.) must be empty/deterministic.
39 hostHome := t.TempDir()
40 t.Setenv("HOME", hostHome)
41 t.Setenv("XDG_CONFIG_HOME", filepath.Join(hostHome, ".config"))
42 t.Setenv("XDG_DATA_HOME", filepath.Join(hostHome, ".local", "share"))
43 t.Setenv("XDG_CACHE_HOME", filepath.Join(hostHome, ".cache"))
44 t.Setenv("CRUSH_SKILLS_DIR", t.TempDir())
45
46 // Each workspace gets its own working directory containing a
47 // distinct project-local skill so the discovery output differs.
48 wdA := t.TempDir()
49 wdB := t.TempDir()
50 writeSkill(t, wdA, "wsa-only-skill", "Workspace A only skill.")
51 writeSkill(t, wdB, "wsb-only-skill", "Workspace B only skill.")
52
53 srvCfg, err := config.Init(wdA, "", false)
54 require.NoError(t, err)
55 b := backend.New(t.Context(), srvCfg, nil)
56
57 wsA, _, err := b.CreateWorkspace(proto.Workspace{
58 Path: wdA,
59 DataDir: filepath.Join(wdA, ".crush"),
60 })
61 require.NoError(t, err)
62 t.Cleanup(func() { b.DeleteWorkspace(wsA.ID) })
63
64 wsB, _, err := b.CreateWorkspace(proto.Workspace{
65 Path: wdB,
66 DataDir: filepath.Join(wdB, ".crush"),
67 })
68 require.NoError(t, err)
69 t.Cleanup(func() { b.DeleteWorkspace(wsB.ID) })
70
71 require.NotNil(t, wsA.Skills, "workspace A must have its own skills.Manager")
72 require.NotNil(t, wsB.Skills, "workspace B must have its own skills.Manager")
73 require.NotSame(t, wsA.Skills, wsB.Skills, "managers must be distinct instances per workspace")
74
75 // Initial snapshots see each workspace's own filesystem skill, and
76 // neither sees the other's.
77 statesA := wsA.Skills.States()
78 statesB := wsB.Skills.States()
79 require.True(t, containsSkillName(statesA, "wsa-only-skill"),
80 "workspace A snapshot missing its own skill")
81 require.False(t, containsSkillName(statesA, "wsb-only-skill"),
82 "workspace A snapshot leaked workspace B's skill")
83 require.True(t, containsSkillName(statesB, "wsb-only-skill"),
84 "workspace B snapshot missing its own skill")
85 require.False(t, containsSkillName(statesB, "wsa-only-skill"),
86 "workspace B snapshot leaked workspace A's skill")
87
88 // Subscribe to each workspace's SSE event stream.
89 ctxA, cancelA := context.WithCancel(t.Context())
90 t.Cleanup(cancelA)
91 chA, err := b.SubscribeEvents(ctxA, wsA.ID)
92 require.NoError(t, err)
93
94 ctxB, cancelB := context.WithCancel(t.Context())
95 t.Cleanup(cancelB)
96 chB, err := b.SubscribeEvents(ctxB, wsB.ID)
97 require.NoError(t, err)
98
99 // Trigger a republish on workspace A only. The marker name lets us
100 // distinguish this event from any incidental backend activity.
101 const marker = "wsa-republish-marker"
102 wsA.Skills.PublishStates([]*skills.SkillState{
103 {Name: marker, State: skills.StateNormal},
104 })
105
106 // Workspace A must receive its own event.
107 require.True(t,
108 waitForSkillsEvent(t, chA, marker, 2*time.Second),
109 "workspace A never received its own skills event")
110
111 // Workspace B must NOT receive workspace A's event.
112 require.False(t,
113 waitForSkillsEvent(t, chB, marker, 250*time.Millisecond),
114 "workspace B leaked workspace A's skills event")
115
116 // And A's published states are now visible on its manager's
117 // snapshot (verifies PublishStates updates the cache, not just the
118 // broker).
119 updatedA := wsA.Skills.States()
120 require.True(t, containsSkillName(updatedA, marker),
121 "PublishStates must update Manager.States()")
122
123 // B's manager snapshot is untouched.
124 require.False(t, containsSkillName(wsB.Skills.States(), marker),
125 "workspace B's Manager.States() leaked workspace A's republish")
126}
127
128func writeSkill(t *testing.T, workingDir, name, desc string) {
129 t.Helper()
130 skillDir := filepath.Join(workingDir, ".agents", "skills", name)
131 require.NoError(t, os.MkdirAll(skillDir, 0o755))
132 content := fmt.Sprintf("---\nname: %s\ndescription: %s\n---\n%s\n", name, desc, desc)
133 require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644))
134}
135
136func containsSkillName(states []*skills.SkillState, name string) bool {
137 for _, s := range states {
138 if s.Name == name {
139 return true
140 }
141 }
142 return false
143}
144
145// waitForSkillsEvent drains the given event channel until either a
146// pubsub.Event[skills.Event] containing a state named marker arrives or
147// the timeout elapses. Non-skills events on the channel are silently
148// skipped โ the backend fans in many event types and we only care
149// about skills here.
150func waitForSkillsEvent(t *testing.T, ch <-chan pubsub.Event[tea.Msg], marker string, timeout time.Duration) bool {
151 t.Helper()
152 deadline := time.After(timeout)
153 for {
154 select {
155 case ev, ok := <-ch:
156 if !ok {
157 return false
158 }
159 se, ok := ev.Payload.(pubsub.Event[skills.Event])
160 if !ok {
161 continue
162 }
163 if containsSkillName(se.Payload.States, marker) {
164 return true
165 }
166 case <-deadline:
167 return false
168 }
169 }
170}