Test tail reads of swarm event logs
- commit
- 8db8b1e84913658dabcd304db5abf07d85661ed1
- author
- Maximilian Feil · · 2026-09-24 11:08 UTC
- parent
- 871ed2b
- signature
- unsigned
1 file · +122 -0
A packages/agent/swarm/event_tail_test.go +122 -0
| @@ -0,0 +1,122 @@ | |||
| 1 | + | package swarm | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "fmt" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "testing" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // writeTailLog writes n events "e0".."e<n-1>" and returns the path and | |
| 12 | + | // the file size. | |
| 13 | + | func writeTailLog(t *testing.T, n int) (string, int64) { | |
| 14 | + | t.Helper() | |
| 15 | + | path := filepath.Join(t.TempDir(), "events.jsonl") | |
| 16 | + | log, err := OpenEventLog(path) | |
| 17 | + | if err != nil { | |
| 18 | + | t.Fatalf("open: %v", err) | |
| 19 | + | } | |
| 20 | + | for i := 0; i < n; i++ { | |
| 21 | + | if err := log.Append(NewEvent(fmt.Sprintf("e%d", i), map[string]any{"pad": "0123456789"})); err != nil { | |
| 22 | + | t.Fatalf("append: %v", err) | |
| 23 | + | } | |
| 24 | + | } | |
| 25 | + | _ = log.Close() | |
| 26 | + | info, err := os.Stat(path) | |
| 27 | + | if err != nil { | |
| 28 | + | t.Fatalf("stat: %v", err) | |
| 29 | + | } | |
| 30 | + | return path, info.Size() | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // lineStarts returns the byte offset of every line in the file. Event | |
| 34 | + | // lines differ in length (timestamps drop trailing zeros), so windows | |
| 35 | + | // are computed from real offsets rather than an average line size. | |
| 36 | + | func lineStarts(t *testing.T, path string) []int64 { | |
| 37 | + | t.Helper() | |
| 38 | + | data, err := os.ReadFile(path) | |
| 39 | + | if err != nil { | |
| 40 | + | t.Fatalf("read: %v", err) | |
| 41 | + | } | |
| 42 | + | starts := []int64{0} | |
| 43 | + | for i, b := range data[:len(data)-1] { | |
| 44 | + | if b == '\n' { | |
| 45 | + | starts = append(starts, int64(i+1)) | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | if !bytes.HasSuffix(data, []byte("\n")) { | |
| 49 | + | t.Fatalf("log does not end with a newline") | |
| 50 | + | } | |
| 51 | + | return starts | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func eventTypes(evs []Event) []string { | |
| 55 | + | out := make([]string, len(evs)) | |
| 56 | + | for i, ev := range evs { | |
| 57 | + | out[i] = ev.Type | |
| 58 | + | } | |
| 59 | + | return out | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func TestReadEventLogTailSmallFileReadsEverything(t *testing.T) { | |
| 63 | + | path, size := writeTailLog(t, 5) | |
| 64 | + | for _, limit := range []int64{0, size, size + 100} { | |
| 65 | + | got, err := ReadEventLogTail(path, limit) | |
| 66 | + | if err != nil { | |
| 67 | + | t.Fatalf("limit %d: %v", limit, err) | |
| 68 | + | } | |
| 69 | + | if len(got) != 5 || got[0].Type != "e0" || got[4].Type != "e4" { | |
| 70 | + | t.Fatalf("limit %d: got %v", limit, eventTypes(got)) | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // The tail window usually starts mid-line; that partial line must be | |
| 76 | + | // dropped rather than parsed, and every later event returned in order. | |
| 77 | + | func TestReadEventLogTailSkipsPartialFirstLine(t *testing.T) { | |
| 78 | + | path, size := writeTailLog(t, 50) | |
| 79 | + | starts := lineStarts(t, path) | |
| 80 | + | // Start the window in the middle of line 46: e46 is partial. | |
| 81 | + | mid := (starts[46] + starts[47]) / 2 | |
| 82 | + | got, err := ReadEventLogTail(path, size-mid) | |
| 83 | + | if err != nil { | |
| 84 | + | t.Fatalf("read: %v", err) | |
| 85 | + | } | |
| 86 | + | want := []string{"e47", "e48", "e49"} | |
| 87 | + | if fmt.Sprint(eventTypes(got)) != fmt.Sprint(want) { | |
| 88 | + | t.Fatalf("got %v, want %v", eventTypes(got), want) | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | // A window that falls exactly on a line boundary still drops its first | |
| 93 | + | // line: it cannot tell a boundary from a partial line, and losing one | |
| 94 | + | // event of a bounded transcript is harmless. | |
| 95 | + | func TestReadEventLogTailBoundaryWindow(t *testing.T) { | |
| 96 | + | path, size := writeTailLog(t, 10) | |
| 97 | + | starts := lineStarts(t, path) | |
| 98 | + | // The window starts exactly at the beginning of e6. | |
| 99 | + | got, err := ReadEventLogTail(path, size-starts[6]) | |
| 100 | + | if err != nil { | |
| 101 | + | t.Fatalf("read: %v", err) | |
| 102 | + | } | |
| 103 | + | want := []string{"e7", "e8", "e9"} | |
| 104 | + | if fmt.Sprint(eventTypes(got)) != fmt.Sprint(want) { | |
| 105 | + | t.Fatalf("got %v, want %v", eventTypes(got), want) | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func TestReadEventLogTailWindowWithoutNewline(t *testing.T) { | |
| 110 | + | path, _ := writeTailLog(t, 10) | |
| 111 | + | got, err := ReadEventLogTail(path, 5) // inside the last line | |
| 112 | + | if err != nil || len(got) != 0 { | |
| 113 | + | t.Fatalf("got %v, %v; want no events and no error", eventTypes(got), err) | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | func TestReadEventLogTailMissingFile(t *testing.T) { | |
| 118 | + | got, err := ReadEventLogTail(filepath.Join(t.TempDir(), "missing.jsonl"), 1024) | |
| 119 | + | if err != nil || got != nil { | |
| 120 | + | t.Fatalf("got %v, %v; want nil, nil", got, err) | |
| 121 | + | } | |
| 122 | + | } | |
| @@ -0,0 +1,122 @@ | |||||
| 1 | + | package swarm | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "fmt" | |||
| 6 | + | "os" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "testing" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // writeTailLog writes n events "e0".."e<n-1>" and returns the path and | |||
| 12 | + | // the file size. | |||
| 13 | + | func writeTailLog(t *testing.T, n int) (string, int64) { | |||
| 14 | + | t.Helper() | |||
| 15 | + | path := filepath.Join(t.TempDir(), "events.jsonl") | |||
| 16 | + | log, err := OpenEventLog(path) | |||
| 17 | + | if err != nil { | |||
| 18 | + | t.Fatalf("open: %v", err) | |||
| 19 | + | } | |||
| 20 | + | for i := 0; i < n; i++ { | |||
| 21 | + | if err := log.Append(NewEvent(fmt.Sprintf("e%d", i), map[string]any{"pad": "0123456789"})); err != nil { | |||
| 22 | + | t.Fatalf("append: %v", err) | |||
| 23 | + | } | |||
| 24 | + | } | |||
| 25 | + | _ = log.Close() | |||
| 26 | + | info, err := os.Stat(path) | |||
| 27 | + | if err != nil { | |||
| 28 | + | t.Fatalf("stat: %v", err) | |||
| 29 | + | } | |||
| 30 | + | return path, info.Size() | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | // lineStarts returns the byte offset of every line in the file. Event | |||
| 34 | + | // lines differ in length (timestamps drop trailing zeros), so windows | |||
| 35 | + | // are computed from real offsets rather than an average line size. | |||
| 36 | + | func lineStarts(t *testing.T, path string) []int64 { | |||
| 37 | + | t.Helper() | |||
| 38 | + | data, err := os.ReadFile(path) | |||
| 39 | + | if err != nil { | |||
| 40 | + | t.Fatalf("read: %v", err) | |||
| 41 | + | } | |||
| 42 | + | starts := []int64{0} | |||
| 43 | + | for i, b := range data[:len(data)-1] { | |||
| 44 | + | if b == '\n' { | |||
| 45 | + | starts = append(starts, int64(i+1)) | |||
| 46 | + | } | |||
| 47 | + | } | |||
| 48 | + | if !bytes.HasSuffix(data, []byte("\n")) { | |||
| 49 | + | t.Fatalf("log does not end with a newline") | |||
| 50 | + | } | |||
| 51 | + | return starts | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func eventTypes(evs []Event) []string { | |||
| 55 | + | out := make([]string, len(evs)) | |||
| 56 | + | for i, ev := range evs { | |||
| 57 | + | out[i] = ev.Type | |||
| 58 | + | } | |||
| 59 | + | return out | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | func TestReadEventLogTailSmallFileReadsEverything(t *testing.T) { | |||
| 63 | + | path, size := writeTailLog(t, 5) | |||
| 64 | + | for _, limit := range []int64{0, size, size + 100} { | |||
| 65 | + | got, err := ReadEventLogTail(path, limit) | |||
| 66 | + | if err != nil { | |||
| 67 | + | t.Fatalf("limit %d: %v", limit, err) | |||
| 68 | + | } | |||
| 69 | + | if len(got) != 5 || got[0].Type != "e0" || got[4].Type != "e4" { | |||
| 70 | + | t.Fatalf("limit %d: got %v", limit, eventTypes(got)) | |||
| 71 | + | } | |||
| 72 | + | } | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // The tail window usually starts mid-line; that partial line must be | |||
| 76 | + | // dropped rather than parsed, and every later event returned in order. | |||
| 77 | + | func TestReadEventLogTailSkipsPartialFirstLine(t *testing.T) { | |||
| 78 | + | path, size := writeTailLog(t, 50) | |||
| 79 | + | starts := lineStarts(t, path) | |||
| 80 | + | // Start the window in the middle of line 46: e46 is partial. | |||
| 81 | + | mid := (starts[46] + starts[47]) / 2 | |||
| 82 | + | got, err := ReadEventLogTail(path, size-mid) | |||
| 83 | + | if err != nil { | |||
| 84 | + | t.Fatalf("read: %v", err) | |||
| 85 | + | } | |||
| 86 | + | want := []string{"e47", "e48", "e49"} | |||
| 87 | + | if fmt.Sprint(eventTypes(got)) != fmt.Sprint(want) { | |||
| 88 | + | t.Fatalf("got %v, want %v", eventTypes(got), want) | |||
| 89 | + | } | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | // A window that falls exactly on a line boundary still drops its first | |||
| 93 | + | // line: it cannot tell a boundary from a partial line, and losing one | |||
| 94 | + | // event of a bounded transcript is harmless. | |||
| 95 | + | func TestReadEventLogTailBoundaryWindow(t *testing.T) { | |||
| 96 | + | path, size := writeTailLog(t, 10) | |||
| 97 | + | starts := lineStarts(t, path) | |||
| 98 | + | // The window starts exactly at the beginning of e6. | |||
| 99 | + | got, err := ReadEventLogTail(path, size-starts[6]) | |||
| 100 | + | if err != nil { | |||
| 101 | + | t.Fatalf("read: %v", err) | |||
| 102 | + | } | |||
| 103 | + | want := []string{"e7", "e8", "e9"} | |||
| 104 | + | if fmt.Sprint(eventTypes(got)) != fmt.Sprint(want) { | |||
| 105 | + | t.Fatalf("got %v, want %v", eventTypes(got), want) | |||
| 106 | + | } | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func TestReadEventLogTailWindowWithoutNewline(t *testing.T) { | |||
| 110 | + | path, _ := writeTailLog(t, 10) | |||
| 111 | + | got, err := ReadEventLogTail(path, 5) // inside the last line | |||
| 112 | + | if err != nil || len(got) != 0 { | |||
| 113 | + | t.Fatalf("got %v, %v; want no events and no error", eventTypes(got), err) | |||
| 114 | + | } | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | func TestReadEventLogTailMissingFile(t *testing.T) { | |||
| 118 | + | got, err := ReadEventLogTail(filepath.Join(t.TempDir(), "missing.jsonl"), 1024) | |||
| 119 | + | if err != nil || got != nil { | |||
| 120 | + | t.Fatalf("got %v, %v; want nil, nil", got, err) | |||
| 121 | + | } | |||
| 122 | + | } | |||