backend_skills_test.go

  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}