backend_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	"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}