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