html_test.go

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