| 1 | package main |
| 2 | |
| 3 | // End-to-end test of the app-builder loop (internal/bot/appbuild.go + |
| 4 | // memberapps.go): a member asks the bot for an app in a DM; the (fake) model |
| 5 | // answers with a create_app tool call, the sidecar generates the module on a |
| 6 | // second call, validates it, encrypts { source, manifest } under the instance |
| 7 | // key, and deploys it via PUT /api/member-apps β all against the real router. |
| 8 | |
| 9 | import ( |
| 10 | "bytes" |
| 11 | "context" |
| 12 | "encoding/json" |
| 13 | "fmt" |
| 14 | "net/http" |
| 15 | "net/http/httptest" |
| 16 | "strings" |
| 17 | "sync" |
| 18 | "testing" |
| 19 | "time" |
| 20 | |
| 21 | "lchat/internal/bot" |
| 22 | "lchat/internal/e2ee" |
| 23 | ) |
| 24 | |
| 25 | // scriptedAnthropic returns a different canned response per call, in order; |
| 26 | // the last one repeats. Each entry is a content list. |
| 27 | func scriptedAnthropic(t *testing.T, responses ...[]map[string]any) *httptest.Server { |
| 28 | t.Helper() |
| 29 | var mu sync.Mutex |
| 30 | calls := 0 |
| 31 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 32 | var body struct { |
| 33 | Stream bool `json:"stream"` |
| 34 | } |
| 35 | json.NewDecoder(r.Body).Decode(&body) |
| 36 | mu.Lock() |
| 37 | i := calls |
| 38 | calls++ |
| 39 | mu.Unlock() |
| 40 | if i >= len(responses) { |
| 41 | i = len(responses) - 1 |
| 42 | } |
| 43 | // Honour stream:true the way the real API does β the build generation call |
| 44 | // streams (internal/bot: createStream), the conversational turn doesn't. |
| 45 | if body.Stream { |
| 46 | writeAnthropicSSE(w, responses[i]) |
| 47 | return |
| 48 | } |
| 49 | json.NewEncoder(w).Encode(map[string]any{"content": responses[i], "stop_reason": "end_turn"}) |
| 50 | })) |
| 51 | t.Cleanup(srv.Close) |
| 52 | return srv |
| 53 | } |
| 54 | |
| 55 | // writeAnthropicSSE serialises a canned content list as the Messages API's SSE |
| 56 | // event stream, so a client using createStream reassembles the same response. |
| 57 | func writeAnthropicSSE(w http.ResponseWriter, blocks []map[string]any) { |
| 58 | w.Header().Set("Content-Type", "text/event-stream") |
| 59 | ev := func(typ string, data map[string]any) { |
| 60 | data["type"] = typ |
| 61 | b, _ := json.Marshal(data) |
| 62 | fmt.Fprintf(w, "event: %s\ndata: %s\n\n", typ, b) |
| 63 | } |
| 64 | ev("message_start", map[string]any{"message": map[string]any{}}) |
| 65 | for i, blk := range blocks { |
| 66 | typ, _ := blk["type"].(string) |
| 67 | if typ == "tool_use" { |
| 68 | ev("content_block_start", map[string]any{"index": i, "content_block": map[string]any{"type": "tool_use", "id": "t", "name": blk["name"], "input": map[string]any{}}}) |
| 69 | pj, _ := json.Marshal(blk["input"]) |
| 70 | ev("content_block_delta", map[string]any{"index": i, "delta": map[string]any{"type": "input_json_delta", "partial_json": string(pj)}}) |
| 71 | } else { |
| 72 | text, _ := blk["text"].(string) |
| 73 | ev("content_block_start", map[string]any{"index": i, "content_block": map[string]any{"type": "text", "text": ""}}) |
| 74 | ev("content_block_delta", map[string]any{"index": i, "delta": map[string]any{"type": "text_delta", "text": text}}) |
| 75 | } |
| 76 | ev("content_block_stop", map[string]any{"index": i}) |
| 77 | } |
| 78 | ev("message_delta", map[string]any{"delta": map[string]any{"stop_reason": "end_turn"}}) |
| 79 | ev("message_stop", map[string]any{}) |
| 80 | } |
| 81 | |
| 82 | const builtNotesApp = "```js\n" + `import { defineApp, addItem, removeItem } from "../apps.js"; |
| 83 | |
| 84 | export const app = defineApp({ |
| 85 | name: "notes", |
| 86 | title: "Quick Notes", |
| 87 | glyph: "π", |
| 88 | initialState: () => ({ items: [] }), |
| 89 | events: { |
| 90 | "notes.add": (s, e) => (e.id && e.text ? { items: addItem(s.items, { id: e.id, text: e.text }) } : s), |
| 91 | "notes.remove": (s, e) => ({ items: removeItem(s.items, e.id) }), |
| 92 | }, |
| 93 | render(state, el, ctx) { |
| 94 | for (const it of ((state && state.items) || [])) { |
| 95 | const row = document.createElement("div"); |
| 96 | row.textContent = it.text; |
| 97 | el.appendChild(row); |
| 98 | } |
| 99 | }, |
| 100 | }); |
| 101 | ` + "\n```" |
| 102 | |
| 103 | func TestBotBuildsAndDeploysMemberApp(t *testing.T) { |
| 104 | fakeAI := scriptedAnthropic(t, |
| 105 | // Call 1 β the conversational turn: ack text + the create_app call. |
| 106 | []map[string]any{ |
| 107 | {"type": "text", "text": "On it β building Quick Notes now."}, |
| 108 | {"type": "tool_use", "name": "create_app", "input": map[string]any{ |
| 109 | "name": "notes", "title": "Quick Notes", "glyph": "π", |
| 110 | "spec": "a personal notes list: add a note via a form, remove with an Γ button", |
| 111 | }}, |
| 112 | }, |
| 113 | // Call 2 β the dedicated build call: the module, fenced. |
| 114 | []map[string]any{{"type": "text", "text": builtNotesApp}}, |
| 115 | ) |
| 116 | t.Setenv("ANTHROPIC_BASE_URL", fakeAI.URL) |
| 117 | |
| 118 | ts, s := newTestServer(t) |
| 119 | owner := provisionNamed(t, ts.URL, "Owner", false) |
| 120 | claude := provisionNamed(t, ts.URL, "Claude", true) |
| 121 | dmID, dmKey := createThreadKind(t, ts.URL, s, "dm", owner, claude) |
| 122 | ownerUID, _ := s.db.deviceTokenUser(owner.Token) |
| 123 | claudeUID, _ := s.db.deviceTokenUser(claude.Token) |
| 124 | |
| 125 | // Grant the instance key to the bot the way a member's client would: |
| 126 | // wrapped to the bot's roster pubkey, posted with the member's token. |
| 127 | instKey, err := e2ee.GenerateKey() |
| 128 | if err != nil { |
| 129 | t.Fatal(err) |
| 130 | } |
| 131 | wrapped, err := e2ee.WrapThreadKey(e2ee.B64uEncode(instKey), claude.UserPub) |
| 132 | if err != nil { |
| 133 | t.Fatal(err) |
| 134 | } |
| 135 | grant, _ := json.Marshal(map[string]any{ |
| 136 | "token": owner.Token, |
| 137 | "grants": []map[string]string{{"user_id": claudeUID, "wrapped": wrapped}}, |
| 138 | }) |
| 139 | if resp, err := http.Post(ts.URL+"/api/instance-key", "application/json", bytes.NewReader(grant)); err != nil || resp.StatusCode != http.StatusOK { |
| 140 | t.Fatalf("grant instance key: %v %d", err, resp.StatusCode) |
| 141 | } |
| 142 | |
| 143 | dir := t.TempDir() |
| 144 | sessionPath := dir + "/claude.session.json" |
| 145 | if err := claude.Save(sessionPath); err != nil { |
| 146 | t.Fatal(err) |
| 147 | } |
| 148 | ready := make(chan struct{}) |
| 149 | ctx, cancel := context.WithCancel(context.Background()) |
| 150 | defer cancel() |
| 151 | eng := &bot.Engine{Base: ts.URL, Session: claude, SessionPath: sessionPath, |
| 152 | Recipe: bot.DefaultRecipe(), AIKey: "test-key", |
| 153 | Ready: func() { close(ready) }, Logf: t.Logf} |
| 154 | go eng.Run(ctx) |
| 155 | awaitReady(t, ready) |
| 156 | |
| 157 | // Ask for the app β with the sender uid the live client injects, so the |
| 158 | // deployed manifest records the requester as its owner. |
| 159 | blob, err := e2ee.EncryptJSON(dmKey, map[string]any{ |
| 160 | "name": owner.Name, "uid": ownerUID, "text": "Can you build me a little notes app?", "ts": time.Now().UnixMilli(), |
| 161 | }) |
| 162 | if err != nil { |
| 163 | t.Fatal(err) |
| 164 | } |
| 165 | body, _ := json.Marshal(map[string]string{"thread_id": dmID, "blob": blob}) |
| 166 | if resp, err := http.Post(ts.URL+"/api/messages?token="+owner.Token, "application/json", bytes.NewReader(body)); err != nil || resp.StatusCode != http.StatusOK { |
| 167 | t.Fatalf("post ask: %v %d", err, resp.StatusCode) |
| 168 | } |
| 169 | |
| 170 | // The deploy lands (the build runs on its own goroutine). |
| 171 | waitFor(t, "member app deployed", func() bool { |
| 172 | apps, _ := s.db.listMemberApps() |
| 173 | return len(apps) == 1 |
| 174 | }) |
| 175 | apps, _ := s.db.listMemberApps() |
| 176 | if apps[0].AuthorID != claudeUID { |
| 177 | t.Fatalf("app author should be the bot: %+v", apps[0]) |
| 178 | } |
| 179 | if apps[0].OwnerID != ownerUID { |
| 180 | t.Fatalf("app owner should be the requester (so it's theirs to manage): %+v", apps[0]) |
| 181 | } |
| 182 | if !strings.HasPrefix(apps[0].ID, "app-") { |
| 183 | t.Fatalf("unexpected app id %q", apps[0].ID) |
| 184 | } |
| 185 | |
| 186 | // The blob decrypts (with the instance key) to the module + a manifest |
| 187 | // naming the requester as owner. |
| 188 | var doc struct { |
| 189 | Source string `json:"source"` |
| 190 | Manifest struct { |
| 191 | V int `json:"v"` |
| 192 | Name string `json:"name"` |
| 193 | Title string `json:"title"` |
| 194 | Glyph string `json:"glyph"` |
| 195 | Places []string `json:"places"` |
| 196 | Types []string `json:"types"` |
| 197 | Owner string `json:"owner"` |
| 198 | By string `json:"by"` |
| 199 | } `json:"manifest"` |
| 200 | } |
| 201 | if err := e2ee.DecryptJSON(instKey, apps[0].Blob, &doc); err != nil { |
| 202 | t.Fatalf("decrypt deployed blob: %v", err) |
| 203 | } |
| 204 | if !strings.Contains(doc.Source, "defineApp") { |
| 205 | t.Fatal("deployed source isn't the module") |
| 206 | } |
| 207 | m := doc.Manifest |
| 208 | // The bot builds personal apps: the manifest now speaks the places |
| 209 | // vocabulary (places-first), not the retired scope enum. |
| 210 | if m.V != 1 || m.Name != "notes" || m.Title != "Quick Notes" || m.Glyph != "π" || fmt.Sprint(m.Places) != "[you]" { |
| 211 | t.Fatalf("bad manifest: %+v", m) |
| 212 | } |
| 213 | if fmt.Sprint(m.Types) != "[notes.add notes.remove]" { |
| 214 | t.Fatalf("bad manifest types: %v", m.Types) |
| 215 | } |
| 216 | if m.Owner != ownerUID || m.By != "Owner" { |
| 217 | t.Fatalf("manifest owner should be the requester: %+v", m) |
| 218 | } |
| 219 | |
| 220 | // The thread gets the model's ack and the deploy confirmation β the latter is |
| 221 | // the status line, edited in place to carry the app id. They race to post |
| 222 | // (the status line goes up as the build starts, alongside the ack), so don't |
| 223 | // assume an order: wait for the id to land, then check both are present. |
| 224 | waitFor(t, "confirmation carries the app id", func() bool { |
| 225 | for _, rp := range botReplies(t, s, dmID, dmKey, "Claude") { |
| 226 | if strings.Contains(rp.Text, apps[0].ID) { |
| 227 | return true |
| 228 | } |
| 229 | } |
| 230 | return false |
| 231 | }) |
| 232 | replies := botReplies(t, s, dmID, dmKey, "Claude") |
| 233 | var haveAck, haveID, haveVersion bool |
| 234 | for _, rp := range replies { |
| 235 | if strings.Contains(rp.Text, "On it") { |
| 236 | haveAck = true |
| 237 | } |
| 238 | if strings.Contains(rp.Text, apps[0].ID) { |
| 239 | haveID = true |
| 240 | } |
| 241 | // The confirmation quotes the VERIFIED deployed version (read back from |
| 242 | // the server after the PUT), so the member can check it in App Studio. |
| 243 | if strings.Contains(rp.Text, "version 1") { |
| 244 | haveVersion = true |
| 245 | } |
| 246 | } |
| 247 | if !haveAck { |
| 248 | t.Fatalf("expected the model's ack among the replies: %+v", replies) |
| 249 | } |
| 250 | if !haveID { |
| 251 | t.Fatalf("expected the deploy confirmation to carry the app id: %+v", replies) |
| 252 | } |
| 253 | if !haveVersion { |
| 254 | t.Fatalf("expected the deploy confirmation to carry the verified version: %+v", replies) |
| 255 | } |
| 256 | if apps[0].Version != 1 { |
| 257 | t.Fatalf("first deploy should be version 1: %+v", apps[0]) |
| 258 | } |
| 259 | } |
| 260 | |
| 261 | // The model sometimes invents an app_id for a BRAND-NEW app (it did this for |
| 262 | // "let me build a hello button" β app_id "hello-button"). That id doesn't |
| 263 | // exist, so the builder must fall through to creating a new app rather than |
| 264 | // failing with "no app with id β¦". |
| 265 | func TestBotBuildsWithBogusAppId(t *testing.T) { |
| 266 | fakeAI := scriptedAnthropic(t, |
| 267 | []map[string]any{ |
| 268 | {"type": "text", "text": "On it."}, |
| 269 | {"type": "tool_use", "name": "create_app", "input": map[string]any{ |
| 270 | "app_id": "hello-button", // does not exist β a model slip |
| 271 | "name": "notes", "title": "Quick Notes", "glyph": "π", |
| 272 | "spec": "notes list", |
| 273 | }}, |
| 274 | }, |
| 275 | []map[string]any{{"type": "text", "text": builtNotesApp}}, |
| 276 | ) |
| 277 | t.Setenv("ANTHROPIC_BASE_URL", fakeAI.URL) |
| 278 | |
| 279 | ts, s := newTestServer(t) |
| 280 | owner := provisionNamed(t, ts.URL, "Owner", false) |
| 281 | claude := provisionNamed(t, ts.URL, "Claude", true) |
| 282 | dmID, dmKey := createThreadKind(t, ts.URL, s, "dm", owner, claude) |
| 283 | ownerUID, _ := s.db.deviceTokenUser(owner.Token) |
| 284 | claudeUID, _ := s.db.deviceTokenUser(claude.Token) |
| 285 | |
| 286 | instKey, _ := e2ee.GenerateKey() |
| 287 | wrapped, _ := e2ee.WrapThreadKey(e2ee.B64uEncode(instKey), claude.UserPub) |
| 288 | grant, _ := json.Marshal(map[string]any{"token": owner.Token, "grants": []map[string]string{{"user_id": claudeUID, "wrapped": wrapped}}}) |
| 289 | if resp, err := http.Post(ts.URL+"/api/instance-key", "application/json", bytes.NewReader(grant)); err != nil || resp.StatusCode != http.StatusOK { |
| 290 | t.Fatalf("grant: %v %d", err, resp.StatusCode) |
| 291 | } |
| 292 | |
| 293 | dir := t.TempDir() |
| 294 | sessionPath := dir + "/claude.session.json" |
| 295 | if err := claude.Save(sessionPath); err != nil { |
| 296 | t.Fatal(err) |
| 297 | } |
| 298 | ready := make(chan struct{}) |
| 299 | ctx, cancel := context.WithCancel(context.Background()) |
| 300 | defer cancel() |
| 301 | go (&bot.Engine{Base: ts.URL, Session: claude, SessionPath: sessionPath, Recipe: bot.DefaultRecipe(), AIKey: "test-key", Ready: func() { close(ready) }, Logf: t.Logf}).Run(ctx) |
| 302 | awaitReady(t, ready) |
| 303 | |
| 304 | blob, _ := e2ee.EncryptJSON(dmKey, map[string]any{"name": owner.Name, "uid": ownerUID, "text": "a hello button please", "ts": time.Now().UnixMilli()}) |
| 305 | body, _ := json.Marshal(map[string]string{"thread_id": dmID, "blob": blob}) |
| 306 | http.Post(ts.URL+"/api/messages?token="+owner.Token, "application/json", bytes.NewReader(body)) |
| 307 | |
| 308 | // Despite the bogus app_id, a NEW app is created (fresh app- id), not failed. |
| 309 | waitFor(t, "app created despite bogus id", func() bool { |
| 310 | apps, _ := s.db.listMemberApps() |
| 311 | return len(apps) == 1 |
| 312 | }) |
| 313 | apps, _ := s.db.listMemberApps() |
| 314 | if !strings.HasPrefix(apps[0].ID, "app-") || apps[0].ID == "hello-button" { |
| 315 | t.Fatalf("should have minted a fresh id, got %q", apps[0].ID) |
| 316 | } |
| 317 | replies := botReplies(t, s, dmID, dmKey, "Claude") |
| 318 | for _, r := range replies { |
| 319 | if strings.Contains(r.Text, "couldn't") || strings.Contains(r.Text, "no app with id") { |
| 320 | t.Fatalf("build should not have reported a failure: %q", r.Text) |
| 321 | } |
| 322 | } |
| 323 | } |
| 324 | |
| 325 | const builtCompanionApp = "```js\n" + `import { defineApp, addItem } from "../apps.js"; |
| 326 | import { formatBytes } from "../format.js"; |
| 327 | |
| 328 | export const app = defineApp({ |
| 329 | name: "habit", |
| 330 | title: "Habits", |
| 331 | glyph: "π±", |
| 332 | initialState: () => ({ items: [] }), |
| 333 | events: { |
| 334 | "habit.add": (s, e) => (e.text ? { items: addItem(s.items, { id: e.id, text: e.text }) } : s), |
| 335 | "habit.tick": (s, e) => ({ items: s.items.map((h) => (h.id === e.id ? { ...h, ticked: true } : h)) }), |
| 336 | }, |
| 337 | chatMessage: (e) => (e.type === "habit.add" ? { text: "tracking " + e.text, action: true } : null), |
| 338 | commands: { |
| 339 | "/habit": (args, ctx) => { |
| 340 | const t = args.trim(); |
| 341 | if (!t) return ctx.reply({ text: "Usage: /habit <name>" }); |
| 342 | ctx.emit({ type: "habit.add", id: ctx.id(), text: t }); |
| 343 | ctx.reply({ text: "Tracking " + t }); |
| 344 | }, |
| 345 | }, |
| 346 | render(state, el, ctx) { |
| 347 | for (const it of ((state && state.items) || [])) { |
| 348 | const row = document.createElement("div"); |
| 349 | row.textContent = it.text; |
| 350 | el.appendChild(row); |
| 351 | } |
| 352 | }, |
| 353 | }); |
| 354 | ` + "\n```" |
| 355 | |
| 356 | // A companion app with a slash command and a format.js import deploys with a |
| 357 | // manifest that reflects all three: chat:true (from the source's chatMessage), |
| 358 | // the declared command, and it passes validation (format import allowed). |
| 359 | func TestBotBuildsCompanionCommandApp(t *testing.T) { |
| 360 | fakeAI := scriptedAnthropic(t, |
| 361 | []map[string]any{ |
| 362 | {"type": "text", "text": "Building it."}, |
| 363 | {"type": "tool_use", "name": "create_app", "input": map[string]any{ |
| 364 | "name": "habit", "title": "Habits", "glyph": "π±", |
| 365 | "spec": "a habit tracker; /habit adds one; the thread reads as a conversation", |
| 366 | "commands": []any{map[string]any{"name": "/habit", "desc": "Track a habit", "args": "<name>"}}, |
| 367 | }}, |
| 368 | }, |
| 369 | []map[string]any{{"type": "text", "text": builtCompanionApp}}, |
| 370 | ) |
| 371 | t.Setenv("ANTHROPIC_BASE_URL", fakeAI.URL) |
| 372 | |
| 373 | ts, s := newTestServer(t) |
| 374 | owner := provisionNamed(t, ts.URL, "Owner", false) |
| 375 | claude := provisionNamed(t, ts.URL, "Claude", true) |
| 376 | dmID, dmKey := createThreadKind(t, ts.URL, s, "dm", owner, claude) |
| 377 | ownerUID, _ := s.db.deviceTokenUser(owner.Token) |
| 378 | claudeUID, _ := s.db.deviceTokenUser(claude.Token) |
| 379 | |
| 380 | instKey, _ := e2ee.GenerateKey() |
| 381 | wrapped, _ := e2ee.WrapThreadKey(e2ee.B64uEncode(instKey), claude.UserPub) |
| 382 | grant, _ := json.Marshal(map[string]any{"token": owner.Token, "grants": []map[string]string{{"user_id": claudeUID, "wrapped": wrapped}}}) |
| 383 | if resp, err := http.Post(ts.URL+"/api/instance-key", "application/json", bytes.NewReader(grant)); err != nil || resp.StatusCode != http.StatusOK { |
| 384 | t.Fatalf("grant instance key: %v %d", err, resp.StatusCode) |
| 385 | } |
| 386 | |
| 387 | dir := t.TempDir() |
| 388 | sessionPath := dir + "/claude.session.json" |
| 389 | if err := claude.Save(sessionPath); err != nil { |
| 390 | t.Fatal(err) |
| 391 | } |
| 392 | ready := make(chan struct{}) |
| 393 | ctx, cancel := context.WithCancel(context.Background()) |
| 394 | defer cancel() |
| 395 | eng := &bot.Engine{Base: ts.URL, Session: claude, SessionPath: sessionPath, Recipe: bot.DefaultRecipe(), AIKey: "test-key", Ready: func() { close(ready) }, Logf: t.Logf} |
| 396 | go eng.Run(ctx) |
| 397 | awaitReady(t, ready) |
| 398 | |
| 399 | blob, _ := e2ee.EncryptJSON(dmKey, map[string]any{"name": owner.Name, "uid": ownerUID, "text": "make me a habit tracker with a /habit command and a chat view", "ts": time.Now().UnixMilli()}) |
| 400 | body, _ := json.Marshal(map[string]string{"thread_id": dmID, "blob": blob}) |
| 401 | if resp, err := http.Post(ts.URL+"/api/messages?token="+owner.Token, "application/json", bytes.NewReader(body)); err != nil || resp.StatusCode != http.StatusOK { |
| 402 | t.Fatalf("post ask: %v %d", err, resp.StatusCode) |
| 403 | } |
| 404 | |
| 405 | waitFor(t, "companion app deployed", func() bool { |
| 406 | apps, _ := s.db.listMemberApps() |
| 407 | return len(apps) == 1 |
| 408 | }) |
| 409 | apps, _ := s.db.listMemberApps() |
| 410 | var doc struct { |
| 411 | Source string `json:"source"` |
| 412 | Manifest struct { |
| 413 | Name string `json:"name"` |
| 414 | Types []string |
| 415 | Chat bool `json:"chat"` |
| 416 | Commands []struct { |
| 417 | Name string `json:"name"` |
| 418 | Desc string `json:"desc"` |
| 419 | Args string `json:"args"` |
| 420 | } `json:"commands"` |
| 421 | } `json:"manifest"` |
| 422 | } |
| 423 | if err := e2ee.DecryptJSON(instKey, apps[0].Blob, &doc); err != nil { |
| 424 | t.Fatalf("decrypt: %v", err) |
| 425 | } |
| 426 | if !doc.Manifest.Chat { |
| 427 | t.Error("manifest.chat should be true (source has chatMessage)") |
| 428 | } |
| 429 | if len(doc.Manifest.Commands) != 1 || doc.Manifest.Commands[0].Name != "/habit" || doc.Manifest.Commands[0].Desc != "Track a habit" { |
| 430 | t.Fatalf("bad manifest commands: %+v", doc.Manifest.Commands) |
| 431 | } |
| 432 | if fmt.Sprint(doc.Manifest.Types) != "[habit.add habit.tick]" { |
| 433 | t.Fatalf("bad types: %v", doc.Manifest.Types) |
| 434 | } |
| 435 | if !strings.Contains(doc.Source, "formatBytes") { |
| 436 | t.Error("source should keep the format.js import (allowed)") |
| 437 | } |
| 438 | } |
| 439 | |