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 TestProcessBodyWithImageProtocol(t *testing.T) {
 674	// Save original environment variables
 675	origTerm := os.Getenv("TERM")
 676	origTermProgram := os.Getenv("TERM_PROGRAM")
 677	origKittyWindow := os.Getenv("KITTY_WINDOW_ID")
 678	origGhosttyResources := os.Getenv("GHOSTTY_RESOURCES_DIR")
 679	origItermlSession := os.Getenv("ITERM_SESSION_ID")
 680	origWeztermExec := os.Getenv("WEZTERM_EXECUTABLE")
 681
 682	// Restore environment variables after test
 683	defer func() {
 684		os.Setenv("TERM", origTerm)
 685		os.Setenv("TERM_PROGRAM", origTermProgram)
 686		os.Setenv("KITTY_WINDOW_ID", origKittyWindow)
 687		os.Setenv("GHOSTTY_RESOURCES_DIR", origGhosttyResources)
 688		os.Setenv("ITERM_SESSION_ID", origItermlSession)
 689		os.Setenv("WEZTERM_EXECUTABLE", origWeztermExec)
 690	}()
 691
 692	h1Style := lipgloss.NewStyle().SetString("H1")
 693	h2Style := lipgloss.NewStyle().SetString("H2")
 694	bodyStyle := lipgloss.NewStyle().SetString("BODY")
 695
 696	// Create a simple base64 PNG image (1x1 pixel white PNG)
 697	testBase64PNG := "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="
 698
 699	testCases := []struct {
 700		name                string
 701		setupImageProtocol  func()
 702		clearAllImageEnv    func()
 703		input               string
 704		expectedContains    string
 705		expectedNotContains string
 706		expectPlacements    bool
 707	}{
 708		{
 709			name: "Data URI image with Kitty support returns placement",
 710			setupImageProtocol: func() {
 711				os.Setenv("TERM", "xterm-kitty")
 712			},
 713			clearAllImageEnv: func() {
 714				os.Unsetenv("KITTY_WINDOW_ID")
 715				os.Unsetenv("GHOSTTY_RESOURCES_DIR")
 716				os.Unsetenv("ITERM_SESSION_ID")
 717				os.Unsetenv("WEZTERM_EXECUTABLE")
 718			},
 719			input:               `<img src="data:image/png;base64,` + testBase64PNG + `" alt="test image">`,
 720			expectedNotContains: "[Image: test image,",
 721			expectPlacements:    true,
 722		},
 723		{
 724			name: "Data URI image with iTerm2 support returns placement",
 725			setupImageProtocol: func() {
 726				os.Setenv("TERM", "xterm")
 727				os.Setenv("TERM_PROGRAM", "iterm.app")
 728			},
 729			clearAllImageEnv: func() {
 730				os.Unsetenv("KITTY_WINDOW_ID")
 731				os.Unsetenv("GHOSTTY_RESOURCES_DIR")
 732				os.Unsetenv("ITERM_SESSION_ID")
 733				os.Unsetenv("WEZTERM_EXECUTABLE")
 734			},
 735			input:               `<img src="data:image/png;base64,` + testBase64PNG + `" alt="test image">`,
 736			expectedNotContains: "[Image: test image,",
 737			expectPlacements:    true,
 738		},
 739		{
 740			name: "Data URI image without protocol support",
 741			setupImageProtocol: func() {
 742				clearAllTerminalEnv()
 743			},
 744			clearAllImageEnv: func() {
 745				// This is handled by clearAllTerminalEnv now
 746			},
 747			input:            `<img src="data:image/png;base64,` + testBase64PNG + `" alt="test image">`,
 748			expectedContains: "[Image: test image,",
 749		},
 750		{
 751			name: "Remote image with WezTerm support (has hyperlink support)",
 752			setupImageProtocol: func() {
 753				clearAllTerminalEnv()
 754				os.Setenv("WEZTERM_EXECUTABLE", "/usr/bin/wezterm")
 755			},
 756			clearAllImageEnv: func() {
 757				// This is handled by clearAllTerminalEnv now
 758			},
 759			input:            `<img src="http://example.com/img.png" alt="remote image">`,
 760			expectedContains: "[Click here to view image: remote image]", // Remote images won't render without actual fetch, but hyperlinks work
 761		},
 762		{
 763			name: "Remote image without protocol support",
 764			setupImageProtocol: func() {
 765				clearAllTerminalEnv()
 766			},
 767			clearAllImageEnv: func() {
 768				// This is handled by clearAllTerminalEnv now
 769			},
 770			input:            `<img src="http://example.com/img.png" alt="remote image">`,
 771			expectedContains: "[Image: remote image,",
 772		},
 773	}
 774
 775	ansiEscapeRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`)
 776
 777	for _, tc := range testCases {
 778		t.Run(tc.name, func(t *testing.T) {
 779			tc.clearAllImageEnv()
 780			tc.setupImageProtocol()
 781
 782			processed, placements, err := ProcessBody(tc.input, "", h1Style, h2Style, bodyStyle, false)
 783			if err != nil {
 784				t.Fatalf("ProcessBody() failed: %v", err)
 785			}
 786
 787			if tc.expectPlacements {
 788				if len(placements) == 0 {
 789					t.Errorf("Expected image placements but got none")
 790				} else {
 791					if placements[0].Base64 == "" {
 792						t.Errorf("Expected non-empty Base64 in placement")
 793					}
 794					if placements[0].Rows < 1 {
 795						t.Errorf("Expected Rows >= 1, got %d", placements[0].Rows)
 796					}
 797				}
 798			}
 799
 800			cleanProcessed := ansiEscapeRegex.ReplaceAllString(processed, "")
 801
 802			if tc.expectedContains != "" && !strings.Contains(cleanProcessed, tc.expectedContains) {
 803				t.Errorf("Processed body does not contain expected text.\nGot: %q\nWant to contain: %q", cleanProcessed, tc.expectedContains)
 804			}
 805
 806			if tc.expectedNotContains != "" && strings.Contains(cleanProcessed, tc.expectedNotContains) {
 807				t.Errorf("Processed body contains unexpected text.\nGot: %q\nShould not contain: %q", cleanProcessed, tc.expectedNotContains)
 808			}
 809		})
 810	}
 811}
 812
 813func TestProcessBody(t *testing.T) {
 814	h1Style := lipgloss.NewStyle().SetString("H1")
 815	h2Style := lipgloss.NewStyle().SetString("H2")
 816	bodyStyle := lipgloss.NewStyle().SetString("BODY")
 817
 818	testCases := []struct {
 819		name     string
 820		input    string
 821		expected string
 822	}{
 823		{
 824			name:     "Simple HTML",
 825			input:    "<p>Hello, world!</p>",
 826			expected: "Hello, world!",
 827		},
 828		{
 829			name:     "With headers HTML",
 830			input:    "<h1>Header 1</h1>",
 831			expected: "Header 1",
 832		},
 833		{
 834			name:     "With headers Markdown",
 835			input:    "# Header 1",
 836			expected: "Header 1",
 837		},
 838		{
 839			name:     "Plain text",
 840			input:    "Just plain text without any markup",
 841			expected: "Just plain text without any markup",
 842		},
 843	}
 844
 845	ansiEscapeRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`)
 846
 847	for _, tc := range testCases {
 848		t.Run(tc.name, func(t *testing.T) {
 849			processed, _, err := ProcessBody(tc.input, "", h1Style, h2Style, bodyStyle, false)
 850			if err != nil {
 851				t.Fatalf("ProcessBody() failed: %v", err)
 852			}
 853
 854			cleanProcessed := ansiEscapeRegex.ReplaceAllString(processed, "")
 855
 856			if !strings.Contains(cleanProcessed, tc.expected) {
 857				t.Errorf("Processed body does not contain expected text.\nGot: %q\nWant to contain: %q", cleanProcessed, tc.expected)
 858			}
 859		})
 860	}
 861}
 862
 863// datadogShapeHTML is the indented attribute-heavy table shape commonly
 864// produced by Datadog Daily Digest, marketing tools, and any sender that
 865// uses HTML <table> for layout. md4c's html_block rule rejects this shape
 866// (leading whitespace, attribute-laden opening tag), so the markdown
 867// pre-pass passes the literal text through, and htmlconv then renders the
 868// raw "<table cellpadding=..." tag as visible body text.
 869const datadogShapeHTML = `    <table cellpadding="0" cellspacing="0" border="0" width="710" style="border:1px solid #E7E7E7;">
 870      <tr>
 871        <td style="background-color: #632ca6; color: white;">
 872          <h1>The Daily Digest</h1>
 873        </td>
 874      </tr>
 875    </table>`
 876
 877// TestProcessBody_LegacyPathManglesIndentedHTML pins the bug this PR fixes.
 878// With an empty MIME type, the renderer falls through to the legacy
 879// markdown→HTML pre-pass, which is what every body went through before this
 880// change. For Datadog-shape input the output literally contains the opening
 881// "<table cellpadding=..." text, which is what users see leaked into the
 882// inbox viewer. This test will pass on master too — it documents the bug,
 883// not the fix.
 884func TestProcessBody_LegacyPathManglesIndentedHTML(t *testing.T) {
 885	ansiEscapeRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`)
 886	processed, _, err := ProcessBody(datadogShapeHTML, "", lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle(), false)
 887	if err != nil {
 888		t.Fatalf("ProcessBody(legacy) failed: %v", err)
 889	}
 890	clean := ansiEscapeRegex.ReplaceAllString(processed, "")
 891	if !strings.Contains(clean, "<table") {
 892		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)
 893	}
 894}
 895
 896// TestProcessBody_HTMLMIMETypeSkipsMarkdownPrepass is the fix counterpart to
 897// the legacy-mangling test above. Same input, but tagged "text/html", goes
 898// straight to htmlconv without the broken markdown pre-pass.
 899func TestProcessBody_HTMLMIMETypeSkipsMarkdownPrepass(t *testing.T) {
 900	ansiEscapeRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`)
 901	bodyStyle := lipgloss.NewStyle()
 902	h1Style := lipgloss.NewStyle()
 903	h2Style := lipgloss.NewStyle()
 904
 905	// Same input as TestProcessBody_LegacyPathManglesIndentedHTML — the
 906	// differential is purely the MIME-type argument.
 907	processed, _, err := ProcessBody(datadogShapeHTML, BodyMIMETypeHTML, h1Style, h2Style, bodyStyle, false)
 908	if err != nil {
 909		t.Fatalf("ProcessBody(text/html) failed: %v", err)
 910	}
 911	clean := ansiEscapeRegex.ReplaceAllString(processed, "")
 912	if strings.Contains(clean, "<table") {
 913		t.Errorf("text/html body should not leak literal '<table' tag. Got:\n%s", clean)
 914	}
 915	if !strings.Contains(clean, "The Daily Digest") {
 916		t.Errorf("expected text content 'The Daily Digest' in output. Got:\n%s", clean)
 917	}
 918
 919	// Sanity: a body labeled as plain text falls through markdownToHTML and
 920	// preserves markdown semantics (heading rendering through the pipeline).
 921	mdBody := "# Heading One\n\nSome **bold** text."
 922	plainProcessed, _, err := ProcessBody(mdBody, BodyMIMETypePlain, h1Style, h2Style, bodyStyle, false)
 923	if err != nil {
 924		t.Fatalf("ProcessBody(text/plain) failed: %v", err)
 925	}
 926	plainClean := ansiEscapeRegex.ReplaceAllString(plainProcessed, "")
 927	if !strings.Contains(plainClean, "Heading One") {
 928		t.Errorf("text/plain body should still render markdown. Got:\n%s", plainClean)
 929	}
 930}
 931
 932func TestRemoteImageCache_EvictsOldestWhenFull(t *testing.T) {
 933	// Start with a clean cache so prior tests don't interfere.
 934	remoteImageCache.Purge()
 935	// cleaning up the current test's cache
 936	defer remoteImageCache.Purge()
 937
 938	// overfilling the cache beyond its configured capacity.
 939	overfillBy := 5
 940	totalInserts := remoteImageCacheSize + overfillBy
 941	for i := range totalInserts {
 942		url := fmt.Sprintf("https://example.com/img%d.png", i)
 943		remoteImageCache.Add(url, "fake-base64-data")
 944	}
 945
 946	// cache should not be overfilled beyond it's capped size
 947	if got := remoteImageCache.Len(); got != remoteImageCacheSize {
 948		t.Errorf("expected cache size %d, got %d", remoteImageCacheSize, got)
 949	}
 950
 951	// old entries should be evicted
 952	for i := range overfillBy {
 953		evictedURL := fmt.Sprintf("https://example.com/img%d.png", i)
 954		if _, ok := remoteImageCache.Get(evictedURL); ok {
 955			t.Errorf("expected %q to be evicted, but it's still in cache", evictedURL)
 956		}
 957	}
 958
 959	// The most recent entries should still be present.
 960	for i := overfillBy; i < totalInserts; i++ {
 961		keptURL := fmt.Sprintf("https://example.com/img%d.png", i)
 962		if _, ok := remoteImageCache.Get(keptURL); !ok {
 963			t.Errorf("expected %q to still be in cache", keptURL)
 964		}
 965	}
 966}
 967
 968func TestAllocImageID_NoRace(t *testing.T) {
 969	// Reset the counter so IDs start from a known value.
 970	atomic.StoreUint32(&nextImageID, 1000)
 971
 972	const goroutines = 100
 973	const idsPerGoroutine = 100
 974
 975	results := make(chan uint32, goroutines*idsPerGoroutine)
 976
 977	var wg sync.WaitGroup
 978	wg.Add(goroutines)
 979	for range goroutines {
 980		go func() {
 981			defer wg.Done()
 982			for range idsPerGoroutine {
 983				results <- allocImageID()
 984			}
 985		}()
 986	}
 987
 988	// Close channel once all writers are done.
 989	go func() {
 990		wg.Wait()
 991		close(results)
 992	}()
 993
 994	// Collect all IDs and verify uniqueness.
 995	seen := make(map[uint32]bool, goroutines*idsPerGoroutine)
 996	for id := range results {
 997		if seen[id] {
 998			t.Fatalf("duplicate image ID allocated: %d (race condition detected)", id)
 999		}
1000		seen[id] = true
1001	}
1002
1003	expected := uint32(goroutines * idsPerGoroutine)
1004	if uint32(len(seen)) != expected {
1005		t.Errorf("expected %d unique IDs, got %d", expected, len(seen))
1006	}
1007}