html_test.go

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