html_test.go

  1package view
  2
  3import (
  4	"os"
  5	"strings"
  6	"testing"
  7
  8	"github.com/charmbracelet/lipgloss"
  9)
 10
 11func TestDecodeQuotedPrintable(t *testing.T) {
 12	testCases := []struct {
 13		name     string
 14		input    string
 15		expected string
 16	}{
 17		{
 18			name:     "Simple case",
 19			input:    "Hello=2C world=21",
 20			expected: "Hello, world!",
 21		},
 22		{
 23			name:     "With soft line break",
 24			input:    "This is a long line that gets wrapped=\r\n and continues here.",
 25			expected: "This is a long line that gets wrapped and continues here.",
 26		},
 27		{
 28			name:     "No encoding",
 29			input:    "Just a plain string.",
 30			expected: "Just a plain string.",
 31		},
 32	}
 33
 34	for _, tc := range testCases {
 35		t.Run(tc.name, func(t *testing.T) {
 36			decoded, err := decodeQuotedPrintable(tc.input)
 37			if err != nil {
 38				t.Fatalf("decodeQuotedPrintable() failed: %v", err)
 39			}
 40			if decoded != tc.expected {
 41				t.Errorf("Expected %q, got %q", tc.expected, decoded)
 42			}
 43		})
 44	}
 45}
 46
 47func TestMarkdownToHTML(t *testing.T) {
 48	testCases := []struct {
 49		name     string
 50		input    string
 51		expected string
 52	}{
 53		{
 54			name:     "Heading",
 55			input:    "# Hello",
 56			expected: "<h1>Hello</h1>",
 57		},
 58		{
 59			name:     "Bold",
 60			input:    "**bold text**",
 61			expected: "<p><strong>bold text</strong></p>",
 62		},
 63		{
 64			name:     "Link",
 65			input:    "[link](http://example.com)",
 66			expected: `<p><a href="http://example.com">link</a></p>`,
 67		},
 68	}
 69
 70	for _, tc := range testCases {
 71		t.Run(tc.name, func(t *testing.T) {
 72			html := markdownToHTML([]byte(tc.input))
 73			// Trim newlines for consistent comparison
 74			if strings.TrimSpace(string(html)) != tc.expected {
 75				t.Errorf("Expected %s, got %s", tc.expected, html)
 76			}
 77		})
 78	}
 79}
 80
 81func TestHyperlinkSupported(t *testing.T) {
 82	testCases := []struct {
 83		name              string
 84		term              string
 85		termProgram       string
 86		vteVersion        string
 87		kittyWindowID     string
 88		ghosttyResources  string
 89		weztermExecutable string
 90		expected          bool
 91	}{
 92		{
 93			name:     "Kitty terminal",
 94			term:     "xterm-kitty",
 95			expected: true,
 96		},
 97		{
 98			name:     "Ghostty terminal",
 99			term:     "ghostty",
100			expected: true,
101		},
102		{
103			name:     "WezTerm terminal",
104			term:     "wezterm",
105			expected: true,
106		},
107		{
108			name:     "Alacritty terminal",
109			term:     "alacritty",
110			expected: true,
111		},
112		{
113			name:        "iTerm2 via TERM_PROGRAM",
114			term:        "xterm-256color",
115			termProgram: "iTerm.app",
116			expected:    true,
117		},
118		{
119			name:       "VTE-based terminal",
120			term:       "xterm-256color",
121			vteVersion: "0.64.2",
122			expected:   true,
123		},
124		{
125			name:          "Kitty via env var",
126			term:          "xterm-256color",
127			kittyWindowID: "1",
128			expected:      true,
129		},
130		{
131			name:             "Ghostty via env var",
132			term:             "xterm-256color",
133			ghosttyResources: "/opt/ghostty",
134			expected:         true,
135		},
136		{
137			name:              "WezTerm via env var",
138			term:              "xterm-256color",
139			weztermExecutable: "/usr/bin/wezterm",
140			expected:          true,
141		},
142		{
143			name:     "Unsupported terminal",
144			term:     "xterm",
145			expected: false,
146		},
147	}
148
149	for _, tc := range testCases {
150		t.Run(tc.name, func(t *testing.T) {
151			// Save original env vars
152			origTerm := os.Getenv("TERM")
153			origTermProgram := os.Getenv("TERM_PROGRAM")
154			origVteVersion := os.Getenv("VTE_VERSION")
155			origKittyWindowID := os.Getenv("KITTY_WINDOW_ID")
156			origGhosttyResources := os.Getenv("GHOSTTY_RESOURCES_DIR")
157			origWeztermExecutable := os.Getenv("WEZTERM_EXECUTABLE")
158
159			// Set test env vars
160			os.Setenv("TERM", tc.term)
161			os.Setenv("TERM_PROGRAM", tc.termProgram)
162			os.Setenv("VTE_VERSION", tc.vteVersion)
163			os.Setenv("KITTY_WINDOW_ID", tc.kittyWindowID)
164			os.Setenv("GHOSTTY_RESOURCES_DIR", tc.ghosttyResources)
165			os.Setenv("WEZTERM_EXECUTABLE", tc.weztermExecutable)
166
167			// Test the function
168			result := hyperlinkSupported()
169
170			// Restore original env vars
171			os.Setenv("TERM", origTerm)
172			os.Setenv("TERM_PROGRAM", origTermProgram)
173			os.Setenv("VTE_VERSION", origVteVersion)
174			os.Setenv("KITTY_WINDOW_ID", origKittyWindowID)
175			os.Setenv("GHOSTTY_RESOURCES_DIR", origGhosttyResources)
176			os.Setenv("WEZTERM_EXECUTABLE", origWeztermExecutable)
177
178			if result != tc.expected {
179				t.Errorf("Expected %v, got %v", tc.expected, result)
180			}
181		})
182	}
183}
184
185func TestHyperlink(t *testing.T) {
186	testCases := []struct {
187		name               string
188		url                string
189		text               string
190		hyperlinkSupported bool
191		expected           string
192	}{
193		{
194			name:               "Supported terminal with different text",
195			url:                "https://example.com",
196			text:               "Click here",
197			hyperlinkSupported: true,
198			expected:           "\x1b]8;;https://example.com\x07Click here\x1b]8;;\x07",
199		},
200		{
201			name:               "Supported terminal with same text as URL",
202			url:                "https://example.com",
203			text:               "https://example.com",
204			hyperlinkSupported: true,
205			expected:           "\x1b]8;;https://example.com\x07https://example.com\x1b]8;;\x07",
206		},
207		{
208			name:               "Supported terminal with empty text",
209			url:                "https://example.com",
210			text:               "",
211			hyperlinkSupported: true,
212			expected:           "\x1b]8;;https://example.com\x07https://example.com\x1b]8;;\x07",
213		},
214		{
215			name:               "Unsupported terminal with different text",
216			url:                "https://example.com",
217			text:               "Click here",
218			hyperlinkSupported: false,
219			expected:           "Click here &lt;https://example.com&gt;",
220		},
221		{
222			name:               "Unsupported terminal with same text as URL",
223			url:                "https://example.com",
224			text:               "https://example.com",
225			hyperlinkSupported: false,
226			expected:           "&lt;https://example.com&gt;",
227		},
228		{
229			name:               "Unsupported terminal with empty text",
230			url:                "https://example.com",
231			text:               "",
232			hyperlinkSupported: false,
233			expected:           "&lt;https://example.com&gt;",
234		},
235	}
236
237	for _, tc := range testCases {
238		t.Run(tc.name, func(t *testing.T) {
239			// Save original env vars
240			origTerm := os.Getenv("TERM")
241			origTermProgram := os.Getenv("TERM_PROGRAM")
242			origVteVersion := os.Getenv("VTE_VERSION")
243			origKittyWindowID := os.Getenv("KITTY_WINDOW_ID")
244			origGhosttyResources := os.Getenv("GHOSTTY_RESOURCES_DIR")
245			origWeztermExecutable := os.Getenv("WEZTERM_EXECUTABLE")
246
247			// Set env to control hyperlink support
248			if tc.hyperlinkSupported {
249				os.Setenv("TERM", "xterm-kitty")
250			} else {
251				// Clear all environment variables that could indicate hyperlink support
252				os.Setenv("TERM", "xterm")
253				os.Unsetenv("TERM_PROGRAM")
254				os.Unsetenv("VTE_VERSION")
255				os.Unsetenv("KITTY_WINDOW_ID")
256				os.Unsetenv("GHOSTTY_RESOURCES_DIR")
257				os.Unsetenv("WEZTERM_EXECUTABLE")
258			}
259
260			result := hyperlink(tc.url, tc.text)
261
262			// Restore original env vars
263			os.Setenv("TERM", origTerm)
264			os.Setenv("TERM_PROGRAM", origTermProgram)
265			os.Setenv("VTE_VERSION", origVteVersion)
266			os.Setenv("KITTY_WINDOW_ID", origKittyWindowID)
267			os.Setenv("GHOSTTY_RESOURCES_DIR", origGhosttyResources)
268			os.Setenv("WEZTERM_EXECUTABLE", origWeztermExecutable)
269
270			if result != tc.expected {
271				t.Errorf("Expected %q, got %q", tc.expected, result)
272			}
273		})
274	}
275}
276
277func TestProcessBody(t *testing.T) {
278	h1Style := lipgloss.NewStyle().SetString("H1")
279	h2Style := lipgloss.NewStyle().SetString("H2")
280	bodyStyle := lipgloss.NewStyle().SetString("BODY")
281
282	testCases := []struct {
283		name     string
284		input    string
285		expected string
286	}{
287		{
288			name:     "Simple HTML",
289			input:    "<p>Hello, world!</p>",
290			expected: "Hello, world!",
291		},
292		{
293			name:     "With link HTML",
294			input:    `<a href="http://example.com">Click here</a>`,
295			expected: "Click here",
296		},
297		{
298			name:     "With image HTML",
299			input:    `<img src="http://example.com/img.png" alt="alt text">`,
300			expected: "[Image: alt text, http://example.com/img.png]",
301		},
302		{
303			name:     "With headers HTML",
304			input:    "<h1>Header 1</h1>",
305			expected: "Header 1",
306		},
307		{
308			name:     "With link Markdown",
309			input:    `[Click here](http://example.com)`,
310			expected: "Click here",
311		},
312		{
313			name:     "With image Markdown",
314			input:    `![alt text](http://example.com/img.png)`,
315			expected: "[Image: alt text, http://example.com/img.png]",
316		},
317		{
318			name:     "With headers Markdown",
319			input:    "# Header 1",
320			expected: "Header 1",
321		},
322	}
323
324	for _, tc := range testCases {
325		t.Run(tc.name, func(t *testing.T) {
326			processed, err := ProcessBody(tc.input, h1Style, h2Style, bodyStyle)
327			if err != nil {
328				t.Fatalf("ProcessBody() failed: %v", err)
329			}
330			// Use Contains because styles add ANSI codes
331			if !strings.Contains(processed, tc.expected) {
332				t.Errorf("Processed body does not contain expected text.\nGot: %q\nWant to contain: %q", processed, tc.expected)
333			}
334		})
335	}
336}
337
338func TestProcessBodyWithHyperlinkSupport(t *testing.T) {
339	h1Style := lipgloss.NewStyle()
340	h2Style := lipgloss.NewStyle()
341	bodyStyle := lipgloss.NewStyle()
342
343	testCases := []struct {
344		name                string
345		input               string
346		hyperlinkSupported  bool
347		expectedContains    []string
348		expectedNotContains []string
349	}{
350		{
351			name:                "Link with hyperlink support",
352			input:               `<a href="https://example.com">Click here</a>`,
353			hyperlinkSupported:  true,
354			expectedContains:    []string{"\x1b]8;;https://example.com\x07Click here\x1b]8;;\x07"},
355			expectedNotContains: []string{"<https://example.com>"},
356		},
357		{
358			name:                "Link without hyperlink support",
359			input:               `<a href="https://example.com">Click here</a>`,
360			hyperlinkSupported:  false,
361			expectedContains:    []string{"Click here <https://example.com>"},
362			expectedNotContains: []string{"\x1b]8;;"},
363		},
364		{
365			name:                "Image with hyperlink support",
366			input:               `<img src="https://example.com/image.png" alt="Test image">`,
367			hyperlinkSupported:  true,
368			expectedContains:    []string{"\x1b]8;;https://example.com/image.png\x07", "[Click here to view image: Test image]"},
369			expectedNotContains: []string{"<https://example.com/image.png>"},
370		},
371		{
372			name:                "Image without hyperlink support",
373			input:               `<img src="https://example.com/image.png" alt="Test image">`,
374			hyperlinkSupported:  false,
375			expectedContains:    []string{"[Image: Test image, https://example.com/image.png]"},
376			expectedNotContains: []string{"\x1b]8;;", "[Click here to view image:"},
377		},
378		{
379			name:                "Markdown link with hyperlink support",
380			input:               `[Visit our site](https://example.com)`,
381			hyperlinkSupported:  true,
382			expectedContains:    []string{"\x1b]8;;https://example.com\x07Visit our site\x1b]8;;\x07"},
383			expectedNotContains: []string{"<https://example.com>"},
384		},
385		{
386			name:                "Markdown link without hyperlink support",
387			input:               `[Visit our site](https://example.com)`,
388			hyperlinkSupported:  false,
389			expectedContains:    []string{"Visit our site <https://example.com>"},
390			expectedNotContains: []string{"\x1b]8;;"},
391		},
392		{
393			name:                "Markdown image with hyperlink support",
394			input:               `![Beautiful sunset](https://example.com/sunset.jpg)`,
395			hyperlinkSupported:  true,
396			expectedContains:    []string{"\x1b]8;;https://example.com/sunset.jpg\x07", "[Click here to view image: Beautiful sunset]"},
397			expectedNotContains: []string{"<https://example.com/sunset.jpg>"},
398		},
399		{
400			name:                "Markdown image without hyperlink support",
401			input:               `![Beautiful sunset](https://example.com/sunset.jpg)`,
402			hyperlinkSupported:  false,
403			expectedContains:    []string{"[Image: Beautiful sunset, https://example.com/sunset.jpg]"},
404			expectedNotContains: []string{"\x1b]8;;", "[Click here to view image:"},
405		},
406	}
407
408	for _, tc := range testCases {
409		t.Run(tc.name, func(t *testing.T) {
410			// Save original env vars
411			origTerm := os.Getenv("TERM")
412			origTermProgram := os.Getenv("TERM_PROGRAM")
413			origVteVersion := os.Getenv("VTE_VERSION")
414			origKittyWindowID := os.Getenv("KITTY_WINDOW_ID")
415			origGhosttyResources := os.Getenv("GHOSTTY_RESOURCES_DIR")
416			origWeztermExecutable := os.Getenv("WEZTERM_EXECUTABLE")
417
418			// Set env to control hyperlink support
419			if tc.hyperlinkSupported {
420				os.Setenv("TERM", "xterm-kitty")
421			} else {
422				// Clear all environment variables that could indicate hyperlink support
423				os.Setenv("TERM", "xterm")
424				os.Unsetenv("TERM_PROGRAM")
425				os.Unsetenv("VTE_VERSION")
426				os.Unsetenv("KITTY_WINDOW_ID")
427				os.Unsetenv("GHOSTTY_RESOURCES_DIR")
428				os.Unsetenv("WEZTERM_EXECUTABLE")
429			}
430
431			processed, err := ProcessBody(tc.input, h1Style, h2Style, bodyStyle)
432
433			// Restore original env vars
434			os.Setenv("TERM", origTerm)
435			os.Setenv("TERM_PROGRAM", origTermProgram)
436			os.Setenv("VTE_VERSION", origVteVersion)
437			os.Setenv("KITTY_WINDOW_ID", origKittyWindowID)
438			os.Setenv("GHOSTTY_RESOURCES_DIR", origGhosttyResources)
439			os.Setenv("WEZTERM_EXECUTABLE", origWeztermExecutable)
440
441			if err != nil {
442				t.Fatalf("ProcessBody() failed: %v", err)
443			}
444
445			// Check that expected strings are present
446			for _, expected := range tc.expectedContains {
447				if !strings.Contains(processed, expected) {
448					t.Errorf("Expected processed body to contain %q, but it didn't.\nProcessed: %q", expected, processed)
449				}
450			}
451
452			// Check that unexpected strings are not present
453			for _, notExpected := range tc.expectedNotContains {
454				if strings.Contains(processed, notExpected) {
455					t.Errorf("Expected processed body to NOT contain %q, but it did.\nProcessed: %q", notExpected, processed)
456				}
457			}
458		})
459	}
460}
461
462func TestImageAndLinkFallbackBehavior(t *testing.T) {
463	h1Style := lipgloss.NewStyle()
464	h2Style := lipgloss.NewStyle()
465	bodyStyle := lipgloss.NewStyle()
466
467	testCases := []struct {
468		name                   string
469		input                  string
470		hyperlinkSupported     bool
471		imageProtocolSupported bool
472		expectedContains       []string
473		expectedNotContains    []string
474	}{
475		{
476			name:                   "Full support - both image protocol and hyperlinks",
477			input:                  `<img src="https://example.com/image.png" alt="Test image"> and <a href="https://example.com">link</a>`,
478			hyperlinkSupported:     true,
479			imageProtocolSupported: true,
480			expectedContains:       []string{"\x1b]8;;https://example.com\x07link\x1b]8;;\x07", "\x1b]8;;https://example.com/image.png\x07", "[Click here to view image: Test image]"},
481			expectedNotContains:    []string{"<https://example.com>", "[Image:"},
482		},
483		{
484			name:                   "Hyperlink support only - no image protocol",
485			input:                  `<img src="https://example.com/image.png" alt="Test image"> and <a href="https://example.com">link</a>`,
486			hyperlinkSupported:     true,
487			imageProtocolSupported: false,
488			expectedContains:       []string{"\x1b]8;;https://example.com\x07link\x1b]8;;\x07", "\x1b]8;;https://example.com/image.png\x07", "[Click here to view image: Test image]"},
489			expectedNotContains:    []string{"<https://example.com>", "[Image:"},
490		},
491		{
492			name:                   "No support - plain text fallback",
493			input:                  `<img src="https://example.com/image.png" alt="Test image"> and <a href="https://example.com">link</a>`,
494			hyperlinkSupported:     false,
495			imageProtocolSupported: false,
496			expectedContains:       []string{"link <https://example.com>", "[Image: Test image, https://example.com/image.png]"},
497			expectedNotContains:    []string{"\x1b]8;;", "[Click here to view image:"},
498		},
499	}
500
501	for _, tc := range testCases {
502		t.Run(tc.name, func(t *testing.T) {
503			// Save original env vars
504			origTerm := os.Getenv("TERM")
505			origTermProgram := os.Getenv("TERM_PROGRAM")
506			origVteVersion := os.Getenv("VTE_VERSION")
507			origKittyWindowID := os.Getenv("KITTY_WINDOW_ID")
508			origGhosttyResources := os.Getenv("GHOSTTY_RESOURCES_DIR")
509			origWeztermExecutable := os.Getenv("WEZTERM_EXECUTABLE")
510
511			// Set environment for hyperlink support
512			if tc.hyperlinkSupported {
513				os.Setenv("TERM", "xterm-kitty")
514			} else {
515				os.Setenv("TERM", "xterm")
516				os.Unsetenv("TERM_PROGRAM")
517				os.Unsetenv("VTE_VERSION")
518				os.Unsetenv("KITTY_WINDOW_ID")
519				os.Unsetenv("GHOSTTY_RESOURCES_DIR")
520				os.Unsetenv("WEZTERM_EXECUTABLE")
521			}
522
523			// Set environment for image protocol support
524			if tc.imageProtocolSupported && !tc.hyperlinkSupported {
525				// This case shouldn't happen in practice, but test it anyway
526				os.Setenv("KITTY_WINDOW_ID", "1")
527			} else if !tc.imageProtocolSupported {
528				os.Unsetenv("KITTY_WINDOW_ID")
529				os.Unsetenv("GHOSTTY_RESOURCES_DIR")
530			}
531
532			processed, err := ProcessBody(tc.input, h1Style, h2Style, bodyStyle)
533
534			// Restore original env vars
535			os.Setenv("TERM", origTerm)
536			os.Setenv("TERM_PROGRAM", origTermProgram)
537			os.Setenv("VTE_VERSION", origVteVersion)
538			os.Setenv("KITTY_WINDOW_ID", origKittyWindowID)
539			os.Setenv("GHOSTTY_RESOURCES_DIR", origGhosttyResources)
540			os.Setenv("WEZTERM_EXECUTABLE", origWeztermExecutable)
541
542			if err != nil {
543				t.Fatalf("ProcessBody() failed: %v", err)
544			}
545
546			// Check that expected strings are present
547			for _, expected := range tc.expectedContains {
548				if !strings.Contains(processed, expected) {
549					t.Errorf("Expected processed body to contain %q, but it didn't.\nProcessed: %q", expected, processed)
550				}
551			}
552
553			// Check that unexpected strings are not present
554			for _, notExpected := range tc.expectedNotContains {
555				if strings.Contains(processed, notExpected) {
556					t.Errorf("Expected processed body to NOT contain %q, but it did.\nProcessed: %q", notExpected, processed)
557				}
558			}
559		})
560	}
561}