-
Notifications
You must be signed in to change notification settings - Fork 45
Expand file tree
/
Copy pathapp_test.go
More file actions
257 lines (229 loc) · 8.33 KB
/
Copy pathapp_test.go
File metadata and controls
257 lines (229 loc) · 8.33 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
package main
import (
"context"
"errors"
"fmt"
"io"
"strings"
"sync"
"testing"
"time"
)
// TestApp_StartupError_CapturesSingleError verifies that a single error
// sent during startup() surfaces through StartupError() after
// drainStartupErr() has run. The frontend calls StartupError() on demand
// after receiving the "app:startup-error" event to render a banner.
func TestApp_StartupError_CapturesSingleError(t *testing.T) {
a := NewApp("")
a.sendStartupErr(errors.New("connection store: init failed"))
if got := a.StartupError(); got != "" {
t.Fatalf("StartupError() before drain = %q, want empty", got)
}
a.drainStartupErr()
got := a.StartupError()
want := "connection store: init failed"
if got != want {
t.Fatalf("StartupError() = %q, want %q", got, want)
}
}
// TestApp_StartupError_JoinsMultipleErrors verifies the errors.Join
// behaviour: multiple sendStartupErr calls during startup() produce a
// single newline-joined string readable by the frontend.
func TestApp_StartupError_JoinsMultipleErrors(t *testing.T) {
a := NewApp("")
a.sendStartupErr(errors.New("connection store: read failed"))
a.sendStartupErr(errors.New("settings store: write failed"))
a.drainStartupErr()
got := a.StartupError()
for _, want := range []string{"connection store: read failed", "settings store: write failed"} {
if !strings.Contains(got, want) {
t.Errorf("StartupError() = %q, missing %q", got, want)
}
}
if !strings.Contains(got, "\n") {
t.Errorf("StartupError() = %q, expected newline-separated entries", got)
}
}
// TestApp_StartupError_NilOnClean verifies StartupError() returns ""
// when no init failures occurred. The frontend uses the empty string to
// decide whether to render the banner.
func TestApp_StartupError_NilOnClean(t *testing.T) {
a := NewApp("")
a.drainStartupErr()
if got := a.StartupError(); got != "" {
t.Fatalf("StartupError() with no errors = %q, want empty", got)
}
}
// TestApp_SendStartupErr_NilIsNoop confirms nil errors do not enqueue.
// Defensive against a future caller passing nil from an error chain.
func TestApp_SendStartupErr_NilIsNoop(t *testing.T) {
a := NewApp("")
a.sendStartupErr(nil)
a.drainStartupErr()
if got := a.StartupError(); got != "" {
t.Fatalf("StartupError() after nil send = %q, want empty", got)
}
}
// TestApp_SendStartupErr_OverflowDrops verifies that when the buffered
// channel (size 16) overflows, sendStartupErr drops the extra error
// instead of blocking. The send runs on the startup goroutine and must
// remain non-blocking; the log line is the last-resort record.
func TestApp_SendStartupErr_OverflowDrops(t *testing.T) {
a := NewApp("")
for i := 0; i < 32; i++ {
a.sendStartupErr(fmt.Errorf("store: %d", i))
}
a.drainStartupErr()
got := a.StartupError()
if got == "" {
t.Fatal("StartupError() empty after 32 sends, want at least one entry")
}
if strings.Count(got, "\n") >= 32 {
t.Errorf("StartupError() kept all 32 sends, expected some drops under cap=16")
}
}
// TestApp_StartupErr_ConcurrentSendSafe sends from many goroutines and
// confirms drainStartupErr() collects everything safely. Catches data
// races in the channel/snapshot interaction under -race.
func TestApp_StartupErr_ConcurrentSendSafe(t *testing.T) {
a := NewApp("")
const N = 32
var wg sync.WaitGroup
wg.Add(N)
for i := 0; i < N; i++ {
go func(i int) {
defer wg.Done()
a.sendStartupErr(fmt.Errorf("concurrent: %d", i))
}(i)
}
wg.Wait()
a.drainStartupErr()
got := a.StartupError()
if got == "" {
t.Fatal("StartupError() empty after concurrent sends")
}
}
// TestApp_PanelLogTitle_FallbackAndLookup exercises panelLogTitle: with
// no registered session the helper returns a synthetic fallback rather
// than crashing on a nil sessionManager, and with a registered session
// it returns non-empty name/protocol under panelLogMu. Regression
// coverage for the helper that PR-15's log-file work depends on.
func TestApp_PanelLogTitle_FallbackAndLookup(t *testing.T) {
a := NewApp("")
// No registration — fallback path.
name, protocol := a.panelLogTitle("panel-X")
if name == "" {
t.Errorf("panelLogTitle empty name for unregistered panel")
}
if protocol == "" {
t.Errorf("panelLogTitle empty protocol for unregistered panel")
}
// Registered session but no live sessionManager entry — helper
// falls through to the synthetic name (no panic).
a.RegisterSessionForPanel("sess-y", "panel-X")
start := time.Now()
for i := 0; i < 1000; i++ {
name, protocol = a.panelLogTitle("panel-X")
}
if elapsed := time.Since(start); elapsed > 50*time.Millisecond {
t.Errorf("1000 panelLogTitle calls took %v, expected < 50ms", elapsed)
}
if name == "" || protocol == "" {
t.Errorf("panelLogTitle returned empty name=%q protocol=%q", name, protocol)
}
}
// TestErrorBodyCap_F305 guards F-305: the error-body read on the non-200
// path is capped at 64 KiB so a hostile or buggy upstream returning a
// multi-GB error body can't OOM the Go process. We can't drive the full
// chatCompletion* HTTP path from a unit test, but the cap is a single
// io.LimitReader wrapper around io.ReadAll — verify the contract that
// reads beyond 64 KiB stop there.
func TestErrorBodyCap_F305(t *testing.T) {
const cap = 64 * 1024
// 1 MiB of 'a' is well above the cap.
big := strings.Repeat("a", 1024*1024)
body, err := io.ReadAll(io.LimitReader(strings.NewReader(big), cap))
if err != nil {
t.Fatalf("LimitReader read failed: %v", err)
}
if len(body) != cap {
t.Errorf("expected capped read of %d bytes, got %d", cap, len(body))
}
// Sanity: a smaller body is read in full.
small := strings.Repeat("b", 1024)
bodySmall, err := io.ReadAll(io.LimitReader(strings.NewReader(small), cap))
if err != nil {
t.Fatalf("LimitReader read failed: %v", err)
}
if len(bodySmall) != len(small) {
t.Errorf("small body got %d bytes, want %d", len(bodySmall), len(small))
}
}
// TestApp_CancelChatStream_NoActiveCall guards the safe-no-op path:
// when no ChatCompletion is in flight, CancelChatStream must not panic.
func TestApp_CancelChatStream_NoActiveCall(t *testing.T) {
a := &App{}
a.CancelChatStream() // must not panic
}
// TestApp_CancelChatStream_OverlappingCalls guards F-308: when two
// ChatCompletion calls overlap, CancelChatStream invoked during the second
// call must still cancel the second call's context — not be silently
// no-op'd because the first call's defer already nil'd the slot.
func TestApp_CancelChatStream_OverlappingCalls(t *testing.T) {
a := &App{}
_, cancelA := context.WithCancel(context.Background())
defer cancelA()
myA := cancelA
a.chatCancel.Store(&myA)
ctxB, cancelB := context.WithCancel(context.Background())
defer cancelB()
myB := cancelB
a.chatCancel.Store(&myB)
// Call A finishes first — its defer must NOT clobber B's slot.
a.chatCancel.CompareAndSwap(&myA, nil)
a.CancelChatStream()
if ctxB.Err() == nil {
t.Errorf("ctxB expected to be cancelled by CancelChatStream")
}
}
// TestApp_ConcurrentCancelSurvives guards F-308's race at scale: the
// old mutex+CancelFunc design could let a CancelChatStream drop a
// cancel because the wrong defer cleared the slot. With
// atomic.Pointer[CancelFunc] + CompareAndSwap(my, nil), only the call
// that currently owns the slot ever clears it — the rest leave it
// alone. This test verifies the simpler invariant: a call's cancel
// survives any number of OTHER calls' defers running first.
func TestApp_ConcurrentCancelSurvives(t *testing.T) {
a := &App{}
// Two overlapping calls.
_, cancelA := context.WithCancel(context.Background())
defer cancelA()
_, cancelB := context.WithCancel(context.Background())
defer cancelB()
myA := cancelA
myB := cancelB
a.chatCancel.Store(&myA)
a.chatCancel.Store(&myB)
// N goroutines, each simulating a call whose defer tries to clear
// the slot. None of them is "call A" or "call B" — their CAS
// addresses don't match anything in the slot, so they all no-op.
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
other := context.CancelFunc(func() {})
a.chatCancel.CompareAndSwap(&other, nil)
}()
}
wg.Wait()
// After all the unrelated defers, the slot must still hold B's cancel
// — the no-op CASes never touched it.
c := a.chatCancel.Load()
if c == nil {
t.Fatalf("chatCancel unexpectedly nil; B's cancel was wiped by a stale defer")
}
if c != &myB {
t.Errorf("chatCancel points at %p, want B's cancel at %p", c, &myB)
}
}