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