v0.3.0 99dc266

wire_test.go RawHistoryFolder

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

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