Files
wehub-resource-sync 7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

341 lines
14 KiB
JavaScript

/**
* Tests for components/custom_dropdown.js
*
* Tests the custom dropdown component's core behavior:
* setup, filtering, selection, keyboard navigation, and cleanup.
*/
import '@js/security/xss-protection.js';
import '@js/components/custom_dropdown.js';
const setupCustomDropdown = window.setupCustomDropdown;
const updateDropdownOptions = window.updateDropdownOptions;
describe('setupCustomDropdown', () => {
let input, hiddenInput, dropdownList, onSelect, options;
beforeEach(() => {
input = document.createElement('input');
input.type = 'text';
input.id = 'test-dropdown-input';
hiddenInput = document.createElement('input');
hiddenInput.type = 'hidden';
hiddenInput.id = 'test-dropdown-input_hidden';
dropdownList = document.createElement('div');
dropdownList.id = 'test-dropdown-list';
const wrapper = document.createElement('div');
wrapper.appendChild(input);
wrapper.appendChild(hiddenInput);
wrapper.appendChild(dropdownList);
document.body.appendChild(wrapper);
onSelect = vi.fn();
options = [
{ value: 'gpt4', label: 'GPT-4' },
{ value: 'claude', label: 'Claude' },
{ value: 'llama', label: 'Llama 3' },
{ value: 'mistral', label: 'Mistral' },
];
});
afterEach(() => {
const wrapper = input.closest('div');
if (wrapper && wrapper.parentNode) {
wrapper.remove();
}
// Clean up any detached dropdown lists
const detached = document.getElementById('test-dropdown-list');
if (detached) detached.remove();
});
it('returns control functions', () => {
const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect);
expect(dd.updateDropdown).toBeTypeOf('function');
expect(dd.showDropdown).toBeTypeOf('function');
expect(dd.hideDropdown).toBeTypeOf('function');
expect(dd.destroy).toBeTypeOf('function');
expect(dd.setValue).toBeTypeOf('function');
dd.destroy();
});
it('initially hides the dropdown', () => {
const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect);
expect(dropdownList.style.display).toBe('none');
dd.destroy();
});
it('shows dropdown on input click', () => {
const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect);
input.click();
expect(dropdownList.style.display).toBe('block');
dd.destroy();
});
it('populates dropdown with all options on click', () => {
const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect);
input.click();
const items = dropdownList.querySelectorAll('.ldr-custom-dropdown-item');
expect(items.length).toBe(4);
dd.destroy();
});
it('filters options when typing', () => {
const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect);
input.value = 'Cl';
input.dispatchEvent(new Event('input'));
const items = dropdownList.querySelectorAll('.ldr-custom-dropdown-item');
expect(items.length).toBe(1);
expect(items[0].getAttribute('data-value')).toBe('claude');
dd.destroy();
});
it('shows "no results" when nothing matches', () => {
const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect);
input.value = 'zzzzz';
input.dispatchEvent(new Event('input'));
expect(dropdownList.querySelector('.ldr-custom-dropdown-no-results')).not.toBeNull();
dd.destroy();
});
it('calls onSelect when an option is clicked', () => {
const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect);
input.click();
const items = dropdownList.querySelectorAll('.ldr-custom-dropdown-item');
items[1].click(); // Click "Claude"
expect(onSelect).toHaveBeenCalledWith('claude', options[1]);
dd.destroy();
});
it('updates input value on selection', () => {
const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect);
input.click();
const items = dropdownList.querySelectorAll('.ldr-custom-dropdown-item');
items[0].click(); // Click "GPT-4"
expect(input.value).toBe('GPT-4');
dd.destroy();
});
it('updates hidden input on selection', () => {
const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect);
input.click();
const items = dropdownList.querySelectorAll('.ldr-custom-dropdown-item');
items[0].click();
expect(hiddenInput.value).toBe('gpt4');
dd.destroy();
});
it('hides dropdown after selection', () => {
const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect);
input.click();
const items = dropdownList.querySelectorAll('.ldr-custom-dropdown-item');
items[0].click();
expect(dropdownList.style.display).toBe('none');
dd.destroy();
});
it('hides dropdown on Escape key', () => {
const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect);
input.click();
expect(dropdownList.style.display).toBe('block');
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(dropdownList.style.display).toBe('none');
dd.destroy();
});
it('sets ARIA attributes for accessibility', () => {
const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect);
// Initially closed
expect(input.getAttribute('aria-expanded')).toBe('false');
// Open
input.click();
expect(input.getAttribute('aria-expanded')).toBe('true');
// Items have role="option"
const items = dropdownList.querySelectorAll('.ldr-custom-dropdown-item');
items.forEach(item => {
expect(item.getAttribute('role')).toBe('option');
});
dd.destroy();
});
describe('setValue', () => {
it('sets value by matching option value', () => {
const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect);
dd.setValue('mistral');
expect(input.value).toBe('Mistral');
expect(hiddenInput.value).toBe('mistral');
dd.destroy();
});
it('calls onSelect callback', () => {
const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect);
dd.setValue('claude');
expect(onSelect).toHaveBeenCalledWith('claude', options[1]);
dd.destroy();
});
it('clears input for unknown value when custom values disallowed', () => {
const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect, false);
dd.setValue('unknown-model');
expect(input.value).toBe('');
dd.destroy();
});
it('sets raw value when custom values are allowed', () => {
const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect, true);
dd.setValue('custom-model');
expect(input.value).toBe('custom-model');
dd.destroy();
});
});
describe('destroy', () => {
it('cleans up event listeners and registry', () => {
const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect);
dd.destroy();
// After destroy, input events should not trigger dropdown
input.click();
// Dropdown should stay hidden (no listener to open it)
expect(dropdownList.style.display).toBe('none');
});
});
describe('keyboard navigation', () => {
it('ArrowDown opens dropdown and selects first item', () => {
const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect);
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
expect(dropdownList.style.display).toBe('block');
const active = dropdownList.querySelector('.active');
expect(active).not.toBeNull();
dd.destroy();
});
it('Enter selects highlighted item', () => {
const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect);
// Open and select first
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
// Select it
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
expect(onSelect).toHaveBeenCalled();
dd.destroy();
});
});
describe('group headers', () => {
const grouped = [
{ value: 'fav1', label: 'Pinned One', group_label: 'Favorites' },
{ value: 'arxiv', label: 'ArXiv', group_label: 'Academic' },
{ value: 'pubmed', label: 'PubMed', group_label: 'Academic' },
{ value: 'tavily', label: 'Tavily', group_label: 'API key' },
];
it('renders one non-selectable header per band, in order', () => {
const dd = setupCustomDropdown(input, dropdownList, () => grouped, onSelect);
input.click();
const headers = dropdownList.querySelectorAll('.ldr-custom-dropdown-group-header');
expect(Array.from(headers).map(h => h.textContent)).toEqual([
'Favorites', 'Academic', 'API key',
]);
headers.forEach(h =>
expect(h.classList.contains('ldr-custom-dropdown-item')).toBe(false)
);
dd.destroy();
});
it('keeps the selectable option count equal to items (headers excluded)', () => {
const dd = setupCustomDropdown(input, dropdownList, () => grouped, onSelect);
input.click();
const items = dropdownList.querySelectorAll('.ldr-custom-dropdown-item');
expect(items.length).toBe(4);
dd.destroy();
});
it('shows a band header once even when it has multiple items', () => {
const dd = setupCustomDropdown(input, dropdownList, () => grouped, onSelect);
input.click();
const academic = Array.from(
dropdownList.querySelectorAll('.ldr-custom-dropdown-group-header')
).filter(h => h.textContent === 'Academic');
expect(academic.length).toBe(1);
dd.destroy();
});
it('hides a band header when filtering removes all its items', () => {
const dd = setupCustomDropdown(input, dropdownList, () => grouped, onSelect);
input.value = 'arxiv';
input.dispatchEvent(new Event('input'));
const headerTexts = Array.from(
dropdownList.querySelectorAll('.ldr-custom-dropdown-group-header')
).map(h => h.textContent);
expect(headerTexts).toEqual(['Academic']);
dd.destroy();
});
it('renders no headers when options have no group_label', () => {
const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect);
input.click();
const headers = dropdownList.querySelectorAll('.ldr-custom-dropdown-group-header');
expect(headers.length).toBe(0);
dd.destroy();
});
it('marks headers as presentational so assistive tech skips them', () => {
const dd = setupCustomDropdown(input, dropdownList, () => grouped, onSelect);
input.click();
const header = dropdownList.querySelector('.ldr-custom-dropdown-group-header');
expect(header.getAttribute('role')).toBe('presentation');
expect(header.getAttribute('aria-hidden')).toBe('true');
dd.destroy();
});
it('keyboard navigation lands on an item, never a header', () => {
const dd = setupCustomDropdown(input, dropdownList, () => grouped, onSelect);
// First ArrowDown must select the first ITEM (fav1), not the
// 'Favorites' header that precedes it.
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
const active = dropdownList.querySelector('.active');
expect(active.classList.contains('ldr-custom-dropdown-item')).toBe(true);
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
expect(onSelect).toHaveBeenCalledWith('fav1', expect.objectContaining({ value: 'fav1' }));
dd.destroy();
});
it('renders both favorite stars and band headers when both apply', () => {
const onFavoriteToggle = vi.fn();
const dd = setupCustomDropdown(
input, dropdownList, () => grouped, onSelect, false, 'No results found.', onFavoriteToggle
);
input.click();
expect(dropdownList.querySelectorAll('.ldr-custom-dropdown-group-header').length).toBe(3);
expect(dropdownList.querySelectorAll('.ldr-dropdown-favorite-star').length).toBe(4);
dd.destroy();
});
it('updateDropdownOptions re-renders band headers for the open list', () => {
const dd = setupCustomDropdown(input, dropdownList, () => grouped, onSelect);
input.click();
const newOptions = [
{ value: 'wiki', label: 'Wikipedia', group_label: 'No API key' },
{ value: 'serper', label: 'Serper', group_label: 'API key' },
];
updateDropdownOptions(input, newOptions);
const headerTexts = Array.from(
dropdownList.querySelectorAll('.ldr-custom-dropdown-group-header')
).map(h => h.textContent);
expect(headerTexts).toEqual(['No API key', 'API key']);
expect(dropdownList.querySelectorAll('.ldr-custom-dropdown-item').length).toBe(2);
dd.destroy();
});
});
});
describe('updateDropdownOptions', () => {
it('does nothing for null input', () => {
expect(() => updateDropdownOptions(null, [])).not.toThrow();
});
});