detached 0ba01b1

test(daemon): add deterministic performance fixture

commit
0ba01b17aa3d651ab68a0d72284cc8f13b220ea2
author
brice · · 2026-07-11 04:56 UTC
committer
brice · · 2026-07-11 06:15 UTC
parent
6a4b48d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +267 -2
M internal/usecase/daemon/daemon_test.go +2 -2
@@ -286,7 +286,7 @@ func listSessions(t *testing.T, d *Daemon) ports.Sessions {
286286 return sessions
287287 }
288288
289-func newCapturingTransport(t *testing.T) (*portsmocks.MockTransport, chan ports.Frame) {
289+func newCapturingTransport(t testing.TB) (*portsmocks.MockTransport, chan ports.Frame) {
290290 t.Helper()
291291 tr := portsmocks.NewMockTransport(t)
292292 sends := make(chan ports.Frame, 64)
@@ -298,7 +298,7 @@ func newCapturingTransport(t *testing.T) (*portsmocks.MockTransport, chan ports.
298298 return tr, sends
299299 }
300300
301-func newManualSessionWithPTYs(t *testing.T, ptys ...ports.PTY) (*Daemon, *session, *attachedClient, chan ports.Frame) {
301+func newManualSessionWithPTYs(t testing.TB, ptys ...ports.PTY) (*Daemon, *session, *attachedClient, chan ports.Frame) {
302302 t.Helper()
303303 d := newTestDaemon(t, nil, stubClock{})
304304 tr, sends := newCapturingTransport(t)
@@ -286,7 +286,7 @@ func listSessions(t *testing.T, d *Daemon) ports.Sessions {
286 return sessions286 return sessions
287 }287 }
288 288
289-func newCapturingTransport(t *testing.T) (*portsmocks.MockTransport, chan ports.Frame) {289+func newCapturingTransport(t testing.TB) (*portsmocks.MockTransport, chan ports.Frame) {
290 t.Helper()290 t.Helper()
291 tr := portsmocks.NewMockTransport(t)291 tr := portsmocks.NewMockTransport(t)
292 sends := make(chan ports.Frame, 64)292 sends := make(chan ports.Frame, 64)
@@ -298,7 +298,7 @@ func newCapturingTransport(t *testing.T) (*portsmocks.MockTransport, chan ports.
298 return tr, sends298 return tr, sends
299 }299 }
300 300
301-func newManualSessionWithPTYs(t *testing.T, ptys ...ports.PTY) (*Daemon, *session, *attachedClient, chan ports.Frame) {301+func newManualSessionWithPTYs(t testing.TB, ptys ...ports.PTY) (*Daemon, *session, *attachedClient, chan ports.Frame) {
302 t.Helper()302 t.Helper()
303 d := newTestDaemon(t, nil, stubClock{})303 d := newTestDaemon(t, nil, stubClock{})
304 tr, sends := newCapturingTransport(t)304 tr, sends := newCapturingTransport(t)
A internal/usecase/daemon/performance_benchmark_test.go +265 -0
@@ -0,0 +1,265 @@
1+package daemon
2+
3+import (
4+ "fmt"
5+ "io"
6+ "sync"
7+ "testing"
8+
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/ports"
13+ "github.com/bnema/vev/internal/usecase/layout"
14+ snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
15+)
16+
17+func TestPerformanceFixtureCounters(t *testing.T) {
18+ fixture := newPerformanceFixture(t, performanceConfig{})
19+
20+ fixture.paintLive()
21+ fixture.paintLive()
22+ metrics := fixture.metrics()
23+ require.Equal(t, uint64(2), metrics.outputFrames)
24+ require.Positive(t, metrics.outputBytes)
25+ require.Positive(t, metrics.outputPayloadBytes)
26+
27+ fixture.captureSnapshot()
28+ metrics = fixture.metrics()
29+ require.Equal(t, uint64(1), metrics.snapshotWrites)
30+ require.Positive(t, metrics.snapshotBytes)
31+
32+ fixture.enterCopySearch()
33+ require.Positive(t, fixture.searchMatches())
34+
35+ fixture.resize()
36+ require.True(t, fixture.resized())
37+}
38+
39+// performanceConfig intentionally describes only an in-process daemon shape:
40+// no PTY readers, schedulers, clocks, or transports are started by this fixture.
41+type performanceConfig struct {
42+ size domain.Size
43+}
44+
45+type performanceMetrics struct {
46+ outputFrames uint64
47+ outputBytes uint64
48+ outputPayloadBytes uint64
49+ snapshotWrites uint64
50+ snapshotBytes uint64
51+}
52+
53+type performanceFixture struct {
54+ t testing.TB
55+ d *Daemon
56+ sess *session
57+ ac *attachedClient
58+ output *countingOutputTransport
59+ snaps *countingSnapshotStore
60+ paints int
61+ resizedSize domain.Size
62+}
63+
64+func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceFixture {
65+ t.Helper()
66+ if !config.size.Valid() {
67+ config.size = domain.Size{Cols: 80, Rows: 24}
68+ }
69+ d, sess, ac, _ := newManualSessionWithPTYs(t, nil, nil)
70+ output := &countingOutputTransport{}
71+ ac.tr = output
72+ ac.size = config.size
73+ sess.name = "performance"
74+ sess.ephemeral = false
75+ sess.snapEligible.Store(true)
76+
77+ fixture := &performanceFixture{t: t, d: d, sess: sess, ac: ac, output: output, snaps: &countingSnapshotStore{}}
78+ WithSnapshotStore(fixture.snaps)(d)
79+ for tabIndex, tb := range sess.tabs {
80+ fixture.configureTab(tb, tabIndex, tabSize(config.size))
81+ }
82+
83+ // Prime the real renderer shadow before measurements. Subsequent paints use
84+ // actual production diffs rather than the initial full frame.
85+ d.paint(sess, ac, true)
86+ fixture.resetMetrics()
87+ return fixture
88+}
89+
90+func (f *performanceFixture) configureTab(tb *tab, tabIndex int, size domain.Size) {
91+ f.t.Helper()
92+ tb.mu.Lock()
93+ tb.size = size
94+ left := tb.focusedPane()
95+ rightID := layout.PaneID(fmt.Sprintf("pane-%d-right", tabIndex+1))
96+ right := newPane(rightID, nil, size)
97+ tb.panes[rightID] = right
98+ require.NoError(f.t, tb.tree.Split(left.id, layout.Right, true, rightID, domain.Rect{Width: size.Cols, Height: size.Rows}))
99+ placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: size.Cols, Height: size.Rows})
100+ require.True(f.t, ok, "performance fixture layout must be solvable")
101+ require.Len(f.t, placements, 2)
102+ f.d.applyLayoutLocked(tb)
103+ for _, p := range tb.panes {
104+ p.mu.Lock()
105+ width, height := p.screen.Frame.Width, p.screen.Frame.Height
106+ for row := range max(height-1, 1) {
107+ p.screen.Write([]byte(performanceFullWidthRow(width, tabIndex, row) + "\r\n"))
108+ }
109+ p.mu.Unlock()
110+ }
111+ tb.mu.Unlock()
112+}
113+
114+// performanceFullWidthRow produces stable, full-width visible content without
115+// relying on process output or timing.
116+func performanceFullWidthRow(width, tabIndex, row int) string {
117+ prefix := fmt.Sprintf("needle-%d-%02d ", tabIndex, row)
118+ out := make([]byte, width)
119+ for i := range out {
120+ out[i] = byte('a' + (tabIndex+row+i)%26)
121+ }
122+ copy(out, prefix)
123+ return string(out)
124+}
125+
126+func (f *performanceFixture) activePane() *pane {
127+ f.t.Helper()
128+ tb := f.sess.activeTab()
129+ require.NotNil(f.t, tb)
130+ tb.mu.Lock()
131+ defer tb.mu.Unlock()
132+ return tb.focusedPane()
133+}
134+
135+func (f *performanceFixture) paintLive() {
136+ f.t.Helper()
137+ p := f.activePane()
138+ x, y := 1, 1
139+ if f.paints%2 == 1 {
140+ x, y = 2, 2
141+ }
142+ p.mu.Lock()
143+ p.screen.Write([]byte(fmt.Sprintf("\x1b[%d;%dH%c", y, x, 'A'+rune(f.paints))))
144+ p.mu.Unlock()
145+ f.d.paint(f.sess, f.ac, false)
146+ f.paints++
147+}
148+
149+func (f *performanceFixture) captureSnapshot() {
150+ f.t.Helper()
151+ require.True(f.t, f.d.captureSession(f.sess))
152+}
153+
154+func (f *performanceFixture) enterCopySearch() {
155+ f.t.Helper()
156+ f.d.enterCopyMode(f.sess, f.ac)
157+ f.d.handleInput(f.sess, f.ac, []byte("/needle\r"))
158+}
159+
160+func (f *performanceFixture) searchMatches() int {
161+ f.ac.overlays.copyMu.Lock()
162+ defer f.ac.overlays.copyMu.Unlock()
163+ if f.ac.overlays.copyMode == nil {
164+ return 0
165+ }
166+ return len(f.ac.overlays.copyMode.Searches)
167+}
168+
169+func (f *performanceFixture) resize() {
170+ f.resizedSize = domain.Size{Cols: 100, Rows: 30}
171+ f.d.resize(f.sess, f.ac, f.resizedSize)
172+}
173+
174+func (f *performanceFixture) resized() bool {
175+ if f.ac.size != f.resizedSize {
176+ return false
177+ }
178+ for _, tb := range f.sess.tabs {
179+ tb.mu.Lock()
180+ ok := tb.size == tabSize(f.resizedSize)
181+ tb.mu.Unlock()
182+ if !ok {
183+ return false
184+ }
185+ }
186+ return true
187+}
188+
189+func (f *performanceFixture) resetMetrics() {
190+ f.output.reset()
191+ f.snaps.reset()
192+}
193+
194+func (f *performanceFixture) metrics() performanceMetrics {
195+ output := f.output.metrics()
196+ snapshots := f.snaps.metrics()
197+ return performanceMetrics{
198+ outputFrames: output.frames, outputBytes: output.bytes, outputPayloadBytes: output.payloadBytes,
199+ snapshotWrites: snapshots.writes, snapshotBytes: snapshots.bytes,
200+ }
201+}
202+
203+type countingOutputMetrics struct{ frames, bytes, payloadBytes uint64 }
204+
205+type countingOutputTransport struct {
206+ mu sync.Mutex
207+ countingOutputMetrics
208+}
209+
210+func (t *countingOutputTransport) Send(frame ports.Frame) error {
211+ if frame.Type != ports.MsgOutput {
212+ return nil
213+ }
214+ output, err := ports.UnmarshalOutput(frame.Payload)
215+ if err != nil {
216+ return err
217+ }
218+ t.mu.Lock()
219+ t.frames++
220+ t.bytes += uint64(len(output.Data))
221+ t.payloadBytes += uint64(len(frame.Payload))
222+ t.mu.Unlock()
223+ return nil
224+}
225+func (*countingOutputTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
226+func (*countingOutputTransport) Close() error { return nil }
227+func (t *countingOutputTransport) reset() {
228+ t.mu.Lock()
229+ t.countingOutputMetrics = countingOutputMetrics{}
230+ t.mu.Unlock()
231+}
232+func (t *countingOutputTransport) metrics() countingOutputMetrics {
233+ t.mu.Lock()
234+ defer t.mu.Unlock()
235+ return t.countingOutputMetrics
236+}
237+
238+type countingSnapshotMetrics struct{ writes, bytes uint64 }
239+type countingSnapshotStore struct {
240+ mu sync.Mutex
241+ countingSnapshotMetrics
242+}
243+
244+func (s *countingSnapshotStore) Write(_ string, data []byte) error {
245+ if _, err := snapcodec.Unmarshal(data); err != nil {
246+ return err
247+ }
248+ s.mu.Lock()
249+ s.writes++
250+ s.bytes += uint64(len(data))
251+ s.mu.Unlock()
252+ return nil
253+}
254+func (*countingSnapshotStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil }
255+func (*countingSnapshotStore) Delete(string) error { return nil }
256+func (s *countingSnapshotStore) reset() {
257+ s.mu.Lock()
258+ s.countingSnapshotMetrics = countingSnapshotMetrics{}
259+ s.mu.Unlock()
260+}
261+func (s *countingSnapshotStore) metrics() countingSnapshotMetrics {
262+ s.mu.Lock()
263+ defer s.mu.Unlock()
264+ return s.countingSnapshotMetrics
265+}
@@ -0,0 +1,265 @@
1+package daemon
2+
3+import (
4+ "fmt"
5+ "io"
6+ "sync"
7+ "testing"
8+
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/ports"
13+ "github.com/bnema/vev/internal/usecase/layout"
14+ snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
15+)
16+
17+func TestPerformanceFixtureCounters(t *testing.T) {
18+ fixture := newPerformanceFixture(t, performanceConfig{})
19+
20+ fixture.paintLive()
21+ fixture.paintLive()
22+ metrics := fixture.metrics()
23+ require.Equal(t, uint64(2), metrics.outputFrames)
24+ require.Positive(t, metrics.outputBytes)
25+ require.Positive(t, metrics.outputPayloadBytes)
26+
27+ fixture.captureSnapshot()
28+ metrics = fixture.metrics()
29+ require.Equal(t, uint64(1), metrics.snapshotWrites)
30+ require.Positive(t, metrics.snapshotBytes)
31+
32+ fixture.enterCopySearch()
33+ require.Positive(t, fixture.searchMatches())
34+
35+ fixture.resize()
36+ require.True(t, fixture.resized())
37+}
38+
39+// performanceConfig intentionally describes only an in-process daemon shape:
40+// no PTY readers, schedulers, clocks, or transports are started by this fixture.
41+type performanceConfig struct {
42+ size domain.Size
43+}
44+
45+type performanceMetrics struct {
46+ outputFrames uint64
47+ outputBytes uint64
48+ outputPayloadBytes uint64
49+ snapshotWrites uint64
50+ snapshotBytes uint64
51+}
52+
53+type performanceFixture struct {
54+ t testing.TB
55+ d *Daemon
56+ sess *session
57+ ac *attachedClient
58+ output *countingOutputTransport
59+ snaps *countingSnapshotStore
60+ paints int
61+ resizedSize domain.Size
62+}
63+
64+func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceFixture {
65+ t.Helper()
66+ if !config.size.Valid() {
67+ config.size = domain.Size{Cols: 80, Rows: 24}
68+ }
69+ d, sess, ac, _ := newManualSessionWithPTYs(t, nil, nil)
70+ output := &countingOutputTransport{}
71+ ac.tr = output
72+ ac.size = config.size
73+ sess.name = "performance"
74+ sess.ephemeral = false
75+ sess.snapEligible.Store(true)
76+
77+ fixture := &performanceFixture{t: t, d: d, sess: sess, ac: ac, output: output, snaps: &countingSnapshotStore{}}
78+ WithSnapshotStore(fixture.snaps)(d)
79+ for tabIndex, tb := range sess.tabs {
80+ fixture.configureTab(tb, tabIndex, tabSize(config.size))
81+ }
82+
83+ // Prime the real renderer shadow before measurements. Subsequent paints use
84+ // actual production diffs rather than the initial full frame.
85+ d.paint(sess, ac, true)
86+ fixture.resetMetrics()
87+ return fixture
88+}
89+
90+func (f *performanceFixture) configureTab(tb *tab, tabIndex int, size domain.Size) {
91+ f.t.Helper()
92+ tb.mu.Lock()
93+ tb.size = size
94+ left := tb.focusedPane()
95+ rightID := layout.PaneID(fmt.Sprintf("pane-%d-right", tabIndex+1))
96+ right := newPane(rightID, nil, size)
97+ tb.panes[rightID] = right
98+ require.NoError(f.t, tb.tree.Split(left.id, layout.Right, true, rightID, domain.Rect{Width: size.Cols, Height: size.Rows}))
99+ placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: size.Cols, Height: size.Rows})
100+ require.True(f.t, ok, "performance fixture layout must be solvable")
101+ require.Len(f.t, placements, 2)
102+ f.d.applyLayoutLocked(tb)
103+ for _, p := range tb.panes {
104+ p.mu.Lock()
105+ width, height := p.screen.Frame.Width, p.screen.Frame.Height
106+ for row := range max(height-1, 1) {
107+ p.screen.Write([]byte(performanceFullWidthRow(width, tabIndex, row) + "\r\n"))
108+ }
109+ p.mu.Unlock()
110+ }
111+ tb.mu.Unlock()
112+}
113+
114+// performanceFullWidthRow produces stable, full-width visible content without
115+// relying on process output or timing.
116+func performanceFullWidthRow(width, tabIndex, row int) string {
117+ prefix := fmt.Sprintf("needle-%d-%02d ", tabIndex, row)
118+ out := make([]byte, width)
119+ for i := range out {
120+ out[i] = byte('a' + (tabIndex+row+i)%26)
121+ }
122+ copy(out, prefix)
123+ return string(out)
124+}
125+
126+func (f *performanceFixture) activePane() *pane {
127+ f.t.Helper()
128+ tb := f.sess.activeTab()
129+ require.NotNil(f.t, tb)
130+ tb.mu.Lock()
131+ defer tb.mu.Unlock()
132+ return tb.focusedPane()
133+}
134+
135+func (f *performanceFixture) paintLive() {
136+ f.t.Helper()
137+ p := f.activePane()
138+ x, y := 1, 1
139+ if f.paints%2 == 1 {
140+ x, y = 2, 2
141+ }
142+ p.mu.Lock()
143+ p.screen.Write([]byte(fmt.Sprintf("\x1b[%d;%dH%c", y, x, 'A'+rune(f.paints))))
144+ p.mu.Unlock()
145+ f.d.paint(f.sess, f.ac, false)
146+ f.paints++
147+}
148+
149+func (f *performanceFixture) captureSnapshot() {
150+ f.t.Helper()
151+ require.True(f.t, f.d.captureSession(f.sess))
152+}
153+
154+func (f *performanceFixture) enterCopySearch() {
155+ f.t.Helper()
156+ f.d.enterCopyMode(f.sess, f.ac)
157+ f.d.handleInput(f.sess, f.ac, []byte("/needle\r"))
158+}
159+
160+func (f *performanceFixture) searchMatches() int {
161+ f.ac.overlays.copyMu.Lock()
162+ defer f.ac.overlays.copyMu.Unlock()
163+ if f.ac.overlays.copyMode == nil {
164+ return 0
165+ }
166+ return len(f.ac.overlays.copyMode.Searches)
167+}
168+
169+func (f *performanceFixture) resize() {
170+ f.resizedSize = domain.Size{Cols: 100, Rows: 30}
171+ f.d.resize(f.sess, f.ac, f.resizedSize)
172+}
173+
174+func (f *performanceFixture) resized() bool {
175+ if f.ac.size != f.resizedSize {
176+ return false
177+ }
178+ for _, tb := range f.sess.tabs {
179+ tb.mu.Lock()
180+ ok := tb.size == tabSize(f.resizedSize)
181+ tb.mu.Unlock()
182+ if !ok {
183+ return false
184+ }
185+ }
186+ return true
187+}
188+
189+func (f *performanceFixture) resetMetrics() {
190+ f.output.reset()
191+ f.snaps.reset()
192+}
193+
194+func (f *performanceFixture) metrics() performanceMetrics {
195+ output := f.output.metrics()
196+ snapshots := f.snaps.metrics()
197+ return performanceMetrics{
198+ outputFrames: output.frames, outputBytes: output.bytes, outputPayloadBytes: output.payloadBytes,
199+ snapshotWrites: snapshots.writes, snapshotBytes: snapshots.bytes,
200+ }
201+}
202+
203+type countingOutputMetrics struct{ frames, bytes, payloadBytes uint64 }
204+
205+type countingOutputTransport struct {
206+ mu sync.Mutex
207+ countingOutputMetrics
208+}
209+
210+func (t *countingOutputTransport) Send(frame ports.Frame) error {
211+ if frame.Type != ports.MsgOutput {
212+ return nil
213+ }
214+ output, err := ports.UnmarshalOutput(frame.Payload)
215+ if err != nil {
216+ return err
217+ }
218+ t.mu.Lock()
219+ t.frames++
220+ t.bytes += uint64(len(output.Data))
221+ t.payloadBytes += uint64(len(frame.Payload))
222+ t.mu.Unlock()
223+ return nil
224+}
225+func (*countingOutputTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
226+func (*countingOutputTransport) Close() error { return nil }
227+func (t *countingOutputTransport) reset() {
228+ t.mu.Lock()
229+ t.countingOutputMetrics = countingOutputMetrics{}
230+ t.mu.Unlock()
231+}
232+func (t *countingOutputTransport) metrics() countingOutputMetrics {
233+ t.mu.Lock()
234+ defer t.mu.Unlock()
235+ return t.countingOutputMetrics
236+}
237+
238+type countingSnapshotMetrics struct{ writes, bytes uint64 }
239+type countingSnapshotStore struct {
240+ mu sync.Mutex
241+ countingSnapshotMetrics
242+}
243+
244+func (s *countingSnapshotStore) Write(_ string, data []byte) error {
245+ if _, err := snapcodec.Unmarshal(data); err != nil {
246+ return err
247+ }
248+ s.mu.Lock()
249+ s.writes++
250+ s.bytes += uint64(len(data))
251+ s.mu.Unlock()
252+ return nil
253+}
254+func (*countingSnapshotStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil }
255+func (*countingSnapshotStore) Delete(string) error { return nil }
256+func (s *countingSnapshotStore) reset() {
257+ s.mu.Lock()
258+ s.countingSnapshotMetrics = countingSnapshotMetrics{}
259+ s.mu.Unlock()
260+}
261+func (s *countingSnapshotStore) metrics() countingSnapshotMetrics {
262+ s.mu.Lock()
263+ defer s.mu.Unlock()
264+ return s.countingSnapshotMetrics
265+}

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 12

Tags 11

↑↓ moveenter openesc close