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}