eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1package bot
2
3import (
4 "os"
5 "regexp"
6 "strings"
7 "testing"
8)
9
10// A small valid member app, the shape the guide teaches.
11const validAppSource = `
12import { defineApp, addItem, removeItem } from "../apps.js";
13
14export const app = defineApp({
15 name: "notes",
16 title: "Notes",
17 glyph: "📝",
18 initialState: () => ({ items: [] }),
19 events: {
20 "notes.add": (s, e) => (e.id && e.text ? { items: addItem(s.items, { id: e.id, text: e.text }) } : s),
21 "notes.remove": (s, e) => ({ items: removeItem(s.items, e.id) }),
22 },
23 render(state, el, ctx) {
24 const items = (state && state.items) || [];
25 for (const it of items) {
26 const row = document.createElement("div");
27 row.textContent = it.text;
28 el.appendChild(row);
29 }
30 },
31});
32`
33
34func TestValidateMemberAppSource(t *testing.T) {
35 types, problems := validateMemberAppSource("notes", validAppSource)
36 if len(problems) != 0 {
37 t.Fatalf("valid app rejected: %v", problems)
38 }
39 if len(types) != 2 || types[0] != "notes.add" || types[1] != "notes.remove" {
40 t.Fatalf("bad types: %v", types)
41 }
42
43 cases := []struct {
44 name string
45 src string
46 want string // substring of a problem
47 }{
48 {"network", strings.Replace(validAppSource, "document.createElement", "fetch(x); document.createElement", 1), "fetch"},
49 {"storage", strings.Replace(validAppSource, "document.createElement", "localStorage.x; document.createElement", 1), "localStorage"},
50 {"innerHTML", strings.Replace(validAppSource, "row.textContent = it.text", "row.innerHTML = it.text", 1), "innerHTML"},
51 {"bad import source", strings.Replace(validAppSource, `"../apps.js"`, `"../other.js"`, 1), `only "../apps.js" and "../format.js"`},
52 {"bad import name", strings.Replace(validAppSource, "addItem,", "foldEvent, addItem,", 1), "not importable"},
53 {"dynamic import", strings.Replace(validAppSource, "render(state, el, ctx) {", "render(state, el, ctx) { import(\"https://x\");", 1), "../apps.js"},
54 {"two defineApps", validAppSource + "\ndefineApp({});", "exactly once"},
55 {"wrong name", strings.Replace(validAppSource, `name: "notes"`, `name: "other"`, 1), "name field"},
56 {"setInterval", strings.Replace(validAppSource, "document.createElement", "setInterval(f, 100); document.createElement", 1), "ctx.interval"},
57 }
58 for _, c := range cases {
59 _, problems := validateMemberAppSource("notes", c.src)
60 found := false
61 for _, p := range problems {
62 if strings.Contains(p, c.want) {
63 found = true
64 }
65 }
66 if !found {
67 t.Errorf("%s: want a problem containing %q, got %v", c.name, c.want, problems)
68 }
69 }
70
71 // Prose in comments/strings must not trip the token scan.
72 commented := strings.Replace(validAppSource, "export const app",
73 "// never call fetch( or use localStorage or innerHTML\nconst note = \"fetch( localStorage innerHTML import \";\nexport const app", 1)
74 if _, problems := validateMemberAppSource("notes", commented); len(problems) != 0 {
75 t.Errorf("comment/string mentions tripped the scan: %v", problems)
76 }
77
78 // Boundary-aware forbidden scan: legitimate identifiers that merely CONTAIN
79 // a forbidden token as a substring must NOT be flagged (regression for the
80 // substring-match false positive).
81 for _, ident := range []string{
82 "const x = refetchList();",
83 "function prefetch() {}",
84 "const r = retrieval(q);",
85 "const m = medieval(y);",
86 } {
87 clean := strings.Replace(validAppSource, "render(state, el, ctx) {", "render(state, el, ctx) { "+ident, 1)
88 if _, problems := validateMemberAppSource("notes", clean); len(problems) != 0 {
89 t.Errorf("legit identifier %q false-tripped the forbidden scan: %v", ident, problems)
90 }
91 }
92
93 // A camelCase (uppercase-tail) event type must be CAUGHT, not silently
94 // dropped — otherwise the manifest ships a subset and that event is dead.
95 camel := strings.Replace(validAppSource, `"notes.remove"`, `"notes.markDone"`, 2)
96 camelTypes, camelProblems := validateMemberAppSource("notes", camel)
97 if !hasProblem(camelProblems, "markDone") {
98 t.Errorf("camelCase type not flagged: types=%v problems=%v", camelTypes, camelProblems)
99 }
100
101 // Unsupported export forms the sandbox transform rejects must be caught here.
102 starExport := strings.Replace(validAppSource, "export const app", `export * from "../apps.js";\nexport const app`, 1)
103 if _, problems := validateMemberAppSource("notes", starExport); !hasProblem(problems, "export") {
104 t.Errorf("`export *` not flagged: %v", problems)
105 }
106
107 // An unrelated earlier `name:` field must not shadow the real one.
108 withOtherName := strings.Replace(validAppSource, "initialState: () => ({ items: [] }),",
109 `initialState: () => ({ items: [], config: { name: "health" } }),`, 1)
110 if _, problems := validateMemberAppSource("notes", withOtherName); hasProblem(problems, "name field") {
111 t.Errorf("unrelated name: field false-rejected the correct app name: %v", problems)
112 }
113}
114
115func hasProblem(problems []string, sub string) bool {
116 for _, p := range problems {
117 if strings.Contains(p, sub) {
118 return true
119 }
120 }
121 return false
122}
123
124func TestParseAppBuildReq(t *testing.T) {
125 good := map[string]any{"name": "habits", "title": "Habit Tracker", "glyph": "🌱", "spec": "track habits"}
126 if _, err := parseAppBuildReq(good); err != nil {
127 t.Fatalf("good req rejected: %v", err)
128 }
129 bad := []map[string]any{
130 {"name": "Habits", "title": "T", "glyph": "🌱", "spec": "s"}, // uppercase name
131 {"name": "9habits", "title": "T", "glyph": "🌱", "spec": "s"}, // starts with digit
132 {"name": "habits", "title": "", "glyph": "🌱", "spec": "s"}, // empty title
133 {"name": "habits", "title": "T", "glyph": "", "spec": "s"}, // empty glyph
134 {"name": "habits", "title": "T", "glyph": "🌱", "spec": ""}, // empty spec
135 {"name": "habits", "title": "T", "glyph": "🌱", "spec": "s", "app_id": "NO"}, // bad app_id
136 }
137 for i, in := range bad {
138 if _, err := parseAppBuildReq(in); err == nil {
139 t.Errorf("bad req %d accepted", i)
140 }
141 }
142}
143
144func TestExtractCodeBlock(t *testing.T) {
145 if got := extractCodeBlock("```js\nconst a = 1;\n```"); got != "const a = 1;" {
146 t.Fatalf("js fence: %q", got)
147 }
148 if got := extractCodeBlock("prose\n```\nshort\n```\nmore\n```javascript\na much longer block wins\n```"); got != "a much longer block wins" {
149 t.Fatalf("largest block: %q", got)
150 }
151 if got := extractCodeBlock("no fence at all"); got != "" {
152 t.Fatalf("no fence: %q", got)
153 }
154}
155
156// TestAppImportsMatchSandbox pins the Go validator's allowed-import lists to
157// the sandbox runtime's ALLOWED_IMPORTS / ALLOWED_FMT (web/static/sandbox-frame.js)
158// — the same drift-proofing pattern as TestCommandsMatchJS. If either SDK
159// surface grows an export, both sides update in the same PR or this fails.
160func TestAppImportsMatchSandbox(t *testing.T) {
161 raw, err := os.ReadFile("../../web/static/sandbox-frame.js")
162 if err != nil {
163 t.Fatal(err)
164 }
165 check := func(jsVar string, goList []string) {
166 m := regexp.MustCompile(`(?s)export const ` + jsVar + ` = \[(.*?)\]`).FindSubmatch(raw)
167 if m == nil {
168 t.Fatalf("%s not found in sandbox-frame.js", jsVar)
169 }
170 var js []string
171 for _, im := range regexp.MustCompile(`"([^"]+)"`).FindAllStringSubmatch(string(m[1]), -1) {
172 js = append(js, im[1])
173 }
174 if len(js) != len(goList) {
175 t.Fatalf("%s: sandbox has %d, Go validator %d", jsVar, len(js), len(goList))
176 }
177 for i := range js {
178 if js[i] != goList[i] {
179 t.Errorf("%s[%d]: sandbox %q, validator %q", jsVar, i, js[i], goList[i])
180 }
181 }
182 }
183 check("ALLOWED_IMPORTS", allowedAppImports)
184 // ALLOWED_FMT is `Object.keys(FMT)` in the sandbox, not a literal array, so
185 // pin against the FMT object's keys.
186 fm := regexp.MustCompile(`(?s)const FMT = \{(.*?)\};`).FindSubmatch(raw)
187 if fm == nil {
188 t.Fatal("FMT not found in sandbox-frame.js")
189 }
190 var fmtKeys []string
191 for _, k := range regexp.MustCompile(`(?m)^\s*(\w+),`).FindAllStringSubmatch(string(fm[1]), -1) {
192 fmtKeys = append(fmtKeys, k[1])
193 }
194 if len(fmtKeys) != len(allowedFmtImports) {
195 t.Fatalf("FMT: sandbox has %d keys, Go validator %d", len(fmtKeys), len(allowedFmtImports))
196 }
197 for i := range fmtKeys {
198 if fmtKeys[i] != allowedFmtImports[i] {
199 t.Errorf("FMT[%d]: sandbox %q, validator %q", i, fmtKeys[i], allowedFmtImports[i])
200 }
201 }
202}
203
204func TestValidatorAcceptsFormatImports(t *testing.T) {
205 src := strings.Replace(
206 validAppSource,
207 `import { defineApp, addItem, removeItem } from "../apps.js";`,
208 `import { defineApp, addItem, removeItem } from "../apps.js";`+"\n"+`import { tokenizeText, formatBytes as fb } from "../format.js";`,
209 1,
210 )
211 if _, problems := validateMemberAppSource("notes", src); len(problems) != 0 {
212 t.Errorf("valid format import rejected: %v", problems)
213 }
214 // A non-format name from format.js is rejected.
215 bad := strings.Replace(src, "tokenizeText, formatBytes as fb", "membershipLine", 1)
216 if _, problems := validateMemberAppSource("notes", bad); !hasProblem(problems, "not importable") {
217 t.Errorf("membershipLine from format.js should be rejected: %v", problems)
218 }
219}
220
221func TestSourceHasChatMessageAndCommands(t *testing.T) {
222 if !sourceHasChatMessage(`chatMessage: (e) => null,`) {
223 t.Error("chatMessage property not detected")
224 }
225 if !sourceHasChatMessage(`chatMessage(e) { return null; }`) {
226 t.Error("chatMessage method not detected")
227 }
228 if sourceHasChatMessage(`// no chatMessage here\nconst x = "chatMessage: fake";`) {
229 t.Error("chatMessage in comment/string should not count")
230 }
231 if !commandDefinedInSource(`commands: { "/habit": (a, c) => {} }`, "/habit") {
232 t.Error("command handler not detected")
233 }
234 if commandDefinedInSource(`commands: { "/other": (a, c) => {} }`, "/habit") {
235 t.Error("absent command wrongly detected")
236 }
237}
238
239func TestParseAppBuildReqCommands(t *testing.T) {
240 req, err := parseAppBuildReq(map[string]any{
241 "name": "habit", "title": "Habit", "glyph": "🌱", "spec": "track",
242 "commands": []any{map[string]any{"name": "/habit", "desc": "add", "args": "<t>"}},
243 })
244 if err != nil {
245 t.Fatalf("valid commands rejected: %v", err)
246 }
247 if len(req.Commands) != 1 || req.Commands[0].Name != "/habit" || req.Commands[0].Desc != "add" {
248 t.Fatalf("bad parsed commands: %+v", req.Commands)
249 }
250 // Bad command name.
251 if _, err := parseAppBuildReq(map[string]any{
252 "name": "habit", "title": "H", "glyph": "🌱", "spec": "s",
253 "commands": []any{map[string]any{"name": "habit"}},
254 }); err == nil {
255 t.Error("command without leading slash accepted")
256 }
257}
258
259// The in-flight guard is what stops a re-ask mid-build from deploying the app
260// twice: the second beginBuild for the same (thread, uid) must be refused until
261// endBuild releases it, while a different thread or requester is unaffected.
262func TestBuildInFlightGuard(t *testing.T) {
263 r := &Runtime{}
264 if !r.beginBuild("t1", "u1") {
265 t.Fatal("first build should be allowed")
266 }
267 if r.beginBuild("t1", "u1") {
268 t.Fatal("a duplicate concurrent build should be refused")
269 }
270 // A different requester, or a different thread, is its own slot.
271 if !r.beginBuild("t1", "u2") {
272 t.Fatal("a different requester should be allowed to build")
273 }
274 if !r.beginBuild("t2", "u1") {
275 t.Fatal("the same requester in another thread should be allowed")
276 }
277 // Releasing t1/u1 lets a retry through; the others stay independent.
278 r.endBuild("t1", "u1")
279 if !r.beginBuild("t1", "u1") {
280 t.Fatal("build should be allowed again after endBuild")
281 }
282}
283
284// The progress/result line is a /me-style action line, not a bot bubble: the
285// payload must carry action:true (plus a stable name/ts) so the web renders it
286// as a centered line and edits keep it that way.
287func TestActionLinePayload(t *testing.T) {
288 a := &actionLine{r: &Runtime{Session: &Session{Name: "Claude"}}, ts: 123}
289 p := a.payload("building…")
290 if p["action"] != true {
291 t.Fatalf("action flag missing: %+v", p)
292 }
293 if p["name"] != "Claude" || p["text"] != "building…" || p["ts"] != int64(123) {
294 t.Fatalf("bad payload: %+v", p)
295 }
296}
297
298func TestSourceDefaultCommand(t *testing.T) {
299 if got := sourceDefaultCommand(`defineApp({ name: "x", defaultCommand: "/add", commands: {} })`); got != "/add" {
300 t.Fatalf("want /add, got %q", got)
301 }
302 if got := sourceDefaultCommand(`defineApp({ name: "x", commands: {} })`); got != "" {
303 t.Fatalf("no default: want empty, got %q", got)
304 }
305}
306
307func TestUniqueAppName(t *testing.T) {
308 // Free name is returned unchanged.
309 if got := uniqueAppName("habits", map[string]bool{"todo": true}); got != "habits" {
310 t.Fatalf("free name: got %q", got)
311 }
312 // Taken name gets the first free numeric variant.
313 taken := map[string]bool{"habits": true, "habits2": true}
314 if got := uniqueAppName("habits", taken); got != "habits3" {
315 t.Fatalf("collision: want habits3, got %q", got)
316 }
317 // A max-length base is trimmed so the suffix keeps it within 24 chars.
318 long := "abcdefghijklmnopqrstuvwx" // 24 chars
319 got := uniqueAppName(long, map[string]bool{long: true})
320 if len(got) > 24 || got == long {
321 t.Fatalf("over-long variant %q (len %d)", got, len(got))
322 }
323 if !appNameRe.MatchString(got) {
324 t.Fatalf("variant %q doesn't match the name rule", got)
325 }
326}
327
328// The apps note lists exactly the SENDER's apps — never another member's,
329// never owner-less rows — with the exact ids the tools need; no owned apps
330// means no note at all.
331func TestMemberAppsNoteText(t *testing.T) {
332 apps := []deployedApp{
333 {ID: "app-aaaa11", Version: 3, Doc: memberAppDoc{Manifest: memberAppManifest{
334 Name: "habits", Title: "Habit Tracker", Glyph: "🌱", Owner: "u1", BuiltBy: "Claude"}}},
335 {ID: "app-bbbb22", Version: 1, Doc: memberAppDoc{Manifest: memberAppManifest{
336 Name: "notes", Title: "Notes", Glyph: "📝", Owner: "u2"}}},
337 {ID: "app-cccc33", Version: 2, Doc: memberAppDoc{Manifest: memberAppManifest{
338 Name: "legacy", Title: "Legacy", Glyph: "🗿"}}}, // owner-less: nobody's
339 }
340 note := memberAppsNoteText(apps, "u1")
341 for _, want := range []string{"app-aaaa11", "Habit Tracker", "version 3", "built by Claude", "create_app", "read_app_source"} {
342 if !strings.Contains(note, want) {
343 t.Errorf("note missing %q:\n%s", want, note)
344 }
345 }
346 for _, banned := range []string{"app-bbbb22", "app-cccc33", "Notes", "Legacy"} {
347 if strings.Contains(note, banned) {
348 t.Errorf("note leaked another member's app (%q):\n%s", banned, note)
349 }
350 }
351 if got := memberAppsNoteText(apps, "u3"); got != "" {
352 t.Errorf("member with no apps should get no note, got:\n%s", got)
353 }
354 if got := memberAppsNoteText(nil, "u1"); got != "" {
355 t.Errorf("empty list should give no note, got:\n%s", got)
356 }
357 // An empty uid must never match owner-less rows.
358 if got := memberAppsNoteText(apps, ""); got != "" {
359 t.Errorf("empty uid should give no note, got:\n%s", got)
360 }
361}
362
363// runReadAppSource's pre-network gates: a malformed id and an unverifiable
364// sender are refused before any fetch happens (no Runtime client needed).
365func TestRunReadAppSourceGates(t *testing.T) {
366 r := &Runtime{}
367 if got := r.runReadAppSource(map[string]any{"app_id": "Not-An-Id!"}, "u1"); !strings.Contains(got, "bad app_id") {
368 t.Errorf("malformed id: %q", got)
369 }
370 if got := r.runReadAppSource(map[string]any{}, "u1"); !strings.Contains(got, "bad app_id") {
371 t.Errorf("missing id: %q", got)
372 }
373 if got := r.runReadAppSource(map[string]any{"app_id": "app-abc123"}, ""); !strings.Contains(got, "can't be verified") {
374 t.Errorf("uid-less sender: %q", got)
375 }
376}
377
378func TestStripJS(t *testing.T) {
379 src := `const a = "fetch("; // fetch( in comment
380/* fetch( in block */ const b = ` + "`fetch(${x}`" + `; real(a);`
381 out := stripJS(src)
382 if strings.Contains(out, "fetch(") {
383 t.Fatalf("strings/comments leaked: %q", out)
384 }
385 if !strings.Contains(out, "real(a)") {
386 t.Fatalf("code lost: %q", out)
387 }
388}
389