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}