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 TestImageProtocolSupported(t *testing.T) {
177 // Save original environment variables
178 origTerm := os.Getenv("TERM")
179 origKittyWindow := os.Getenv("KITTY_WINDOW_ID")
180 origTermProgram := os.Getenv("TERM_PROGRAM")
181 origGhosttyResources := os.Getenv("GHOSTTY_RESOURCES_DIR")
182 origItermlSession := os.Getenv("ITERM_SESSION_ID")
183 origWeztermExec := os.Getenv("WEZTERM_EXECUTABLE")
184 origWarpLocal := os.Getenv("WARP_IS_LOCAL_SHELL_SESSION")
185 origKonsoleDBus := os.Getenv("KONSOLE_DBUS_SESSION")
186
187 // Restore environment variables after test
188 defer func() {
189 os.Setenv("TERM", origTerm)
190 os.Setenv("KITTY_WINDOW_ID", origKittyWindow)
191 os.Setenv("TERM_PROGRAM", origTermProgram)
192 os.Setenv("GHOSTTY_RESOURCES_DIR", origGhosttyResources)
193 os.Setenv("ITERM_SESSION_ID", origItermlSession)
194 os.Setenv("WEZTERM_EXECUTABLE", origWeztermExec)
195 os.Setenv("WARP_IS_LOCAL_SHELL_SESSION", origWarpLocal)
196 os.Setenv("KONSOLE_DBUS_SESSION", origKonsoleDBus)
197 }()
198
199 testCases := []struct {
200 name string
201 setupEnv func()
202 clearAllEnv func()
203 expected bool
204 }{
205 {
206 name: "No supported terminals",
207 setupEnv: func() {
208 os.Setenv("TERM", "xterm")
209 os.Setenv("TERM_PROGRAM", "basic")
210 },
211 clearAllEnv: func() {
212 os.Unsetenv("KITTY_WINDOW_ID")
213 os.Unsetenv("GHOSTTY_RESOURCES_DIR")
214 os.Unsetenv("ITERM_SESSION_ID")
215 os.Unsetenv("WEZTERM_EXECUTABLE")
216 os.Unsetenv("WARP_IS_LOCAL_SHELL_SESSION")
217 os.Unsetenv("KONSOLE_DBUS_SESSION")
218 },
219 expected: false,
220 },
221 {
222 name: "Kitty supported via TERM",
223 setupEnv: func() {
224 os.Setenv("TERM", "xterm-kitty")
225 },
226 clearAllEnv: func() {
227 os.Unsetenv("KITTY_WINDOW_ID")
228 os.Unsetenv("GHOSTTY_RESOURCES_DIR")
229 os.Unsetenv("ITERM_SESSION_ID")
230 os.Unsetenv("WEZTERM_EXECUTABLE")
231 os.Unsetenv("WARP_IS_LOCAL_SHELL_SESSION")
232 os.Unsetenv("KONSOLE_DBUS_SESSION")
233 },
234 expected: true,
235 },
236 {
237 name: "Kitty supported via KITTY_WINDOW_ID",
238 setupEnv: func() {
239 os.Setenv("TERM", "xterm")
240 os.Setenv("KITTY_WINDOW_ID", "1")
241 },
242 clearAllEnv: func() {
243 os.Unsetenv("GHOSTTY_RESOURCES_DIR")
244 os.Unsetenv("ITERM_SESSION_ID")
245 os.Unsetenv("WEZTERM_EXECUTABLE")
246 os.Unsetenv("WARP_IS_LOCAL_SHELL_SESSION")
247 os.Unsetenv("KONSOLE_DBUS_SESSION")
248 },
249 expected: true,
250 },
251 {
252 name: "Ghostty supported via TERM_PROGRAM",
253 setupEnv: func() {
254 os.Setenv("TERM", "xterm")
255 os.Setenv("TERM_PROGRAM", "ghostty")
256 },
257 clearAllEnv: func() {
258 os.Unsetenv("KITTY_WINDOW_ID")
259 os.Unsetenv("GHOSTTY_RESOURCES_DIR")
260 os.Unsetenv("ITERM_SESSION_ID")
261 os.Unsetenv("WEZTERM_EXECUTABLE")
262 os.Unsetenv("WARP_IS_LOCAL_SHELL_SESSION")
263 os.Unsetenv("KONSOLE_DBUS_SESSION")
264 },
265 expected: true,
266 },
267 {
268 name: "iTerm2 supported via TERM_PROGRAM",
269 setupEnv: func() {
270 os.Setenv("TERM", "xterm")
271 os.Setenv("TERM_PROGRAM", "iterm.app")
272 },
273 clearAllEnv: func() {
274 os.Unsetenv("KITTY_WINDOW_ID")
275 os.Unsetenv("GHOSTTY_RESOURCES_DIR")
276 os.Unsetenv("ITERM_SESSION_ID")
277 os.Unsetenv("WEZTERM_EXECUTABLE")
278 os.Unsetenv("WARP_IS_LOCAL_SHELL_SESSION")
279 os.Unsetenv("KONSOLE_DBUS_SESSION")
280 },
281 expected: true,
282 },
283 {
284 name: "WezTerm supported via WEZTERM_EXECUTABLE",
285 setupEnv: func() {
286 os.Setenv("TERM", "xterm")
287 os.Setenv("WEZTERM_EXECUTABLE", "/usr/bin/wezterm")
288 },
289 clearAllEnv: func() {
290 os.Unsetenv("KITTY_WINDOW_ID")
291 os.Unsetenv("GHOSTTY_RESOURCES_DIR")
292 os.Unsetenv("ITERM_SESSION_ID")
293 os.Unsetenv("WARP_IS_LOCAL_SHELL_SESSION")
294 os.Unsetenv("KONSOLE_DBUS_SESSION")
295 },
296 expected: true,
297 },
298 {
299 name: "Warp supported via WARP_IS_LOCAL_SHELL_SESSION",
300 setupEnv: func() {
301 os.Setenv("TERM", "xterm")
302 os.Setenv("WARP_IS_LOCAL_SHELL_SESSION", "1")
303 },
304 clearAllEnv: func() {
305 os.Unsetenv("KITTY_WINDOW_ID")
306 os.Unsetenv("GHOSTTY_RESOURCES_DIR")
307 os.Unsetenv("ITERM_SESSION_ID")
308 os.Unsetenv("WEZTERM_EXECUTABLE")
309 os.Unsetenv("KONSOLE_DBUS_SESSION")
310 },
311 expected: true,
312 },
313 {
314 name: "Konsole supported via KONSOLE_DBUS_SESSION",
315 setupEnv: func() {
316 os.Setenv("TERM", "xterm")
317 os.Setenv("KONSOLE_DBUS_SESSION", "/Sessions/1")
318 },
319 clearAllEnv: func() {
320 os.Unsetenv("KITTY_WINDOW_ID")
321 os.Unsetenv("GHOSTTY_RESOURCES_DIR")
322 os.Unsetenv("ITERM_SESSION_ID")
323 os.Unsetenv("WEZTERM_EXECUTABLE")
324 os.Unsetenv("WARP_IS_LOCAL_SHELL_SESSION")
325 },
326 expected: true,
327 },
328 }
329
330 for _, tc := range testCases {
331 t.Run(tc.name, func(t *testing.T) {
332 tc.clearAllEnv()
333 tc.setupEnv()
334
335 result := imageProtocolSupported()
336 if result != tc.expected {
337 t.Errorf("Expected %t, got %t", tc.expected, result)
338 }
339 })
340 }
341}
342
343func TestHyperlinkSupported(t *testing.T) {
344 // Save original environment variables
345 origTerm := os.Getenv("TERM")
346 origTermProgram := os.Getenv("TERM_PROGRAM")
347 origVTEVersion := os.Getenv("VTE_VERSION")
348 origKittyWindow := os.Getenv("KITTY_WINDOW_ID")
349 origGhosttyResources := os.Getenv("GHOSTTY_RESOURCES_DIR")
350 origWeztermExec := os.Getenv("WEZTERM_EXECUTABLE")
351
352 // Restore environment variables after test
353 defer func() {
354 os.Setenv("TERM", origTerm)
355 os.Setenv("TERM_PROGRAM", origTermProgram)
356 os.Setenv("VTE_VERSION", origVTEVersion)
357 os.Setenv("KITTY_WINDOW_ID", origKittyWindow)
358 os.Setenv("GHOSTTY_RESOURCES_DIR", origGhosttyResources)
359 os.Setenv("WEZTERM_EXECUTABLE", origWeztermExec)
360 }()
361
362 testCases := []struct {
363 name string
364 setupEnv func()
365 clearAllEnv func()
366 expected bool
367 }{
368 {
369 name: "No hyperlink support",
370 setupEnv: func() {
371 os.Setenv("TERM", "xterm")
372 os.Setenv("TERM_PROGRAM", "basic")
373 },
374 clearAllEnv: func() {
375 os.Unsetenv("VTE_VERSION")
376 os.Unsetenv("KITTY_WINDOW_ID")
377 os.Unsetenv("GHOSTTY_RESOURCES_DIR")
378 os.Unsetenv("WEZTERM_EXECUTABLE")
379 },
380 expected: false,
381 },
382 {
383 name: "Kitty hyperlink support via TERM",
384 setupEnv: func() {
385 os.Setenv("TERM", "xterm-kitty")
386 },
387 clearAllEnv: func() {
388 os.Unsetenv("VTE_VERSION")
389 os.Unsetenv("KITTY_WINDOW_ID")
390 os.Unsetenv("GHOSTTY_RESOURCES_DIR")
391 os.Unsetenv("WEZTERM_EXECUTABLE")
392 },
393 expected: true,
394 },
395 {
396 name: "VTE-based terminal hyperlink support",
397 setupEnv: func() {
398 os.Setenv("TERM", "xterm")
399 os.Setenv("VTE_VERSION", "0.60.3")
400 },
401 clearAllEnv: func() {
402 os.Unsetenv("KITTY_WINDOW_ID")
403 os.Unsetenv("GHOSTTY_RESOURCES_DIR")
404 os.Unsetenv("WEZTERM_EXECUTABLE")
405 },
406 expected: true,
407 },
408 {
409 name: "iTerm2 hyperlink support",
410 setupEnv: func() {
411 os.Setenv("TERM", "xterm")
412 os.Setenv("TERM_PROGRAM", "iterm.app")
413 },
414 clearAllEnv: func() {
415 os.Unsetenv("VTE_VERSION")
416 os.Unsetenv("KITTY_WINDOW_ID")
417 os.Unsetenv("GHOSTTY_RESOURCES_DIR")
418 os.Unsetenv("WEZTERM_EXECUTABLE")
419 },
420 expected: true,
421 },
422 {
423 name: "WezTerm hyperlink support",
424 setupEnv: func() {
425 os.Setenv("TERM", "xterm")
426 os.Setenv("WEZTERM_EXECUTABLE", "/usr/bin/wezterm")
427 },
428 clearAllEnv: func() {
429 os.Unsetenv("VTE_VERSION")
430 os.Unsetenv("KITTY_WINDOW_ID")
431 os.Unsetenv("GHOSTTY_RESOURCES_DIR")
432 },
433 expected: true,
434 },
435 }
436
437 for _, tc := range testCases {
438 t.Run(tc.name, func(t *testing.T) {
439 tc.clearAllEnv()
440 tc.setupEnv()
441
442 result := hyperlinkSupported()
443 if result != tc.expected {
444 t.Errorf("Expected %t, got %t", tc.expected, result)
445 }
446 })
447 }
448}
449
450func TestProcessBodyWithHyperlinkSupport(t *testing.T) {
451 // Save original environment variables
452 origTerm := os.Getenv("TERM")
453 origTermProgram := os.Getenv("TERM_PROGRAM")
454 origVTEVersion := os.Getenv("VTE_VERSION")
455 origKittyWindow := os.Getenv("KITTY_WINDOW_ID")
456
457 // Restore environment variables after test
458 defer func() {
459 os.Setenv("TERM", origTerm)
460 os.Setenv("TERM_PROGRAM", origTermProgram)
461 os.Setenv("VTE_VERSION", origVTEVersion)
462 os.Setenv("KITTY_WINDOW_ID", origKittyWindow)
463 }()
464
465 h1Style := lipgloss.NewStyle().SetString("H1")
466 h2Style := lipgloss.NewStyle().SetString("H2")
467 bodyStyle := lipgloss.NewStyle().SetString("BODY")
468
469 testCases := []struct {
470 name string
471 setupHyperlinks func()
472 input string
473 expectedContains string
474 expectedNotContains string
475 }{
476 {
477 name: "Link with hyperlink support",
478 setupHyperlinks: func() {
479 os.Setenv("TERM", "xterm-kitty")
480 os.Unsetenv("VTE_VERSION")
481 os.Unsetenv("KITTY_WINDOW_ID")
482 },
483 input: `<a href="http://example.com">Click here</a>`,
484 expectedContains: "Click here",
485 expectedNotContains: "<http://example.com>",
486 },
487 {
488 name: "Link without hyperlink support",
489 setupHyperlinks: func() {
490 clearAllTerminalEnv()
491 },
492 input: `<a href="http://example.com">Click here</a>`,
493 expectedContains: "Click here <http://example.com>",
494 },
495 {
496 name: "Image link with hyperlink support",
497 setupHyperlinks: func() {
498 os.Setenv("TERM", "xterm")
499 os.Setenv("VTE_VERSION", "0.60.3")
500 os.Unsetenv("KITTY_WINDOW_ID")
501 },
502 input: `<img src="http://example.com/img.png" alt="alt text">`,
503 expectedContains: "[Click here to view image: alt text]",
504 expectedNotContains: "<http://example.com/img.png>",
505 },
506 {
507 name: "Image link without hyperlink support",
508 setupHyperlinks: func() {
509 clearAllTerminalEnv()
510 },
511 input: `<img src="http://example.com/img.png" alt="alt text">`,
512 expectedContains: "[Image: alt text, http://example.com/img.png]",
513 },
514 }
515
516 // Regex to strip out ANSI SGR escape codes (e.g. \x1b[38;2;...m)
517 ansiEscapeRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`)
518
519 for _, tc := range testCases {
520 t.Run(tc.name, func(t *testing.T) {
521 tc.setupHyperlinks()
522
523 processed, _, err := ProcessBody(tc.input, h1Style, h2Style, bodyStyle, false)
524 if err != nil {
525 t.Fatalf("ProcessBody() failed: %v", err)
526 }
527
528 cleanProcessed := ansiEscapeRegex.ReplaceAllString(processed, "")
529
530 if !strings.Contains(cleanProcessed, tc.expectedContains) {
531 t.Errorf("Processed body does not contain expected text.\nGot: %q\nWant to contain: %q", cleanProcessed, tc.expectedContains)
532 }
533
534 if tc.expectedNotContains != "" && strings.Contains(cleanProcessed, tc.expectedNotContains) {
535 t.Errorf("Processed body contains unexpected text.\nGot: %q\nShould not contain: %q", cleanProcessed, tc.expectedNotContains)
536 }
537 })
538 }
539}
540
541func TestProcessBodyWithImageProtocol(t *testing.T) {
542 // Save original environment variables
543 origTerm := os.Getenv("TERM")
544 origTermProgram := os.Getenv("TERM_PROGRAM")
545 origKittyWindow := os.Getenv("KITTY_WINDOW_ID")
546 origGhosttyResources := os.Getenv("GHOSTTY_RESOURCES_DIR")
547 origItermlSession := os.Getenv("ITERM_SESSION_ID")
548 origWeztermExec := os.Getenv("WEZTERM_EXECUTABLE")
549
550 // Restore environment variables after test
551 defer func() {
552 os.Setenv("TERM", origTerm)
553 os.Setenv("TERM_PROGRAM", origTermProgram)
554 os.Setenv("KITTY_WINDOW_ID", origKittyWindow)
555 os.Setenv("GHOSTTY_RESOURCES_DIR", origGhosttyResources)
556 os.Setenv("ITERM_SESSION_ID", origItermlSession)
557 os.Setenv("WEZTERM_EXECUTABLE", origWeztermExec)
558 }()
559
560 h1Style := lipgloss.NewStyle().SetString("H1")
561 h2Style := lipgloss.NewStyle().SetString("H2")
562 bodyStyle := lipgloss.NewStyle().SetString("BODY")
563
564 // Create a simple base64 PNG image (1x1 pixel white PNG)
565 testBase64PNG := "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="
566
567 testCases := []struct {
568 name string
569 setupImageProtocol func()
570 clearAllImageEnv func()
571 input string
572 expectedContains string
573 expectedNotContains string
574 expectPlacements bool
575 }{
576 {
577 name: "Data URI image with Kitty support returns placement",
578 setupImageProtocol: func() {
579 os.Setenv("TERM", "xterm-kitty")
580 },
581 clearAllImageEnv: func() {
582 os.Unsetenv("KITTY_WINDOW_ID")
583 os.Unsetenv("GHOSTTY_RESOURCES_DIR")
584 os.Unsetenv("ITERM_SESSION_ID")
585 os.Unsetenv("WEZTERM_EXECUTABLE")
586 },
587 input: `<img src="data:image/png;base64,` + testBase64PNG + `" alt="test image">`,
588 expectedNotContains: "[Image: test image,",
589 expectPlacements: true,
590 },
591 {
592 name: "Data URI image with iTerm2 support returns placement",
593 setupImageProtocol: func() {
594 os.Setenv("TERM", "xterm")
595 os.Setenv("TERM_PROGRAM", "iterm.app")
596 },
597 clearAllImageEnv: func() {
598 os.Unsetenv("KITTY_WINDOW_ID")
599 os.Unsetenv("GHOSTTY_RESOURCES_DIR")
600 os.Unsetenv("ITERM_SESSION_ID")
601 os.Unsetenv("WEZTERM_EXECUTABLE")
602 },
603 input: `<img src="data:image/png;base64,` + testBase64PNG + `" alt="test image">`,
604 expectedNotContains: "[Image: test image,",
605 expectPlacements: true,
606 },
607 {
608 name: "Data URI image without protocol support",
609 setupImageProtocol: func() {
610 clearAllTerminalEnv()
611 },
612 clearAllImageEnv: func() {
613 // This is handled by clearAllTerminalEnv now
614 },
615 input: `<img src="data:image/png;base64,` + testBase64PNG + `" alt="test image">`,
616 expectedContains: "[Image: test image,",
617 },
618 {
619 name: "Remote image with WezTerm support (has hyperlink support)",
620 setupImageProtocol: func() {
621 clearAllTerminalEnv()
622 os.Setenv("WEZTERM_EXECUTABLE", "/usr/bin/wezterm")
623 },
624 clearAllImageEnv: func() {
625 // This is handled by clearAllTerminalEnv now
626 },
627 input: `<img src="http://example.com/img.png" alt="remote image">`,
628 expectedContains: "[Click here to view image: remote image]", // Remote images won't render without actual fetch, but hyperlinks work
629 },
630 {
631 name: "Remote image without protocol support",
632 setupImageProtocol: func() {
633 clearAllTerminalEnv()
634 },
635 clearAllImageEnv: func() {
636 // This is handled by clearAllTerminalEnv now
637 },
638 input: `<img src="http://example.com/img.png" alt="remote image">`,
639 expectedContains: "[Image: remote image,",
640 },
641 }
642
643 ansiEscapeRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`)
644
645 for _, tc := range testCases {
646 t.Run(tc.name, func(t *testing.T) {
647 tc.clearAllImageEnv()
648 tc.setupImageProtocol()
649
650 processed, placements, err := ProcessBody(tc.input, h1Style, h2Style, bodyStyle, false)
651 if err != nil {
652 t.Fatalf("ProcessBody() failed: %v", err)
653 }
654
655 if tc.expectPlacements {
656 if len(placements) == 0 {
657 t.Errorf("Expected image placements but got none")
658 } else {
659 if placements[0].Base64 == "" {
660 t.Errorf("Expected non-empty Base64 in placement")
661 }
662 if placements[0].Rows < 1 {
663 t.Errorf("Expected Rows >= 1, got %d", placements[0].Rows)
664 }
665 }
666 }
667
668 cleanProcessed := ansiEscapeRegex.ReplaceAllString(processed, "")
669
670 if tc.expectedContains != "" && !strings.Contains(cleanProcessed, tc.expectedContains) {
671 t.Errorf("Processed body does not contain expected text.\nGot: %q\nWant to contain: %q", cleanProcessed, tc.expectedContains)
672 }
673
674 if tc.expectedNotContains != "" && strings.Contains(cleanProcessed, tc.expectedNotContains) {
675 t.Errorf("Processed body contains unexpected text.\nGot: %q\nShould not contain: %q", cleanProcessed, tc.expectedNotContains)
676 }
677 })
678 }
679}
680
681func TestProcessBody(t *testing.T) {
682 h1Style := lipgloss.NewStyle().SetString("H1")
683 h2Style := lipgloss.NewStyle().SetString("H2")
684 bodyStyle := lipgloss.NewStyle().SetString("BODY")
685
686 testCases := []struct {
687 name string
688 input string
689 expected string
690 }{
691 {
692 name: "Simple HTML",
693 input: "<p>Hello, world!</p>",
694 expected: "Hello, world!",
695 },
696 {
697 name: "With headers HTML",
698 input: "<h1>Header 1</h1>",
699 expected: "Header 1",
700 },
701 {
702 name: "With headers Markdown",
703 input: "# Header 1",
704 expected: "Header 1",
705 },
706 {
707 name: "Plain text",
708 input: "Just plain text without any markup",
709 expected: "Just plain text without any markup",
710 },
711 }
712
713 ansiEscapeRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`)
714
715 for _, tc := range testCases {
716 t.Run(tc.name, func(t *testing.T) {
717 processed, _, err := ProcessBody(tc.input, h1Style, h2Style, bodyStyle, false)
718 if err != nil {
719 t.Fatalf("ProcessBody() failed: %v", err)
720 }
721
722 cleanProcessed := ansiEscapeRegex.ReplaceAllString(processed, "")
723
724 if !strings.Contains(cleanProcessed, tc.expected) {
725 t.Errorf("Processed body does not contain expected text.\nGot: %q\nWant to contain: %q", cleanProcessed, tc.expected)
726 }
727 })
728 }
729}
730
731func TestRemoteImageCache_EvictsOldestWhenFull(t *testing.T) {
732 // Start with a clean cache so prior tests don't interfere.
733 remoteImageCache.Purge()
734 // cleaning up the current test's cache
735 defer remoteImageCache.Purge()
736
737 // overfilling the cache beyond its configured capacity.
738 overfillBy := 5
739 totalInserts := remoteImageCacheSize + overfillBy
740 for i := range totalInserts {
741 url := fmt.Sprintf("https://example.com/img%d.png", i)
742 remoteImageCache.Add(url, "fake-base64-data")
743 }
744
745 // cache should not be overfilled beyond it's capped size
746 if got := remoteImageCache.Len(); got != remoteImageCacheSize {
747 t.Errorf("expected cache size %d, got %d", remoteImageCacheSize, got)
748 }
749
750 // old entries should be evicted
751 for i := range overfillBy {
752 evictedURL := fmt.Sprintf("https://example.com/img%d.png", i)
753 if _, ok := remoteImageCache.Get(evictedURL); ok {
754 t.Errorf("expected %q to be evicted, but it's still in cache", evictedURL)
755 }
756 }
757
758 // The most recent entries should still be present.
759 for i := overfillBy; i < totalInserts; i++ {
760 keptURL := fmt.Sprintf("https://example.com/img%d.png", i)
761 if _, ok := remoteImageCache.Get(keptURL); !ok {
762 t.Errorf("expected %q to still be in cache", keptURL)
763 }
764 }
765}
766
767func TestAllocImageID_NoRace(t *testing.T) {
768 // Reset the counter so IDs start from a known value.
769 atomic.StoreUint32(&nextImageID, 1000)
770
771 const goroutines = 100
772 const idsPerGoroutine = 100
773
774 results := make(chan uint32, goroutines*idsPerGoroutine)
775
776 var wg sync.WaitGroup
777 wg.Add(goroutines)
778 for range goroutines {
779 go func() {
780 defer wg.Done()
781 for range idsPerGoroutine {
782 results <- allocImageID()
783 }
784 }()
785 }
786
787 // Close channel once all writers are done.
788 go func() {
789 wg.Wait()
790 close(results)
791 }()
792
793 // Collect all IDs and verify uniqueness.
794 seen := make(map[uint32]bool, goroutines*idsPerGoroutine)
795 for id := range results {
796 if seen[id] {
797 t.Fatalf("duplicate image ID allocated: %d (race condition detected)", id)
798 }
799 seen[id] = true
800 }
801
802 expected := uint32(goroutines * idsPerGoroutine)
803 if uint32(len(seen)) != expected {
804 t.Errorf("expected %d unique IDs, got %d", expected, len(seen))
805 }
806}