1package core
2
3import (
4 "encoding/json"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9
10 "github.com/bnema/zut/packages/provider"
11)
12
13// TestSessionExportImportRoundTrip writes a few messages to a live
14// session, exports it, imports the export under a different cwd,
15// and verifies OpenSession on the imported file yields the same
16// message payloads.
17func TestSessionExportImportRoundTrip(t *testing.T) {
18 root := t.TempDir()
19 originalCWD := "/path/to/project"
20 sess, err := NewSession(root, originalCWD, "anthropic", "claude-opus-4-7", "0.0.0-test")
21 if err != nil {
22 t.Fatal(err)
23 }
24 _ = sess.AppendMessage(provider.Message{
25 Role: provider.RoleUser,
26 Content: []provider.Content{provider.TextBlock{Text: "hello from the exporter"}},
27 })
28 _ = sess.AppendMessage(provider.Message{
29 Role: provider.RoleAssistant,
30 Content: []provider.Content{provider.TextBlock{Text: "hi — reply from the assistant"}},
31 })
32 _ = sess.Close()
33
34 // Export to a directory — helper should build a name inside it.
35 exportDir := t.TempDir()
36 exportPath, err := ExportSession(sess.Path, exportDir)
37 if err != nil {
38 t.Fatalf("ExportSession: %v", err)
39 }
40 if !strings.HasSuffix(exportPath, PortableExt) {
41 t.Errorf("exported path should end in %s, got %q", PortableExt, exportPath)
42 }
43 if _, err := os.Stat(exportPath); err != nil {
44 t.Fatalf("exported file doesn't exist: %v", err)
45 }
46
47 // Import into a different root + cwd.
48 root2 := t.TempDir()
49 cwd2 := "/some/other/project"
50 importedPath, err := ImportSession(exportPath, root2, cwd2, "0.0.0-test")
51 if err != nil {
52 t.Fatalf("ImportSession: %v", err)
53 }
54 if filepath.Dir(importedPath) != SessionsDir(root2, cwd2) {
55 t.Errorf("imported file should land in SessionsDir, got %q", importedPath)
56 }
57
58 // Reopen and verify message round-trip.
59 imported, msgs, err := OpenSession(importedPath)
60 if err != nil {
61 t.Fatalf("OpenSession: %v", err)
62 }
63 defer imported.Close()
64 if imported.Meta.CWD != cwd2 {
65 t.Errorf("meta cwd: want %q, got %q", cwd2, imported.Meta.CWD)
66 }
67 if imported.Meta.ID == sess.ID {
68 t.Errorf("imported session kept the original id %q; must be rotated", sess.ID)
69 }
70 if imported.Meta.Model != "claude-opus-4-7" {
71 t.Errorf("model not preserved: %q", imported.Meta.Model)
72 }
73 if len(msgs) != 2 {
74 t.Fatalf("want 2 messages, got %d", len(msgs))
75 }
76 // Text should round-trip.
77 if extractText(msgs[0]) != "hello from the exporter" {
78 t.Errorf("msg 0 mismatch: %q", extractText(msgs[0]))
79 }
80 if extractText(msgs[1]) != "hi — reply from the assistant" {
81 t.Errorf("msg 1 mismatch: %q", extractText(msgs[1]))
82 }
83}
84
85func TestExportImportUsesLatestProviderModelMetadata(t *testing.T) {
86 root := t.TempDir()
87 session, err := NewSession(root, "/source", "old-provider", "old-model", "test")
88 if err != nil {
89 t.Fatal(err)
90 }
91 _ = session.AppendMessage(provider.Message{
92 Role: provider.RoleUser,
93 Content: []provider.Content{provider.TextBlock{Text: "metadata prompt"}},
94 })
95 if err := session.UpdateModel("new-provider", "new-model"); err != nil {
96 t.Fatal(err)
97 }
98 path := session.Path
99 if err := session.Close(); err != nil {
100 t.Fatal(err)
101 }
102
103 exportPath, err := ExportSession(path, filepath.Join(t.TempDir(), "latest"+PortableExt))
104 if err != nil {
105 t.Fatalf("ExportSession: %v", err)
106 }
107 exported, err := ReadSessionSnapshot(exportPath)
108 if err != nil {
109 t.Fatalf("Read exported snapshot: %v", err)
110 }
111 if exported.Meta.Provider != "new-provider" || exported.Meta.Model != "new-model" {
112 t.Fatalf("export metadata = provider %q model %q, want latest values", exported.Meta.Provider, exported.Meta.Model)
113 }
114
115 importedPath, err := ImportSession(exportPath, t.TempDir(), "/destination", "test")
116 if err != nil {
117 t.Fatalf("ImportSession: %v", err)
118 }
119 imported, _, err := OpenSession(importedPath)
120 if err != nil {
121 t.Fatalf("Open imported session: %v", err)
122 }
123 defer imported.Close()
124 if imported.Meta.Provider != "new-provider" || imported.Meta.Model != "new-model" {
125 t.Fatalf("import metadata = provider %q model %q, want latest values", imported.Meta.Provider, imported.Meta.Model)
126 }
127}
128
129// TestScanSessionMetaReturnsLatestRowWithoutHydratingMessages verifies that
130// metadata lookup follows append-only model updates without reconstructing the
131// transcript.
132func TestScanSessionMetaReturnsLatestRowWithoutHydratingMessages(t *testing.T) {
133 root := t.TempDir()
134 session, err := NewSession(root, "/workspace", "old-provider", "old-model", "test")
135 if err != nil {
136 t.Fatal(err)
137 }
138 if err := session.AppendMessage(provider.Message{
139 Role: provider.RoleUser,
140 Content: []provider.Content{provider.TextBlock{Text: strings.Repeat("large transcript ", 1000)}},
141 }); err != nil {
142 t.Fatal(err)
143 }
144 if err := session.UpdateModel("new-provider", "new-model"); err != nil {
145 t.Fatal(err)
146 }
147 path := session.Path
148 if err := session.Close(); err != nil {
149 t.Fatal(err)
150 }
151
152 meta, err := scanSessionMeta(path)
153 if err != nil {
154 t.Fatal(err)
155 }
156 if meta.Provider != "new-provider" || meta.Model != "new-model" {
157 t.Fatalf("scanned metadata = %q/%q, want latest values", meta.Provider, meta.Model)
158 }
159}
160
161// TestExportToFilePath writes to an explicit file path (no directory
162// guessing) and checks the .zutsession extension is appended when missing.
163func TestExportToFilePath(t *testing.T) {
164 root := t.TempDir()
165 sess, err := NewSession(root, "/cwd", "anthropic", "claude-opus-4-7", "0.0.0-test")
166 if err != nil {
167 t.Fatal(err)
168 }
169 _ = sess.AppendMessage(provider.Message{
170 Role: provider.RoleUser,
171 Content: []provider.Content{provider.TextBlock{Text: "x"}},
172 })
173 _ = sess.Close()
174
175 // No extension — should add .zutsession.
176 dst := filepath.Join(t.TempDir(), "mysession")
177 out, err := ExportSession(sess.Path, dst)
178 if err != nil {
179 t.Fatal(err)
180 }
181 if !strings.HasSuffix(out, PortableExt) {
182 t.Errorf("want .zutsession suffix on %q", out)
183 }
184}
185
186// TestExportStripsCWDFromMeta verifies the exported meta no longer
187// carries the source user's cwd (not useful to the recipient).
188func TestExportSessionHandlesHugeJSONLRows(t *testing.T) {
189 root := t.TempDir()
190 sess, err := NewSession(root, "/cwd", "anthropic", "claude-opus-4-7", "0.0.0-test")
191 if err != nil {
192 t.Fatal(err)
193 }
194 _ = sess.AppendMessage(provider.Message{
195 Role: provider.RoleUser,
196 Content: []provider.Content{provider.TextBlock{Text: "first prompt names export"}},
197 })
198 _ = sess.AppendMessage(provider.Message{
199 Role: provider.RoleAssistant,
200 Content: []provider.Content{provider.TextBlock{Text: strings.Repeat("x", 22*1024*1024)}},
201 })
202 _ = sess.Close()
203
204 exportDir := t.TempDir()
205 out, err := ExportSession(sess.Path, exportDir)
206 if err != nil {
207 t.Fatalf("ExportSession with huge row: %v", err)
208 }
209 if _, err := os.Stat(out); err != nil {
210 t.Fatalf("exported file missing: %v", err)
211 }
212 opened, msgs, err := OpenSession(sess.Path)
213 if err != nil {
214 t.Fatalf("OpenSession with huge row: %v", err)
215 }
216 _ = opened.Close()
217 if len(msgs) != 2 {
218 t.Fatalf("OpenSession messages=%d, want 2", len(msgs))
219 }
220 // Ensure the exported file is still readable JSONL and contains the
221 // huge assistant message row, not just the meta/header.
222 b, err := os.ReadFile(out)
223 if err != nil {
224 t.Fatal(err)
225 }
226 if !strings.Contains(string(b), strings.Repeat("x", 1024)) {
227 t.Fatalf("export does not appear to contain huge assistant row")
228 }
229 for n, line := range strings.Split(strings.TrimSpace(string(b)), "\n") {
230 var raw map[string]any
231 if err := json.Unmarshal([]byte(line), &raw); err != nil {
232 t.Fatalf("exported line %d is invalid json: %v", n+1, err)
233 }
234 }
235}
236
237func TestExportStripsCWDFromMeta(t *testing.T) {
238 root := t.TempDir()
239 sess, err := NewSession(root, "/original/cwd", "anthropic", "claude-opus-4-7", "0.0.0-test")
240 if err != nil {
241 t.Fatal(err)
242 }
243 _ = sess.AppendMessage(provider.Message{
244 Role: provider.RoleUser,
245 Content: []provider.Content{provider.TextBlock{Text: "x"}},
246 })
247 _ = sess.Close()
248
249 out, err := ExportSession(sess.Path, filepath.Join(t.TempDir(), "x"+PortableExt))
250 if err != nil {
251 t.Fatal(err)
252 }
253 b, _ := os.ReadFile(out)
254 if strings.Contains(string(b), "/original/cwd") {
255 t.Errorf("exported file leaks the source cwd: %s", string(b))
256 }
257}
258
259// TestBranchSessionCopiesPrefix writes several messages to a
260// session, branches at message index 2 (first user + first
261// assistant), and verifies the new session has exactly those two
262// messages with parent + fork_point meta set.
263func TestBranchSessionCopiesPrefix(t *testing.T) {
264 root := t.TempDir()
265 cwd := "/project"
266 parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
267 if err != nil {
268 t.Fatal(err)
269 }
270 _ = parent.AppendMessage(provider.Message{
271 Role: provider.RoleUser,
272 Content: []provider.Content{provider.TextBlock{Text: "first"}},
273 })
274 _ = parent.AppendMessage(provider.Message{
275 Role: provider.RoleAssistant,
276 Content: []provider.Content{provider.TextBlock{Text: "first reply"}},
277 })
278 _ = parent.AppendMessage(provider.Message{
279 Role: provider.RoleUser,
280 Content: []provider.Content{provider.TextBlock{Text: "second"}},
281 })
282 _ = parent.Close()
283
284 // Branch at the first user+assistant pair (upToMessageIdx=2).
285 branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 2)
286 if err != nil {
287 t.Fatalf("BranchSession: %v", err)
288 }
289 branch, msgs, err := OpenSession(branchPath)
290 if err != nil {
291 t.Fatal(err)
292 }
293 defer branch.Close()
294
295 if len(msgs) != 2 {
296 t.Errorf("want 2 copied messages, got %d", len(msgs))
297 }
298 if branch.Meta.Parent != parent.Meta.ID {
299 t.Errorf("parent id: want %q, got %q", parent.Meta.ID, branch.Meta.Parent)
300 }
301 if branch.Meta.ForkPoint != 2 {
302 t.Errorf("fork_point: want 2, got %d", branch.Meta.ForkPoint)
303 }
304 if branch.Meta.ID == parent.Meta.ID {
305 t.Errorf("branch kept parent id; must rotate")
306 }
307 if branch.Meta.Title != "" {
308 t.Errorf("meta title should remain append-only, got %q", branch.Meta.Title)
309 }
310 if summary := describeSession(branchPath); summary.Title != "first" {
311 t.Errorf("branch title: want %q, got %q", "first", summary.Title)
312 }
313}
314
315func TestBranchSessionTitleUsesSelectedUserPrompt(t *testing.T) {
316 root := t.TempDir()
317 cwd := "/project"
318 parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
319 if err != nil {
320 t.Fatal(err)
321 }
322 for _, msg := range []provider.Message{
323 {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "first prompt"}}},
324 {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "first reply"}}},
325 {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "second prompt"}}},
326 } {
327 _ = parent.AppendMessage(msg)
328 }
329 _ = parent.Close()
330
331 branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 3)
332 if err != nil {
333 t.Fatalf("BranchSession: %v", err)
334 }
335 if summary := describeSession(branchPath); summary.Title != "second prompt" {
336 t.Errorf("branch title: want %q, got %q", "second prompt", summary.Title)
337 }
338}
339
340func TestBranchSessionTitleUsesFirstPromptAfterForkWhenBranchDiverges(t *testing.T) {
341 root := t.TempDir()
342 cwd := "/project"
343 parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
344 if err != nil {
345 t.Fatal(err)
346 }
347 _ = parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}})
348 _ = parent.Close()
349
350 branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 1)
351 if err != nil {
352 t.Fatalf("BranchSession: %v", err)
353 }
354 branch, _, err := OpenSession(branchPath)
355 if err != nil {
356 t.Fatal(err)
357 }
358 _ = branch.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "how are you?"}}})
359 _ = branch.AppendMessage(provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "fine"}}})
360 _ = branch.Close()
361
362 if summary := describeSession(branchPath); summary.Title != "how are you?" {
363 t.Errorf("branch title: want %q, got %q", "how are you?", summary.Title)
364 }
365}
366
367func TestGeneratedBranchTitleWinsWhenWrittenBeforeBranchPrompt(t *testing.T) {
368 root := t.TempDir()
369 cwd := "/project"
370 parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
371 if err != nil {
372 t.Fatal(err)
373 }
374 _ = parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}})
375 _ = parent.Close()
376
377 branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 1)
378 if err != nil {
379 t.Fatalf("BranchSession: %v", err)
380 }
381 branch, _, err := OpenSession(branchPath)
382 if err != nil {
383 t.Fatal(err)
384 }
385 if err := branch.UpdateTitle("AI branch title"); err != nil {
386 t.Fatal(err)
387 }
388 if err := branch.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "how are you?"}}}); err != nil {
389 t.Fatal(err)
390 }
391 if err := branch.Close(); err != nil {
392 t.Fatal(err)
393 }
394
395 if summary := describeSession(branchPath); summary.Title != "AI branch title" {
396 t.Errorf("branch title: want %q, got %q", "AI branch title", summary.Title)
397 }
398}
399
400func TestRenamedBranchKeepsExplicitTitle(t *testing.T) {
401 root := t.TempDir()
402 cwd := "/project"
403 parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
404 if err != nil {
405 t.Fatal(err)
406 }
407 _ = parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}})
408 _ = parent.Close()
409
410 branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 1)
411 if err != nil {
412 t.Fatalf("BranchSession: %v", err)
413 }
414 branch, _, err := OpenSession(branchPath)
415 if err != nil {
416 t.Fatal(err)
417 }
418 _ = branch.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "how are you?"}}})
419 _ = branch.Close()
420 if err := RenameSession(branchPath, "friendly branch name"); err != nil {
421 t.Fatal(err)
422 }
423
424 if summary := describeSession(branchPath); summary.Title != "friendly branch name" {
425 t.Errorf("branch title: want %q, got %q", "friendly branch name", summary.Title)
426 }
427}
428
429func TestBranchSessionUsesEffectiveTranscriptAfterCompaction(t *testing.T) {
430 root := t.TempDir()
431 cwd := "/project"
432 parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
433 if err != nil {
434 t.Fatal(err)
435 }
436 for _, text := range []string{"raw-a", "raw-b", "raw-c", "raw-d"} {
437 _ = parent.AppendMessage(provider.Message{
438 Role: provider.RoleUser,
439 Content: []provider.Content{provider.TextBlock{Text: text}},
440 })
441 }
442 _ = parent.AppendCompaction([]provider.Message{
443 {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "summary"}}},
444 {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "tail-c"}}},
445 {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "tail-d"}}},
446 })
447 _ = parent.AppendMessage(provider.Message{
448 Role: provider.RoleUser,
449 Content: []provider.Content{provider.TextBlock{Text: "after-compact"}},
450 })
451 _ = parent.Close()
452
453 opened, msgs, err := OpenSession(parent.Path)
454 if err != nil {
455 t.Fatalf("OpenSession parent: %v", err)
456 }
457 _ = opened.Close()
458 assertMessageTexts(t, msgs, []string{"summary", "tail-c", "tail-d", "after-compact"})
459
460 branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 4)
461 if err != nil {
462 t.Fatalf("BranchSession: %v", err)
463 }
464 branch, branchMsgs, err := OpenSession(branchPath)
465 if err != nil {
466 t.Fatalf("OpenSession branch: %v", err)
467 }
468 defer branch.Close()
469
470 assertMessageTexts(t, branchMsgs, []string{"summary", "tail-c", "tail-d", "after-compact"})
471 if branch.Meta.Parent != parent.Meta.ID {
472 t.Errorf("parent id: want %q, got %q", parent.Meta.ID, branch.Meta.Parent)
473 }
474 if branch.Meta.ForkPoint != 4 {
475 t.Errorf("fork_point: want 4, got %d", branch.Meta.ForkPoint)
476 }
477
478 shortBranchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 2)
479 if err != nil {
480 t.Fatalf("BranchSession short fork: %v", err)
481 }
482 shortBranch, shortBranchMsgs, err := OpenSession(shortBranchPath)
483 if err != nil {
484 t.Fatalf("OpenSession short branch: %v", err)
485 }
486 defer shortBranch.Close()
487
488 assertMessageTexts(t, shortBranchMsgs, []string{"summary", "tail-c"})
489 if shortBranch.Meta.ForkPoint != 2 {
490 t.Errorf("short branch fork_point: want 2, got %d", shortBranch.Meta.ForkPoint)
491 }
492}
493
494func TestBranchSessionCopiesExtensionStateAtFork(t *testing.T) {
495 root := t.TempDir()
496 cwd := "/project"
497 parent, err := NewSession(root, cwd, "anthropic", "claude", "test")
498 if err != nil {
499 t.Fatal(err)
500 }
501 if err := parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "first"}}}); err != nil {
502 t.Fatal(err)
503 }
504 if err := parent.AppendExtensionState("tasked-phases", json.RawMessage(`{"version":1}`)); err != nil {
505 t.Fatal(err)
506 }
507 if err := parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "second"}}}); err != nil {
508 t.Fatal(err)
509 }
510 if err := parent.AppendExtensionState("tasked-phases", json.RawMessage(`{"version":2}`)); err != nil {
511 t.Fatal(err)
512 }
513 if err := parent.Close(); err != nil {
514 t.Fatal(err)
515 }
516
517 branchPath, err := BranchSession(parent.Path, root, cwd, "test", 1)
518 if err != nil {
519 t.Fatal(err)
520 }
521 branch, _, err := OpenSession(branchPath)
522 if err != nil {
523 t.Fatal(err)
524 }
525 t.Cleanup(func() { _ = branch.Close() })
526 if got := string(branch.ExtensionState["tasked-phases"]); got != `{"version":1}` {
527 t.Fatalf("branch extension state = %q, want version 1", got)
528 }
529}
530
531func TestBranchSessionCarriesStateAcrossCompactionBoundary(t *testing.T) {
532 root := t.TempDir()
533 cwd := "/project"
534 parent, err := NewSession(root, cwd, "anthropic", "claude", "test")
535 if err != nil {
536 t.Fatal(err)
537 }
538 for _, text := range []string{"before-a", "before-b"} {
539 if err := parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: text}}}); err != nil {
540 t.Fatal(err)
541 }
542 }
543 if err := parent.AppendExtensionState("tasked-phases", json.RawMessage(`{"version":1}`)); err != nil {
544 t.Fatal(err)
545 }
546 if err := parent.AppendCompaction([]provider.Message{
547 {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "summary"}}},
548 {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "tail"}}},
549 }); err != nil {
550 t.Fatal(err)
551 }
552 if err := parent.Close(); err != nil {
553 t.Fatal(err)
554 }
555
556 branchPath, err := BranchSession(parent.Path, root, cwd, "test", 1)
557 if err != nil {
558 t.Fatal(err)
559 }
560 branch, _, err := OpenSession(branchPath)
561 if err != nil {
562 t.Fatal(err)
563 }
564 t.Cleanup(func() { _ = branch.Close() })
565 if got := string(branch.ExtensionState["tasked-phases"]); got != `{"version":1}` {
566 t.Fatalf("compacted branch extension state = %q, want version 1", got)
567 }
568}
569
570// TestBuildSessionTree verifies parent/child edges are rebuilt
571// from meta + sibling-scan.
572func TestBuildSessionTree(t *testing.T) {
573 root := t.TempDir()
574 cwd := "/project"
575 parent, _ := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
576 _ = parent.AppendMessage(provider.Message{
577 Role: provider.RoleUser,
578 Content: []provider.Content{provider.TextBlock{Text: "x"}},
579 })
580 _ = parent.Close()
581
582 childA, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 1)
583 if err != nil {
584 t.Fatal(err)
585 }
586 childB, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 1)
587 if err != nil {
588 t.Fatal(err)
589 }
590 _ = childA
591 _ = childB
592
593 tree := BuildSessionTree(root, cwd)
594 if len(tree) != 1 {
595 t.Fatalf("want 1 root, got %d", len(tree))
596 }
597 rootNode := tree[0]
598 if rootNode.Meta.ID != parent.Meta.ID {
599 t.Errorf("root should be parent %q, got %q", parent.Meta.ID, rootNode.Meta.ID)
600 }
601 if len(rootNode.Children) != 2 {
602 t.Errorf("want 2 children, got %d", len(rootNode.Children))
603 }
604}
605
606func assertMessageTexts(t *testing.T, msgs []provider.Message, want []string) {
607 t.Helper()
608 if len(msgs) != len(want) {
609 t.Fatalf("message count: want %d, got %d", len(want), len(msgs))
610 }
611 for i, msg := range msgs {
612 if got := extractText(msg); got != want[i] {
613 t.Errorf("message %d: want %q, got %q", i, want[i], got)
614 }
615 }
616}