eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "net/http"
8 "net/http/httptest"
9 "os"
10 "strings"
11 "sync"
12 "sync/atomic"
13 "testing"
14 "time"
15
16 "lchat/internal/bot"
17 "lchat/internal/e2ee"
18)
19
20// provisionNamed provisions an identity whose roster label matches its payload
21// name — how real members and bots look. (provisionTestBot's shared "TestBot"
22// label would defeat the agent-skip rule these tests pin.)
23func provisionNamed(t *testing.T, tsURL, name string, agent bool) *bot.Session {
24 t.Helper()
25 secret := mintInviteForTest(t, tsURL, name, agent)
26 sess, err := bot.Provision(tsURL, secret, name)
27 if err != nil {
28 t.Fatalf("provision %s: %v", name, err)
29 }
30 return sess
31}
32
33// createThreadKind is createGroupWith for an arbitrary thread kind ("dm" for
34// the bot-DM tests below).
35func createThreadKind(t *testing.T, tsURL string, s *Server, kind string, sessions ...*bot.Session) (string, []byte) {
36 t.Helper()
37 threadKey, err := e2ee.GenerateKey()
38 if err != nil {
39 t.Fatal(err)
40 }
41 members := []map[string]string{}
42 for _, sess := range sessions {
43 uid, _ := s.db.deviceTokenUser(sess.Token)
44 wrapped, err := e2ee.WrapThreadKey(e2ee.B64uEncode(threadKey), sess.UserPub)
45 if err != nil {
46 t.Fatal(err)
47 }
48 members = append(members, map[string]string{"user_id": uid, "wrapped": wrapped})
49 }
50 body, _ := json.Marshal(map[string]any{"token": sessions[0].Token, "kind": kind, "members": members})
51 resp, err := http.Post(tsURL+"/api/threads", "application/json", bytes.NewReader(body))
52 if err != nil {
53 t.Fatal(err)
54 }
55 defer resp.Body.Close()
56 var out struct {
57 ThreadID string `json:"thread_id"`
58 }
59 if err := json.NewDecoder(resp.Body).Decode(&out); err != nil || out.ThreadID == "" {
60 t.Fatalf("create %s thread: status %d err %v", kind, resp.StatusCode, err)
61 }
62 return out.ThreadID, threadKey
63}
64
65// postAs encrypts a plain chat payload under the thread key and posts it over
66// the REST doorway (stores + broadcasts, same path as a WS "msg" frame).
67func postAs(t *testing.T, tsURL string, sess *bot.Session, threadID string, threadKey []byte, text string) {
68 t.Helper()
69 blob, err := e2ee.EncryptJSON(threadKey, map[string]any{"name": sess.Name, "text": text, "ts": time.Now().UnixMilli()})
70 if err != nil {
71 t.Fatal(err)
72 }
73 body, _ := json.Marshal(map[string]string{"thread_id": threadID, "blob": blob})
74 resp, err := http.Post(tsURL+"/api/messages?token="+sess.Token, "application/json", bytes.NewReader(body))
75 if err != nil {
76 t.Fatal(err)
77 }
78 resp.Body.Close()
79 if resp.StatusCode != http.StatusOK {
80 t.Fatalf("post message: %d", resp.StatusCode)
81 }
82}
83
84// botReplies returns the decrypted payloads authored by `name` in a thread.
85func botReplies(t *testing.T, s *Server, threadID string, threadKey []byte, name string) []struct {
86 Text string
87 Actions []map[string]any
88} {
89 t.Helper()
90 msgs, err := s.db.messagesAfter(threadID, 0, 200)
91 if err != nil {
92 t.Fatal(err)
93 }
94 var out []struct {
95 Text string
96 Actions []map[string]any
97 }
98 for _, m := range msgs {
99 var p struct {
100 Name string `json:"name"`
101 Text string `json:"text"`
102 Actions []map[string]any `json:"actions"`
103 }
104 if e2ee.DecryptJSON(threadKey, m.Blob, &p) != nil || p.Name != name {
105 continue
106 }
107 out = append(out, struct {
108 Text string
109 Actions []map[string]any
110 }{p.Text, p.Actions})
111 }
112 return out
113}
114
115func waitFor(t *testing.T, what string, cond func() bool) {
116 t.Helper()
117 deadline := time.Now().Add(5 * time.Second)
118 for time.Now().Before(deadline) {
119 if cond() {
120 return
121 }
122 time.Sleep(20 * time.Millisecond)
123 }
124 t.Fatalf("timed out waiting for %s", what)
125}
126
127// awaitReady blocks until the bot is live (reading frames). A message posted
128// before then is past the connect-time cursor and would only surface on a
129// reconnect — so tests that post-then-assert must wait for this first.
130func awaitReady(t *testing.T, ready <-chan struct{}) {
131 t.Helper()
132 select {
133 case <-ready:
134 case <-time.After(5 * time.Second):
135 t.Fatal("bot never became ready")
136 }
137}
138
139// TestBotRunCommandFloor drives the live runtime end-to-end against the real
140// router: a provisioned bot connects over WebSocket, and the deterministic
141// command floor answers a group @mention and a DM — with the non-mention group
142// message provably ignored (replies are processed in order, so one reply after
143// two posts means the first was skipped, no sleep needed).
144func TestBotRunCommandFloor(t *testing.T) {
145 ts, s := newTestServer(t)
146 owner := provisionNamed(t, ts.URL, "Owner", false)
147 eleven := provisionNamed(t, ts.URL, "Eleven", true)
148 groupID, groupKey := createGroupWith(t, ts.URL, s, owner, eleven)
149
150 ready := make(chan struct{})
151 ctx, cancel := context.WithCancel(context.Background())
152 defer cancel()
153 rt := &bot.Runtime{Base: ts.URL, Session: eleven, Handler: bot.CommandFloor("Eleven", false),
154 Ready: func() { close(ready) }, Logf: t.Logf}
155 go rt.Run(ctx)
156 awaitReady(t, ready)
157
158 // A plain group message must not trigger the bot; the @mention after it must.
159 postAs(t, ts.URL, owner, groupID, groupKey, "just chatting amongst ourselves")
160 postAs(t, ts.URL, owner, groupID, groupKey, "@Eleven /theme lagoon")
161 waitFor(t, "group reply", func() bool { return len(botReplies(t, s, groupID, groupKey, "Eleven")) > 0 })
162 replies := botReplies(t, s, groupID, groupKey, "Eleven")
163 if len(replies) != 1 {
164 t.Fatalf("got %d replies, want 1 (the plain message must be ignored)", len(replies))
165 }
166 if !strings.Contains(replies[0].Text, "Lagoon") ||
167 len(replies[0].Actions) != 1 || replies[0].Actions[0]["theme"] != "lagoon" {
168 t.Fatalf("bad /theme reply: %+v", replies[0])
169 }
170
171 // A DM created while the bot is already connected exercises the
172 // unknown-thread path — and its FIRST message must get a reply (the cursor
173 // rewind in onFrame; the Node runtime drops this one).
174 dmID, dmKey := createThreadKind(t, ts.URL, s, "dm", owner, eleven)
175 postAs(t, ts.URL, owner, dmID, dmKey, "hello there")
176 waitFor(t, "dm reply", func() bool { return len(botReplies(t, s, dmID, dmKey, "Eleven")) > 0 })
177 dmReplies := botReplies(t, s, dmID, dmKey, "Eleven")
178 if !strings.Contains(dmReplies[0].Text, "/help") {
179 t.Fatalf("dm to a no-AI bot should degrade to the commands hint: %+v", dmReplies[0])
180 }
181
182 // Bare command in the DM answers deterministically.
183 postAs(t, ts.URL, owner, dmID, dmKey, "/help")
184 waitFor(t, "help reply", func() bool { return len(botReplies(t, s, dmID, dmKey, "Eleven")) >= 2 })
185 dmReplies = botReplies(t, s, dmID, dmKey, "Eleven")
186 help := dmReplies[len(dmReplies)-1]
187 if !strings.Contains(help.Text, "/theme") || !strings.Contains(help.Text, "aren't enabled") {
188 t.Fatalf("bad /help reply: %+v", help)
189 }
190}
191
192// TestBotRunIgnoresOtherAgents pins the bots-never-talk-to-bots rule: a message
193// authored under another agent's roster label gets no reply, even in a DM.
194func TestBotRunIgnoresOtherAgents(t *testing.T) {
195 ts, s := newTestServer(t)
196 owner := provisionNamed(t, ts.URL, "Owner", false)
197 eleven := provisionNamed(t, ts.URL, "Eleven", true)
198 announcer := provisionNamed(t, ts.URL, "Announcer", true)
199 // All three share the group: the ping-pong risk the rule prevents only
200 // exists between bots in the same thread — which is also what puts the
201 // Announcer in Eleven's (connections-only) roster.
202 threadID, threadKey := createThreadKind(t, ts.URL, s, "group", owner, eleven, announcer)
203
204 ready := make(chan struct{})
205 ctx, cancel := context.WithCancel(context.Background())
206 defer cancel()
207 rt := &bot.Runtime{Base: ts.URL, Session: eleven, Handler: bot.CommandFloor("Eleven", false),
208 Ready: func() { close(ready) }, Logf: t.Logf}
209 go rt.Run(ctx)
210 awaitReady(t, ready)
211
212 // An agent-authored mention must be skipped; prove it with an ordinary
213 // mention after it that gets the (single) reply.
214 postAs(t, ts.URL, announcer, threadID, threadKey, "@Eleven /help")
215 postAs(t, ts.URL, owner, threadID, threadKey, "@Eleven /light")
216 waitFor(t, "reply", func() bool { return len(botReplies(t, s, threadID, threadKey, "Eleven")) > 0 })
217 replies := botReplies(t, s, threadID, threadKey, "Eleven")
218 if len(replies) != 1 || replies[0].Actions[0]["appearance"] != "light" {
219 t.Fatalf("agent-authored mention must be ignored; got %+v", replies)
220 }
221}
222
223// TestBotEngineRecipe drives a recipe end-to-end: a watcher rule nudges on a
224// keyword in an unaddressed group message, the commands rule still answers
225// mentions, and a changelog rule announces fresh entries — one Engine, one
226// identity, all three behaviours live.
227func TestBotEngineRecipe(t *testing.T) {
228 ts, s := newTestServer(t)
229 if len(s.changelog) < 1 {
230 t.Fatal("embedded changelog is empty")
231 }
232 owner := provisionNamed(t, ts.URL, "Owner", false)
233 eleven := provisionNamed(t, ts.URL, "Eleven", true)
234 groupID, groupKey := createGroupWith(t, ts.URL, s, owner, eleven)
235
236 dir := t.TempDir()
237 sessionPath := dir + "/eleven.session.json"
238 if err := eleven.Save(sessionPath); err != nil {
239 t.Fatal(err)
240 }
241 recipeSrc := `{"rules":[
242 {"on":"message","match":"any","regex":"(?i)\\bguys\\b","action":"reply","text":"Hey {name} — how about \"folks\"?"},
243 {"on":"message","action":"commands"},
244 {"on":"changelog","action":"announce","every":"50ms"}
245 ]}`
246 if err := os.WriteFile(bot.RecipePathFor(sessionPath), []byte(recipeSrc), 0o600); err != nil {
247 t.Fatal(err)
248 }
249 recipe, err := bot.LoadRecipe(bot.RecipePathFor(sessionPath))
250 if err != nil {
251 t.Fatal(err)
252 }
253
254 ready := make(chan struct{})
255 ctx, cancel := context.WithCancel(context.Background())
256 defer cancel()
257 eng := &bot.Engine{Base: ts.URL, Session: eleven, SessionPath: sessionPath, Recipe: recipe,
258 Ready: func() { close(ready) }, Logf: t.Logf}
259 go eng.Run(ctx)
260 awaitReady(t, ready)
261
262 // The changelog poller baselines silently on its first tick.
263 statePath := bot.StatePathFor(sessionPath)
264 waitFor(t, "announce baseline", func() bool { _, err := os.Stat(statePath); return err == nil })
265
266 // Unaddressed keyword → the watcher's templated nudge.
267 postAs(t, ts.URL, owner, groupID, groupKey, "morning guys")
268 waitFor(t, "nudge", func() bool { return len(botReplies(t, s, groupID, groupKey, "Eleven")) > 0 })
269 replies := botReplies(t, s, groupID, groupKey, "Eleven")
270 if replies[0].Text != "Hey Owner — how about \"folks\"?" {
271 t.Fatalf("bad nudge: %+v", replies[0])
272 }
273
274 // Mentioned command still reaches the floor through the recipe.
275 postAs(t, ts.URL, owner, groupID, groupKey, "@Eleven /dark")
276 waitFor(t, "command reply", func() bool { return len(botReplies(t, s, groupID, groupKey, "Eleven")) >= 2 })
277 replies = botReplies(t, s, groupID, groupKey, "Eleven")
278 if replies[1].Actions[0]["appearance"] != "dark" {
279 t.Fatalf("bad command reply: %+v", replies[1])
280 }
281
282 // Forget the newest changelog entry → the poller announces it within a tick.
283 raw, err := os.ReadFile(statePath)
284 if err != nil {
285 t.Fatal(err)
286 }
287 var state struct {
288 Announced []string `json:"announced"`
289 }
290 if err := json.Unmarshal(raw, &state); err != nil {
291 t.Fatal(err)
292 }
293 state.Announced = state.Announced[:len(state.Announced)-1]
294 raw, _ = json.Marshal(state)
295 if err := os.WriteFile(statePath, raw, 0o600); err != nil {
296 t.Fatal(err)
297 }
298 waitFor(t, "announcement", func() bool {
299 for _, r := range botReplies(t, s, groupID, groupKey, "Eleven") {
300 if strings.Contains(r.Text, "🚀 Just shipped:") {
301 return true
302 }
303 }
304 return false
305 })
306}
307
308// fakeAnthropic stands in for the Messages API: it records the last request and
309// returns a scripted reply (optionally with a tool_use chip). Pointed at via
310// ANTHROPIC_BASE_URL, it lets the model path run end-to-end with no network.
311type fakeAnthropic struct {
312 srv *httptest.Server
313 mu sync.Mutex
314 lastReq map[string]any
315 reply string
316 tool map[string]any // e.g. {"name":"set_theme","input":{"theme":"lagoon"}}
317 calls int
318}
319
320func newFakeAnthropic(t *testing.T, reply string, tool map[string]any) *fakeAnthropic {
321 f := &fakeAnthropic{reply: reply, tool: tool}
322 f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
323 var req map[string]any
324 json.NewDecoder(r.Body).Decode(&req)
325 f.mu.Lock()
326 f.lastReq = req
327 f.calls++
328 f.mu.Unlock()
329 content := []map[string]any{}
330 if f.reply != "" {
331 content = append(content, map[string]any{"type": "text", "text": f.reply})
332 }
333 if f.tool != nil {
334 content = append(content, map[string]any{
335 "type": "tool_use", "name": f.tool["name"], "input": f.tool["input"],
336 })
337 }
338 json.NewEncoder(w).Encode(map[string]any{"content": content, "stop_reason": "end_turn"})
339 }))
340 t.Cleanup(f.srv.Close)
341 return f
342}
343
344// TestBotEngineModelReply drives the model action end-to-end: a mentioned
345// message with no matching command reaches the "model" rule, which asks the
346// (fake) Claude endpoint and posts its reply with the chip it proposed.
347func TestBotEngineModelReply(t *testing.T) {
348 fake := newFakeAnthropic(t, "Sure — switching you to Lagoon.",
349 map[string]any{"name": "set_theme", "input": map[string]any{"theme": "lagoon"}})
350 t.Setenv("ANTHROPIC_BASE_URL", fake.srv.URL)
351
352 ts, s := newTestServer(t)
353 owner := provisionNamed(t, ts.URL, "Owner", false)
354 eleven := provisionNamed(t, ts.URL, "Eleven", true)
355 groupID, groupKey := createGroupWith(t, ts.URL, s, owner, eleven)
356
357 dir := t.TempDir()
358 sessionPath := dir + "/eleven.session.json"
359 if err := eleven.Save(sessionPath); err != nil {
360 t.Fatal(err)
361 }
362 // Default recipe (commands then model) covers this — no recipe file needed.
363 ready := make(chan struct{})
364 ctx, cancel := context.WithCancel(context.Background())
365 defer cancel()
366 eng := &bot.Engine{Base: ts.URL, Session: eleven, SessionPath: sessionPath,
367 Recipe: bot.DefaultRecipe(), AIKey: "test-key",
368 Ready: func() { close(ready) }, Logf: t.Logf}
369 go eng.Run(ctx)
370
371 awaitReady(t, ready)
372 // A model brain badges "ai".
373 uid, _ := s.db.deviceTokenUser(eleven.Token)
374 waitFor(t, "bot badge ai", func() bool {
375 var kind string
376 s.db.sql.QueryRow("SELECT COALESCE(kind, '') FROM bot_configs WHERE user_id = ?", uid).Scan(&kind)
377 return kind == "ai"
378 })
379
380 postAs(t, ts.URL, owner, groupID, groupKey, "@Eleven make it lagoon please")
381 waitFor(t, "model reply", func() bool { return len(botReplies(t, s, groupID, groupKey, "Eleven")) > 0 })
382 replies := botReplies(t, s, groupID, groupKey, "Eleven")
383 if replies[0].Text != "Sure — switching you to Lagoon." {
384 t.Fatalf("bad model reply text: %+v", replies[0])
385 }
386 if len(replies[0].Actions) != 1 || replies[0].Actions[0]["theme"] != "lagoon" {
387 t.Fatalf("model reply missing chip: %+v", replies[0])
388 }
389
390 // The request carried the cacheable system prompt and the triggering line.
391 fake.mu.Lock()
392 defer fake.mu.Unlock()
393 sys, _ := fake.lastReq["system"].([]any)
394 if len(sys) == 0 {
395 t.Fatal("no system blocks sent")
396 }
397 block := sys[0].(map[string]any)
398 if _, ok := block["cache_control"]; !ok {
399 t.Error("system block not marked cacheable")
400 }
401 // Default model is Sonnet, which supports adaptive thinking.
402 if th, _ := fake.lastReq["thinking"].(map[string]any); th["type"] != "adaptive" {
403 t.Errorf("Sonnet reply should send adaptive thinking, got %v", fake.lastReq["thinking"])
404 }
405}
406
407// TestBotEngineModelReplyHaikuNoThinking is the regression guard for the live
408// outage: Haiku 400s on adaptive thinking, so a Haiku bot must send NO thinking
409// field. Same flow as above with LCHAT_MODEL=haiku; asserts the request omits
410// thinking (and still produces a reply).
411func TestBotEngineModelReplyHaikuNoThinking(t *testing.T) {
412 fake := newFakeAnthropic(t, "On it.", nil)
413 t.Setenv("ANTHROPIC_BASE_URL", fake.srv.URL)
414 t.Setenv("LCHAT_MODEL", "claude-haiku-4-5")
415
416 ts, s := newTestServer(t)
417 owner := provisionNamed(t, ts.URL, "Owner", false)
418 eleven := provisionNamed(t, ts.URL, "Eleven", true)
419 groupID, groupKey := createGroupWith(t, ts.URL, s, owner, eleven)
420
421 dir := t.TempDir()
422 sessionPath := dir + "/eleven.session.json"
423 if err := eleven.Save(sessionPath); err != nil {
424 t.Fatal(err)
425 }
426 ready := make(chan struct{})
427 ctx, cancel := context.WithCancel(context.Background())
428 defer cancel()
429 eng := &bot.Engine{Base: ts.URL, Session: eleven, SessionPath: sessionPath,
430 Recipe: bot.DefaultRecipe(), AIKey: "test-key",
431 Ready: func() { close(ready) }, Logf: t.Logf}
432 go eng.Run(ctx)
433 awaitReady(t, ready)
434
435 postAs(t, ts.URL, owner, groupID, groupKey, "@Eleven hello")
436 waitFor(t, "haiku reply", func() bool { return len(botReplies(t, s, groupID, groupKey, "Eleven")) > 0 })
437
438 fake.mu.Lock()
439 defer fake.mu.Unlock()
440 if fake.lastReq["model"] != "claude-haiku-4-5" {
441 t.Fatalf("expected haiku model, got %v", fake.lastReq["model"])
442 }
443 if _, present := fake.lastReq["thinking"]; present {
444 t.Fatalf("Haiku request must omit thinking, got %v", fake.lastReq["thinking"])
445 }
446}
447
448// TestBotEngineChangelogBadge pins the recipe-driven badge: a bot running a
449// changelog-only recipe with the shared ANTHROPIC_API_KEY present must still
450// badge BOT (write-only, no model rule), not AI — the bug that would appear
451// when several identities run from one `lchat bot run` with one key.
452func TestBotEngineChangelogBadge(t *testing.T) {
453 ts, s := newTestServer(t)
454 owner := provisionNamed(t, ts.URL, "Owner", false)
455 announcer := provisionNamed(t, ts.URL, "Deploys", true)
456 createGroupWith(t, ts.URL, s, owner, announcer)
457
458 dir := t.TempDir()
459 sessionPath := dir + "/deploys.session.json"
460 if err := announcer.Save(sessionPath); err != nil {
461 t.Fatal(err)
462 }
463 recipePath := dir + "/deploys.recipe.json"
464 if err := os.WriteFile(recipePath, []byte(`{"rules":[{"on":"changelog","action":"announce"}]}`), 0o600); err != nil {
465 t.Fatal(err)
466 }
467 recipe, err := bot.LoadRecipe(recipePath)
468 if err != nil {
469 t.Fatal(err)
470 }
471 ready := make(chan struct{})
472 ctx, cancel := context.WithCancel(context.Background())
473 defer cancel()
474 eng := &bot.Engine{Base: ts.URL, Session: announcer, SessionPath: sessionPath,
475 Recipe: recipe, AIKey: "a-key-that-should-be-ignored",
476 Ready: func() { close(ready) }, Logf: t.Logf}
477 go eng.Run(ctx)
478 awaitReady(t, ready)
479
480 uid, _ := s.db.deviceTokenUser(announcer.Token)
481 waitFor(t, "announcer badge", func() bool {
482 var kind string
483 s.db.sql.QueryRow("SELECT COALESCE(kind, '') FROM bot_configs WHERE user_id = ?", uid).Scan(&kind)
484 return kind == "bot"
485 })
486 // Give it a beat to (not) declare ai — if the badge ever became "ai" the
487 // wait above would have to have seen "bot" first anyway; assert it stays bot.
488 var kind string
489 s.db.sql.QueryRow("SELECT COALESCE(kind, '') FROM bot_configs WHERE user_id = ?", uid).Scan(&kind)
490 if kind != "bot" {
491 t.Fatalf("announcer with a key present should badge bot, got %q", kind)
492 }
493}
494
495// The live sidecar marks EVERY request it makes to its instance — the
496// WebSocket dial and all REST calls — with bot.SidecarHeader, so a hibernating
497// instance's router can exclude bot traffic from its idle clock and refuse to
498// be woken by a sidecar reconnect loop (activator.go). Pinned at the wire: a
499// recording front door in the engine's path fails the test on any unmarked
500// request. (Provisioning happens against the unwrapped server on purpose —
501// claiming an invite is an operator action and stays unmarked.)
502func TestBotRuntimeMarksAllRequests(t *testing.T) {
503 ts, s := newTestServer(t)
504 handler := s.handler()
505 var seen atomic.Int32
506 front := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
507 seen.Add(1)
508 if r.Header.Get(bot.SidecarHeader) == "" {
509 t.Errorf("unmarked sidecar request: %s %s", r.Method, r.URL.Path)
510 }
511 handler.ServeHTTP(w, r)
512 }))
513 t.Cleanup(front.Close)
514
515 eleven := provisionNamed(t, ts.URL, "Eleven", true)
516 sessionPath := t.TempDir() + "/eleven.session.json"
517 if err := eleven.Save(sessionPath); err != nil {
518 t.Fatal(err)
519 }
520
521 ready := make(chan struct{})
522 ctx, cancel := context.WithCancel(context.Background())
523 defer cancel()
524 eng := &bot.Engine{Base: front.URL, Session: eleven, SessionPath: sessionPath,
525 Recipe: bot.DefaultRecipe(), Ready: func() { close(ready) }, Logf: t.Logf}
526 go eng.Run(ctx)
527 awaitReady(t, ready)
528
529 // Ready fires after the connect sequence — config, roster, threads,
530 // declare-kind, catch-up, and the WS upgrade itself — all through the
531 // front door; make sure it actually saw them.
532 if seen.Load() < 5 {
533 t.Fatalf("front door saw only %d engine requests", seen.Load())
534 }
535}
536