html_test.go

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