html_test.go

  1package view
  2
  3import (
  4	"fmt"
  5	"os"
  6	"regexp"
  7	"strings"
  8	"testing"
  9
 10	"charm.land/lipgloss/v2"
 11)
 12
 13// clearAllTerminalEnv clears all environment variables that could indicate terminal capabilities
 14func clearAllTerminalEnv() {
 15	// Clear hyperlink support indicators
 16	os.Unsetenv("VTE_VERSION")
 17	os.Unsetenv("KITTY_WINDOW_ID")
 18	os.Unsetenv("GHOSTTY_RESOURCES_DIR")
 19	os.Unsetenv("WEZTERM_EXECUTABLE")
 20	os.Unsetenv("WEZTERM_CONFIG_FILE")
 21	os.Unsetenv("ITERM_SESSION_ID")
 22	os.Unsetenv("ITERM_PROFILE")
 23	os.Unsetenv("WARP_IS_LOCAL_SHELL_SESSION")
 24	os.Unsetenv("WARP_COMBINED_PROMPT_COMMAND_FINISHED")
 25	os.Unsetenv("KONSOLE_DBUS_SESSION")
 26	os.Unsetenv("KONSOLE_VERSION")
 27
 28	// Set basic terminal that doesn't support anything special
 29	os.Setenv("TERM", "xterm")
 30	os.Setenv("TERM_PROGRAM", "basic")
 31}
 32
 33func TestDecodeQuotedPrintable(t *testing.T) {
 34	testCases := []struct {
 35		name     string
 36		input    string
 37		expected string
 38	}{
 39		{
 40			name:     "Simple case",
 41			input:    "Hello=2C world=21",
 42			expected: "Hello, world!",
 43		},
 44		{
 45			name:     "With soft line break",
 46			input:    "This is a long line that gets wrapped=\r\n and continues here.",
 47			expected: "This is a long line that gets wrapped and continues here.",
 48		},
 49		{
 50			name:     "No encoding",
 51			input:    "Just a plain string.",
 52			expected: "Just a plain string.",
 53		},
 54	}
 55
 56	for _, tc := range testCases {
 57		t.Run(tc.name, func(t *testing.T) {
 58			decoded, err := decodeQuotedPrintable(tc.input)
 59			if err != nil {
 60				t.Fatalf("decodeQuotedPrintable() failed: %v", err)
 61			}
 62			if decoded != tc.expected {
 63				t.Errorf("Expected %q, got %q", tc.expected, decoded)
 64			}
 65		})
 66	}
 67}
 68
 69func TestMarkdownToHTML(t *testing.T) {
 70	testCases := []struct {
 71		name     string
 72		input    string
 73		expected string
 74	}{
 75		{
 76			name:     "Heading",
 77			input:    "# Hello",
 78			expected: "<h1>Hello</h1>",
 79		},
 80		{
 81			name:     "Bold",
 82			input:    "**bold text**",
 83			expected: "<p><strong>bold text</strong></p>",
 84		},
 85		{
 86			name:     "Link",
 87			input:    "[link](http://example.com)",
 88			expected: `<p><a href="http://example.com">link</a></p>`,
 89		},
 90	}
 91
 92	for _, tc := range testCases {
 93		t.Run(tc.name, func(t *testing.T) {
 94			html := markdownToHTML([]byte(tc.input))
 95			// Trim newlines for consistent comparison
 96			if strings.TrimSpace(string(html)) != tc.expected {
 97				t.Errorf("Expected %s, got %s", tc.expected, html)
 98			}
 99		})
100	}
101}
102
103func TestGhosttySupported(t *testing.T) {
104	// Save original environment variables
105	origTerm := os.Getenv("TERM")
106	origTermProgram := os.Getenv("TERM_PROGRAM")
107	origGhosttyResources := os.Getenv("GHOSTTY_RESOURCES_DIR")
108
109	// Restore environment variables after test
110	defer func() {
111		os.Setenv("TERM", origTerm)
112		os.Setenv("TERM_PROGRAM", origTermProgram)
113		os.Setenv("GHOSTTY_RESOURCES_DIR", origGhosttyResources)
114	}()
115
116	testCases := []struct {
117		name                string
118		term                string
119		termProgram         string
120		ghosttyResourcesDir string
121		expected            bool
122	}{
123		{
124			name:                "No Ghostty environment variables",
125			term:                "xterm",
126			termProgram:         "",
127			ghosttyResourcesDir: "",
128			expected:            false,
129		},
130		{
131			name:                "TERM contains ghostty",
132			term:                "xterm-ghostty",
133			termProgram:         "",
134			ghosttyResourcesDir: "",
135			expected:            true,
136		},
137		{
138			name:                "TERM_PROGRAM is ghostty",
139			term:                "xterm",
140			termProgram:         "ghostty",
141			ghosttyResourcesDir: "",
142			expected:            true,
143		},
144		{
145			name:                "GHOSTTY_RESOURCES_DIR is set",
146			term:                "xterm",
147			termProgram:         "",
148			ghosttyResourcesDir: "/usr/share/ghostty",
149			expected:            true,
150		},
151		{
152			name:                "Multiple Ghostty indicators",
153			term:                "ghostty",
154			termProgram:         "ghostty",
155			ghosttyResourcesDir: "/usr/share/ghostty",
156			expected:            true,
157		},
158	}
159
160	for _, tc := range testCases {
161		t.Run(tc.name, func(t *testing.T) {
162			os.Setenv("TERM", tc.term)
163			os.Setenv("TERM_PROGRAM", tc.termProgram)
164			os.Setenv("GHOSTTY_RESOURCES_DIR", tc.ghosttyResourcesDir)
165
166			result := ghosttySupported()
167			if result != tc.expected {
168				t.Errorf("Expected %t, got %t", tc.expected, result)
169			}
170		})
171	}
172}
173
174func TestImageProtocolSupported(t *testing.T) {
175	// Save original environment variables
176	origTerm := os.Getenv("TERM")
177	origKittyWindow := os.Getenv("KITTY_WINDOW_ID")
178	origTermProgram := os.Getenv("TERM_PROGRAM")
179	origGhosttyResources := os.Getenv("GHOSTTY_RESOURCES_DIR")
180	origItermlSession := os.Getenv("ITERM_SESSION_ID")
181	origWeztermExec := os.Getenv("WEZTERM_EXECUTABLE")
182	origWarpLocal := os.Getenv("WARP_IS_LOCAL_SHELL_SESSION")
183	origKonsoleDBus := os.Getenv("KONSOLE_DBUS_SESSION")
184
185	// Restore environment variables after test
186	defer func() {
187		os.Setenv("TERM", origTerm)
188		os.Setenv("KITTY_WINDOW_ID", origKittyWindow)
189		os.Setenv("TERM_PROGRAM", origTermProgram)
190		os.Setenv("GHOSTTY_RESOURCES_DIR", origGhosttyResources)
191		os.Setenv("ITERM_SESSION_ID", origItermlSession)
192		os.Setenv("WEZTERM_EXECUTABLE", origWeztermExec)
193		os.Setenv("WARP_IS_LOCAL_SHELL_SESSION", origWarpLocal)
194		os.Setenv("KONSOLE_DBUS_SESSION", origKonsoleDBus)
195	}()
196
197	testCases := []struct {
198		name        string
199		setupEnv    func()
200		clearAllEnv func()
201		expected    bool
202	}{
203		{
204			name: "No supported terminals",
205			setupEnv: func() {
206				os.Setenv("TERM", "xterm")
207				os.Setenv("TERM_PROGRAM", "basic")
208			},
209			clearAllEnv: func() {
210				os.Unsetenv("KITTY_WINDOW_ID")
211				os.Unsetenv("GHOSTTY_RESOURCES_DIR")
212				os.Unsetenv("ITERM_SESSION_ID")
213				os.Unsetenv("WEZTERM_EXECUTABLE")
214				os.Unsetenv("WARP_IS_LOCAL_SHELL_SESSION")
215				os.Unsetenv("KONSOLE_DBUS_SESSION")
216			},
217			expected: false,
218		},
219		{
220			name: "Kitty supported via TERM",
221			setupEnv: func() {
222				os.Setenv("TERM", "xterm-kitty")
223			},
224			clearAllEnv: func() {
225				os.Unsetenv("KITTY_WINDOW_ID")
226				os.Unsetenv("GHOSTTY_RESOURCES_DIR")
227				os.Unsetenv("ITERM_SESSION_ID")
228				os.Unsetenv("WEZTERM_EXECUTABLE")
229				os.Unsetenv("WARP_IS_LOCAL_SHELL_SESSION")
230				os.Unsetenv("KONSOLE_DBUS_SESSION")
231			},
232			expected: true,
233		},
234		{
235			name: "Kitty supported via KITTY_WINDOW_ID",
236			setupEnv: func() {
237				os.Setenv("TERM", "xterm")
238				os.Setenv("KITTY_WINDOW_ID", "1")
239			},
240			clearAllEnv: func() {
241				os.Unsetenv("GHOSTTY_RESOURCES_DIR")
242				os.Unsetenv("ITERM_SESSION_ID")
243				os.Unsetenv("WEZTERM_EXECUTABLE")
244				os.Unsetenv("WARP_IS_LOCAL_SHELL_SESSION")
245				os.Unsetenv("KONSOLE_DBUS_SESSION")
246			},
247			expected: true,
248		},
249		{
250			name: "Ghostty supported via TERM_PROGRAM",
251			setupEnv: func() {
252				os.Setenv("TERM", "xterm")
253				os.Setenv("TERM_PROGRAM", "ghostty")
254			},
255			clearAllEnv: func() {
256				os.Unsetenv("KITTY_WINDOW_ID")
257				os.Unsetenv("GHOSTTY_RESOURCES_DIR")
258				os.Unsetenv("ITERM_SESSION_ID")
259				os.Unsetenv("WEZTERM_EXECUTABLE")
260				os.Unsetenv("WARP_IS_LOCAL_SHELL_SESSION")
261				os.Unsetenv("KONSOLE_DBUS_SESSION")
262			},
263			expected: true,
264		},
265		{
266			name: "iTerm2 supported via TERM_PROGRAM",
267			setupEnv: func() {
268				os.Setenv("TERM", "xterm")
269				os.Setenv("TERM_PROGRAM", "iterm.app")
270			},
271			clearAllEnv: func() {
272				os.Unsetenv("KITTY_WINDOW_ID")
273				os.Unsetenv("GHOSTTY_RESOURCES_DIR")
274				os.Unsetenv("ITERM_SESSION_ID")
275				os.Unsetenv("WEZTERM_EXECUTABLE")
276				os.Unsetenv("WARP_IS_LOCAL_SHELL_SESSION")
277				os.Unsetenv("KONSOLE_DBUS_SESSION")
278			},
279			expected: true,
280		},
281		{
282			name: "WezTerm supported via WEZTERM_EXECUTABLE",
283			setupEnv: func() {
284				os.Setenv("TERM", "xterm")
285				os.Setenv("WEZTERM_EXECUTABLE", "/usr/bin/wezterm")
286			},
287			clearAllEnv: func() {
288				os.Unsetenv("KITTY_WINDOW_ID")
289				os.Unsetenv("GHOSTTY_RESOURCES_DIR")
290				os.Unsetenv("ITERM_SESSION_ID")
291				os.Unsetenv("WARP_IS_LOCAL_SHELL_SESSION")
292				os.Unsetenv("KONSOLE_DBUS_SESSION")
293			},
294			expected: true,
295		},
296		{
297			name: "Warp supported via WARP_IS_LOCAL_SHELL_SESSION",
298			setupEnv: func() {
299				os.Setenv("TERM", "xterm")
300				os.Setenv("WARP_IS_LOCAL_SHELL_SESSION", "1")
301			},
302			clearAllEnv: func() {
303				os.Unsetenv("KITTY_WINDOW_ID")
304				os.Unsetenv("GHOSTTY_RESOURCES_DIR")
305				os.Unsetenv("ITERM_SESSION_ID")
306				os.Unsetenv("WEZTERM_EXECUTABLE")
307				os.Unsetenv("KONSOLE_DBUS_SESSION")
308			},
309			expected: true,
310		},
311		{
312			name: "Konsole supported via KONSOLE_DBUS_SESSION",
313			setupEnv: func() {
314				os.Setenv("TERM", "xterm")
315				os.Setenv("KONSOLE_DBUS_SESSION", "/Sessions/1")
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			},
324			expected: true,
325		},
326	}
327
328	for _, tc := range testCases {
329		t.Run(tc.name, func(t *testing.T) {
330			tc.clearAllEnv()
331			tc.setupEnv()
332
333			result := imageProtocolSupported()
334			if result != tc.expected {
335				t.Errorf("Expected %t, got %t", tc.expected, result)
336			}
337		})
338	}
339}
340
341func TestHyperlinkSupported(t *testing.T) {
342	// Save original environment variables
343	origTerm := os.Getenv("TERM")
344	origTermProgram := os.Getenv("TERM_PROGRAM")
345	origVTEVersion := os.Getenv("VTE_VERSION")
346	origKittyWindow := os.Getenv("KITTY_WINDOW_ID")
347	origGhosttyResources := os.Getenv("GHOSTTY_RESOURCES_DIR")
348	origWeztermExec := os.Getenv("WEZTERM_EXECUTABLE")
349
350	// Restore environment variables after test
351	defer func() {
352		os.Setenv("TERM", origTerm)
353		os.Setenv("TERM_PROGRAM", origTermProgram)
354		os.Setenv("VTE_VERSION", origVTEVersion)
355		os.Setenv("KITTY_WINDOW_ID", origKittyWindow)
356		os.Setenv("GHOSTTY_RESOURCES_DIR", origGhosttyResources)
357		os.Setenv("WEZTERM_EXECUTABLE", origWeztermExec)
358	}()
359
360	testCases := []struct {
361		name        string
362		setupEnv    func()
363		clearAllEnv func()
364		expected    bool
365	}{
366		{
367			name: "No hyperlink support",
368			setupEnv: func() {
369				os.Setenv("TERM", "xterm")
370				os.Setenv("TERM_PROGRAM", "basic")
371			},
372			clearAllEnv: func() {
373				os.Unsetenv("VTE_VERSION")
374				os.Unsetenv("KITTY_WINDOW_ID")
375				os.Unsetenv("GHOSTTY_RESOURCES_DIR")
376				os.Unsetenv("WEZTERM_EXECUTABLE")
377			},
378			expected: false,
379		},
380		{
381			name: "Kitty hyperlink support via TERM",
382			setupEnv: func() {
383				os.Setenv("TERM", "xterm-kitty")
384			},
385			clearAllEnv: func() {
386				os.Unsetenv("VTE_VERSION")
387				os.Unsetenv("KITTY_WINDOW_ID")
388				os.Unsetenv("GHOSTTY_RESOURCES_DIR")
389				os.Unsetenv("WEZTERM_EXECUTABLE")
390			},
391			expected: true,
392		},
393		{
394			name: "VTE-based terminal hyperlink support",
395			setupEnv: func() {
396				os.Setenv("TERM", "xterm")
397				os.Setenv("VTE_VERSION", "0.60.3")
398			},
399			clearAllEnv: func() {
400				os.Unsetenv("KITTY_WINDOW_ID")
401				os.Unsetenv("GHOSTTY_RESOURCES_DIR")
402				os.Unsetenv("WEZTERM_EXECUTABLE")
403			},
404			expected: true,
405		},
406		{
407			name: "iTerm2 hyperlink support",
408			setupEnv: func() {
409				os.Setenv("TERM", "xterm")
410				os.Setenv("TERM_PROGRAM", "iterm.app")
411			},
412			clearAllEnv: func() {
413				os.Unsetenv("VTE_VERSION")
414				os.Unsetenv("KITTY_WINDOW_ID")
415				os.Unsetenv("GHOSTTY_RESOURCES_DIR")
416				os.Unsetenv("WEZTERM_EXECUTABLE")
417			},
418			expected: true,
419		},
420		{
421			name: "WezTerm hyperlink support",
422			setupEnv: func() {
423				os.Setenv("TERM", "xterm")
424				os.Setenv("WEZTERM_EXECUTABLE", "/usr/bin/wezterm")
425			},
426			clearAllEnv: func() {
427				os.Unsetenv("VTE_VERSION")
428				os.Unsetenv("KITTY_WINDOW_ID")
429				os.Unsetenv("GHOSTTY_RESOURCES_DIR")
430			},
431			expected: true,
432		},
433	}
434
435	for _, tc := range testCases {
436		t.Run(tc.name, func(t *testing.T) {
437			tc.clearAllEnv()
438			tc.setupEnv()
439
440			result := hyperlinkSupported()
441			if result != tc.expected {
442				t.Errorf("Expected %t, got %t", tc.expected, result)
443			}
444		})
445	}
446}
447
448func TestProcessBodyWithHyperlinkSupport(t *testing.T) {
449	// Save original environment variables
450	origTerm := os.Getenv("TERM")
451	origTermProgram := os.Getenv("TERM_PROGRAM")
452	origVTEVersion := os.Getenv("VTE_VERSION")
453	origKittyWindow := os.Getenv("KITTY_WINDOW_ID")
454
455	// Restore environment variables after test
456	defer func() {
457		os.Setenv("TERM", origTerm)
458		os.Setenv("TERM_PROGRAM", origTermProgram)
459		os.Setenv("VTE_VERSION", origVTEVersion)
460		os.Setenv("KITTY_WINDOW_ID", origKittyWindow)
461	}()
462
463	h1Style := lipgloss.NewStyle().SetString("H1")
464	h2Style := lipgloss.NewStyle().SetString("H2")
465	bodyStyle := lipgloss.NewStyle().SetString("BODY")
466
467	testCases := []struct {
468		name                string
469		setupHyperlinks     func()
470		input               string
471		expectedContains    string
472		expectedNotContains string
473	}{
474		{
475			name: "Link with hyperlink support",
476			setupHyperlinks: func() {
477				os.Setenv("TERM", "xterm-kitty")
478				os.Unsetenv("VTE_VERSION")
479				os.Unsetenv("KITTY_WINDOW_ID")
480			},
481			input:               `<a href="http://example.com">Click here</a>`,
482			expectedContains:    "Click here",
483			expectedNotContains: "<http://example.com>",
484		},
485		{
486			name: "Link without hyperlink support",
487			setupHyperlinks: func() {
488				clearAllTerminalEnv()
489			},
490			input:            `<a href="http://example.com">Click here</a>`,
491			expectedContains: "Click here <http://example.com>",
492		},
493		{
494			name: "Image link with hyperlink support",
495			setupHyperlinks: func() {
496				os.Setenv("TERM", "xterm")
497				os.Setenv("VTE_VERSION", "0.60.3")
498				os.Unsetenv("KITTY_WINDOW_ID")
499			},
500			input:               `<img src="http://example.com/img.png" alt="alt text">`,
501			expectedContains:    "[Click here to view image: alt text]",
502			expectedNotContains: "<http://example.com/img.png>",
503		},
504		{
505			name: "Image link without hyperlink support",
506			setupHyperlinks: func() {
507				clearAllTerminalEnv()
508			},
509			input:            `<img src="http://example.com/img.png" alt="alt text">`,
510			expectedContains: "[Image: alt text, http://example.com/img.png]",
511		},
512	}
513
514	// Regex to strip out ANSI SGR escape codes (e.g. \x1b[38;2;...m)
515	ansiEscapeRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`)
516
517	for _, tc := range testCases {
518		t.Run(tc.name, func(t *testing.T) {
519			tc.setupHyperlinks()
520
521			processed, _, err := ProcessBody(tc.input, h1Style, h2Style, bodyStyle, false)
522			if err != nil {
523				t.Fatalf("ProcessBody() failed: %v", err)
524			}
525
526			cleanProcessed := ansiEscapeRegex.ReplaceAllString(processed, "")
527
528			if !strings.Contains(cleanProcessed, tc.expectedContains) {
529				t.Errorf("Processed body does not contain expected text.\nGot: %q\nWant to contain: %q", cleanProcessed, tc.expectedContains)
530			}
531
532			if tc.expectedNotContains != "" && strings.Contains(cleanProcessed, tc.expectedNotContains) {
533				t.Errorf("Processed body contains unexpected text.\nGot: %q\nShould not contain: %q", cleanProcessed, tc.expectedNotContains)
534			}
535		})
536	}
537}
538
539func TestProcessBodyWithImageProtocol(t *testing.T) {
540	// Save original environment variables
541	origTerm := os.Getenv("TERM")
542	origTermProgram := os.Getenv("TERM_PROGRAM")
543	origKittyWindow := os.Getenv("KITTY_WINDOW_ID")
544	origGhosttyResources := os.Getenv("GHOSTTY_RESOURCES_DIR")
545	origItermlSession := os.Getenv("ITERM_SESSION_ID")
546	origWeztermExec := os.Getenv("WEZTERM_EXECUTABLE")
547
548	// Restore environment variables after test
549	defer func() {
550		os.Setenv("TERM", origTerm)
551		os.Setenv("TERM_PROGRAM", origTermProgram)
552		os.Setenv("KITTY_WINDOW_ID", origKittyWindow)
553		os.Setenv("GHOSTTY_RESOURCES_DIR", origGhosttyResources)
554		os.Setenv("ITERM_SESSION_ID", origItermlSession)
555		os.Setenv("WEZTERM_EXECUTABLE", origWeztermExec)
556	}()
557
558	h1Style := lipgloss.NewStyle().SetString("H1")
559	h2Style := lipgloss.NewStyle().SetString("H2")
560	bodyStyle := lipgloss.NewStyle().SetString("BODY")
561
562	// Create a simple base64 PNG image (1x1 pixel white PNG)
563	testBase64PNG := "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="
564
565	testCases := []struct {
566		name                string
567		setupImageProtocol  func()
568		clearAllImageEnv    func()
569		input               string
570		expectedContains    string
571		expectedNotContains string
572		expectPlacements    bool
573	}{
574		{
575			name: "Data URI image with Kitty support returns placement",
576			setupImageProtocol: func() {
577				os.Setenv("TERM", "xterm-kitty")
578			},
579			clearAllImageEnv: func() {
580				os.Unsetenv("KITTY_WINDOW_ID")
581				os.Unsetenv("GHOSTTY_RESOURCES_DIR")
582				os.Unsetenv("ITERM_SESSION_ID")
583				os.Unsetenv("WEZTERM_EXECUTABLE")
584			},
585			input:               `<img src="data:image/png;base64,` + testBase64PNG + `" alt="test image">`,
586			expectedNotContains: "[Image: test image,",
587			expectPlacements:    true,
588		},
589		{
590			name: "Data URI image with iTerm2 support returns placement",
591			setupImageProtocol: func() {
592				os.Setenv("TERM", "xterm")
593				os.Setenv("TERM_PROGRAM", "iterm.app")
594			},
595			clearAllImageEnv: func() {
596				os.Unsetenv("KITTY_WINDOW_ID")
597				os.Unsetenv("GHOSTTY_RESOURCES_DIR")
598				os.Unsetenv("ITERM_SESSION_ID")
599				os.Unsetenv("WEZTERM_EXECUTABLE")
600			},
601			input:               `<img src="data:image/png;base64,` + testBase64PNG + `" alt="test image">`,
602			expectedNotContains: "[Image: test image,",
603			expectPlacements:    true,
604		},
605		{
606			name: "Data URI image without protocol support",
607			setupImageProtocol: func() {
608				clearAllTerminalEnv()
609			},
610			clearAllImageEnv: func() {
611				// This is handled by clearAllTerminalEnv now
612			},
613			input:            `<img src="data:image/png;base64,` + testBase64PNG + `" alt="test image">`,
614			expectedContains: "[Image: test image,",
615		},
616		{
617			name: "Remote image with WezTerm support (has hyperlink support)",
618			setupImageProtocol: func() {
619				clearAllTerminalEnv()
620				os.Setenv("WEZTERM_EXECUTABLE", "/usr/bin/wezterm")
621			},
622			clearAllImageEnv: func() {
623				// This is handled by clearAllTerminalEnv now
624			},
625			input:            `<img src="http://example.com/img.png" alt="remote image">`,
626			expectedContains: "[Click here to view image: remote image]", // Remote images won't render without actual fetch, but hyperlinks work
627		},
628		{
629			name: "Remote image without protocol support",
630			setupImageProtocol: func() {
631				clearAllTerminalEnv()
632			},
633			clearAllImageEnv: func() {
634				// This is handled by clearAllTerminalEnv now
635			},
636			input:            `<img src="http://example.com/img.png" alt="remote image">`,
637			expectedContains: "[Image: remote image,",
638		},
639	}
640
641	ansiEscapeRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`)
642
643	for _, tc := range testCases {
644		t.Run(tc.name, func(t *testing.T) {
645			tc.clearAllImageEnv()
646			tc.setupImageProtocol()
647
648			processed, placements, err := ProcessBody(tc.input, h1Style, h2Style, bodyStyle, false)
649			if err != nil {
650				t.Fatalf("ProcessBody() failed: %v", err)
651			}
652
653			if tc.expectPlacements {
654				if len(placements) == 0 {
655					t.Errorf("Expected image placements but got none")
656				} else {
657					if placements[0].Base64 == "" {
658						t.Errorf("Expected non-empty Base64 in placement")
659					}
660					if placements[0].Rows < 1 {
661						t.Errorf("Expected Rows >= 1, got %d", placements[0].Rows)
662					}
663				}
664			}
665
666			cleanProcessed := ansiEscapeRegex.ReplaceAllString(processed, "")
667
668			if tc.expectedContains != "" && !strings.Contains(cleanProcessed, tc.expectedContains) {
669				t.Errorf("Processed body does not contain expected text.\nGot: %q\nWant to contain: %q", cleanProcessed, tc.expectedContains)
670			}
671
672			if tc.expectedNotContains != "" && strings.Contains(cleanProcessed, tc.expectedNotContains) {
673				t.Errorf("Processed body contains unexpected text.\nGot: %q\nShould not contain: %q", cleanProcessed, tc.expectedNotContains)
674			}
675		})
676	}
677}
678
679func TestProcessBody(t *testing.T) {
680	h1Style := lipgloss.NewStyle().SetString("H1")
681	h2Style := lipgloss.NewStyle().SetString("H2")
682	bodyStyle := lipgloss.NewStyle().SetString("BODY")
683
684	testCases := []struct {
685		name     string
686		input    string
687		expected string
688	}{
689		{
690			name:     "Simple HTML",
691			input:    "<p>Hello, world!</p>",
692			expected: "Hello, world!",
693		},
694		{
695			name:     "With headers HTML",
696			input:    "<h1>Header 1</h1>",
697			expected: "Header 1",
698		},
699		{
700			name:     "With headers Markdown",
701			input:    "# Header 1",
702			expected: "Header 1",
703		},
704		{
705			name:     "Plain text",
706			input:    "Just plain text without any markup",
707			expected: "Just plain text without any markup",
708		},
709	}
710
711	ansiEscapeRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`)
712
713	for _, tc := range testCases {
714		t.Run(tc.name, func(t *testing.T) {
715			processed, _, err := ProcessBody(tc.input, h1Style, h2Style, bodyStyle, false)
716			if err != nil {
717				t.Fatalf("ProcessBody() failed: %v", err)
718			}
719
720			cleanProcessed := ansiEscapeRegex.ReplaceAllString(processed, "")
721
722			if !strings.Contains(cleanProcessed, tc.expected) {
723				t.Errorf("Processed body does not contain expected text.\nGot: %q\nWant to contain: %q", cleanProcessed, tc.expected)
724			}
725		})
726	}
727}
728
729func TestRemoteImageCache_EvictsOldestWhenFull(t *testing.T) {
730	// Start with a clean cache so prior tests don't interfere.
731	remoteImageCache.Purge()
732	// cleaning up the current test's cache
733	defer remoteImageCache.Purge()
734
735	// overfilling the cache beyond its configured capacity.
736	overfillBy := 5
737	totalInserts := remoteImageCacheSize + overfillBy
738	for i := range totalInserts {
739		url := fmt.Sprintf("https://example.com/img%d.png", i)
740		remoteImageCache.Add(url, "fake-base64-data")
741	}
742
743	// cache should not be overfilled beyond it's capped size
744	if got := remoteImageCache.Len(); got != remoteImageCacheSize {
745		t.Errorf("expected cache size %d, got %d", remoteImageCacheSize, got)
746	}
747
748	// old entries should be evicted
749	for i := range overfillBy {
750		evictedURL := fmt.Sprintf("https://example.com/img%d.png", i)
751		if _, ok := remoteImageCache.Get(evictedURL); ok {
752			t.Errorf("expected %q to be evicted, but it's still in cache", evictedURL)
753		}
754	}
755
756	// The most recent entries should still be present.
757	for i := overfillBy; i < totalInserts; i++ {
758		keptURL := fmt.Sprintf("https://example.com/img%d.png", i)
759		if _, ok := remoteImageCache.Get(keptURL); !ok {
760			t.Errorf("expected %q to still be in cache", keptURL)
761		}
762	}
763}