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
837func TestRemoteImageCache_EvictsOldestWhenFull(t *testing.T) {
838 // Start with a clean cache so prior tests don't interfere.
839 remoteImageCache.Purge()
840 // cleaning up the current test's cache
841 defer remoteImageCache.Purge()
842
843 // overfilling the cache beyond its configured capacity.
844 overfillBy := 5
845 totalInserts := remoteImageCacheSize + overfillBy
846 for i := range totalInserts {
847 url := fmt.Sprintf("https://example.com/img%d.png", i)
848 remoteImageCache.Add(url, "fake-base64-data")
849 }
850
851 // cache should not be overfilled beyond it's capped size
852 if got := remoteImageCache.Len(); got != remoteImageCacheSize {
853 t.Errorf("expected cache size %d, got %d", remoteImageCacheSize, got)
854 }
855
856 // old entries should be evicted
857 for i := range overfillBy {
858 evictedURL := fmt.Sprintf("https://example.com/img%d.png", i)
859 if _, ok := remoteImageCache.Get(evictedURL); ok {
860 t.Errorf("expected %q to be evicted, but it's still in cache", evictedURL)
861 }
862 }
863
864 // The most recent entries should still be present.
865 for i := overfillBy; i < totalInserts; i++ {
866 keptURL := fmt.Sprintf("https://example.com/img%d.png", i)
867 if _, ok := remoteImageCache.Get(keptURL); !ok {
868 t.Errorf("expected %q to still be in cache", keptURL)
869 }
870 }
871}
872
873func TestAllocImageID_NoRace(t *testing.T) {
874 // Reset the counter so IDs start from a known value.
875 atomic.StoreUint32(&nextImageID, 1000)
876
877 const goroutines = 100
878 const idsPerGoroutine = 100
879
880 results := make(chan uint32, goroutines*idsPerGoroutine)
881
882 var wg sync.WaitGroup
883 wg.Add(goroutines)
884 for range goroutines {
885 go func() {
886 defer wg.Done()
887 for range idsPerGoroutine {
888 results <- allocImageID()
889 }
890 }()
891 }
892
893 // Close channel once all writers are done.
894 go func() {
895 wg.Wait()
896 close(results)
897 }()
898
899 // Collect all IDs and verify uniqueness.
900 seen := make(map[uint32]bool, goroutines*idsPerGoroutine)
901 for id := range results {
902 if seen[id] {
903 t.Fatalf("duplicate image ID allocated: %d (race condition detected)", id)
904 }
905 seen[id] = true
906 }
907
908 expected := uint32(goroutines * idsPerGoroutine)
909 if uint32(len(seen)) != expected {
910 t.Errorf("expected %d unique IDs, got %d", expected, len(seen))
911 }
912}