detached 8db8b1e

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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close