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 := "[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 TestProcessBodyWithImageProtocol(t *testing.T) {
674 // Save original environment variables
675 origTerm := os.Getenv("TERM")
676 origTermProgram := os.Getenv("TERM_PROGRAM")
677 origKittyWindow := os.Getenv("KITTY_WINDOW_ID")
678 origGhosttyResources := os.Getenv("GHOSTTY_RESOURCES_DIR")
679 origItermlSession := os.Getenv("ITERM_SESSION_ID")
680 origWeztermExec := os.Getenv("WEZTERM_EXECUTABLE")
681
682 // Restore environment variables after test
683 defer func() {
684 os.Setenv("TERM", origTerm)
685 os.Setenv("TERM_PROGRAM", origTermProgram)
686 os.Setenv("KITTY_WINDOW_ID", origKittyWindow)
687 os.Setenv("GHOSTTY_RESOURCES_DIR", origGhosttyResources)
688 os.Setenv("ITERM_SESSION_ID", origItermlSession)
689 os.Setenv("WEZTERM_EXECUTABLE", origWeztermExec)
690 }()
691
692 h1Style := lipgloss.NewStyle().SetString("H1")
693 h2Style := lipgloss.NewStyle().SetString("H2")
694 bodyStyle := lipgloss.NewStyle().SetString("BODY")
695
696 // Create a simple base64 PNG image (1x1 pixel white PNG)
697 testBase64PNG := "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="
698
699 testCases := []struct {
700 name string
701 setupImageProtocol func()
702 clearAllImageEnv func()
703 input string
704 expectedContains string
705 expectedNotContains string
706 expectPlacements bool
707 }{
708 {
709 name: "Data URI image with Kitty support returns placement",
710 setupImageProtocol: func() {
711 os.Setenv("TERM", "xterm-kitty")
712 },
713 clearAllImageEnv: func() {
714 os.Unsetenv("KITTY_WINDOW_ID")
715 os.Unsetenv("GHOSTTY_RESOURCES_DIR")
716 os.Unsetenv("ITERM_SESSION_ID")
717 os.Unsetenv("WEZTERM_EXECUTABLE")
718 },
719 input: `<img src="data:image/png;base64,` + testBase64PNG + `" alt="test image">`,
720 expectedNotContains: "[Image: test image,",
721 expectPlacements: true,
722 },
723 {
724 name: "Data URI image with iTerm2 support returns placement",
725 setupImageProtocol: func() {
726 os.Setenv("TERM", "xterm")
727 os.Setenv("TERM_PROGRAM", "iterm.app")
728 },
729 clearAllImageEnv: func() {
730 os.Unsetenv("KITTY_WINDOW_ID")
731 os.Unsetenv("GHOSTTY_RESOURCES_DIR")
732 os.Unsetenv("ITERM_SESSION_ID")
733 os.Unsetenv("WEZTERM_EXECUTABLE")
734 },
735 input: `<img src="data:image/png;base64,` + testBase64PNG + `" alt="test image">`,
736 expectedNotContains: "[Image: test image,",
737 expectPlacements: true,
738 },
739 {
740 name: "Data URI image without protocol support",
741 setupImageProtocol: func() {
742 clearAllTerminalEnv()
743 },
744 clearAllImageEnv: func() {
745 // This is handled by clearAllTerminalEnv now
746 },
747 input: `<img src="data:image/png;base64,` + testBase64PNG + `" alt="test image">`,
748 expectedContains: "[Image: test image,",
749 },
750 {
751 name: "Remote image with WezTerm support (has hyperlink support)",
752 setupImageProtocol: func() {
753 clearAllTerminalEnv()
754 os.Setenv("WEZTERM_EXECUTABLE", "/usr/bin/wezterm")
755 },
756 clearAllImageEnv: func() {
757 // This is handled by clearAllTerminalEnv now
758 },
759 input: `<img src="http://example.com/img.png" alt="remote image">`,
760 expectedContains: "[Click here to view image: remote image]", // Remote images won't render without actual fetch, but hyperlinks work
761 },
762 {
763 name: "Remote image without protocol support",
764 setupImageProtocol: func() {
765 clearAllTerminalEnv()
766 },
767 clearAllImageEnv: func() {
768 // This is handled by clearAllTerminalEnv now
769 },
770 input: `<img src="http://example.com/img.png" alt="remote image">`,
771 expectedContains: "[Image: remote image,",
772 },
773 }
774
775 ansiEscapeRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`)
776
777 for _, tc := range testCases {
778 t.Run(tc.name, func(t *testing.T) {
779 tc.clearAllImageEnv()
780 tc.setupImageProtocol()
781
782 processed, placements, err := ProcessBody(tc.input, "", h1Style, h2Style, bodyStyle, false)
783 if err != nil {
784 t.Fatalf("ProcessBody() failed: %v", err)
785 }
786
787 if tc.expectPlacements {
788 if len(placements) == 0 {
789 t.Errorf("Expected image placements but got none")
790 } else {
791 if placements[0].Base64 == "" {
792 t.Errorf("Expected non-empty Base64 in placement")
793 }
794 if placements[0].Rows < 1 {
795 t.Errorf("Expected Rows >= 1, got %d", placements[0].Rows)
796 }
797 }
798 }
799
800 cleanProcessed := ansiEscapeRegex.ReplaceAllString(processed, "")
801
802 if tc.expectedContains != "" && !strings.Contains(cleanProcessed, tc.expectedContains) {
803 t.Errorf("Processed body does not contain expected text.\nGot: %q\nWant to contain: %q", cleanProcessed, tc.expectedContains)
804 }
805
806 if tc.expectedNotContains != "" && strings.Contains(cleanProcessed, tc.expectedNotContains) {
807 t.Errorf("Processed body contains unexpected text.\nGot: %q\nShould not contain: %q", cleanProcessed, tc.expectedNotContains)
808 }
809 })
810 }
811}
812
813func TestProcessBody(t *testing.T) {
814 h1Style := lipgloss.NewStyle().SetString("H1")
815 h2Style := lipgloss.NewStyle().SetString("H2")
816 bodyStyle := lipgloss.NewStyle().SetString("BODY")
817
818 testCases := []struct {
819 name string
820 input string
821 expected string
822 }{
823 {
824 name: "Simple HTML",
825 input: "<p>Hello, world!</p>",
826 expected: "Hello, world!",
827 },
828 {
829 name: "With headers HTML",
830 input: "<h1>Header 1</h1>",
831 expected: "Header 1",
832 },
833 {
834 name: "With headers Markdown",
835 input: "# Header 1",
836 expected: "Header 1",
837 },
838 {
839 name: "Plain text",
840 input: "Just plain text without any markup",
841 expected: "Just plain text without any markup",
842 },
843 }
844
845 ansiEscapeRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`)
846
847 for _, tc := range testCases {
848 t.Run(tc.name, func(t *testing.T) {
849 processed, _, err := ProcessBody(tc.input, "", h1Style, h2Style, bodyStyle, false)
850 if err != nil {
851 t.Fatalf("ProcessBody() failed: %v", err)
852 }
853
854 cleanProcessed := ansiEscapeRegex.ReplaceAllString(processed, "")
855
856 if !strings.Contains(cleanProcessed, tc.expected) {
857 t.Errorf("Processed body does not contain expected text.\nGot: %q\nWant to contain: %q", cleanProcessed, tc.expected)
858 }
859 })
860 }
861}
862
863// datadogShapeHTML is the indented attribute-heavy table shape commonly
864// produced by Datadog Daily Digest, marketing tools, and any sender that
865// uses HTML <table> for layout. md4c's html_block rule rejects this shape
866// (leading whitespace, attribute-laden opening tag), so the markdown
867// pre-pass passes the literal text through, and htmlconv then renders the
868// raw "<table cellpadding=..." tag as visible body text.
869const datadogShapeHTML = ` <table cellpadding="0" cellspacing="0" border="0" width="710" style="border:1px solid #E7E7E7;">
870 <tr>
871 <td style="background-color: #632ca6; color: white;">
872 <h1>The Daily Digest</h1>
873 </td>
874 </tr>
875 </table>`
876
877// TestProcessBody_LegacyPathManglesIndentedHTML pins the bug this PR fixes.
878// With an empty MIME type, the renderer falls through to the legacy
879// markdown→HTML pre-pass, which is what every body went through before this
880// change. For Datadog-shape input the output literally contains the opening
881// "<table cellpadding=..." text, which is what users see leaked into the
882// inbox viewer. This test will pass on master too — it documents the bug,
883// not the fix.
884func TestProcessBody_LegacyPathManglesIndentedHTML(t *testing.T) {
885 ansiEscapeRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`)
886 processed, _, err := ProcessBody(datadogShapeHTML, "", lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle(), false)
887 if err != nil {
888 t.Fatalf("ProcessBody(legacy) failed: %v", err)
889 }
890 clean := ansiEscapeRegex.ReplaceAllString(processed, "")
891 if !strings.Contains(clean, "<table") {
892 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)
893 }
894}
895
896// TestProcessBody_HTMLMIMETypeSkipsMarkdownPrepass is the fix counterpart to
897// the legacy-mangling test above. Same input, but tagged "text/html", goes
898// straight to htmlconv without the broken markdown pre-pass.
899func TestProcessBody_HTMLMIMETypeSkipsMarkdownPrepass(t *testing.T) {
900 ansiEscapeRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`)
901 bodyStyle := lipgloss.NewStyle()
902 h1Style := lipgloss.NewStyle()
903 h2Style := lipgloss.NewStyle()
904
905 // Same input as TestProcessBody_LegacyPathManglesIndentedHTML — the
906 // differential is purely the MIME-type argument.
907 processed, _, err := ProcessBody(datadogShapeHTML, BodyMIMETypeHTML, h1Style, h2Style, bodyStyle, false)
908 if err != nil {
909 t.Fatalf("ProcessBody(text/html) failed: %v", err)
910 }
911 clean := ansiEscapeRegex.ReplaceAllString(processed, "")
912 if strings.Contains(clean, "<table") {
913 t.Errorf("text/html body should not leak literal '<table' tag. Got:\n%s", clean)
914 }
915 if !strings.Contains(clean, "The Daily Digest") {
916 t.Errorf("expected text content 'The Daily Digest' in output. Got:\n%s", clean)
917 }
918
919 // Sanity: a body labeled as plain text falls through markdownToHTML and
920 // preserves markdown semantics (heading rendering through the pipeline).
921 mdBody := "# Heading One\n\nSome **bold** text."
922 plainProcessed, _, err := ProcessBody(mdBody, BodyMIMETypePlain, h1Style, h2Style, bodyStyle, false)
923 if err != nil {
924 t.Fatalf("ProcessBody(text/plain) failed: %v", err)
925 }
926 plainClean := ansiEscapeRegex.ReplaceAllString(plainProcessed, "")
927 if !strings.Contains(plainClean, "Heading One") {
928 t.Errorf("text/plain body should still render markdown. Got:\n%s", plainClean)
929 }
930}
931
932func TestRemoteImageCache_EvictsOldestWhenFull(t *testing.T) {
933 // Start with a clean cache so prior tests don't interfere.
934 remoteImageCache.Purge()
935 // cleaning up the current test's cache
936 defer remoteImageCache.Purge()
937
938 // overfilling the cache beyond its configured capacity.
939 overfillBy := 5
940 totalInserts := remoteImageCacheSize + overfillBy
941 for i := range totalInserts {
942 url := fmt.Sprintf("https://example.com/img%d.png", i)
943 remoteImageCache.Add(url, "fake-base64-data")
944 }
945
946 // cache should not be overfilled beyond it's capped size
947 if got := remoteImageCache.Len(); got != remoteImageCacheSize {
948 t.Errorf("expected cache size %d, got %d", remoteImageCacheSize, got)
949 }
950
951 // old entries should be evicted
952 for i := range overfillBy {
953 evictedURL := fmt.Sprintf("https://example.com/img%d.png", i)
954 if _, ok := remoteImageCache.Get(evictedURL); ok {
955 t.Errorf("expected %q to be evicted, but it's still in cache", evictedURL)
956 }
957 }
958
959 // The most recent entries should still be present.
960 for i := overfillBy; i < totalInserts; i++ {
961 keptURL := fmt.Sprintf("https://example.com/img%d.png", i)
962 if _, ok := remoteImageCache.Get(keptURL); !ok {
963 t.Errorf("expected %q to still be in cache", keptURL)
964 }
965 }
966}
967
968func TestAllocImageID_NoRace(t *testing.T) {
969 // Reset the counter so IDs start from a known value.
970 atomic.StoreUint32(&nextImageID, 1000)
971
972 const goroutines = 100
973 const idsPerGoroutine = 100
974
975 results := make(chan uint32, goroutines*idsPerGoroutine)
976
977 var wg sync.WaitGroup
978 wg.Add(goroutines)
979 for range goroutines {
980 go func() {
981 defer wg.Done()
982 for range idsPerGoroutine {
983 results <- allocImageID()
984 }
985 }()
986 }
987
988 // Close channel once all writers are done.
989 go func() {
990 wg.Wait()
991 close(results)
992 }()
993
994 // Collect all IDs and verify uniqueness.
995 seen := make(map[uint32]bool, goroutines*idsPerGoroutine)
996 for id := range results {
997 if seen[id] {
998 t.Fatalf("duplicate image ID allocated: %d (race condition detected)", id)
999 }
1000 seen[id] = true
1001 }
1002
1003 expected := uint32(goroutines * idsPerGoroutine)
1004 if uint32(len(seen)) != expected {
1005 t.Errorf("expected %d unique IDs, got %d", expected, len(seen))
1006 }
1007}