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