diff --git a/src/__test__/components/form-items/select.spec.ts b/src/__test__/components/form-items/select.spec.ts index f7fa0eec3..934622ba0 100644 --- a/src/__test__/components/form-items/select.spec.ts +++ b/src/__test__/components/form-items/select.spec.ts @@ -6,6 +6,21 @@ import { Select, SelectInternal, SelectProps } from '../../../components/form-items/select'; import { MynahIcons } from '../../../components/icon'; import { DomBuilder } from '../../../helper/dom'; +import { configureMarked } from '../../../helper/marked'; + +// Mock the overlay component so we can capture the tooltip content that gets rendered, +// without depending on the real Overlay's positioning logic. +jest.mock('../../../components/overlay', () => ({ + Overlay: jest.fn().mockImplementation(() => ({ + close: jest.fn() + })), + OverlayHorizontalDirection: { + START_TO_RIGHT: 'start-to-right' + }, + OverlayVerticalDirection: { + TO_TOP: 'to-top' + } +})); describe('Select Component', () => { let select: SelectInternal; @@ -361,4 +376,87 @@ describe('Select Component', () => { expect(select.getValue()).toBe('option2'); }); }); + + describe('Tooltip content', () => { + // Options that carry a description mirror the model-selector use case, where the + // hover tooltip shows the selected model's label and description. + const optionsWithDescription = [ + { value: 'model1', label: 'Claude Sonnet 4', description: 'Hybrid reasoning and coding for regular use' }, + { value: 'model2', label: 'Claude Haiku', description: 'Fast responses for lightweight tasks' } + ]; + + beforeEach(() => { + // The tooltip is rendered through the markdown parser, which (in production) escapes + // raw HTML. Configure marked here so the test reflects real rendering behavior. + configureMarked(); + jest.useFakeTimers(); + const { Overlay } = jest.requireMock('../../../components/overlay'); + (Overlay as jest.Mock).mockClear(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + const openTooltip = (): HTMLElement => { + document.body.appendChild(select.render); + const container = document.body.querySelector('.mynah-form-input-container') as HTMLElement; + container.dispatchEvent(new MouseEvent('mouseenter')); + jest.advanceTimersByTime(350); + + const { Overlay } = jest.requireMock('../../../components/overlay'); + expect(Overlay).toHaveBeenCalledTimes(1); + const overlayProps = (Overlay as jest.Mock).mock.calls[0][0]; + // The overlay renders a Card whose body is the parsed tooltip content. + return overlayProps.children[0] as HTMLElement; + }; + + it('should render the tooltip as markdown, not raw HTML tags', () => { + select = new SelectInternal({ + options: optionsWithDescription, + value: 'model1' + }); + + const tooltipContent = openTooltip(); + + // The label must render as a real bold element, not literal "" text. + const strongElement = tooltipContent.querySelector('strong'); + expect(strongElement).not.toBeNull(); + expect(strongElement?.textContent).toBe('Claude Sonnet 4'); + + // Regression guard: the raw HTML tags must never leak into the visible text. + expect(tooltipContent.textContent).not.toContain(''); + expect(tooltipContent.textContent).not.toContain('
'); + + // Both label and description should be present in the rendered tooltip. + expect(tooltipContent.textContent).toContain('Claude Sonnet 4'); + expect(tooltipContent.textContent).toContain('Hybrid reasoning and coding for regular use'); + }); + + it('should reflect the currently selected option in the tooltip', () => { + select = new SelectInternal({ + options: optionsWithDescription, + value: 'model1' + }); + select.setValue('model2'); + + const tooltipContent = openTooltip(); + + expect(tooltipContent.querySelector('strong')?.textContent).toBe('Claude Haiku'); + expect(tooltipContent.textContent).toContain('Fast responses for lightweight tasks'); + expect(tooltipContent.textContent).not.toContain(''); + }); + + it('should fall back to the base tooltip when the option has no description', () => { + select = new SelectInternal({ + options: testOptions, + value: 'option1', + tooltip: 'Base tooltip text' + }); + + const tooltipContent = openTooltip(); + + expect(tooltipContent.textContent).toContain('Base tooltip text'); + }); + }); }); diff --git a/src/components/form-items/select.ts b/src/components/form-items/select.ts index 31c7b2d60..cdda65b57 100644 --- a/src/components/form-items/select.ts +++ b/src/components/form-items/select.ts @@ -149,9 +149,11 @@ export class SelectInternal { const currentValue = this.selectElement.value; const selectedOption = this.props.options?.find(option => option.value === currentValue); - // If there's a selected option, show label and description; otherwise use the base tooltip + // If there's a selected option, show label and description; otherwise use the base tooltip. + // The tooltip is rendered through the markdown parser (via CardBody), which escapes raw HTML, + // so use markdown syntax (bold + line break) instead of HTML tags to avoid showing literal markup. if (selectedOption?.description != null) { - return `${selectedOption.label}
${selectedOption.description}`; + return `**${selectedOption.label}**\n${selectedOption.description}`; } return this.props.tooltip ?? ''; }; diff --git a/ui-tests/__test__/flows/quick-action-commands-header.ts b/ui-tests/__test__/flows/quick-action-commands-header.ts index c90e28b6f..a0e6c7ae8 100644 --- a/ui-tests/__test__/flows/quick-action-commands-header.ts +++ b/ui-tests/__test__/flows/quick-action-commands-header.ts @@ -137,9 +137,9 @@ export const verifyQuickActionCommandsHeaderStatusVariations = async (page: Page let foundStatusClass = false; for (const statusClass of statusClasses) { - const hasStatus = await headerElement.evaluate((el, className) => + const hasStatus = Boolean(await headerElement.evaluate((el, className) => el.classList.contains(className), statusClass - ); + )); if (hasStatus) { foundStatusClass = true; console.log(`Found status class: ${statusClass}`);