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 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}