Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions src/__test__/components/form-items/select.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 "<strong>" 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('<strong>');
expect(tooltipContent.textContent).not.toContain('<br>');

// 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('<strong>');
});

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');
});
});
});
6 changes: 4 additions & 2 deletions src/components/form-items/select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<strong>${selectedOption.label}</strong><br>${selectedOption.description}`;
return `**${selectedOption.label}**\n${selectedOption.description}`;
}
return this.props.tooltip ?? '';
};
Expand Down
4 changes: 2 additions & 2 deletions ui-tests/__test__/flows/quick-action-commands-header.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand Down
Loading