multiclient_integration_test.go

  1package workspace_test
  2
  3import (
  4	"context"
  5	"net/http/httptest"
  6	"net/url"
  7	"testing"
  8	"time"
  9
 10	tea "charm.land/bubbletea/v2"
 11	"github.com/charmbracelet/crush/internal/client"
 12	"github.com/charmbracelet/crush/internal/config"
 13	"github.com/charmbracelet/crush/internal/proto"
 14	"github.com/charmbracelet/crush/internal/pubsub"
 15	"github.com/charmbracelet/crush/internal/server"
 16	"github.com/charmbracelet/crush/internal/workspace"
 17	"github.com/stretchr/testify/require"
 18)
 19
 20// xdgIsolate redirects HOME and XDG_* to fresh temp dirs so config
 21// loading does not touch the host's real config.
 22func xdgIsolate(t *testing.T) {
 23	t.Helper()
 24	t.Setenv("HOME", t.TempDir())
 25	t.Setenv("XDG_CACHE_HOME", t.TempDir())
 26	t.Setenv("XDG_CONFIG_HOME", t.TempDir())
 27	t.Setenv("XDG_DATA_HOME", t.TempDir())
 28}
 29
 30// runtimeServer wires the production server handler around an
 31// httptest.NewServer for integration testing.
 32type runtimeServer struct {
 33	httpSrv *httptest.Server
 34	host    string
 35}
 36
 37func newRuntimeServer(t *testing.T) *runtimeServer {
 38	t.Helper()
 39	s := server.NewServer(nil, "tcp", "127.0.0.1:0")
 40	hs := httptest.NewServer(s.Handler())
 41	t.Cleanup(hs.Close)
 42
 43	u, err := url.Parse(hs.URL)
 44	require.NoError(t, err)
 45	return &runtimeServer{httpSrv: hs, host: u.Host}
 46}
 47
 48func (r *runtimeServer) newClient(t *testing.T, path string) *client.Client {
 49	t.Helper()
 50	c, err := client.NewClient(path, "tcp", r.host)
 51	require.NoError(t, err)
 52	return c
 53}
 54
 55// TestClientWorkspace_ConfigChangedRefreshesSiblingCache is the
 56// cross-client refresh end-to-end test required by PLAN item 4. Two
 57// ClientWorkspace instances pointed at the same backend workspace
 58// subscribe to events; when one mutates configuration via the server,
 59// the other's cached Config snapshot reflects the new value without
 60// a manual refresh.
 61func TestClientWorkspace_ConfigChangedRefreshesSiblingCache(t *testing.T) {
 62	xdgIsolate(t)
 63	rt := newRuntimeServer(t)
 64
 65	cwd := t.TempDir()
 66	dataDir := t.TempDir()
 67
 68	cA := rt.newClient(t, cwd)
 69	cB := rt.newClient(t, cwd)
 70	ctx, cancel := context.WithCancel(context.Background())
 71	t.Cleanup(cancel)
 72
 73	wsProto, err := cA.CreateWorkspace(ctx, proto.Workspace{Path: cwd, DataDir: dataDir})
 74	require.NoError(t, err)
 75	// Client B joins the same workspace by path; the server
 76	// deduplicates and returns the existing workspace.
 77	wsProtoB, err := cB.CreateWorkspace(ctx, proto.Workspace{Path: cwd, DataDir: dataDir})
 78	require.NoError(t, err)
 79	require.Equal(t, wsProto.ID, wsProtoB.ID)
 80
 81	wsA := workspace.NewClientWorkspace(cA, *wsProto)
 82	wsB := workspace.NewClientWorkspace(cB, *wsProtoB)
 83
 84	// Both clients attach event streams. They run for the
 85	// lifetime of the test; cancelling via context tears them
 86	// down. consumeEvents is exercised by Subscribe in production;
 87	// here we run it inline so we don't need a real *tea.Program.
 88	evcA, err := cA.SubscribeEvents(ctx, wsProto.ID)
 89	require.NoError(t, err)
 90	evcB, err := cB.SubscribeEvents(ctx, wsProto.ID)
 91	require.NoError(t, err)
 92
 93	go wsA.ConsumeEventsForTest(evcA, func(tea.Msg) {})
 94	go wsB.ConsumeEventsForTest(evcB, func(tea.Msg) {})
 95
 96	// Pre-condition: neither cache has compact mode enabled yet.
 97	require.NotNil(t, wsA.Config())
 98	require.NotNil(t, wsB.Config())
 99	require.False(t, compactMode(wsA.Config()), "compact mode must start disabled on client A")
100	require.False(t, compactMode(wsB.Config()), "compact mode must start disabled on client B")
101
102	// Client A flips a real config-mutating workspace operation
103	// (SetCompactMode) via the server. PLAN item 4 acceptance:
104	// B's cached ws.Config must reflect this change without restart.
105	// SetCompactMode is used over UpdatePreferredModel because the
106	// latter's autoReload reverts unknown-provider models back to
107	// defaults during configureSelectedModels, which would make the
108	// assertion test infrastructure rather than the cache wiring.
109	require.NoError(t, wsA.SetCompactMode(config.ScopeGlobal, true))
110
111	// Client A writes and refreshes synchronously inside
112	// SetCompactMode, so its cache must already reflect the change.
113	// Eventually here absorbs any background work but should pass
114	// immediately.
115	require.Eventually(t, func() bool { return compactMode(wsA.Config()) },
116		3*time.Second, 25*time.Millisecond,
117		"client A cache must reflect its own compact-mode mutation")
118
119	// Client B must see the same change via the ConfigChanged SSE
120	// event triggering its own cached refresh.
121	require.Eventually(t, func() bool { return compactMode(wsB.Config()) },
122		3*time.Second, 25*time.Millisecond,
123		"client B cache must reflect A's compact-mode mutation via SSE")
124}
125
126// compactMode is a tiny accessor that survives nil intermediates so
127// the Eventually polling loop can call it on a transient cache state.
128func compactMode(cfg *config.Config) bool {
129	if cfg == nil || cfg.Options == nil {
130		return false
131	}
132	return cfg.Options.TUI.CompactMode
133}
134
135// TestClientWorkspace_ConfigChangedSignalArrives is a smaller test
136// that asserts the SSE wiring delivers a ConfigChanged event to the
137// raw client subscription. It catches breakage in the
138// wrapEvent/decoder bridge independent of the workspace cache.
139func TestClientWorkspace_ConfigChangedSignalArrives(t *testing.T) {
140	xdgIsolate(t)
141	rt := newRuntimeServer(t)
142
143	cwd := t.TempDir()
144	dataDir := t.TempDir()
145
146	c := rt.newClient(t, cwd)
147	ctx, cancel := context.WithCancel(context.Background())
148	t.Cleanup(cancel)
149
150	wsProto, err := c.CreateWorkspace(ctx, proto.Workspace{Path: cwd, DataDir: dataDir})
151	require.NoError(t, err)
152
153	evc, err := c.SubscribeEvents(ctx, wsProto.ID)
154	require.NoError(t, err)
155
156	require.NoError(t, c.SetConfigField(ctx, wsProto.ID, config.ScopeGlobal, "options.debug", true))
157
158	gotConfigChanged := false
159	deadline := time.After(3 * time.Second)
160loop:
161	for !gotConfigChanged {
162		select {
163		case ev, ok := <-evc:
164			if !ok {
165				break loop
166			}
167			if cc, isCC := ev.(pubsub.Event[proto.ConfigChanged]); isCC {
168				require.Equal(t, wsProto.ID, cc.Payload.WorkspaceID)
169				gotConfigChanged = true
170			}
171		case <-deadline:
172			break loop
173		}
174	}
175	require.True(t, gotConfigChanged, "expected ConfigChanged event over SSE")
176}