v0.1.0 7deb448

wire_test.go RawHistoryFolder

1package ports
2
3import (
4	"bytes"
5	"fmt"
6	"math"
7	"reflect"
8	"testing"
9
10	"github.com/bnema/vev/internal/domain"
11	"github.com/bnema/vev/pkg/renderer"
12	"github.com/stretchr/testify/require"
13)
14
15// mustNotPanic runs f and fails the test (instead of crashing the test
16// binary) if f panics.
17func mustNotPanic(t *testing.T, f func()) {
18	t.Helper()
19	defer func() {
20		if r := recover(); r != nil {
21			t.Fatalf("panicked: %v", r)
22		}
23	}()
24	f()
25}
26
27// assertAllPrefixesFail decodes every strict prefix of full and requires an
28// error (never a panic, never a successful decode) for each one. This
29// exercises truncation at every possible boundary — mid-length,
30// mid-string-len, mid-string — in one sweep.
31func assertAllPrefixesFail[T any](t *testing.T, full []byte, unmarshal func([]byte) (T, error)) {
32	t.Helper()
33	for n := range len(full) {
34		t.Run(fmt.Sprintf("truncated_at_%d", n), func(t *testing.T) {
35			mustNotPanic(t, func() {
36				if _, err := unmarshal(full[:n]); err == nil {
37					t.Fatalf("expected error decoding %d/%d truncated bytes, got nil", n, len(full))
38				}
39			})
40		})
41	}
42}
43
44// assertTrailingGarbageFails decodes full with extra bytes appended and
45// requires an error for a fixed-shape message.
46func assertTrailingGarbageFails[T any](t *testing.T, full []byte, unmarshal func([]byte) (T, error)) {
47	t.Helper()
48	garbage := append(append([]byte(nil), full...), 0xff)
49	mustNotPanic(t, func() {
50		if _, err := unmarshal(garbage); err == nil {
51			t.Fatalf("expected error decoding payload with trailing garbage, got nil")
52		}
53	})
54}
55
56func TestHelloGoldenAndRoundTrip(t *testing.T) {
57	tests := []struct {
58		name string
59		msg  Hello
60		want []byte
61	}{
62		{
63			name: "typical",
64			msg: Hello{
65				Version:           1,
66				Intent:            IntentNew,
67				ClientID:          [16]byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10},
68				ResumeToken:       0x0102030405060708,
69				Name:              "w0",
70				Size:              domain.Size{Cols: 80, Rows: 24},
71				TermEnv:           "xterm-256color",
72				Cwd:               "/tmp/project",
73				TrueColor:         true,
74				MaxOutputInFlight: 8,
75			},
76			want: []byte{0x00, 0x01, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x02, 0x77, 0x30, 0x00, 0x50, 0x00, 0x18, 0x00, 0x0e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x2d, 0x32, 0x35, 0x36, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x00, 0x0c, 0x2f, 0x74, 0x6d, 0x70, 0x2f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x01, 0x08, 0x00, 0x00, 0x00, 0x00},
77		},
78		{
79			name: "empty strings",
80			msg: Hello{
81				Version:   1,
82				Intent:    IntentEphemeral,
83				Name:      "",
84				Size:      domain.Size{Cols: 0, Rows: 0},
85				TermEnv:   "",
86				Cwd:       "",
87				TrueColor: false,
88			},
89			want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
90		},
91	}
92
93	for _, tt := range tests {
94		t.Run(tt.name, func(t *testing.T) {
95			got := MarshalHello(tt.msg)
96			if !bytes.Equal(got, tt.want) {
97				t.Fatalf("MarshalHello() = %#v, want %#v", got, tt.want)
98			}
99			back, err := UnmarshalHello(got)
100			if err != nil {
101				t.Fatalf("UnmarshalHello() error = %v", err)
102			}
103			if !reflect.DeepEqual(back, tt.msg) {
104				t.Fatalf("round trip = %#v, want %#v", back, tt.msg)
105			}
106			peek, ok := PeekHelloVersion(got)
107			if !ok || peek != tt.msg.Version {
108				t.Fatalf("PeekHelloVersion() = %d, %v; want %d, true", peek, ok, tt.msg.Version)
109			}
110		})
111	}
112
113	full := MarshalHello(tests[0].msg)
114	assertAllPrefixesFail(t, full, UnmarshalHello)
115	assertTrailingGarbageFails(t, full, UnmarshalHello)
116}
117
118func TestHelloEnvironmentCodec(t *testing.T) {
119	t.Run("literal wire layout", func(t *testing.T) {
120		got := MarshalHello(Hello{
121			Version: ProtocolVersion,
122			Env:     []string{"A=B", "XY=123"},
123		})
124		want := []byte{
125			0x00, 0x13, 0x00, // version, intent
126			0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
127			0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
128			0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
129			0x00, 0x00, // name
130			0x00, 0x00, 0x00, 0x00, // size
131			0x00, 0x00, // TERM
132			0x00, 0x00, // cwd
133			0x00, 0x00, // true color, max output in flight
134			0x00, 0x00, 0x00, 0x02, // uint32 environment entry count
135			0x00, 0x00, 0x00, 0x03, 'A', '=', 'B',
136			0x00, 0x00, 0x00, 0x06, 'X', 'Y', '=', '1', '2', '3',
137		}
138		require.Equal(t, want, got)
139		assertAllPrefixesFail(t, got, UnmarshalHello)
140		assertTrailingGarbageFails(t, got, UnmarshalHello)
141	})
142
143	t.Run("lossless order and values", func(t *testing.T) {
144		entryOverUint16 := "LARGE=" + string(bytes.Repeat([]byte("x"), 65536))
145		want := []string{"A=first", "TOKEN=a=b=c", "EMPTY=", entryOverUint16, "A=second"}
146		payload := MarshalHello(Hello{Version: ProtocolVersion, Env: want})
147		got, err := UnmarshalHello(payload)
148		require.NoError(t, err)
149		require.Equal(t, want, got.Env)
150	})
151
152	t.Run("empty", func(t *testing.T) {
153		payload := MarshalHello(Hello{Version: ProtocolVersion, Env: []string{}})
154		got, err := UnmarshalHello(payload)
155		require.NoError(t, err)
156		require.Empty(t, got.Env)
157	})
158
159	base := MarshalHello(Hello{Version: ProtocolVersion})
160	withCount := func(count byte) []byte {
161		payload := append([]byte(nil), base...)
162		payload[len(payload)-1] = count
163		return payload
164	}
165	tests := []struct {
166		name    string
167		payload []byte
168	}{
169		{name: "truncated count", payload: base[:len(base)-1]},
170		{name: "truncated entry length", payload: append(withCount(1), 0, 0)},
171		{name: "truncated entry body", payload: append(withCount(1), 0, 0, 0, 3, 'x')},
172		{name: "impossible count", payload: withCount(2)},
173		{name: "trailing garbage", payload: append(append([]byte(nil), base...), 0xff)},
174	}
175	for _, tt := range tests {
176		t.Run(tt.name, func(t *testing.T) {
177			_, err := UnmarshalHello(tt.payload)
178			require.Error(t, err)
179		})
180	}
181}
182
183func TestInputGoldenAndRoundTrip(t *testing.T) {
184	tests := []struct {
185		name string
186		msg  Input
187		want []byte
188	}{
189		{name: "data", msg: Input{InputSeq: 0x0102030405060708, Data: []byte("hi")}, want: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x68, 0x69}},
190		{name: "empty", msg: Input{InputSeq: 0, Data: nil}, want: []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}},
191	}
192
193	for _, tt := range tests {
194		t.Run(tt.name, func(t *testing.T) {
195			got := MarshalInput(tt.msg)
196			if !bytes.Equal(got, tt.want) {
197				t.Fatalf("MarshalInput() = %#v, want %#v", got, tt.want)
198			}
199			back, err := UnmarshalInput(got)
200			if err != nil {
201				t.Fatalf("UnmarshalInput() error = %v", err)
202			}
203			if !reflect.DeepEqual(back, tt.msg) {
204				t.Fatalf("round trip = %#v, want %#v", back, tt.msg)
205			}
206		})
207	}
208
209	full := MarshalInput(tests[0].msg)
210	assertAllPrefixesFail(t, full[:8], UnmarshalInput)
211}
212
213func TestImagePushGoldenAndRoundTrip(t *testing.T) {
214	tests := []struct {
215		name string
216		msg  ImagePush
217		want []byte
218	}{
219		{
220			name: "png data",
221			msg:  ImagePush{InputSeq: 7, Mime: "image/png", Data: []byte{0x01, 0x02, 0x03}},
222			want: []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x07, 0x00, 0x09, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x2f, 0x70, 0x6e, 0x67, 0x01, 0x02, 0x03},
223		},
224		{name: "empty", msg: ImagePush{}, want: []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}},
225	}
226
227	for _, tt := range tests {
228		t.Run(tt.name, func(t *testing.T) {
229			got := MarshalImagePush(tt.msg)
230			if !bytes.Equal(got, tt.want) {
231				t.Fatalf("MarshalImagePush() = %#v, want %#v", got, tt.want)
232			}
233			back, err := UnmarshalImagePush(got)
234			if err != nil {
235				t.Fatalf("UnmarshalImagePush() error = %v", err)
236			}
237			if !reflect.DeepEqual(back, tt.msg) {
238				t.Fatalf("round trip = %#v, want %#v", back, tt.msg)
239			}
240		})
241	}
242
243	full := MarshalImagePush(tests[0].msg)
244	assertAllPrefixesFail(t, full[:19], UnmarshalImagePush)
245}
246
247func TestThemeGoldenAndRoundTrip(t *testing.T) {
248	palette := [16]renderer.RGB{
249		{R: 0x10, G: 0x11, B: 0x12}, {R: 0x13, G: 0x14, B: 0x15},
250		{R: 0x16, G: 0x17, B: 0x18}, {R: 0x19, G: 0x1a, B: 0x1b},
251		{R: 0x1c, G: 0x1d, B: 0x1e}, {R: 0x1f, G: 0x20, B: 0x21},
252		{R: 0x22, G: 0x23, B: 0x24}, {R: 0x25, G: 0x26, B: 0x27},
253		{R: 0x28, G: 0x29, B: 0x2a}, {R: 0x2b, G: 0x2c, B: 0x2d},
254		{R: 0x2e, G: 0x2f, B: 0x30}, {R: 0x31, G: 0x32, B: 0x33},
255		{R: 0x34, G: 0x35, B: 0x36}, {R: 0x37, G: 0x38, B: 0x39},
256		{R: 0x3a, G: 0x3b, B: 0x3c}, {R: 0x3d, G: 0x3e, B: 0x3f},
257	}
258	zeroes := make([]byte, 57)
259	tests := []struct {
260		name string
261		msg  Theme
262		want []byte
263	}{
264		{
265			name: "empty",
266			msg:  Theme{},
267			want: zeroes,
268		},
269		{
270			name: "foreground only",
271			msg: Theme{
272				HasForeground: true,
273				Foreground:    renderer.RGB{R: 10, G: 20, B: 30},
274				Background:    renderer.RGB{R: 40, G: 50, B: 60},
275			},
276			want: append([]byte{0x01, 0x0a, 0x14, 0x1e, 0x28, 0x32, 0x3c}, zeroes[7:]...),
277		},
278		{
279			name: "light flag without known scheme",
280			msg: Theme{
281				Light: true,
282			},
283			want: append([]byte{0x10}, zeroes[1:]...),
284		},
285		{
286			name: "full palette",
287			msg: Theme{
288				HasForeground: true,
289				Foreground:    renderer.RGB{R: 1, G: 2, B: 3},
290				HasBackground: true,
291				Background:    renderer.RGB{R: 4, G: 5, B: 6},
292				TrueColor:     true,
293				SchemeKnown:   true,
294				PaletteKnown:  0x8001,
295				Palette:       palette,
296			},
297			want: []byte{
298				0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x80, 0x01,
299				0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18,
300				0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, 0x21,
301				0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a,
302				0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30, 0x31, 0x32, 0x33,
303				0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x3b, 0x3c,
304				0x3d, 0x3e, 0x3f,
305			},
306		},
307	}
308
309	for _, tt := range tests {
310		t.Run(tt.name, func(t *testing.T) {
311			got := MarshalTheme(tt.msg)
312			require.Len(t, got, 57)
313			require.Equal(t, tt.want, got)
314			back, err := UnmarshalTheme(got)
315			require.NoError(t, err)
316			require.Equal(t, tt.msg, back)
317		})
318	}
319
320	full := MarshalTheme(tests[len(tests)-1].msg)
321	assertAllPrefixesFail(t, full, UnmarshalTheme)
322	assertTrailingGarbageFails(t, full, UnmarshalTheme)
323}
324
325func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T) {
326	// A generation clear retains defaults/capabilities but has no palette bits.
327	// This literal payload locks the existing 57-byte Theme layout while the
328	// full definitive-palette golden above locks the final publication.
329	cleared := Theme{
330		HasForeground: true,
331		Foreground:    renderer.RGB{R: 1, G: 2, B: 3},
332		HasBackground: true,
333		Background:    renderer.RGB{R: 4, G: 5, B: 6},
334		TrueColor:     true,
335		SchemeKnown:   true,
336	}
337	want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
338	require.Equal(t, want, MarshalTheme(cleared))
339	require.Equal(t, uint16(19), ProtocolVersion)
340}
341
342func TestResizeGoldenAndRoundTrip(t *testing.T) {
343	msg := Resize{Size: domain.Size{Cols: 100, Rows: 40}}
344	want := []byte{0x00, 0x64, 0x00, 0x28}
345
346	got := MarshalResize(msg)
347	if !bytes.Equal(got, want) {
348		t.Fatalf("MarshalResize() = %#v, want %#v", got, want)
349	}
350	back, err := UnmarshalResize(got)
351	if err != nil {
352		t.Fatalf("UnmarshalResize() error = %v", err)
353	}
354	if !reflect.DeepEqual(back, msg) {
355		t.Fatalf("round trip = %#v, want %#v", back, msg)
356	}
357
358	assertAllPrefixesFail(t, got, UnmarshalResize)
359	assertTrailingGarbageFails(t, got, UnmarshalResize)
360}
361
362func TestDetachGoldenAndRoundTrip(t *testing.T) {
363	got := MarshalDetach(Detach{})
364	if len(got) != 0 {
365		t.Fatalf("MarshalDetach() = %#v, want empty", got)
366	}
367	back, err := UnmarshalDetach(got)
368	if err != nil {
369		t.Fatalf("UnmarshalDetach() error = %v", err)
370	}
371	if back != (Detach{}) {
372		t.Fatalf("round trip = %#v, want %#v", back, Detach{})
373	}
374	assertTrailingGarbageFails(t, got, UnmarshalDetach)
375}
376
377func TestPingGoldenAndRoundTrip(t *testing.T) {
378	got := MarshalPing(Ping{})
379	if len(got) != 0 {
380		t.Fatalf("MarshalPing() = %#v, want empty", got)
381	}
382	back, err := UnmarshalPing(got)
383	if err != nil {
384		t.Fatalf("UnmarshalPing() error = %v", err)
385	}
386	if back != (Ping{}) {
387		t.Fatalf("round trip = %#v, want %#v", back, Ping{})
388	}
389	assertTrailingGarbageFails(t, got, UnmarshalPing)
390}
391
392func TestPongGoldenAndRoundTrip(t *testing.T) {
393	got := MarshalPong(Pong{})
394	if len(got) != 0 {
395		t.Fatalf("MarshalPong() = %#v, want empty", got)
396	}
397	back, err := UnmarshalPong(got)
398	if err != nil {
399		t.Fatalf("UnmarshalPong() error = %v", err)
400	}
401	if back != (Pong{}) {
402		t.Fatalf("round trip = %#v, want %#v", back, Pong{})
403	}
404	assertTrailingGarbageFails(t, got, UnmarshalPong)
405}
406
407func TestWelcomeGoldenAndRoundTrip(t *testing.T) {
408	tests := []struct {
409		name string
410		msg  Welcome
411		want []byte
412	}{
413		{
414			name: "ephemeral",
415			msg:  Welcome{SessionID: "sess-1", SessionName: "main", Ephemeral: true, ResumeToken: 0x0102030405060708, Capabilities: CapabilityResume | CapabilityPredict},
416			want: []byte{0x00, 0x06, 0x73, 0x65, 0x73, 0x73, 0x2d, 0x31, 0x00, 0x04, 0x6d, 0x61, 0x69, 0x6e, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x00, 0x00, 0x05},
417		},
418		{
419			name: "non-ephemeral empty name",
420			msg:  Welcome{SessionID: "abc", SessionName: "", Ephemeral: false},
421			want: []byte{0x00, 0x03, 0x61, 0x62, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
422		},
423	}
424
425	for _, tt := range tests {
426		t.Run(tt.name, func(t *testing.T) {
427			got := MarshalWelcome(tt.msg)
428			if !bytes.Equal(got, tt.want) {
429				t.Fatalf("MarshalWelcome() = %#v, want %#v", got, tt.want)
430			}
431			back, err := UnmarshalWelcome(got)
432			if err != nil {
433				t.Fatalf("UnmarshalWelcome() error = %v", err)
434			}
435			if !reflect.DeepEqual(back, tt.msg) {
436				t.Fatalf("round trip = %#v, want %#v", back, tt.msg)
437			}
438		})
439	}
440
441	full := MarshalWelcome(tests[0].msg)
442	assertAllPrefixesFail(t, full, UnmarshalWelcome)
443	assertTrailingGarbageFails(t, full, UnmarshalWelcome)
444}
445
446func TestCommandErrorCodes(t *testing.T) {
447	require.Equal(t, uint16(6), ErrUnknownCommand)
448	require.Equal(t, uint16(7), ErrNotScriptable)
449	require.Equal(t, uint16(8), ErrInvalidCommandArgs)
450	require.Equal(t, uint16(9), ErrNoSuchTarget)
451	require.Equal(t, uint16(10), ErrAmbiguousTarget)
452}
453
454func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
455	tests := []struct {
456		name string
457		msg  CommandRequest
458		want []byte
459	}{
460		{
461			name: "minimal",
462			msg:  CommandRequest{Version: ProtocolVersion, Slug: "split-right"},
463			want: []byte{
464				0x00, 0x13,
465				0x00,
466				0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',
467				0x00, 0x00,
468				0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
469				0x00,
470			},
471		},
472		{
473			name: "full",
474			msg: CommandRequest{
475				Version:       ProtocolVersion,
476				Self:          true,
477				Slug:          "toast",
478				Args:          []string{"-l", "warn", "tests KO"},
479				TargetSession: "dev",
480				TargetTab:     "t_abc",
481				TargetPane:    "p_def",
482				JSON:          true,
483			},
484			want: []byte{
485				0x00, 0x13,
486				0x01,
487				0x00, 0x05, 't', 'o', 'a', 's', 't',
488				0x00, 0x03,
489				0x00, 0x00, 0x00, 0x02, '-', 'l',
490				0x00, 0x00, 0x00, 0x04, 'w', 'a', 'r', 'n',
491				0x00, 0x00, 0x00, 0x08, 't', 'e', 's', 't', 's', ' ', 'K', 'O',
492				0x00, 0x03, 'd', 'e', 'v',
493				0x00, 0x05, 't', '_', 'a', 'b', 'c',
494				0x00, 0x05, 'p', '_', 'd', 'e', 'f',
495				0x01,
496			},
497		},
498	}
499
500	for _, tt := range tests {
501		t.Run(tt.name, func(t *testing.T) {
502			got, err := MarshalCommandRequest(tt.msg)
503			require.NoError(t, err)
504			require.Equal(t, tt.want, got)
505			back, err := UnmarshalCommandRequest(got)
506			require.NoError(t, err)
507			require.Equal(t, tt.msg, back)
508			version, ok := PeekCommandVersion(got)
509			require.True(t, ok)
510			require.Equal(t, tt.msg.Version, version)
511			assertAllPrefixesFail(t, got, UnmarshalCommandRequest)
512			assertTrailingGarbageFails(t, got, UnmarshalCommandRequest)
513		})
514	}
515
516	if _, ok := PeekCommandVersion([]byte{0x00}); ok {
517		t.Fatal("PeekCommandVersion accepted a one-byte payload")
518	}
519}
520
521func TestMarshalCommandRequestRejectsTooManyArguments(t *testing.T) {
522	payload, err := MarshalCommandRequest(CommandRequest{
523		Version: ProtocolVersion,
524		Slug:    "toast",
525		Args:    make([]string, math.MaxUint16+1),
526	})
527
528	require.Nil(t, payload)
529	require.ErrorIs(t, err, ErrTooManyCommandArgs)
530}
531
532func TestCommandRequestRejectsImpossibleArgumentCount(t *testing.T) {
533	payload, err := MarshalCommandRequest(CommandRequest{Version: ProtocolVersion, Slug: "toast"})
534	require.NoError(t, err)
535	argCountOffset := 2 + 1 + 2 + len("toast")
536	payload[argCountOffset] = 0xff
537	payload[argCountOffset+1] = 0xff
538
539	if _, err := UnmarshalCommandRequest(payload); err == nil {
540		t.Fatal("UnmarshalCommandRequest accepted an impossible argument count")
541	}
542}
543
544func TestCommandResultGoldenAndRoundTrip(t *testing.T) {
545	tests := []struct {
546		name string
547		msg  CommandResult
548		want []byte
549	}{
550		{
551			name: "error",
552			msg:  CommandResult{Code: ErrNoSuchTarget, Text: "no such session"},
553			want: []byte{
554				0x00,
555				0x00, 0x09,
556				0x00, 0x0f, 'n', 'o', ' ', 's', 'u', 'c', 'h', ' ', 's', 'e', 's', 's', 'i', 'o', 'n',
557				0x00, 0x00, 0x00, 0x00,
558			},
559		},
560		{
561			name: "success with output",
562			msg:  CommandResult{OK: true, Text: "listed", Output: "ID\tFOCUSED\n"},
563			want: []byte{
564				0x01,
565				0x00, 0x00,
566				0x00, 0x06, 'l', 'i', 's', 't', 'e', 'd',
567				0x00, 0x00, 0x00, 0x0b, 'I', 'D', '\t', 'F', 'O', 'C', 'U', 'S', 'E', 'D', '\n',
568			},
569		},
570	}
571
572	for _, tt := range tests {
573		t.Run(tt.name, func(t *testing.T) {
574			got := MarshalCommandResult(tt.msg)
575			require.Equal(t, tt.want, got)
576			back, err := UnmarshalCommandResult(got)
577			require.NoError(t, err)
578			require.Equal(t, tt.msg, back)
579			assertAllPrefixesFail(t, got, UnmarshalCommandResult)
580			assertTrailingGarbageFails(t, got, UnmarshalCommandResult)
581		})
582	}
583}
584
585func TestErrorMsgGoldenAndRoundTrip(t *testing.T) {
586	msg := ErrorMsg{Code: ErrNameTaken, Text: "name taken"}
587	want := []byte{0x00, 0x03, 0x00, 0x0a, 0x6e, 0x61, 0x6d, 0x65, 0x20, 0x74, 0x61, 0x6b, 0x65, 0x6e}
588
589	got := MarshalErrorMsg(msg)
590	if !bytes.Equal(got, want) {
591		t.Fatalf("MarshalErrorMsg() = %#v, want %#v", got, want)
592	}
593	back, err := UnmarshalErrorMsg(got)
594	if err != nil {
595		t.Fatalf("UnmarshalErrorMsg() error = %v", err)
596	}
597	if !reflect.DeepEqual(back, msg) {
598		t.Fatalf("round trip = %#v, want %#v", back, msg)
599	}
600
601	assertAllPrefixesFail(t, got, UnmarshalErrorMsg)
602	assertTrailingGarbageFails(t, got, UnmarshalErrorMsg)
603}
604
605func TestOutputGoldenAndRoundTrip(t *testing.T) {
606	msg := Output{BaseStateNum: 1, NewStateNum: 2, EchoAck: 3, Data: []byte("hello\n")}
607	want := []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x0a}
608
609	got := MarshalOutput(msg)
610	if !bytes.Equal(got, want) {
611		t.Fatalf("MarshalOutput() = %#v, want %#v", got, want)
612	}
613	back, err := UnmarshalOutput(got)
614	if err != nil {
615		t.Fatalf("UnmarshalOutput() error = %v", err)
616	}
617	if !reflect.DeepEqual(back, msg) {
618		t.Fatalf("round trip = %#v, want %#v", back, msg)
619	}
620
621	assertAllPrefixesFail(t, got[:24], UnmarshalOutput)
622}
623
624func TestAckGoldenAndRoundTrip(t *testing.T) {
625	msg := Ack{AckedStateNum: 0x0102030405060708}
626	want := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}
627
628	got := MarshalAck(msg)
629	if !bytes.Equal(got, want) {
630		t.Fatalf("MarshalAck() = %#v, want %#v", got, want)
631	}
632	back, err := UnmarshalAck(got)
633	if err != nil {
634		t.Fatalf("UnmarshalAck() error = %v", err)
635	}
636	if back != msg {
637		t.Fatalf("round trip = %#v, want %#v", back, msg)
638	}
639
640	assertAllPrefixesFail(t, got, UnmarshalAck)
641	assertTrailingGarbageFails(t, got, UnmarshalAck)
642}
643
644func TestDetachedGoldenAndRoundTrip(t *testing.T) {
645	msg := Detached{Reason: ReasonReplaced}
646	want := []byte{0x03}
647
648	got := MarshalDetached(msg)
649	if !bytes.Equal(got, want) {
650		t.Fatalf("MarshalDetached() = %#v, want %#v", got, want)
651	}
652	back, err := UnmarshalDetached(got)
653	if err != nil {
654		t.Fatalf("UnmarshalDetached() error = %v", err)
655	}
656	if !reflect.DeepEqual(back, msg) {
657		t.Fatalf("round trip = %#v, want %#v", back, msg)
658	}
659
660	assertAllPrefixesFail(t, got, UnmarshalDetached)
661	assertTrailingGarbageFails(t, got, UnmarshalDetached)
662}
663
664func TestClientNoticeGoldenAndStrict(t *testing.T) {
665	tests := []struct {
666		name string
667		msg  ClientNotice
668		want []byte
669	}{
670		{name: "clipboard fallback", msg: ClientNotice{Action: ClientNoticeClipboardFallback}, want: []byte{0x01}},
671		{name: "clipboard too large", msg: ClientNotice{Action: ClientNoticeClipboardTooLarge}, want: []byte{0x02}},
672		{name: "link degraded", msg: ClientNotice{Action: ClientNoticeLinkDegraded}, want: []byte{0x03}},
673		{name: "link connected", msg: ClientNotice{Action: ClientNoticeLinkConnected}, want: []byte{0x04}},
674	}
675	for _, tt := range tests {
676		t.Run(tt.name, func(t *testing.T) {
677			got := MarshalClientNotice(tt.msg)
678			require.Equal(t, tt.want, got)
679			back, err := UnmarshalClientNotice(got)
680			require.NoError(t, err)
681			require.Equal(t, tt.msg, back)
682			assertAllPrefixesFail(t, got, UnmarshalClientNotice)
683			assertTrailingGarbageFails(t, got, UnmarshalClientNotice)
684		})
685	}
686	_, err := UnmarshalClientNotice([]byte{0xff})
687	require.Error(t, err, "unknown actions must not become display text")
688}
689
690func TestListGoldenAndRoundTrip(t *testing.T) {
691	got := MarshalList(List{})
692	if len(got) != 0 {
693		t.Fatalf("MarshalList() = %#v, want empty", got)
694	}
695	back, err := UnmarshalList(got)
696	if err != nil {
697		t.Fatalf("UnmarshalList() error = %v", err)
698	}
699	if back != (List{}) {
700		t.Fatalf("round trip = %#v, want %#v", back, List{})
701	}
702	assertTrailingGarbageFails(t, got, UnmarshalList)
703}
704
705func TestKillGoldenAndRoundTrip(t *testing.T) {
706	tests := []struct {
707		name string
708		msg  Kill
709		want []byte
710	}{
711		{name: "named", msg: Kill{Name: "main"}, want: []byte{0x00, 0x04, 0x6d, 0x61, 0x69, 0x6e}},
712		{name: "empty", msg: Kill{Name: ""}, want: []byte{0x00, 0x00}},
713		{name: "all", msg: Kill{All: true}, want: []byte{0x00, 0x00, 0x01}},
714	}
715
716	for _, tt := range tests {
717		t.Run(tt.name, func(t *testing.T) {
718			got := MarshalKill(tt.msg)
719			if !bytes.Equal(got, tt.want) {
720				t.Fatalf("MarshalKill() = %#v, want %#v", got, tt.want)
721			}
722			back, err := UnmarshalKill(got)
723			if err != nil {
724				t.Fatalf("UnmarshalKill() error = %v", err)
725			}
726			if !reflect.DeepEqual(back, tt.msg) {
727				t.Fatalf("round trip = %#v, want %#v", back, tt.msg)
728			}
729		})
730	}
731
732	legacy := MarshalKill(tests[0].msg)
733	back, err := UnmarshalKill(legacy)
734	if err != nil {
735		t.Fatalf("UnmarshalKill(legacy) error = %v", err)
736	}
737	if !reflect.DeepEqual(back, Kill{Name: "main"}) {
738		t.Fatalf("legacy round trip = %#v, want named kill", back)
739	}
740	assertAllPrefixesFail(t, legacy, UnmarshalKill)
741	assertTrailingGarbageFails(t, append(append([]byte(nil), legacy...), 0x00), UnmarshalKill)
742}
743
744func TestSessionInfoRecoveryState(t *testing.T) {
745	tests := []struct {
746		name  string
747		state SessionState
748		want  []byte
749	}{
750		{name: "running", state: SessionRunning, want: []byte{0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0}},
751		{name: "stopped", state: SessionStopped, want: []byte{0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1}},
752		{name: "broken", state: SessionBroken, want: []byte{0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 2}},
753	}
754	for _, tt := range tests {
755		t.Run(tt.name, func(t *testing.T) {
756			payload := MarshalSessions(Sessions{Sessions: []SessionInfo{{State: tt.state}}})
757			require.Equal(t, tt.want, payload)
758			got, err := UnmarshalSessions(payload)
759			require.NoError(t, err)
760			require.Equal(t, tt.state, got.Sessions[0].State)
761			assertAllPrefixesFail(t, payload, UnmarshalSessions)
762			assertTrailingGarbageFails(t, payload, UnmarshalSessions)
763		})
764	}
765	for _, state := range []byte{3, 255} {
766		payload := append([]byte(nil), tests[0].want...)
767		payload[len(payload)-1] = state
768		_, err := UnmarshalSessions(payload)
769		require.Error(t, err, "state %d", state)
770	}
771}
772
773func TestSessionsGoldenAndRoundTrip(t *testing.T) {
774	tests := []struct {
775		name string
776		msg  Sessions
777		want []byte
778	}{
779		{
780			name: "empty",
781			msg:  Sessions{},
782			want: []byte{0x00, 0x00},
783		},
784		{
785			name: "two",
786			msg: Sessions{Sessions: []SessionInfo{
787				{SessionID: "0", Name: "0", State: SessionRunning, Ephemeral: true, Tabs: 1, Attached: false},
788				{SessionID: "work", Name: "proj", State: SessionStopped, Ephemeral: false, Tabs: 5, Attached: true},
789			}},
790			want: []byte{
791				0x00, 0x02,
792				0x00, 0x01, 0x30, 0x00, 0x01, 0x30, 0x01, 0x00, 0x01, 0x00, 0x00,
793				0x00, 0x04, 0x77, 0x6f, 0x72, 0x6b, 0x00, 0x04, 0x70, 0x72, 0x6f, 0x6a, 0x00, 0x00, 0x05, 0x01, 0x01,
794			},
795		},
796	}
797
798	for _, tt := range tests {
799		t.Run(tt.name, func(t *testing.T) {
800			got := MarshalSessions(tt.msg)
801			if !bytes.Equal(got, tt.want) {
802				t.Fatalf("MarshalSessions() = %#v, want %#v", got, tt.want)
803			}
804			back, err := UnmarshalSessions(got)
805			if err != nil {
806				t.Fatalf("UnmarshalSessions() error = %v", err)
807			}
808			// Normalize nil vs empty slice for comparison.
809			if len(back.Sessions) == 0 && len(tt.msg.Sessions) == 0 {
810				return
811			}
812			if !reflect.DeepEqual(back, tt.msg) {
813				t.Fatalf("round trip = %#v, want %#v", back, tt.msg)
814			}
815		})
816	}
817
818	full := MarshalSessions(tests[1].msg)
819	assertAllPrefixesFail(t, full, UnmarshalSessions)
820	assertTrailingGarbageFails(t, full, UnmarshalSessions)
821}
822
823// TestMsgTypeConstantsDistinct guards the enumerations defined in this
824// package against accidental collisions (Intent, ErrorMsg code, Detached
825// reason are each independent small spaces, but duplicates within one
826// space would silently corrupt the protocol).
827func TestMsgTypeConstantsDistinct(t *testing.T) {
828	t.Run("intents", func(t *testing.T) {
829		vals := map[uint8]string{
830			IntentEphemeral: "IntentEphemeral",
831			IntentNew:       "IntentNew",
832			IntentAttach:    "IntentAttach",
833			IntentResume:    "IntentResume",
834		}
835		if len(vals) != 4 {
836			t.Fatalf("expected 4 distinct intent values, got %d", len(vals))
837		}
838	})
839
840	t.Run("error codes", func(t *testing.T) {
841		vals := map[uint16]string{
842			ErrVersionMismatch:    "ErrVersionMismatch",
843			ErrNoSuchSession:      "ErrNoSuchSession",
844			ErrNameTaken:          "ErrNameTaken",
845			ErrServerShutdown:     "ErrServerShutdown",
846			ErrInvalidSessionName: "ErrInvalidSessionName",
847			ErrInternal:           "ErrInternal",
848		}
849		if len(vals) != 6 {
850			t.Fatalf("expected 6 distinct error codes, got %d", len(vals))
851		}
852	})
853
854	t.Run("detached reasons", func(t *testing.T) {
855		vals := map[uint8]string{
856			ReasonDetach:         "ReasonDetach",
857			ReasonSessionKilled:  "ReasonSessionKilled",
858			ReasonServerShutdown: "ReasonServerShutdown",
859			ReasonReplaced:       "ReasonReplaced",
860		}
861		if len(vals) != 4 {
862			t.Fatalf("expected 4 distinct detached reasons, got %d", len(vals))
863		}
864	})
865}
866
867func TestHelloOutputWindowByteExactValues(t *testing.T) {
868	for _, window := range []uint8{0, 1, 8} {
869		t.Run(fmt.Sprintf("window_%d", window), func(t *testing.T) {
870			hello := Hello{Version: 14, MaxOutputInFlight: window}
871			// The empty Hello has 38 zero bytes before the negotiated output window,
872			// followed by a uint32 zero environment-entry count.
873			want := append(make([]byte, 38), window, 0, 0, 0, 0)
874			want[1] = 14
875			got := MarshalHello(hello)
876			requireBytesEqual(t, want, got)
877			back, err := UnmarshalHello(got)
878			require.NoError(t, err)
879			require.Equal(t, window, back.MaxOutputInFlight)
880			assertAllPrefixesFail(t, got, UnmarshalHello)
881			assertTrailingGarbageFails(t, got, UnmarshalHello)
882		})
883	}
884}
885
886func requireBytesEqual(t *testing.T, want, got []byte) {
887	t.Helper()
888	if !bytes.Equal(want, got) {
889		t.Fatalf("bytes = %#v, want %#v", got, want)
890	}
891}

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