v0.3.0 99dc266

visual_probe_test.go RawHistoryFolder

1package main
2
3import (
4	"encoding/json"
5	"errors"
6	"os"
7	"strings"
8	"testing"
9
10	"github.com/bnema/vev/internal/domain"
11	"github.com/bnema/vev/internal/ports"
12	"github.com/stretchr/testify/require"
13)
14
15const (
16	probeTestCols = 32
17	probeTestRows = 2
18)
19
20type probeTestTransport struct {
21	sent []ports.Frame
22}
23
24func (t *probeTestTransport) Send(frame ports.Frame) error {
25	t.sent = append(t.sent, frame)
26	return nil
27}
28
29func (*probeTestTransport) Recv() (ports.Frame, error) { return ports.Frame{}, errors.New("not used") }
30func (*probeTestTransport) Close() error               { return nil }
31
32func probeTestOutput(t *testing.T, output ports.Output) ports.Frame {
33	t.Helper()
34	payload, err := ports.MarshalOutput(output)
35	if err != nil {
36		t.Fatalf("marshal output: %v", err)
37	}
38	return ports.Frame{Type: ports.MsgOutput, Payload: payload}
39}
40
41func TestVisualProbePersistsFullAndIncrementalOutput(t *testing.T) {
42	size := domain.Size{Cols: probeTestCols, Rows: probeTestRows}
43	probe := newVisualProbe(size)
44	for _, tt := range []struct {
45		name   string
46		output ports.Output
47		want   string
48		state  uint64
49	}{
50		{
51			name:   "full",
52			output: ports.Output{Epoch: 1, New: 1, Full: true, ViewRevision: 4, Size: size, Data: []byte("full")},
53			want:   "full",
54			state:  1,
55		},
56		{
57			name:   "incremental",
58			output: ports.Output{Epoch: 1, Base: 1, New: 2, ViewRevision: 4, Size: size, Data: []byte(" incremental")},
59			want:   "full incremental",
60			state:  2,
61		},
62	} {
63		t.Run(tt.name, func(t *testing.T) {
64			result := probe.apply(tt.output)
65			if !result.Accepted || !result.StateBearing {
66				t.Fatalf("result = %+v, want accepted state-bearing output", result)
67			}
68			if result.Ack.Epoch != tt.output.Epoch || result.Ack.State != tt.state {
69				t.Fatalf("ack = %+v, want epoch=%d state=%d", result.Ack, tt.output.Epoch, tt.state)
70			}
71			if !strings.Contains(probe.text(), tt.want) {
72				t.Fatalf("screen = %q, want %q", probe.text(), tt.want)
73			}
74			if probe.state.state != tt.state {
75				t.Fatalf("state = %d, want %d", probe.state.state, tt.state)
76			}
77		})
78	}
79	if got := len(probe.checkpoints); got != 2 {
80		t.Fatalf("checkpoints = %d, want 2", got)
81	}
82	if got := len(probe.events); got != 2 {
83		t.Fatalf("events = %d, want 2", got)
84	}
85}
86
87func TestVisualProbeResizesToAcceptedOutput(t *testing.T) {
88	probe := newVisualProbe(domain.Size{Cols: probeTestCols, Rows: probeTestRows})
89	outputSize := domain.Size{Cols: 48, Rows: 3}
90	result := probe.apply(ports.Output{
91		Epoch: 1, New: 1, Full: true, ViewRevision: 1, Size: outputSize, Data: []byte("resized"),
92	})
93
94	require.True(t, result.Accepted)
95	require.Equal(t, outputSize.Cols, probe.screen.Frame.Width)
96	require.Equal(t, outputSize.Rows, probe.screen.Frame.Height)
97	require.Len(t, probe.checkpoints, 1)
98	require.Equal(t, outputSize.Cols, probe.checkpoints[0].snapshot.Columns())
99	require.Equal(t, outputSize.Rows, probe.checkpoints[0].snapshot.Rows())
100}
101
102func TestVisualProbeRejectsOutputWithoutMutationOrAck(t *testing.T) {
103	size := domain.Size{Cols: probeTestCols, Rows: probeTestRows}
104	for _, tt := range []struct {
105		name   string
106		output ports.Output
107	}{
108		{
109			name:   "base gap",
110			output: ports.Output{Epoch: 1, Base: 0, New: 3, Full: true, ViewRevision: 4, Size: size, Data: []byte("rejected")},
111		},
112		{
113			name:   "revision gap",
114			output: ports.Output{Epoch: 1, Base: 1, New: 2, ViewRevision: 5, Size: size, Data: []byte("rejected")},
115		},
116		{
117			name:   "stale epoch",
118			output: ports.Output{Epoch: 0, Base: 1, New: 2, ViewRevision: 4, Size: size, Data: []byte("rejected")},
119		},
120	} {
121		t.Run(tt.name, func(t *testing.T) {
122			probe := newVisualProbe(size)
123			first := ports.Output{Epoch: 1, New: 1, Full: true, ViewRevision: 4, Size: size, Data: []byte("keep")}
124			if result := probe.apply(first); !result.Accepted {
125				t.Fatal("seed output was rejected")
126			}
127			beforeText, beforeState, beforeCheckpoints := probe.text(), probe.state, len(probe.checkpoints)
128			result := probe.apply(tt.output)
129			if result.Accepted || result.StateBearing || result.Ack != (ports.Ack{}) {
130				t.Fatalf("result = %+v, want rejected output without ACK", result)
131			}
132			if got := probe.text(); got != beforeText {
133				t.Fatalf("screen changed from %q to %q", beforeText, got)
134			}
135			if probe.state != beforeState {
136				t.Fatalf("state = %+v, want unchanged %+v", probe.state, beforeState)
137			}
138			if len(probe.checkpoints) != beforeCheckpoints {
139				t.Fatalf("checkpoints = %d, want %d", len(probe.checkpoints), beforeCheckpoints)
140			}
141		})
142	}
143}
144
145func TestVisualProbeRejectsInvalidUninitializedSideEffects(t *testing.T) {
146	size := domain.Size{Cols: probeTestCols, Rows: probeTestRows}
147	for _, output := range []ports.Output{
148		{Epoch: 1, Base: 1, Size: size, Data: []byte("invalid base")},
149		{Epoch: 1, Full: true, Size: size, Data: []byte("invalid full")},
150	} {
151		probe := newVisualProbe(size)
152		result := probe.apply(output)
153		if result.Accepted || result.StateBearing || result.Ack != (ports.Ack{}) {
154			t.Fatalf("result = %+v, want rejected uninitialized side effect", result)
155		}
156		require.Len(t, probe.events, 1)
157		require.False(t, probe.events[0].Accepted)
158		require.Empty(t, probe.checkpoints)
159	}
160}
161
162func TestVisualProbeSideEffectsApplyWithoutAck(t *testing.T) {
163	size := domain.Size{Cols: probeTestCols, Rows: probeTestRows}
164	for _, tt := range []struct {
165		name   string
166		seed   *ports.Output
167		effect ports.Output
168	}{
169		{
170			name:   "before state-bearing output",
171			effect: ports.Output{Epoch: 1, Size: size, Data: []byte("side effect")},
172		},
173		{
174			name:   "after state-bearing output",
175			seed:   &ports.Output{Epoch: 1, New: 1, Full: true, ViewRevision: 4, Size: size, Data: []byte("seed")},
176			effect: ports.Output{Epoch: 1, ViewRevision: 4, Size: size, Data: []byte(" side effect")},
177		},
178	} {
179		t.Run(tt.name, func(t *testing.T) {
180			probe := newVisualProbe(size)
181			if tt.seed != nil && !probe.apply(*tt.seed).Accepted {
182				t.Fatal("seed output was rejected")
183			}
184			beforeState, beforeCheckpoints := probe.state, len(probe.checkpoints)
185			result := probe.apply(tt.effect)
186			if !result.Accepted || result.StateBearing || result.Ack != (ports.Ack{}) {
187				t.Fatalf("result = %+v, want accepted side effect without ACK", result)
188			}
189			if !strings.Contains(probe.text(), string(tt.effect.Data)) {
190				t.Fatalf("screen = %q, want side effect %q", probe.text(), tt.effect.Data)
191			}
192			if probe.state != beforeState {
193				t.Fatalf("state = %+v, want unchanged %+v", probe.state, beforeState)
194			}
195			if len(probe.checkpoints) != beforeCheckpoints+1 {
196				t.Fatalf("checkpoints = %d, want %d", len(probe.checkpoints), beforeCheckpoints+1)
197			}
198		})
199	}
200}
201
202func TestProcessOutputFrameACKsOnlyAcceptedStateBearingFrames(t *testing.T) {
203	size := domain.Size{Cols: probeTestCols, Rows: probeTestRows}
204	for _, tt := range []struct {
205		name       string
206		seed       *ports.Output
207		output     ports.Output
208		wantACKs   int
209		wantResets int
210	}{
211		{
212			name:     "accepted full",
213			output:   ports.Output{Epoch: 1, New: 1, Full: true, ViewRevision: 4, Size: size, Data: []byte("full")},
214			wantACKs: 1,
215		},
216		{
217			name:   "accepted side effect",
218			output: ports.Output{Epoch: 1, Size: size, Data: []byte("side effect")},
219		},
220		{
221			name:       "rejected state",
222			seed:       &ports.Output{Epoch: 1, New: 1, Full: true, ViewRevision: 4, Size: size, Data: []byte("seed")},
223			output:     ports.Output{Epoch: 1, New: 3, Full: true, ViewRevision: 4, Size: size, Data: []byte("gap")},
224			wantResets: 1,
225		},
226	} {
227		t.Run(tt.name, func(t *testing.T) {
228			transport := &probeTestTransport{}
229			tr := &harnessTransport{Transport: transport, probe: newVisualProbe(size)}
230			if tt.seed != nil {
231				if err := processOutputFrame(tr, probeTestOutput(t, *tt.seed)); err != nil {
232					t.Fatal(err)
233				}
234				transport.sent = nil
235			}
236			if err := processOutputFrame(tr, probeTestOutput(t, tt.output)); err != nil {
237				t.Fatal(err)
238			}
239			var ackCount, resetCount int
240			for _, frame := range transport.sent {
241				switch frame.Type {
242				case ports.MsgAck:
243					ackCount++
244				case ports.MsgOutputResetRequest:
245					resetCount++
246				}
247			}
248			if ackCount != tt.wantACKs {
249				t.Fatalf("ACK count = %d, want %d", ackCount, tt.wantACKs)
250			}
251			if resetCount != tt.wantResets {
252				t.Fatalf("reset count = %d, want %d", resetCount, tt.wantResets)
253			}
254		})
255	}
256}
257
258func TestCaptureUnifiedPickerRowsRequiresLocalAndRemoteEntries(t *testing.T) {
259	probe := newVisualProbe(domain.Size{Cols: 48, Rows: 4})
260	probe.screen.Write([]byte("local-picker\npicker@remote"))
261
262	rows := capturePickerRows(probe)
263	if err := assertUnifiedPickerRows(rows, "local-picker", "picker"); err != nil {
264		t.Fatalf("assert unified picker rows: %v; rows = %#v", err, rows)
265	}
266}
267
268func TestVisualProbeRecordsUnexpectedAttachTarget(t *testing.T) {
269	probe := newVisualProbe(domain.Size{Cols: probeTestCols, Rows: probeTestRows})
270	probe.recordIncoming(ports.Frame{Type: ports.MsgAttachTarget})
271
272	if probe.unexpectedHandoffs != 1 {
273		t.Fatalf("unexpected handoffs = %d, want 1", probe.unexpectedHandoffs)
274	}
275	if len(probe.events) != 1 || probe.events[0].Kind != probeEventUnexpectedHandoff {
276		t.Fatalf("events = %#v, want one unexpected handoff event", probe.events)
277	}
278}
279
280func TestAssertNoRemoteChromeIgnoresLocalBarsButRejectsContentLeak(t *testing.T) {
281	require.Error(t, assertNoRemoteChrome(nil, "picker"))
282	for _, test := range []struct {
283		name    string
284		content string
285		wantErr bool
286	}{
287		{name: "local bars", content: "picker@remote\r\nremote content\r\nlocal bottom", wantErr: false},
288		{name: "remote status in content", content: "local top\r\npicker@remote\r\nlocal bottom", wantErr: true},
289	} {
290		t.Run(test.name, func(t *testing.T) {
291			probe := newVisualProbe(domain.Size{Cols: 48, Rows: 3})
292			probe.screen.Write([]byte(test.content))
293			err := assertNoRemoteChrome(probe, "picker")
294			if (err != nil) != test.wantErr {
295				t.Fatalf("error = %v, want error = %t", err, test.wantErr)
296			}
297		})
298	}
299}
300
301func TestHarnessArtifactIsBoundedAndContainsOnlyMetadata(t *testing.T) {
302	dir := t.TempDir()
303	artifact := newHarnessArtifact(dir)
304	size := domain.Size{Cols: probeTestCols, Rows: probeTestRows}
305	probe := newVisualProbe(size)
306	probe.configure("local-picker")
307	artifact.registerProbe(probe)
308	secret := "not-for-artifact"
309	for state := uint64(1); state <= maxProbeEvents+1; state++ {
310		output := ports.Output{Epoch: 1, Base: state - 1, New: state, Size: size, Data: []byte(secret)}
311		if state == 1 {
312			output.Full = true
313		}
314		if result := probe.apply(output); !result.Accepted {
315			t.Fatalf("probe output %d was rejected", state)
316		}
317	}
318	if err := artifact.write(true); err != nil {
319		t.Fatal(err)
320	}
321	path := dir + "/remote-picker-harness.json"
322	data, err := os.ReadFile(path)
323	if err != nil {
324		t.Fatal(err)
325	}
326	if len(data) > maxArtifactBytes {
327		t.Fatalf("artifact bytes = %d, want <= %d", len(data), maxArtifactBytes)
328	}
329	if strings.Contains(string(data), secret) {
330		t.Fatal("artifact contained raw output")
331	}
332	var document artifactDocument
333	if err := json.Unmarshal(data, &document); err != nil {
334		t.Fatalf("artifact JSON: %v", err)
335	}
336	if !document.Passed || len(document.Probes) != 1 {
337		t.Fatalf("artifact document = %+v, want one passed probe", document)
338	}
339	if len(document.Probes[0].Events) != maxProbeEvents {
340		t.Fatalf("artifact events = %d, want %d", len(document.Probes[0].Events), maxProbeEvents)
341	}
342	if len(document.Probes[0].Checkpoints) != maxProbeCheckpoints {
343		t.Fatalf("artifact checkpoints = %d, want %d", len(document.Probes[0].Checkpoints), maxProbeCheckpoints)
344	}
345}

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