v0.3.0 da0887c

control_test.go RawHistoryFolder

1package module
2
3import (
4	"bytes"
5	"fmt"
6	"log/slog"
7	"strings"
8	"testing"
9	"unicode"
10	"unicode/utf8"
11)
12
13// newInteractive is newTest for a module that may send control lines.
14func newInteractive() (*Module, chan struct{}) {
15	m, wake := newTest()
16	m.Interactive = true
17	return m, wake
18}
19
20func TestParseControlValid(t *testing.T) {
21	c, err := ParseControl([]byte(`{"type":"tooltip","col":2,"width":1,"title":"Steam","body":"line1\nline2"}`))
22	if err != nil || c.Type != ControlTooltip || c.Col != 2 || c.Width != 1 || c.Title != "Steam" || c.Body != "line1\nline2" {
23		t.Fatalf("tooltip: %+v, %v", c, err)
24	}
25	c, err = ParseControl([]byte(`{"type":"menu","col":0,"width":2,"click":9,"items":[
26		{"id":1,"label":"Open"},{"id":2,"kind":"separator"},
27		{"id":3,"label":"Mute","kind":"check","checked":true},
28		{"id":4,"label":"Settings","items":[{"id":5,"label":"Advanced"}]},
29		{"id":6,"label":"Quit","enabled":false}]}`))
30	if err != nil {
31		t.Fatal(err)
32	}
33	if c.Click != 9 || len(c.Items) != 5 {
34		t.Fatalf("menu: %+v", c)
35	}
36	if !c.Items[0].Enabled || c.Items[0].Kind != KindNormal {
37		t.Fatalf("defaults: %+v", c.Items[0])
38	}
39	if c.Items[1].Kind != KindSeparator || c.Items[2].Kind != KindCheck || !c.Items[2].Checked {
40		t.Fatalf("kinds: %+v", c.Items)
41	}
42	if len(c.Items[3].Items) != 1 || c.Items[3].Items[0].ID != 5 || c.Items[4].Enabled {
43		t.Fatalf("submenu/disabled: %+v", c.Items)
44	}
45	if c, err = ParseControl([]byte(`{"type":"close"}`)); err != nil || c.Type != ControlClose {
46		t.Fatalf("close: %+v, %v", c, err)
47	}
48}
49
50// nested builds a menu with depth levels of submenus.
51func nested(depth int) string {
52	s := `{"id":1,"label":"x"}`
53	for i := 1; i < depth; i++ {
54		s = fmt.Sprintf(`{"id":1,"label":"x","items":[%s]}`, s)
55	}
56	return `{"type":"menu","col":0,"width":1,"click":1,"items":[` + s + `]}`
57}
58
59func TestParseControlLimits(t *testing.T) {
60	if _, err := ParseControl([]byte(nested(MaxMenuDepth))); err != nil {
61		t.Fatalf("depth %d must pass: %v", MaxMenuDepth, err)
62	}
63	many := func(n int) string {
64		return `{"type":"menu","col":0,"width":1,"click":1,"items":[` + strings.TrimSuffix(strings.Repeat(`{"id":1,"label":"a"},`, n), ",") + `]}`
65	}
66	if _, err := ParseControl([]byte(many(MaxMenuItems))); err != nil {
67		t.Fatalf("%d items must pass: %v", MaxMenuItems, err)
68	}
69	for name, in := range map[string]string{
70		"too deep":      nested(MaxMenuDepth + 1),
71		"too many":      many(MaxMenuItems + 1),
72		"oversize":      `{"type":"tooltip","col":0,"width":1,"title":"` + strings.Repeat("a", MaxControl) + `"}`,
73		"unknown type":  `{"type":"explode"}`,
74		"no type":       `{}`,
75		"bad json":      `{"type":`,
76		"not an object": `[1]`,
77		"neg col":       `{"type":"tooltip","col":-1,"width":1,"title":"a"}`,
78		"huge width":    `{"type":"tooltip","col":0,"width":99999999,"title":"a"}`,
79		"zero width":    `{"type":"tooltip","col":0,"width":0,"title":"a"}`,
80		"empty tip":     `{"type":"tooltip","col":0,"width":1}`,
81		"bad kind":      `{"type":"menu","col":0,"width":1,"click":1,"items":[{"id":1,"label":"a","kind":"weird"}]}`,
82		"neg id":        `{"type":"menu","col":0,"width":1,"click":1,"items":[{"id":-4,"label":"a"}]}`,
83		"id overflow":   `{"type":"menu","col":0,"width":1,"click":1,"items":[{"id":4294967296,"label":"a"}]}`,
84		"no items":      `{"type":"menu","col":0,"width":1,"click":1,"items":[]}`,
85		"neg click":     `{"type":"menu","col":0,"width":1,"click":-1,"items":[{"id":1}]}`,
86	} {
87		if _, err := ParseControl([]byte(in)); err == nil {
88			t.Errorf("%s: want an error", name)
89		}
90	}
91}
92
93func TestParseControlCleansText(t *testing.T) {
94	long := strings.Repeat("é", MaxLabel) // 2 bytes each
95	c, err := ParseControl([]byte(`{"type":"menu","col":0,"width":1,"click":1,"items":[{"id":1,"label":"a\u001b[31mb\u0007\n\u202ec` + long + `"}]}`))
96	if err != nil {
97		t.Fatal(err)
98	}
99	got := c.Items[0].Label
100	if len(got) > MaxLabel || !strings.HasPrefix(got, "a[31mbc") || strings.ContainsAny(got, "\x1b\x07\n\u202e") {
101		t.Fatalf("label = %q (%d bytes)", got, len(got))
102	}
103	if strings.ContainsRune(got, '\uFFFD') {
104		t.Fatalf("cut inside a rune: %q", got)
105	}
106	c, err = ParseControl([]byte(`{"type":"tooltip","col":0,"width":1,"title":"T\u001b","body":"a\nb\u0000c"}`))
107	if err != nil || c.Title != "T" || c.Body != "a\nbc" {
108		t.Fatalf("tooltip text: %+v, %v", c, err)
109	}
110	c, err = ParseControl([]byte(`{"type":"tooltip","col":0,"width":1,"rows":[["Sig\nnal","62\u001b%"],["Band"]]}`))
111	if err != nil || len(c.Rows) != 2 || c.Rows[0][0] != "Signal" || c.Rows[0][1] != "62%" || c.Rows[1][0] != "Band" {
112		t.Fatalf("tooltip rows: %+v, %v", c, err)
113	}
114}
115
116func TestParseControlTableLimits(t *testing.T) {
117	rows := func(n int) string {
118		return `{"type":"tooltip","col":0,"width":1,"rows":[` + strings.TrimSuffix(strings.Repeat(`["a"],`, n), ",") + `]}`
119	}
120	cells := func(n int) string {
121		return `{"type":"tooltip","col":0,"width":1,"rows":[[` + strings.TrimSuffix(strings.Repeat(`"a",`, n), ",") + `]]}`
122	}
123	for name, tc := range map[string]struct {
124		in string
125		ok bool
126	}{
127		"max rows":       {rows(MaxRows), true},
128		"too many rows":  {rows(MaxRows + 1), false},
129		"max cells":      {cells(MaxCells), true},
130		"too many cells": {cells(MaxCells + 1), false},
131		"empty rows":     {`{"type":"tooltip","col":0,"width":1,"rows":[[]]}`, false},
132		"blank cells":    {`{"type":"tooltip","col":0,"width":1,"rows":[["","\u001b"]]}`, false},
133	} {
134		if _, err := ParseControl([]byte(tc.in)); (err == nil) != tc.ok {
135			t.Errorf("%s: err = %v, want ok %v", name, err, tc.ok)
136		}
137	}
138	long := `{"type":"tooltip","col":0,"width":1,"rows":[["` + strings.Repeat("é", MaxLabel) + `"]]}` // 2 bytes each
139	c, err := ParseControl([]byte(long))
140	if err != nil {
141		t.Fatal(err)
142	}
143	if cell := c.Rows[0][0]; len(cell) > MaxLabel || len(cell) < MaxLabel-1 || !utf8.ValidString(cell) {
144		t.Fatalf("long cell cut to %d bytes, valid %v", len(cell), utf8.ValidString(cell))
145	}
146}
147
148func TestControlLineBetweenFramesLeavesBothIntact(t *testing.T) {
149	m, _ := newInteractive()
150	m.readFrames(strings.NewReader("one\n\x1b]777;neferbar;{\"type\":\"close\"}\x07\ntwo\n"))
151	got, changed, _ := m.Take(nil)
152	if !changed || string(got) != "two" {
153		t.Fatalf("frame = %q changed=%v, want two", got, changed)
154	}
155	b, ok := m.TakeControl()
156	if !ok || string(b) != `{"type":"close"}` {
157		t.Fatalf("control = %q %v", b, ok)
158	}
159	if _, ok = m.TakeControl(); ok {
160		t.Fatal("one control line expected")
161	}
162
163	// The control line alone never becomes a frame.
164	m, _ = newInteractive()
165	m.readFrames(strings.NewReader("one\n"))
166	m.Take(nil)
167	m.readFrames(strings.NewReader("\x1b]777;neferbar;{\"type\":\"close\"}\x07\n"))
168	if _, changed, _ = m.Take(nil); changed {
169		t.Fatal("a control line must not be published as a frame")
170	}
171}
172
173func TestUnterminatedControlIsAFrame(t *testing.T) {
174	m, _ := newInteractive()
175	m.readFrames(strings.NewReader("\x1b]777;neferbar;{bad\n"))
176	if _, ok := m.TakeControl(); ok {
177		t.Fatal("a line without BEL is not a control line")
178	}
179	got, changed, _ := m.Take(nil)
180	if !changed || string(got) != "\x1b]777;neferbar;{bad" {
181		t.Fatalf("frame = %q changed=%v", got, changed)
182	}
183}
184
185func TestOversizeControlIsDropped(t *testing.T) {
186	m, _ := newInteractive()
187	m.readFrames(strings.NewReader("a\n\x1b]777;neferbar;" + strings.Repeat("x", MaxControl) + "\x07\nb\n"))
188	if _, ok := m.TakeControl(); ok {
189		t.Fatal("an oversize control line must be dropped")
190	}
191	if got, _, _ := m.Take(nil); string(got) != "b" {
192		t.Fatalf("frame = %q, want b", got)
193	}
194}
195
196func TestControlQueueDropsWhenFull(t *testing.T) {
197	m, wake := newInteractive()
198	var in strings.Builder
199	for range 20 {
200		in.WriteString("\x1b]777;neferbar;{\"type\":\"close\"}\x07\n")
201	}
202	m.readFrames(strings.NewReader(in.String()))
203	n := 0
204	for {
205		if _, ok := m.TakeControl(); !ok {
206			break
207		}
208		n++
209	}
210	if n != 8 || len(wake) != 1 {
211		t.Fatalf("queued %d (want 8), wake %d", n, len(wake))
212	}
213}
214
215func TestControlLinesOfANonInteractiveModuleAreDropped(t *testing.T) {
216	m, wake := newTest()
217	m.readFrames(strings.NewReader("one\n\x1b]777;neferbar;{\"type\":\"close\"}\x07\ntwo\n"))
218	if _, ok := m.TakeControl(); ok {
219		t.Fatal("a non-interactive module queued a control line")
220	}
221	if got, _, _ := m.Take(nil); string(got) != "two" {
222		t.Fatalf("frame = %q, want two (the control line is still not a frame)", got)
223	}
224	if len(wake) != 1 { // from the frames only
225		t.Fatalf("wake = %d", len(wake))
226	}
227}
228
229func TestOversizeControlWarnsOncePerRun(t *testing.T) {
230	var logs bytes.Buffer
231	m, _ := newInteractive()
232	m.log = slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug}))
233	big := "\x1b]777;neferbar;" + strings.Repeat("x", MaxControl) + "\x07\n"
234	m.readFrames(strings.NewReader(big + big + big))
235	warns := func() int { return strings.Count(logs.String(), "level=WARN") }
236	if warns() != 1 || strings.Count(logs.String(), "level=DEBUG") != 2 {
237		t.Fatalf("want 1 warning and 2 debug lines:\n%s", logs.String())
238	}
239	// A new run of the script warns again; runOnce resets the flag.
240	m.overLogged = false
241	m.readFrames(strings.NewReader(big))
242	if warns() != 2 {
243		t.Fatalf("a new run must warn again:\n%s", logs.String())
244	}
245}
246
247func FuzzParseControl(f *testing.F) {
248	for _, s := range []string{
249		`{"type":"close"}`,
250		`{"type":"tooltip","col":2,"width":1,"title":"Steam","body":"a\nb"}`,
251		`{"type":"menu","col":0,"width":2,"click":9,"items":[{"id":1,"label":"Open"},{"id":2,"kind":"separator"},{"id":3,"label":"Mute","kind":"check","checked":true},{"id":4,"label":"S","items":[{"id":5,"label":"A"}]},{"id":6,"enabled":false}]}`,
252		nested(MaxMenuDepth), nested(MaxMenuDepth + 1),
253		`{"type":"menu","col":0,"width":1,"click":1,"items":[{"id":1,"label":"a\u001b[31m\u202e\ud800"}]}`,
254		`{"type":"tooltip","col":-1,"width":99999999,"title":"x"}`,
255		`{"type":"tooltip","col":0,"width":1,"rows":[["a\n\u001b","\ud800"],[]]}`,
256		`{"type":`, `[]`, `null`, ``, `{"type":"menu","items":null}`,
257	} {
258		f.Add([]byte(s))
259	}
260	f.Fuzz(func(t *testing.T, b []byte) {
261		c, err := ParseControl(b)
262		if err != nil {
263			return
264		}
265		// What is accepted respects every limit and carries no control rune.
266		if c.Col < 0 || c.Col > maxColumn || c.Width < 0 || c.Width > maxColumn {
267			t.Fatalf("column %d width %d", c.Col, c.Width)
268		}
269		bad := func(s string, max int) {
270			if len(s) > max || !utf8.ValidString(s) || strings.ContainsFunc(s, func(r rune) bool { return unicode.IsControl(r) && r != '\n' }) {
271				t.Fatalf("text %q", s)
272			}
273		}
274		bad(c.Title, MaxLabel)
275		bad(c.Body, MaxText)
276		if len(c.Rows) > MaxRows {
277			t.Fatalf("%d rows", len(c.Rows))
278		}
279		for _, row := range c.Rows {
280			if len(row) > MaxCells {
281				t.Fatalf("%d cells", len(row))
282			}
283			for _, cell := range row {
284				bad(cell, MaxLabel)
285				if strings.ContainsRune(cell, '\n') {
286					t.Fatalf("cell %q", cell)
287				}
288			}
289		}
290		count := 0
291		var walk func(items []MenuItem, depth int)
292		walk = func(items []MenuItem, depth int) {
293			if len(items) > 0 && depth > MaxMenuDepth {
294				t.Fatalf("depth %d", depth)
295			}
296			for _, it := range items {
297				count++
298				bad(it.Label, MaxLabel)
299				if strings.ContainsRune(it.Label, '\n') || it.ID < 0 {
300					t.Fatalf("item %+v", it)
301				}
302				switch it.Kind {
303				case KindNormal, KindSeparator, KindCheck, KindRadio:
304				default:
305					t.Fatalf("kind %q", it.Kind)
306				}
307				walk(it.Items, depth+1)
308			}
309		}
310		walk(c.Items, 1)
311		if count > MaxMenuItems {
312			t.Fatalf("%d items", count)
313		}
314		if c.Type == ControlMenu && len(c.Items) == 0 {
315			t.Fatal("an empty menu was accepted")
316		}
317	})
318}

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 5

Tags 4

↑↓ moveenter openesc close