1package vt
2
3import (
4 "strings"
5 "testing"
6
7 "github.com/stretchr/testify/require"
8)
9
10func TestOnBellFiresForLoneBEL(t *testing.T) {
11 s := NewScreen(10, 2)
12 rang := 0
13 s.OnBell = func() { rang++ }
14 s.Write([]byte("hi\ahi\a"))
15 require.Equal(t, 2, rang)
16 require.Equal(t, "hihi", strings.TrimRight(rowString(s.frame.Row(0)), " "))
17}
18
19func TestOnBellIgnoresOSCTerminator(t *testing.T) {
20 s := NewScreen(10, 2)
21 rang := 0
22 s.OnBell = func() { rang++ }
23 s.Write([]byte("\x1b]0;title\a")) // BEL terminates the OSC, it is not a bell
24 require.Equal(t, 0, rang)
25}
26
27func TestBELWithoutCallbackIsDiscarded(t *testing.T) {
28 s := NewScreen(10, 2)
29 s.Write([]byte("a\ab")) // no OnBell set: must not panic
30 require.Equal(t, "ab", strings.TrimRight(rowString(s.frame.Row(0)), " "))
31}
32
33func TestTerminalTitleRetainsOSC0AndOSC2(t *testing.T) {
34 tests := []struct {
35 name string
36 seq string
37 want string
38 }{
39 {name: "OSC 0 BEL terminated", seq: "\x1b]0;zero title\x07", want: "zero title"},
40 {name: "OSC 0 ST terminated", seq: "\x1b]0;zero title\x1b\\", want: "zero title"},
41 {name: "OSC 2 BEL terminated", seq: "\x1b]2;two title\x07", want: "two title"},
42 {name: "OSC 2 ST terminated", seq: "\x1b]2;two title\x1b\\", want: "two title"},
43 }
44
45 for _, tt := range tests {
46 t.Run(tt.name, func(t *testing.T) {
47 s := NewScreen(10, 2)
48 bells := 0
49 s.OnBell = func() { bells++ }
50 s.ClearDamage()
51
52 s.Write([]byte(tt.seq))
53
54 require.Equal(t, tt.want, s.TerminalTitle())
55 require.Equal(t, 0, bells)
56 require.Empty(t, s.Damage())
57 require.Equal(t, strings.Repeat(" ", s.frame.Width), rowString(s.frame.Row(0)))
58 })
59 }
60}
61
62func TestTerminalTitleHandlesSplitWritesReplacementAndClear(t *testing.T) {
63 s := NewScreen(10, 2)
64
65 s.Write([]byte("\x1b]0;split"))
66 s.Write([]byte(" title\x1b"))
67 require.Empty(t, s.TerminalTitle())
68 s.Write([]byte("\\"))
69 require.Equal(t, "split title", s.TerminalTitle())
70
71 s.Write([]byte("\x1b]2;replacement\x07"))
72 require.Equal(t, "replacement", s.TerminalTitle())
73
74 s.Write([]byte("\x1b]0;\x07"))
75 require.Empty(t, s.TerminalTitle())
76}
77
78func TestTerminalTitleIgnoresUnrelatedAndOversizedOSC(t *testing.T) {
79 s := NewScreen(10, 2)
80 s.Write([]byte("\x1b]0;retained\x07"))
81
82 for _, seq := range []string{
83 "\x1b]1;icon title\x07",
84 "\x1b]20;not selector two\x1b\\",
85 "\x1b]777;other;x;y\x07",
86 } {
87 s.Write([]byte(seq))
88 require.Equal(t, "retained", s.TerminalTitle())
89 }
90
91 payload := strings.Repeat("x", maxEscapeBufferLen+1)
92 s.Write([]byte("\x1b]2;" + payload))
93 require.Equal(t, "retained", s.TerminalTitle())
94 require.Empty(t, s.escapeBuf)
95}
96
97func TestOnNotifyOSC9(t *testing.T) {
98 s := NewScreen(10, 2)
99 var gotTitle, gotBody string
100 calls := 0
101 s.OnNotify = func(title, body string) { gotTitle, gotBody = title, body; calls++ }
102 s.Write([]byte("\x1b]9;agent done\x07"))
103 require.Equal(t, 1, calls)
104 require.Equal(t, "", gotTitle)
105 require.Equal(t, "agent done", gotBody)
106}
107
108func TestOSC9ProgressTransitions(t *testing.T) {
109 tests := []struct {
110 name string
111 seqs []string
112 want []bool
113 }{
114 {
115 name: "finish fires once",
116 seqs: []string{"9;4;1;50", "9;4;0;100", "9;4;0;100"},
117 want: []bool{false},
118 },
119 {
120 name: "error fires once repeat no fire",
121 seqs: []string{"9;4;2;0", "9;4;2;50"},
122 want: []bool{true},
123 },
124 {
125 name: "indeterminate clear fires",
126 seqs: []string{"9;4;3", "9;4;0"},
127 want: []bool{false},
128 },
129 {
130 name: "paused clear fires",
131 seqs: []string{"9;4;4;80", "9;4;0;80"},
132 want: []bool{false},
133 },
134 {
135 name: "ongoing updates silent",
136 seqs: []string{"9;4;1;10", "9;4;1;50", "9;4;3;90", "9;4;4;90"},
137 },
138 {
139 name: "bare clear silent",
140 seqs: []string{"9;4;0"},
141 },
142 {
143 name: "bare progress ignored",
144 seqs: []string{"9;4"},
145 },
146 {
147 name: "unparseable ignored",
148 seqs: []string{"9;4;bogus", "9;4;99", "9;4;1", "9;4;0"},
149 want: []bool{false},
150 },
151 {
152 name: "error active clear fires error then finish",
153 seqs: []string{"9;4;2;0", "9;4;1;50", "9;4;0;100"},
154 want: []bool{true, false},
155 },
156 {
157 name: "error clear only fires error",
158 seqs: []string{"9;4;2;0", "9;4;0;100"},
159 want: []bool{true},
160 },
161 }
162
163 for _, tt := range tests {
164 t.Run(tt.name, func(t *testing.T) {
165 s := NewScreen(10, 2)
166 var got []bool
167 s.OnProgress = func(errored bool) { got = append(got, errored) }
168 for _, seq := range tt.seqs {
169 s.Write([]byte("\x1b]" + seq + "\x07"))
170 }
171 require.Equal(t, tt.want, got)
172 })
173 }
174}
175
176func TestOSC9ProgressSplitAcrossWritesFiresOnce(t *testing.T) {
177 s := NewScreen(10, 2)
178 calls := 0
179 s.OnProgress = func(bool) { calls++ }
180 s.Write([]byte("\x1b]9;4;1"))
181 s.Write([]byte(";50\x07\x1b]9;4;0;100\x07"))
182 require.Equal(t, 1, calls)
183}
184
185func TestOSC9ProgressNilCallbackDoesNotPanic(t *testing.T) {
186 s := NewScreen(10, 2)
187 require.NotPanics(t, func() {
188 s.Write([]byte("\x1b]9;4;1;50\x07"))
189 s.Write([]byte("\x1b]9;4;0;100\x07"))
190 s.Write([]byte("\x1b]9;4;2;0\x07"))
191 })
192}
193
194func TestOSC9ProgressSTTerminated(t *testing.T) {
195 s := NewScreen(10, 2)
196 var got []bool
197 s.OnProgress = func(errored bool) { got = append(got, errored) }
198 s.Write([]byte("\x1b]9;4;1;50\x1b\\\x1b]9;4;0;100\x1b\\"))
199 require.Equal(t, []bool{false}, got)
200}
201
202func TestOSC9ProgressStateTrackedBeforeCallbackSet(t *testing.T) {
203 s := NewScreen(10, 2)
204 s.Write([]byte("\x1b]9;4;1;50\x07"))
205 var got []bool
206 s.OnProgress = func(errored bool) { got = append(got, errored) }
207 s.Write([]byte("\x1b]9;4;0;100\x07"))
208 require.Equal(t, []bool{false}, got)
209}
210
211func TestOnNotifyIgnoresOSC9Progress(t *testing.T) {
212 s := NewScreen(10, 2)
213 calls := 0
214 s.OnNotify = func(string, string) { calls++ }
215 s.Write([]byte("\x1b]9;4;1;50\x07"))
216 s.Write([]byte("\x1b]9;4;0;100\x07"))
217 s.Write([]byte("\x1b]9;4;2;0\x07"))
218 require.Equal(t, 0, calls)
219}
220
221func TestOnNotifyOSC940RemainsGeneric(t *testing.T) {
222 s := NewScreen(10, 2)
223 var gotTitle, gotBody string
224 calls := 0
225 s.OnNotify = func(title, body string) { gotTitle, gotBody = title, body; calls++ }
226 s.Write([]byte("\x1b]9;40;not progress\x07"))
227 require.Equal(t, 1, calls)
228 require.Equal(t, "", gotTitle)
229 require.Equal(t, "40;not progress", gotBody)
230}
231
232func TestOnNotifyOSC777STTerminated(t *testing.T) {
233 s := NewScreen(10, 2)
234 var gotTitle, gotBody string
235 calls := 0
236 s.OnNotify = func(title, body string) { gotTitle, gotBody = title, body; calls++ }
237 s.Write([]byte("\x1b]777;notify;Claude;needs input\x1b\\"))
238 require.Equal(t, 1, calls)
239 require.Equal(t, "Claude", gotTitle)
240 require.Equal(t, "needs input", gotBody)
241}
242
243func TestOnNotifyIgnoresOtherOSC(t *testing.T) {
244 s := NewScreen(10, 2)
245 calls := 0
246 s.OnNotify = func(string, string) { calls++ }
247 s.Write([]byte("\x1b]0;window title\x07")) // title: discarded
248 s.Write([]byte("\x1b]777;other;x;y\x1b\\")) // not "notify": discarded
249 require.Equal(t, 0, calls)
250}
251
252func TestOnNotifySplitAcrossWrites(t *testing.T) {
253 s := NewScreen(10, 2)
254 calls := 0
255 s.OnNotify = func(string, string) { calls++ }
256 s.Write([]byte("\x1b]9;par"))
257 s.Write([]byte("tial\x07"))
258 require.Equal(t, 1, calls)
259}
260
261func TestOnClipboardBELTerminated(t *testing.T) {
262 s := NewScreen(10, 2)
263 var got string
264 calls := 0
265 s.OnClipboard = func(b64 string) { got = b64; calls++ }
266 s.Write([]byte("\x1b]52;c;aGVsbG8=\x07"))
267 require.Equal(t, 1, calls)
268 require.Equal(t, "aGVsbG8=", got)
269}
270
271func TestOnClipboardSTTerminated(t *testing.T) {
272 s := NewScreen(10, 2)
273 var got string
274 calls := 0
275 s.OnClipboard = func(b64 string) { got = b64; calls++ }
276 s.Write([]byte("\x1b]52;c;aGVsbG8=\x1b\\"))
277 require.Equal(t, 1, calls)
278 require.Equal(t, "aGVsbG8=", got)
279}
280
281func TestOnClipboardQueryIgnored(t *testing.T) {
282 s := NewScreen(10, 2)
283 calls := 0
284 s.OnClipboard = func(string) { calls++ }
285 s.Write([]byte("\x1b]52;c;?\x07"))
286 require.Equal(t, 0, calls)
287}
288
289func TestOnClipboardEmptySelection(t *testing.T) {
290 s := NewScreen(10, 2)
291 var got string
292 calls := 0
293 s.OnClipboard = func(b64 string) { got = b64; calls++ }
294 s.Write([]byte("\x1b]52;;aGVsbG8=\x07"))
295 require.Equal(t, 1, calls)
296 require.Equal(t, "aGVsbG8=", got)
297}
298
299func TestOnClipboardNilCallbackDoesNotPanic(t *testing.T) {
300 s := NewScreen(10, 2)
301 require.NotPanics(t, func() {
302 s.Write([]byte("\x1b]52;c;aGVsbG8=\x07"))
303 })
304}
305
306func TestOnClipboardSplitAcrossWrites(t *testing.T) {
307 s := NewScreen(10, 2)
308 var got string
309 calls := 0
310 s.OnClipboard = func(b64 string) { got = b64; calls++ }
311 s.Write([]byte("\x1b]52;c;aGVs"))
312 s.Write([]byte("bG8=\x07"))
313 require.Equal(t, 1, calls)
314 require.Equal(t, "aGVsbG8=", got)
315}
316
317func TestOnClipboardNoSecondSemicolonIgnored(t *testing.T) {
318 s := NewScreen(10, 2)
319 calls := 0
320 s.OnClipboard = func(string) { calls++ }
321 s.Write([]byte("\x1b]52;c\x07")) // no second ";" -> no data field
322 require.Equal(t, 0, calls)
323}
324
325func TestOSCSequences(t *testing.T) {
326 tests := []struct {
327 name string
328 run func(t *testing.T)
329 }{
330 {
331 name: "BEL-terminated OSC is ignored",
332 run: func(t *testing.T) {
333 s := NewScreen(20, 2)
334 s.Write([]byte("\x1b]0;~/Projects/ymux - fish\x07fish$ "))
335
336 assertCell(t, s, 0, 0, 'f')
337 assertCell(t, s, 1, 0, 'i')
338 assertCell(t, s, 2, 0, 's')
339 assertCell(t, s, 3, 0, 'h')
340 assertCell(t, s, 4, 0, '$')
341 assertCell(t, s, 5, 0, ' ')
342 if s.Col != 6 || s.Row != 0 {
343 t.Errorf("cursor at col=%d row=%d, want col=6 row=0", s.Col, s.Row)
344 }
345 },
346 },
347 {
348 name: "ST-terminated OSC is ignored",
349 run: func(t *testing.T) {
350 s := NewScreen(20, 2)
351 s.Write([]byte("\x1b]133;A;click_events=1\x1b\\> "))
352
353 assertCell(t, s, 0, 0, '>')
354 assertCell(t, s, 1, 0, ' ')
355 if s.Col != 2 || s.Row != 0 {
356 t.Errorf("cursor at col=%d row=%d, want col=2 row=0", s.Col, s.Row)
357 }
358 },
359 },
360 {
361 name: "multiple OSC sequences in a row are all ignored",
362 run: func(t *testing.T) {
363 s := NewScreen(20, 2)
364 s.Write([]byte("\x1b]7;file://host/tmp\x07\x1b]11;?\\=\x07\x1b]133;B\x1b\\prompt"))
365
366 assertCell(t, s, 0, 0, 'p')
367 assertCell(t, s, 1, 0, 'r')
368 assertCell(t, s, 2, 0, 'o')
369 assertCell(t, s, 3, 0, 'm')
370 assertCell(t, s, 4, 0, 'p')
371 assertCell(t, s, 5, 0, 't')
372 if s.Col != 6 || s.Row != 0 {
373 t.Errorf("cursor at col=%d row=%d, want col=6 row=0", s.Col, s.Row)
374 }
375 },
376 },
377 {
378 name: "BEL-terminated OSC split across writes is ignored",
379 run: func(t *testing.T) {
380 s := NewScreen(20, 2)
381 s.Write([]byte("\x1b]0;~/Projects/ymux"))
382 s.Write([]byte(" - fish\x07fish$ "))
383
384 assertCell(t, s, 0, 0, 'f')
385 assertCell(t, s, 1, 0, 'i')
386 assertCell(t, s, 2, 0, 's')
387 assertCell(t, s, 3, 0, 'h')
388 assertCell(t, s, 4, 0, '$')
389 assertCell(t, s, 5, 0, ' ')
390 if s.Col != 6 || s.Row != 0 {
391 t.Errorf("cursor at col=%d row=%d, want col=6 row=0", s.Col, s.Row)
392 }
393 },
394 },
395 {
396 name: "ST-terminated OSC split across writes is ignored",
397 run: func(t *testing.T) {
398 s := NewScreen(20, 2)
399 s.Write([]byte("\x1b]133;A;click_events=1\x1b"))
400 s.Write([]byte("\\> "))
401
402 assertCell(t, s, 0, 0, '>')
403 assertCell(t, s, 1, 0, ' ')
404 if s.Col != 2 || s.Row != 0 {
405 t.Errorf("cursor at col=%d row=%d, want col=2 row=0", s.Col, s.Row)
406 }
407 },
408 },
409 {
410 name: "unterminated OSC sequence is bounded and dropped",
411 run: func(t *testing.T) {
412 s := NewScreen(20, 2)
413 payload := strings.Repeat("x", maxEscapeBufferLen+1)
414 s.Write([]byte("\x1b]0;" + payload))
415 s.Write([]byte("OK"))
416
417 assertCell(t, s, 0, 0, 'O')
418 assertCell(t, s, 1, 0, 'K')
419 if len(s.escapeBuf) != 0 {
420 t.Fatalf("escapeBuf length = %d, want 0 after overlong unterminated OSC", len(s.escapeBuf))
421 }
422 },
423 },
424 }
425
426 for _, tt := range tests {
427 t.Run(tt.name, tt.run)
428 }
429}