1package view
2
3import (
4 "bytes"
5 "fmt"
6 "log"
7 "os"
8 "regexp"
9 "strings"
10 "sync"
11 "sync/atomic"
12 "testing"
13
14 "charm.land/lipgloss/v2"
15)
16
17// clearAllTerminalEnv clears all environment variables that could indicate terminal capabilities
18func clearAllTerminalEnv() {
19 // Clear hyperlink support indicators
20 os.Unsetenv("VTE_VERSION")
21 os.Unsetenv("KITTY_WINDOW_ID")
22 os.Unsetenv("GHOSTTY_RESOURCES_DIR")
23 os.Unsetenv("WEZTERM_EXECUTABLE")
24 os.Unsetenv("WEZTERM_CONFIG_FILE")
25 os.Unsetenv("ITERM_SESSION_ID")
26 os.Unsetenv("ITERM_PROFILE")
27 os.Unsetenv("WARP_IS_LOCAL_SHELL_SESSION")
28 os.Unsetenv("WARP_COMBINED_PROMPT_COMMAND_FINISHED")
29 os.Unsetenv("KONSOLE_DBUS_SESSION")
30 os.Unsetenv("KONSOLE_VERSION")
31
32 // Set basic terminal that doesn't support anything special
33 os.Setenv("TERM", "xterm")
34 os.Setenv("TERM_PROGRAM", "basic")
35}
36
37func TestDecodeQuotedPrintable(t *testing.T) {
38 testCases := []struct {
39 name string
40 input string
41 expected string
42 }{
43 {
44 name: "Simple case",
45 input: "Hello=2C world=21",
46 expected: "Hello, world!",
47 },
48 {
49 name: "With soft line break",
50 input: "This is a long line that gets wrapped=\r\n and continues here.",
51 expected: "This is a long line that gets wrapped and continues here.",
52 },
53 {
54 name: "No encoding",
55 input: "Just a plain string.",
56 expected: "Just a plain string.",
57 },
58 }
59
60 for _, tc := range testCases {
61 t.Run(tc.name, func(t *testing.T) {
62 decoded, err := decodeQuotedPrintable(tc.input)
63 if err != nil {
64 t.Fatalf("decodeQuotedPrintable() failed: %v", err)
65 }
66 if decoded != tc.expected {
67 t.Errorf("Expected %q, got %q", tc.expected, decoded)
68 }
69 })
70 }
71}
72
73func TestDebugImageProtocolUsesLogger(t *testing.T) {
74 t.Setenv("DEBUG_IMAGE_PROTOCOL", "1")
75 t.Setenv("DEBUG_IMAGE_PROTOCOL_LOG", "")
76 t.Setenv("DEBUG_KITTY_IMAGES", "")
77 t.Setenv("DEBUG_KITTY_LOG", "")
78
79 var logBuf bytes.Buffer
80 originalLogOutput := log.Writer()
81 originalLogFlags := log.Flags()
82 log.SetOutput(&logBuf)
83 log.SetFlags(0)
84 t.Cleanup(func() {
85 log.SetOutput(originalLogOutput)
86 log.SetFlags(originalLogFlags)
87 })
88
89 debugImageProtocol("hello %s", "world")
90
91 want := "info: [img-protocol] hello world\n"
92 if got := logBuf.String(); got != want {
93 t.Fatalf("debugImageProtocol log output = %q, want %q", got, want)
94 }
95}
96
97func TestMarkdownToHTML(t *testing.T) {
98 testCases := []struct {
99 name string
100 input string
101 expected string
102 }{
103 {
104 name: "Heading",
105 input: "# Hello",
106 expected: "<h1>Hello</h1>",
107 },
108 {
109 name: "Bold",
110 input: "**bold text**",
111 expected: "<p><strong>bold text</strong></p>",
112 },
113 {
114 name: "Link",
115 input: "[link](http://example.com)",
116 expected: `<p><a href="http://example.com">link</a></p>`,
117 },
118 }
119
120 for _, tc := range testCases {
121 t.Run(tc.name, func(t *testing.T) {
122 html := markdownToHTML([]byte(tc.input))
123 // Trim newlines for consistent comparison
124 if strings.TrimSpace(string(html)) != tc.expected {
125 t.Errorf("Expected %s, got %s", tc.expected, html)
126 }
127 })
128 }
129}
130
131func TestGhosttySupported(t *testing.T) {
132 // Save original environment variables
133 origTerm := os.Getenv("TERM")
134 origTermProgram := os.Getenv("TERM_PROGRAM")
135 origGhosttyResources := os.Getenv("GHOSTTY_RESOURCES_DIR")
136
137 // Restore environment variables after test
138 defer func() {
139 os.Setenv("TERM", origTerm)
140 os.Setenv("TERM_PROGRAM", origTermProgram)
141 os.Setenv("GHOSTTY_RESOURCES_DIR", origGhosttyResources)
142 }()
143
144 testCases := []struct {
145 name string
146 term string
147 termProgram string
148 ghosttyResourcesDir string
149 expected bool
150 }{
151 {
152 name: "No Ghostty environment variables",
153 term: "xterm",
154 termProgram: "",
155 ghosttyResourcesDir: "",
156 expected: false,
157 },
158 {
159 name: "TERM contains ghostty",
160 term: "xterm-ghostty",
161 termProgram: "",
162 ghosttyResourcesDir: "",
163 expected: true,
164 },
165 {
166 name: "TERM_PROGRAM is ghostty",
167 term: "xterm",
168 termProgram: "ghostty",
169 ghosttyResourcesDir: "",
170 expected: true,
171 },
172 {
173 name: "GHOSTTY_RESOURCES_DIR is set",
174 term: "xterm",
175 termProgram: "",
176 ghosttyResourcesDir: "/usr/share/ghostty",
177 expected: true,
178 },
179 {
180 name: "Multiple Ghostty indicators",
181 term: "ghostty",
182 termProgram: "ghostty",
183 ghosttyResourcesDir: "/usr/share/ghostty",
184 expected: true,
185 },
186 }
187
188 for _, tc := range testCases {
189 t.Run(tc.name, func(t *testing.T) {
190 os.Setenv("TERM", tc.term)
191 os.Setenv("TERM_PROGRAM", tc.termProgram)
192 os.Setenv("GHOSTTY_RESOURCES_DIR", tc.ghosttyResourcesDir)
193
194 result := ghosttySupported()
195 if result != tc.expected {
196 t.Errorf("Expected %t, got %t", tc.expected, result)
197 }
198 })
199 }
200}
201
202func TestZellijDetection(t *testing.T) {
203 tests := []struct {
204 name string
205 env map[string]string
206 expected bool
207 }{
208 {"ZELLIJ set", map[string]string{"ZELLIJ": "1"}, true},
209 {"ZELLIJ_SESSION_NAME set", map[string]string{"ZELLIJ_SESSION_NAME": "test"}, true},
210 {"No Zellij", map[string]string{}, false},
211 }
212
213 for _, tt := range tests {
214 t.Run(tt.name, func(t *testing.T) {
215 // Save and restore env
216 origZellij := os.Getenv("ZELLIJ")
217 origZellijSession := os.Getenv("ZELLIJ_SESSION_NAME")
218 defer func() {
219 if origZellij != "" {
220 os.Setenv("ZELLIJ", origZellij)
221 } else {
222 os.Unsetenv("ZELLIJ")
223 }
224 if origZellijSession != "" {
225 os.Setenv("ZELLIJ_SESSION_NAME", origZellijSession)
226 } else {
227 os.Unsetenv("ZELLIJ_SESSION_NAME")
228 }
229 }()
230
231 // Clear first
232 os.Unsetenv("ZELLIJ")
233 os.Unsetenv("ZELLIJ_SESSION_NAME")
234
235 // Set test env
236 for k, v := range tt.env {
237 os.Setenv(k, v)
238 }
239
240 if got := zellijSupported(); got != tt.expected {
241 t.Errorf("zellijSupported() = %v, want %v", got, tt.expected)
242 }
243 })
244 }
245}
246
247func TestSixelDetection(t *testing.T) {
248 tests := []struct {
249 name string
250 env map[string]string
251 expected bool
252 }{
253 {"Zellij", map[string]string{"ZELLIJ": "1"}, true},
254 {"MLterm", map[string]string{"TERM": "mlterm"}, true},
255 {"foot", map[string]string{"TERM": "foot"}, true},
256 {"xterm with SIXEL", map[string]string{"TERM": "xterm", "SIXEL": "1"}, true},
257 {"plain xterm", map[string]string{"TERM": "xterm"}, false},
258 }
259
260 for _, tt := range tests {
261 t.Run(tt.name, func(t *testing.T) {
262 // Save and restore env
263 origZellij := os.Getenv("ZELLIJ")
264 origZellijSession := os.Getenv("ZELLIJ_SESSION_NAME")
265 origTerm := os.Getenv("TERM")
266 origSixel := os.Getenv("SIXEL")
267 defer func() {
268 if origZellij != "" {
269 os.Setenv("ZELLIJ", origZellij)
270 } else {
271 os.Unsetenv("ZELLIJ")
272 }
273 if origZellijSession != "" {
274 os.Setenv("ZELLIJ_SESSION_NAME", origZellijSession)
275 } else {
276 os.Unsetenv("ZELLIJ_SESSION_NAME")
277 }
278 if origTerm != "" {
279 os.Setenv("TERM", origTerm)
280 } else {
281 os.Unsetenv("TERM")
282 }
283 if origSixel != "" {
284 os.Setenv("SIXEL", origSixel)
285 } else {
286 os.Unsetenv("SIXEL")
287 }
288 }()
289
290 // Clear all env first
291 os.Unsetenv("ZELLIJ")
292 os.Unsetenv("ZELLIJ_SESSION_NAME")
293 os.Unsetenv("TERM")
294 os.Unsetenv("SIXEL")
295
296 // Set test env
297 for k, v := range tt.env {
298 os.Setenv(k, v)
299 }
300
301 if got := sixelSupported(); got != tt.expected {
302 t.Errorf("sixelSupported() = %v, want %v", got, tt.expected)
303 }
304 })
305 }
306}
307
308func TestImageProtocolSupported(t *testing.T) {
309 // Save original environment variables
310 origTerm := os.Getenv("TERM")
311 origKittyWindow := os.Getenv("KITTY_WINDOW_ID")
312 origTermProgram := os.Getenv("TERM_PROGRAM")
313 origGhosttyResources := os.Getenv("GHOSTTY_RESOURCES_DIR")
314 origItermlSession := os.Getenv("ITERM_SESSION_ID")
315 origWeztermExec := os.Getenv("WEZTERM_EXECUTABLE")
316 origWarpLocal := os.Getenv("WARP_IS_LOCAL_SHELL_SESSION")
317 origKonsoleDBus := os.Getenv("KONSOLE_DBUS_SESSION")
318
319 // Restore environment variables after test
320 defer func() {
321 os.Setenv("TERM", origTerm)
322 os.Setenv("KITTY_WINDOW_ID", origKittyWindow)
323 os.Setenv("TERM_PROGRAM", origTermProgram)
324 os.Setenv("GHOSTTY_RESOURCES_DIR", origGhosttyResources)
325 os.Setenv("ITERM_SESSION_ID", origItermlSession)
326 os.Setenv("WEZTERM_EXECUTABLE", origWeztermExec)
327 os.Setenv("WARP_IS_LOCAL_SHELL_SESSION", origWarpLocal)
328 os.Setenv("KONSOLE_DBUS_SESSION", origKonsoleDBus)
329 }()
330
331 testCases := []struct {
332 name string
333 setupEnv func()
334 clearAllEnv func()
335 expected bool
336 }{
337 {
338 name: "No supported terminals",
339 setupEnv: func() {
340 os.Setenv("TERM", "xterm")
341 os.Setenv("TERM_PROGRAM", "basic")
342 },
343 clearAllEnv: func() {
344 os.Unsetenv("KITTY_WINDOW_ID")
345 os.Unsetenv("GHOSTTY_RESOURCES_DIR")
346 os.Unsetenv("ITERM_SESSION_ID")
347 os.Unsetenv("WEZTERM_EXECUTABLE")
348 os.Unsetenv("WARP_IS_LOCAL_SHELL_SESSION")
349 os.Unsetenv("KONSOLE_DBUS_SESSION")
350 },
351 expected: false,
352 },
353 {
354 name: "Kitty supported via TERM",
355 setupEnv: func() {
356 os.Setenv("TERM", "xterm-kitty")
357 },
358 clearAllEnv: func() {
359 os.Unsetenv("KITTY_WINDOW_ID")
360 os.Unsetenv("GHOSTTY_RESOURCES_DIR")
361 os.Unsetenv("ITERM_SESSION_ID")
362 os.Unsetenv("WEZTERM_EXECUTABLE")
363 os.Unsetenv("WARP_IS_LOCAL_SHELL_SESSION")
364 os.Unsetenv("KONSOLE_DBUS_SESSION")
365 },
366 expected: true,
367 },
368 {
369 name: "Kitty supported via KITTY_WINDOW_ID",
370 setupEnv: func() {
371 os.Setenv("TERM", "xterm")
372 os.Setenv("KITTY_WINDOW_ID", "1")
373 },
374 clearAllEnv: func() {
375 os.Unsetenv("GHOSTTY_RESOURCES_DIR")
376 os.Unsetenv("ITERM_SESSION_ID")
377 os.Unsetenv("WEZTERM_EXECUTABLE")
378 os.Unsetenv("WARP_IS_LOCAL_SHELL_SESSION")
379 os.Unsetenv("KONSOLE_DBUS_SESSION")
380 },
381 expected: true,
382 },
383 {
384 name: "Ghostty supported via TERM_PROGRAM",
385 setupEnv: func() {
386 os.Setenv("TERM", "xterm")
387 os.Setenv("TERM_PROGRAM", "ghostty")
388 },
389 clearAllEnv: func() {
390 os.Unsetenv("KITTY_WINDOW_ID")
391 os.Unsetenv("GHOSTTY_RESOURCES_DIR")
392 os.Unsetenv("ITERM_SESSION_ID")
393 os.Unsetenv("WEZTERM_EXECUTABLE")
394 os.Unsetenv("WARP_IS_LOCAL_SHELL_SESSION")
395 os.Unsetenv("KONSOLE_DBUS_SESSION")
396 },
397 expected: true,
398 },
399 {
400 name: "iTerm2 supported via TERM_PROGRAM",
401 setupEnv: func() {
402 os.Setenv("TERM", "xterm")
403 os.Setenv("TERM_PROGRAM", "iterm.app")
404 },
405 clearAllEnv: func() {
406 os.Unsetenv("KITTY_WINDOW_ID")
407 os.Unsetenv("GHOSTTY_RESOURCES_DIR")
408 os.Unsetenv("ITERM_SESSION_ID")
409 os.Unsetenv("WEZTERM_EXECUTABLE")
410 os.Unsetenv("WARP_IS_LOCAL_SHELL_SESSION")
411 os.Unsetenv("KONSOLE_DBUS_SESSION")
412 },
413 expected: true,
414 },
415 {
416 name: "WezTerm supported via WEZTERM_EXECUTABLE",
417 setupEnv: func() {
418 os.Setenv("TERM", "xterm")
419 os.Setenv("WEZTERM_EXECUTABLE", "/usr/bin/wezterm")
420 },
421 clearAllEnv: func() {
422 os.Unsetenv("KITTY_WINDOW_ID")
423 os.Unsetenv("GHOSTTY_RESOURCES_DIR")
424 os.Unsetenv("ITERM_SESSION_ID")
425 os.Unsetenv("WARP_IS_LOCAL_SHELL_SESSION")
426 os.Unsetenv("KONSOLE_DBUS_SESSION")
427 },
428 expected: true,
429 },
430 {
431 name: "Warp supported via WARP_IS_LOCAL_SHELL_SESSION",
432 setupEnv: func() {
433 os.Setenv("TERM", "xterm")
434 os.Setenv("WARP_IS_LOCAL_SHELL_SESSION", "1")
435 },
436 clearAllEnv: func() {
437 os.Unsetenv("KITTY_WINDOW_ID")
438 os.Unsetenv("GHOSTTY_RESOURCES_DIR")
439 os.Unsetenv("ITERM_SESSION_ID")
440 os.Unsetenv("WEZTERM_EXECUTABLE")
441 os.Unsetenv("KONSOLE_DBUS_SESSION")
442 },
443 expected: true,
444 },
445 {
446 name: "Konsole supported via KONSOLE_DBUS_SESSION",
447 setupEnv: func() {
448 os.Setenv("TERM", "xterm")
449 os.Setenv("KONSOLE_DBUS_SESSION", "/Sessions/1")
450 },
451 clearAllEnv: func() {
452 os.Unsetenv("KITTY_WINDOW_ID")
453 os.Unsetenv("GHOSTTY_RESOURCES_DIR")
454 os.Unsetenv("ITERM_SESSION_ID")
455 os.Unsetenv("WEZTERM_EXECUTABLE")
456 os.Unsetenv("WARP_IS_LOCAL_SHELL_SESSION")
457 },
458 expected: true,
459 },
460 }
461
462 for _, tc := range testCases {
463 t.Run(tc.name, func(t *testing.T) {
464 tc.clearAllEnv()
465 tc.setupEnv()
466
467 result := imageProtocolSupported()
468 if result != tc.expected {
469 t.Errorf("Expected %t, got %t", tc.expected, result)
470 }
471 })
472 }
473}
474
475func TestHyperlinkSupported(t *testing.T) {
476 // Save original environment variables
477 origTerm := os.Getenv("TERM")
478 origTermProgram := os.Getenv("TERM_PROGRAM")
479 origVTEVersion := os.Getenv("VTE_VERSION")
480 origKittyWindow := os.Getenv("KITTY_WINDOW_ID")
481 origGhosttyResources := os.Getenv("GHOSTTY_RESOURCES_DIR")
482 origWeztermExec := os.Getenv("WEZTERM_EXECUTABLE")
483
484 // Restore environment variables after test
485 defer func() {
486 os.Setenv("TERM", origTerm)
487 os.Setenv("TERM_PROGRAM", origTermProgram)
488 os.Setenv("VTE_VERSION", origVTEVersion)
489 os.Setenv("KITTY_WINDOW_ID", origKittyWindow)
490 os.Setenv("GHOSTTY_RESOURCES_DIR", origGhosttyResources)
491 os.Setenv("WEZTERM_EXECUTABLE", origWeztermExec)
492 }()
493
494 testCases := []struct {
495 name string
496 setupEnv func()
497 clearAllEnv func()
498 expected bool
499 }{
500 {
501 name: "No hyperlink support",
502 setupEnv: func() {
503 os.Setenv("TERM", "xterm")
504 os.Setenv("TERM_PROGRAM", "basic")
505 },
506 clearAllEnv: func() {
507 os.Unsetenv("VTE_VERSION")
508 os.Unsetenv("KITTY_WINDOW_ID")
509 os.Unsetenv("GHOSTTY_RESOURCES_DIR")
510 os.Unsetenv("WEZTERM_EXECUTABLE")
511 },
512 expected: false,
513 },
514 {
515 name: "Kitty hyperlink support via TERM",
516 setupEnv: func() {
517 os.Setenv("TERM", "xterm-kitty")
518 },
519 clearAllEnv: func() {
520 os.Unsetenv("VTE_VERSION")
521 os.Unsetenv("KITTY_WINDOW_ID")
522 os.Unsetenv("GHOSTTY_RESOURCES_DIR")
523 os.Unsetenv("WEZTERM_EXECUTABLE")
524 },
525 expected: true,
526 },
527 {
528 name: "VTE-based terminal hyperlink support",
529 setupEnv: func() {
530 os.Setenv("TERM", "xterm")
531 os.Setenv("VTE_VERSION", "0.60.3")
532 },
533 clearAllEnv: func() {
534 os.Unsetenv("KITTY_WINDOW_ID")
535 os.Unsetenv("GHOSTTY_RESOURCES_DIR")
536 os.Unsetenv("WEZTERM_EXECUTABLE")
537 },
538 expected: true,
539 },
540 {
541 name: "iTerm2 hyperlink support",
542 setupEnv: func() {
543 os.Setenv("TERM", "xterm")
544 os.Setenv("TERM_PROGRAM", "iterm.app")
545 },
546 clearAllEnv: func() {
547 os.Unsetenv("VTE_VERSION")
548 os.Unsetenv("KITTY_WINDOW_ID")
549 os.Unsetenv("GHOSTTY_RESOURCES_DIR")
550 os.Unsetenv("WEZTERM_EXECUTABLE")
551 },
552 expected: true,
553 },
554 {
555 name: "WezTerm hyperlink support",
556 setupEnv: func() {
557 os.Setenv("TERM", "xterm")
558 os.Setenv("WEZTERM_EXECUTABLE", "/usr/bin/wezterm")
559 },
560 clearAllEnv: func() {
561 os.Unsetenv("VTE_VERSION")
562 os.Unsetenv("KITTY_WINDOW_ID")
563 os.Unsetenv("GHOSTTY_RESOURCES_DIR")
564 },
565 expected: true,
566 },
567 }
568
569 for _, tc := range testCases {
570 t.Run(tc.name, func(t *testing.T) {
571 tc.clearAllEnv()
572 tc.setupEnv()
573
574 result := hyperlinkSupported()
575 if result != tc.expected {
576 t.Errorf("Expected %t, got %t", tc.expected, result)
577 }
578 })
579 }
580}
581
582func TestProcessBodyWithHyperlinkSupport(t *testing.T) {
583 // Save original environment variables
584 origTerm := os.Getenv("TERM")
585 origTermProgram := os.Getenv("TERM_PROGRAM")
586 origVTEVersion := os.Getenv("VTE_VERSION")
587 origKittyWindow := os.Getenv("KITTY_WINDOW_ID")
588
589 // Restore environment variables after test
590 defer func() {
591 os.Setenv("TERM", origTerm)
592 os.Setenv("TERM_PROGRAM", origTermProgram)
593 os.Setenv("VTE_VERSION", origVTEVersion)
594 os.Setenv("KITTY_WINDOW_ID", origKittyWindow)
595 }()
596
597 h1Style := lipgloss.NewStyle().SetString("H1")
598 h2Style := lipgloss.NewStyle().SetString("H2")
599 bodyStyle := lipgloss.NewStyle().SetString("BODY")
600
601 testCases := []struct {
602 name string
603 setupHyperlinks func()
604 input string
605 expectedContains string
606 expectedNotContains string
607 }{
608 {
609 name: "Link with hyperlink support",
610 setupHyperlinks: func() {
611 os.Setenv("TERM", "xterm-kitty")
612 os.Unsetenv("VTE_VERSION")
613 os.Unsetenv("KITTY_WINDOW_ID")
614 },
615 input: `<a href="http://example.com">Click here</a>`,
616 expectedContains: "Click here",
617 expectedNotContains: "<http://example.com>",
618 },
619 {
620 name: "Link without hyperlink support",
621 setupHyperlinks: func() {
622 clearAllTerminalEnv()
623 },
624 input: `<a href="http://example.com">Click here</a>`,
625 expectedContains: "Click here <http://example.com>",
626 },
627 {
628 name: "Image link with hyperlink support",
629 setupHyperlinks: func() {
630 os.Setenv("TERM", "xterm")
631 os.Setenv("VTE_VERSION", "0.60.3")
632 os.Unsetenv("KITTY_WINDOW_ID")
633 },
634 input: `<img src="http://example.com/img.png" alt="alt text">`,
635 expectedContains: "[Click here to view image: alt text]",
636 expectedNotContains: "<http://example.com/img.png>",
637 },
638 {
639 name: "Image link without hyperlink support",
640 setupHyperlinks: func() {
641 clearAllTerminalEnv()
642 },
643 input: `<img src="http://example.com/img.png" alt="alt text">`,
644 expectedContains: "[Image: alt text, http://example.com/img.png]",
645 },
646 }
647
648 // Regex to strip out ANSI SGR escape codes (e.g. \x1b[38;2;...m)
649 ansiEscapeRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`)
650
651 for _, tc := range testCases {
652 t.Run(tc.name, func(t *testing.T) {
653 tc.setupHyperlinks()
654
655 processed, _, err := ProcessBody(tc.input, "", h1Style, h2Style, bodyStyle, false)
656 if err != nil {
657 t.Fatalf("ProcessBody() failed: %v", err)
658 }
659
660 cleanProcessed := ansiEscapeRegex.ReplaceAllString(processed, "")
661
662 if !strings.Contains(cleanProcessed, tc.expectedContains) {
663 t.Errorf("Processed body does not contain expected text.\nGot: %q\nWant to contain: %q", cleanProcessed, tc.expectedContains)
664 }
665
666 if tc.expectedNotContains != "" && strings.Contains(cleanProcessed, tc.expectedNotContains) {
667 t.Errorf("Processed body contains unexpected text.\nGot: %q\nShould not contain: %q", cleanProcessed, tc.expectedNotContains)
668 }
669 })
670 }
671}
672
673func TestProcessBodySanitizesUnsafeHTMLLinks(t *testing.T) {
674 origTerm := os.Getenv("TERM")
675 origTermProgram := os.Getenv("TERM_PROGRAM")
676 origVTEVersion := os.Getenv("VTE_VERSION")
677 defer func() {
678 os.Setenv("TERM", origTerm)
679 os.Setenv("TERM_PROGRAM", origTermProgram)
680 os.Setenv("VTE_VERSION", origVTEVersion)
681 }()
682
683 os.Setenv("TERM", "xterm-kitty")
684 os.Setenv("TERM_PROGRAM", "")
685 os.Unsetenv("VTE_VERSION")
686
687 h1Style := lipgloss.NewStyle()
688 h2Style := lipgloss.NewStyle()
689 bodyStyle := lipgloss.NewStyle()
690
691 tests := []struct {
692 name string
693 input string
694 wantContains string
695 forbiddenContains []string
696 }{
697 {
698 name: "javascript link is rendered as text only",
699 input: `<a href="javascript:alert(1)">Click here</a>`,
700 wantContains: "Click here",
701 forbiddenContains: []string{
702 "javascript:",
703 "\x1b]8;;javascript:",
704 },
705 },
706 {
707 name: "mixed-case javascript link is rejected",
708 input: `<a href="JaVaScRiPt:alert(1)">Click here</a>`,
709 wantContains: "Click here",
710 forbiddenContains: []string{
711 "JaVaScRiPt:",
712 "javascript:",
713 },
714 },
715 {
716 name: "unsafe image source is not linked",
717 input: `<img src="javascript:alert(1)" alt="bad image">After`,
718 wantContains: "After",
719 forbiddenContains: []string{
720 "javascript:",
721 "bad image",
722 "Click here to view image",
723 },
724 },
725 {
726 name: "data image href is not rendered as a link",
727 input: `<a href="data:image/png;base64,iVBORw0KGgo=">data link</a>`,
728 wantContains: "data link",
729 forbiddenContains: []string{
730 "data:image",
731 "\x1b]8;;data:",
732 },
733 },
734 {
735 name: "cid href is not rendered as a link",
736 input: `<a href="cid:test-image@example.com">cid link</a>`,
737 wantContains: "cid link",
738 forbiddenContains: []string{
739 "cid:test-image",
740 "\x1b]8;;cid:",
741 },
742 },
743 {
744 name: "OSC control characters are stripped from safe links",
745 input: "<a href=\"https://example.com/\x1b]8;;file:///tmp/pwn\x07\">safe</a>",
746 wantContains: "safe",
747 forbiddenContains: []string{
748 "\x1b]8;;file:",
749 "file:///tmp/pwn",
750 "\x07",
751 },
752 },
753 }
754
755 for _, tt := range tests {
756 t.Run(tt.name, func(t *testing.T) {
757 processed, _, err := ProcessBody(tt.input, BodyMIMETypeHTML, h1Style, h2Style, bodyStyle, false)
758 if err != nil {
759 t.Fatalf("ProcessBody() failed: %v", err)
760 }
761 if !strings.Contains(processed, tt.wantContains) {
762 t.Fatalf("processed body does not contain %q:\n%q", tt.wantContains, processed)
763 }
764 for _, forbidden := range tt.forbiddenContains {
765 if strings.Contains(processed, forbidden) {
766 t.Fatalf("processed body contains forbidden %q:\n%q", forbidden, processed)
767 }
768 }
769 })
770 }
771}
772
773func TestProcessBodyDoesNotHyperlinkNonRemoteImageFallbacks(t *testing.T) {
774 t.Setenv("TERM", "xterm")
775 t.Setenv("TERM_PROGRAM", "")
776 t.Setenv("WEZTERM_EXECUTABLE", "/usr/bin/wezterm")
777
778 h1Style := lipgloss.NewStyle()
779 h2Style := lipgloss.NewStyle()
780 bodyStyle := lipgloss.NewStyle()
781
782 input := `
783 <img src="data:image/png;base64,iVBORw0KGgo=" alt="data image">
784 <img src="cid:test-image@example.com" alt="cid image">
785 `
786
787 processed, _, err := ProcessBody(input, BodyMIMETypeHTML, h1Style, h2Style, bodyStyle, true)
788 if err != nil {
789 t.Fatalf("ProcessBody() failed: %v", err)
790 }
791
792 for _, want := range []string{
793 "[Image: data image, data:image/png;base64,iVBORw0KGgo=]",
794 "[Image: cid image, cid:test-image@example.com]",
795 } {
796 if !strings.Contains(processed, want) {
797 t.Fatalf("processed body does not contain %q:\n%q", want, processed)
798 }
799 }
800
801 for _, forbidden := range []string{
802 "Click here to view image",
803 "\x1b]8;;data:",
804 "\x1b]8;;cid:",
805 } {
806 if strings.Contains(processed, forbidden) {
807 t.Fatalf("processed body contains forbidden %q:\n%q", forbidden, processed)
808 }
809 }
810}
811
812func TestIsRemoteImageURLAllowsUppercaseHTTPSScheme(t *testing.T) {
813 tests := []struct {
814 src string
815 want bool
816 }{
817 {src: "http://example.com/image.png", want: true},
818 {src: "HTTPS://example.com/image.png", want: true},
819 {src: "cid:test-image@example.com", want: false},
820 {src: "data:image/png;base64,iVBORw0KGgo=", want: false},
821 }
822
823 for _, tt := range tests {
824 if got := isRemoteImageURL(tt.src); got != tt.want {
825 t.Fatalf("isRemoteImageURL(%q) = %v, want %v", tt.src, got, tt.want)
826 }
827 }
828}
829
830func TestProcessBodyWithImageProtocol(t *testing.T) {
831 // Save original environment variables
832 origTerm := os.Getenv("TERM")
833 origTermProgram := os.Getenv("TERM_PROGRAM")
834 origKittyWindow := os.Getenv("KITTY_WINDOW_ID")
835 origGhosttyResources := os.Getenv("GHOSTTY_RESOURCES_DIR")
836 origItermlSession := os.Getenv("ITERM_SESSION_ID")
837 origWeztermExec := os.Getenv("WEZTERM_EXECUTABLE")
838
839 // Restore environment variables after test
840 defer func() {
841 os.Setenv("TERM", origTerm)
842 os.Setenv("TERM_PROGRAM", origTermProgram)
843 os.Setenv("KITTY_WINDOW_ID", origKittyWindow)
844 os.Setenv("GHOSTTY_RESOURCES_DIR", origGhosttyResources)
845 os.Setenv("ITERM_SESSION_ID", origItermlSession)
846 os.Setenv("WEZTERM_EXECUTABLE", origWeztermExec)
847 }()
848
849 h1Style := lipgloss.NewStyle().SetString("H1")
850 h2Style := lipgloss.NewStyle().SetString("H2")
851 bodyStyle := lipgloss.NewStyle().SetString("BODY")
852
853 // Create a simple base64 PNG image (1x1 pixel white PNG)
854 testBase64PNG := "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="
855
856 testCases := []struct {
857 name string
858 setupImageProtocol func()
859 clearAllImageEnv func()
860 input string
861 expectedContains string
862 expectedNotContains string
863 expectPlacements bool
864 }{
865 {
866 name: "Data URI image with Kitty support returns placement",
867 setupImageProtocol: func() {
868 os.Setenv("TERM", "xterm-kitty")
869 },
870 clearAllImageEnv: func() {
871 os.Unsetenv("KITTY_WINDOW_ID")
872 os.Unsetenv("GHOSTTY_RESOURCES_DIR")
873 os.Unsetenv("ITERM_SESSION_ID")
874 os.Unsetenv("WEZTERM_EXECUTABLE")
875 },
876 input: `<img src="data:image/png;base64,` + testBase64PNG + `" alt="test image">`,
877 expectedNotContains: "[Image: test image,",
878 expectPlacements: true,
879 },
880 {
881 name: "Data URI image with iTerm2 support returns placement",
882 setupImageProtocol: func() {
883 os.Setenv("TERM", "xterm")
884 os.Setenv("TERM_PROGRAM", "iterm.app")
885 },
886 clearAllImageEnv: func() {
887 os.Unsetenv("KITTY_WINDOW_ID")
888 os.Unsetenv("GHOSTTY_RESOURCES_DIR")
889 os.Unsetenv("ITERM_SESSION_ID")
890 os.Unsetenv("WEZTERM_EXECUTABLE")
891 },
892 input: `<img src="data:image/png;base64,` + testBase64PNG + `" alt="test image">`,
893 expectedNotContains: "[Image: test image,",
894 expectPlacements: true,
895 },
896 {
897 name: "Data URI image without protocol support",
898 setupImageProtocol: func() {
899 clearAllTerminalEnv()
900 },
901 clearAllImageEnv: func() {
902 // This is handled by clearAllTerminalEnv now
903 },
904 input: `<img src="data:image/png;base64,` + testBase64PNG + `" alt="test image">`,
905 expectedContains: "[Image: test image,",
906 },
907 {
908 name: "Remote image with WezTerm support (has hyperlink support)",
909 setupImageProtocol: func() {
910 clearAllTerminalEnv()
911 os.Setenv("WEZTERM_EXECUTABLE", "/usr/bin/wezterm")
912 },
913 clearAllImageEnv: func() {
914 // This is handled by clearAllTerminalEnv now
915 },
916 input: `<img src="http://example.com/img.png" alt="remote image">`,
917 expectedContains: "[Click here to view image: remote image]", // Remote images won't render without actual fetch, but hyperlinks work
918 },
919 {
920 name: "Remote image without protocol support",
921 setupImageProtocol: func() {
922 clearAllTerminalEnv()
923 },
924 clearAllImageEnv: func() {
925 // This is handled by clearAllTerminalEnv now
926 },
927 input: `<img src="http://example.com/img.png" alt="remote image">`,
928 expectedContains: "[Image: remote image,",
929 },
930 }
931
932 ansiEscapeRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`)
933
934 for _, tc := range testCases {
935 t.Run(tc.name, func(t *testing.T) {
936 tc.clearAllImageEnv()
937 tc.setupImageProtocol()
938
939 processed, placements, err := ProcessBody(tc.input, "", h1Style, h2Style, bodyStyle, false)
940 if err != nil {
941 t.Fatalf("ProcessBody() failed: %v", err)
942 }
943
944 if tc.expectPlacements {
945 if len(placements) == 0 {
946 t.Errorf("Expected image placements but got none")
947 } else {
948 if placements[0].Base64 == "" {
949 t.Errorf("Expected non-empty Base64 in placement")
950 }
951 if placements[0].Rows < 1 {
952 t.Errorf("Expected Rows >= 1, got %d", placements[0].Rows)
953 }
954 }
955 }
956
957 cleanProcessed := ansiEscapeRegex.ReplaceAllString(processed, "")
958
959 if tc.expectedContains != "" && !strings.Contains(cleanProcessed, tc.expectedContains) {
960 t.Errorf("Processed body does not contain expected text.\nGot: %q\nWant to contain: %q", cleanProcessed, tc.expectedContains)
961 }
962
963 if tc.expectedNotContains != "" && strings.Contains(cleanProcessed, tc.expectedNotContains) {
964 t.Errorf("Processed body contains unexpected text.\nGot: %q\nShould not contain: %q", cleanProcessed, tc.expectedNotContains)
965 }
966 })
967 }
968}
969
970func TestProcessBody(t *testing.T) {
971 h1Style := lipgloss.NewStyle().SetString("H1")
972 h2Style := lipgloss.NewStyle().SetString("H2")
973 bodyStyle := lipgloss.NewStyle().SetString("BODY")
974
975 testCases := []struct {
976 name string
977 input string
978 expected string
979 }{
980 {
981 name: "Simple HTML",
982 input: "<p>Hello, world!</p>",
983 expected: "Hello, world!",
984 },
985 {
986 name: "With headers HTML",
987 input: "<h1>Header 1</h1>",
988 expected: "Header 1",
989 },
990 {
991 name: "With headers Markdown",
992 input: "# Header 1",
993 expected: "Header 1",
994 },
995 {
996 name: "Plain text",
997 input: "Just plain text without any markup",
998 expected: "Just plain text without any markup",
999 },
1000 }
1001
1002 ansiEscapeRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`)
1003
1004 for _, tc := range testCases {
1005 t.Run(tc.name, func(t *testing.T) {
1006 processed, _, err := ProcessBody(tc.input, "", h1Style, h2Style, bodyStyle, false)
1007 if err != nil {
1008 t.Fatalf("ProcessBody() failed: %v", err)
1009 }
1010
1011 cleanProcessed := ansiEscapeRegex.ReplaceAllString(processed, "")
1012
1013 if !strings.Contains(cleanProcessed, tc.expected) {
1014 t.Errorf("Processed body does not contain expected text.\nGot: %q\nWant to contain: %q", cleanProcessed, tc.expected)
1015 }
1016 })
1017 }
1018}
1019
1020// datadogShapeHTML is the indented attribute-heavy table shape commonly
1021// produced by Datadog Daily Digest, marketing tools, and any sender that
1022// uses HTML <table> for layout. md4c's html_block rule rejects this shape
1023// (leading whitespace, attribute-laden opening tag), so the markdown
1024// pre-pass passes the literal text through, and htmlconv then renders the
1025// raw "<table cellpadding=..." tag as visible body text.
1026const datadogShapeHTML = ` <table cellpadding="0" cellspacing="0" border="0" width="710" style="border:1px solid #E7E7E7;">
1027 <tr>
1028 <td style="background-color: #632ca6; color: white;">
1029 <h1>The Daily Digest</h1>
1030 </td>
1031 </tr>
1032 </table>`
1033
1034// TestProcessBody_LegacyPathManglesIndentedHTML pins the bug this PR fixes.
1035// With an empty MIME type, the renderer falls through to the legacy
1036// markdown→HTML pre-pass, which is what every body went through before this
1037// change. For Datadog-shape input the output literally contains the opening
1038// "<table cellpadding=..." text, which is what users see leaked into the
1039// inbox viewer. This test will pass on master too — it documents the bug,
1040// not the fix.
1041func TestProcessBody_LegacyPathManglesIndentedHTML(t *testing.T) {
1042 ansiEscapeRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`)
1043 processed, _, err := ProcessBody(datadogShapeHTML, "", lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle(), false)
1044 if err != nil {
1045 t.Fatalf("ProcessBody(legacy) failed: %v", err)
1046 }
1047 clean := ansiEscapeRegex.ReplaceAllString(processed, "")
1048 if !strings.Contains(clean, "<table") {
1049 t.Errorf("legacy path should leak literal '<table' tag for indented attribute-heavy HTML — if this assertion stops firing, md4c's html_block handling has improved and this PR's premise needs re-evaluation. Got:\n%s", clean)
1050 }
1051}
1052
1053// TestProcessBody_HTMLMIMETypeSkipsMarkdownPrepass is the fix counterpart to
1054// the legacy-mangling test above. Same input, but tagged "text/html", goes
1055// straight to htmlconv without the broken markdown pre-pass.
1056func TestProcessBody_HTMLMIMETypeSkipsMarkdownPrepass(t *testing.T) {
1057 ansiEscapeRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`)
1058 bodyStyle := lipgloss.NewStyle()
1059 h1Style := lipgloss.NewStyle()
1060 h2Style := lipgloss.NewStyle()
1061
1062 // Same input as TestProcessBody_LegacyPathManglesIndentedHTML — the
1063 // differential is purely the MIME-type argument.
1064 processed, _, err := ProcessBody(datadogShapeHTML, BodyMIMETypeHTML, h1Style, h2Style, bodyStyle, false)
1065 if err != nil {
1066 t.Fatalf("ProcessBody(text/html) failed: %v", err)
1067 }
1068 clean := ansiEscapeRegex.ReplaceAllString(processed, "")
1069 if strings.Contains(clean, "<table") {
1070 t.Errorf("text/html body should not leak literal '<table' tag. Got:\n%s", clean)
1071 }
1072 if !strings.Contains(clean, "The Daily Digest") {
1073 t.Errorf("expected text content 'The Daily Digest' in output. Got:\n%s", clean)
1074 }
1075
1076 // Sanity: a body labeled as plain text falls through markdownToHTML and
1077 // preserves markdown semantics (heading rendering through the pipeline).
1078 mdBody := "# Heading One\n\nSome **bold** text."
1079 plainProcessed, _, err := ProcessBody(mdBody, BodyMIMETypePlain, h1Style, h2Style, bodyStyle, false)
1080 if err != nil {
1081 t.Fatalf("ProcessBody(text/plain) failed: %v", err)
1082 }
1083 plainClean := ansiEscapeRegex.ReplaceAllString(plainProcessed, "")
1084 if !strings.Contains(plainClean, "Heading One") {
1085 t.Errorf("text/plain body should still render markdown. Got:\n%s", plainClean)
1086 }
1087}
1088
1089func TestRemoteImageCache_EvictsOldestWhenFull(t *testing.T) {
1090 // Start with a clean cache so prior tests don't interfere.
1091 remoteImageCache.Purge()
1092 // cleaning up the current test's cache
1093 defer remoteImageCache.Purge()
1094
1095 // overfilling the cache beyond its configured capacity.
1096 overfillBy := 5
1097 totalInserts := remoteImageCacheSize + overfillBy
1098 for i := range totalInserts {
1099 url := fmt.Sprintf("https://example.com/img%d.png", i)
1100 remoteImageCache.Add(url, "fake-base64-data")
1101 }
1102
1103 // cache should not be overfilled beyond it's capped size
1104 if got := remoteImageCache.Len(); got != remoteImageCacheSize {
1105 t.Errorf("expected cache size %d, got %d", remoteImageCacheSize, got)
1106 }
1107
1108 // old entries should be evicted
1109 for i := range overfillBy {
1110 evictedURL := fmt.Sprintf("https://example.com/img%d.png", i)
1111 if _, ok := remoteImageCache.Get(evictedURL); ok {
1112 t.Errorf("expected %q to be evicted, but it's still in cache", evictedURL)
1113 }
1114 }
1115
1116 // The most recent entries should still be present.
1117 for i := overfillBy; i < totalInserts; i++ {
1118 keptURL := fmt.Sprintf("https://example.com/img%d.png", i)
1119 if _, ok := remoteImageCache.Get(keptURL); !ok {
1120 t.Errorf("expected %q to still be in cache", keptURL)
1121 }
1122 }
1123}
1124
1125func TestAllocImageID_NoRace(t *testing.T) {
1126 // Reset the counter so IDs start from a known value.
1127 atomic.StoreUint32(&nextImageID, 1000)
1128
1129 const goroutines = 100
1130 const idsPerGoroutine = 100
1131
1132 results := make(chan uint32, goroutines*idsPerGoroutine)
1133
1134 var wg sync.WaitGroup
1135 wg.Add(goroutines)
1136 for range goroutines {
1137 go func() {
1138 defer wg.Done()
1139 for range idsPerGoroutine {
1140 results <- allocImageID()
1141 }
1142 }()
1143 }
1144
1145 // Close channel once all writers are done.
1146 go func() {
1147 wg.Wait()
1148 close(results)
1149 }()
1150
1151 // Collect all IDs and verify uniqueness.
1152 seen := make(map[uint32]bool, goroutines*idsPerGoroutine)
1153 for id := range results {
1154 if seen[id] {
1155 t.Fatalf("duplicate image ID allocated: %d (race condition detected)", id)
1156 }
1157 seen[id] = true
1158 }
1159
1160 expected := uint32(goroutines * idsPerGoroutine)
1161 if uint32(len(seen)) != expected {
1162 t.Errorf("expected %d unique IDs, got %d", expected, len(seen))
1163 }
1164}