项目文件夹

文件
wehub-resource-sync 2771cff92b
CI / lint (push) Failing after 1s
CI / typecheck (push) Failing after 2s
CI / test (push) Failing after 1s
CI / build (push) Has been skipped
chore: import upstream snapshot with attribution
2026-07-13 12:22:56 +08:00

509 行
16 KiB
TypeScript

import { createMockEl } from '@test/helpers/mockElement';
import type { ProviderCommandDropdownConfig } from '@/core/providers/commands/ProviderCommandCatalog';
import type { ProviderCommandEntry } from '@/core/providers/commands/ProviderCommandEntry';
import {
SlashCommandDropdown,
type SlashCommandDropdownCallbacks,
} from '@/shared/components/SlashCommandDropdown';
jest.mock('@/core/commands/builtInCommands', () => ({
getBuiltInCommandsForDropdown: jest.fn(() => [
{ id: 'builtin:clear', name: 'clear', description: 'Start a new conversation', content: '' },
{ id: 'builtin:add-dir', name: 'add-dir', description: 'Add external context directory', content: '', argumentHint: 'path/to/directory' },
]),
}));
function createMockInput(): any {
return {
value: '',
selectionStart: 0,
selectionEnd: 0,
focus: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
};
}
function createMockCallbacks(overrides: Partial<SlashCommandDropdownCallbacks> = {}): SlashCommandDropdownCallbacks {
return {
onSelect: jest.fn(),
onHide: jest.fn(),
...overrides,
};
}
function getRenderedItems(containerEl: any): { name: string; description: string }[] {
const dropdownEl = containerEl.children.find(
(c: any) => c.hasClass('claudian-slash-dropdown')
);
if (!dropdownEl) return [];
const items = dropdownEl.querySelectorAll('.claudian-slash-item');
return items.map((item: any) => {
const nameSpan = item.children.find((c: any) => c.hasClass('claudian-slash-name'));
const descDiv = item.children.find((c: any) => c.hasClass('claudian-slash-desc'));
return {
name: nameSpan?.textContent?.replace(/^\//, '') ?? '',
description: descDiv?.textContent ?? '',
};
});
}
function getRenderedCommandNames(containerEl: any): string[] {
return getRenderedItems(containerEl).map(i => i.name);
}
const CLAUDE_CONFIG: ProviderCommandDropdownConfig = {
providerId: 'claude',
triggerChars: ['/'],
builtInPrefix: '/',
skillPrefix: '/',
commandPrefix: '/',
};
function makeEntry(name: string, description = ''): ProviderCommandEntry {
return {
id: `cmd-${name}`, providerId: 'claude', kind: 'command', name,
description, content: '', scope: 'runtime', source: 'sdk',
isEditable: false, isDeletable: false, displayPrefix: '/', insertPrefix: '/',
};
}
const PROVIDER_ENTRIES: ProviderCommandEntry[] = [
makeEntry('commit', 'Create a git commit'),
makeEntry('pr', 'Create a pull request'),
makeEntry('review', 'Review code'),
makeEntry('my-custom', 'Custom command'),
makeEntry('compact', 'Compact context'),
];
describe('SlashCommandDropdown', () => {
let containerEl: any;
let inputEl: any;
let callbacks: SlashCommandDropdownCallbacks;
let dropdown: SlashCommandDropdown;
beforeEach(() => {
containerEl = createMockEl();
inputEl = createMockInput();
callbacks = createMockCallbacks();
dropdown = new SlashCommandDropdown(containerEl, inputEl, callbacks);
});
afterEach(() => {
dropdown.destroy();
});
describe('constructor', () => {
it('creates dropdown with container and input elements', () => {
expect(dropdown).toBeInstanceOf(SlashCommandDropdown);
});
it('adds input event listener', () => {
expect(inputEl.addEventListener).toHaveBeenCalledWith('input', expect.any(Function));
});
it('accepts optional hiddenCommands in options', () => {
const hiddenCommands = new Set(['commit', 'pr']);
const dropdownWithHidden = new SlashCommandDropdown(
containerEl, inputEl, callbacks, { hiddenCommands }
);
expect(dropdownWithHidden).toBeInstanceOf(SlashCommandDropdown);
dropdownWithHidden.destroy();
});
});
describe('setEnabled', () => {
it('should not show dropdown when disabled', async () => {
dropdown.setEnabled(false);
inputEl.value = '/';
inputEl.selectionStart = 1;
dropdown.handleInputChange();
await new Promise(resolve => setTimeout(resolve, 0));
expect(dropdown.isVisible()).toBe(false);
expect(getRenderedCommandNames(containerEl)).toEqual([]);
});
it('should hide dropdown when disabling while visible', async () => {
inputEl.value = '/';
inputEl.selectionStart = 1;
dropdown.handleInputChange();
await new Promise(resolve => setTimeout(resolve, 0));
expect(dropdown.isVisible()).toBe(true);
dropdown.setEnabled(false);
expect(dropdown.isVisible()).toBe(false);
});
});
describe('hidden commands filtering', () => {
it('should filter out user-hidden commands from provider entries', async () => {
const getProviderEntries = jest.fn().mockResolvedValue(PROVIDER_ENTRIES);
const hiddenCommands = new Set(['commit', 'pr']);
const dropdownWithHidden = new SlashCommandDropdown(
containerEl, inputEl, callbacks,
{ hiddenCommands, providerConfig: CLAUDE_CONFIG, getProviderEntries }
);
inputEl.value = '/';
inputEl.selectionStart = 1;
dropdownWithHidden.handleInputChange();
await new Promise(resolve => setTimeout(resolve, 10));
const commandNames = getRenderedCommandNames(containerEl);
expect(commandNames).not.toContain('commit');
expect(commandNames).not.toContain('pr');
expect(commandNames).toContain('review');
expect(commandNames).toContain('my-custom');
dropdownWithHidden.destroy();
});
it('should NOT filter out built-in commands even if in hiddenCommands', async () => {
const getProviderEntries = jest.fn().mockResolvedValue(PROVIDER_ENTRIES);
const hiddenCommands = new Set(['clear', 'add-dir']);
const dropdownWithHidden = new SlashCommandDropdown(
containerEl, inputEl, callbacks,
{ hiddenCommands, providerConfig: CLAUDE_CONFIG, getProviderEntries }
);
inputEl.value = '/';
inputEl.selectionStart = 1;
dropdownWithHidden.handleInputChange();
await new Promise(resolve => setTimeout(resolve, 10));
const commandNames = getRenderedCommandNames(containerEl);
expect(commandNames).toContain('clear');
expect(commandNames).toContain('add-dir');
dropdownWithHidden.destroy();
});
});
describe('deduplication', () => {
it('should deduplicate commands by name (built-in takes priority)', async () => {
const entriesWithDuplicate: ProviderCommandEntry[] = [
makeEntry('clear', 'Provider clear command'),
makeEntry('commit', 'Create commit'),
];
const getProviderEntries = jest.fn().mockResolvedValue(entriesWithDuplicate);
const dropdownWithEntries = new SlashCommandDropdown(
containerEl, inputEl, callbacks,
{ providerConfig: CLAUDE_CONFIG, getProviderEntries }
);
inputEl.value = '/cle';
inputEl.selectionStart = 4;
dropdownWithEntries.handleInputChange();
await new Promise(resolve => setTimeout(resolve, 10));
const items = getRenderedItems(containerEl);
const clearItems = items.filter(i => i.name === 'clear');
expect(clearItems).toHaveLength(1);
expect(clearItems[0].description).toBe('Start a new conversation');
dropdownWithEntries.destroy();
});
});
describe('provider entry caching', () => {
it('should cache entries after first successful fetch', async () => {
const getProviderEntries = jest.fn().mockResolvedValue(PROVIDER_ENTRIES);
const d = new SlashCommandDropdown(
containerEl, inputEl, callbacks,
{ providerConfig: CLAUDE_CONFIG, getProviderEntries }
);
inputEl.value = '/';
inputEl.selectionStart = 1;
d.handleInputChange();
await new Promise(resolve => setTimeout(resolve, 10));
inputEl.value = '/c';
inputEl.selectionStart = 2;
d.handleInputChange();
await new Promise(resolve => setTimeout(resolve, 10));
expect(getProviderEntries).toHaveBeenCalledTimes(1);
d.destroy();
});
it('should retry fetch when previous result was empty', async () => {
const getProviderEntries = jest.fn()
.mockResolvedValueOnce([])
.mockResolvedValueOnce(PROVIDER_ENTRIES);
const d = new SlashCommandDropdown(
containerEl, inputEl, callbacks,
{ providerConfig: CLAUDE_CONFIG, getProviderEntries }
);
inputEl.value = '/';
inputEl.selectionStart = 1;
d.handleInputChange();
await new Promise(resolve => setTimeout(resolve, 10));
inputEl.value = '/c';
inputEl.selectionStart = 2;
d.handleInputChange();
await new Promise(resolve => setTimeout(resolve, 10));
expect(getProviderEntries).toHaveBeenCalledTimes(2);
d.destroy();
});
it('should retry fetch when previous call threw error', async () => {
const getProviderEntries = jest.fn()
.mockRejectedValueOnce(new Error('Not ready'))
.mockResolvedValueOnce(PROVIDER_ENTRIES);
const d = new SlashCommandDropdown(
containerEl, inputEl, callbacks,
{ providerConfig: CLAUDE_CONFIG, getProviderEntries }
);
inputEl.value = '/';
inputEl.selectionStart = 1;
d.handleInputChange();
await new Promise(resolve => setTimeout(resolve, 10));
inputEl.value = '/c';
inputEl.selectionStart = 2;
d.handleInputChange();
await new Promise(resolve => setTimeout(resolve, 10));
expect(getProviderEntries).toHaveBeenCalledTimes(2);
d.destroy();
});
});
describe('race condition handling', () => {
it('should discard stale results when newer request is made', async () => {
let resolveFirst: (value: ProviderCommandEntry[]) => void;
const firstPromise = new Promise<ProviderCommandEntry[]>(resolve => { resolveFirst = resolve; });
const getProviderEntries = jest.fn()
.mockReturnValueOnce(firstPromise)
.mockResolvedValueOnce([makeEntry('new-command', 'New')]);
const d = new SlashCommandDropdown(
containerEl, inputEl, callbacks,
{ providerConfig: CLAUDE_CONFIG, getProviderEntries }
);
inputEl.value = '/';
inputEl.selectionStart = 1;
d.handleInputChange();
inputEl.value = '/n';
inputEl.selectionStart = 2;
d.handleInputChange();
await new Promise(resolve => setTimeout(resolve, 10));
resolveFirst!(PROVIDER_ENTRIES);
await new Promise(resolve => setTimeout(resolve, 10));
inputEl.value = '/';
inputEl.selectionStart = 1;
d.handleInputChange();
await new Promise(resolve => setTimeout(resolve, 10));
const names = getRenderedCommandNames(containerEl);
expect(names).toContain('new-command');
expect(names).not.toContain('commit');
d.destroy();
});
});
describe('setHiddenCommands', () => {
it('should update hidden commands set', async () => {
const getProviderEntries = jest.fn().mockResolvedValue(PROVIDER_ENTRIES);
const d = new SlashCommandDropdown(
containerEl, inputEl, callbacks,
{ providerConfig: CLAUDE_CONFIG, getProviderEntries }
);
inputEl.value = '/';
inputEl.selectionStart = 1;
d.handleInputChange();
await new Promise(resolve => setTimeout(resolve, 10));
expect(getRenderedCommandNames(containerEl)).toContain('commit');
d.setHiddenCommands(new Set(['commit']));
inputEl.value = '/c';
inputEl.selectionStart = 2;
d.handleInputChange();
await new Promise(resolve => setTimeout(resolve, 10));
expect(getRenderedCommandNames(containerEl)).not.toContain('commit');
d.destroy();
});
});
describe('resetSdkSkillsCache', () => {
it('should clear cached entries and allow refetch', async () => {
const getProviderEntries = jest.fn().mockResolvedValue(PROVIDER_ENTRIES);
const d = new SlashCommandDropdown(
containerEl, inputEl, callbacks,
{ providerConfig: CLAUDE_CONFIG, getProviderEntries }
);
inputEl.value = '/';
inputEl.selectionStart = 1;
d.handleInputChange();
await new Promise(resolve => setTimeout(resolve, 10));
expect(getProviderEntries).toHaveBeenCalledTimes(1);
d.resetSdkSkillsCache();
inputEl.value = '/c';
inputEl.selectionStart = 2;
d.handleInputChange();
await new Promise(resolve => setTimeout(resolve, 10));
expect(getProviderEntries).toHaveBeenCalledTimes(2);
d.destroy();
});
});
describe('handleInputChange', () => {
it('should hide dropdown when no valid trigger is found', () => {
inputEl.value = 'text without trigger';
inputEl.selectionStart = 20;
dropdown.handleInputChange();
expect(callbacks.onHide).toHaveBeenCalled();
});
it('should hide dropdown when whitespace follows command', () => {
inputEl.value = '/clear ';
inputEl.selectionStart = 7;
dropdown.handleInputChange();
expect(callbacks.onHide).toHaveBeenCalled();
});
it('should show dropdown when / is at position 0', async () => {
inputEl.value = '/';
inputEl.selectionStart = 1;
dropdown.handleInputChange();
await new Promise(resolve => setTimeout(resolve, 10));
expect(containerEl.children.length).toBeGreaterThan(0);
});
});
describe('handleKeydown', () => {
it('should return false when dropdown is not visible', () => {
const event = { key: 'ArrowDown', preventDefault: jest.fn() } as any;
const handled = dropdown.handleKeydown(event);
expect(handled).toBe(false);
expect(event.preventDefault).not.toHaveBeenCalled();
});
});
describe('isVisible', () => {
it('should return false initially', () => {
expect(dropdown.isVisible()).toBe(false);
});
});
describe('hide', () => {
it('should call onHide callback', () => {
dropdown.hide();
expect(callbacks.onHide).toHaveBeenCalled();
});
});
describe('destroy', () => {
it('should remove input event listener', () => {
dropdown.destroy();
expect(inputEl.removeEventListener).toHaveBeenCalledWith('input', expect.any(Function));
});
});
describe('search filtering', () => {
it('should filter commands by name', async () => {
const getProviderEntries = jest.fn().mockResolvedValue(PROVIDER_ENTRIES);
const d = new SlashCommandDropdown(
containerEl, inputEl, callbacks,
{ providerConfig: CLAUDE_CONFIG, getProviderEntries }
);
inputEl.value = '/com';
inputEl.selectionStart = 4;
d.handleInputChange();
await new Promise(resolve => setTimeout(resolve, 10));
const commandNames = getRenderedCommandNames(containerEl);
expect(commandNames).toContain('commit');
expect(commandNames).not.toContain('pr');
d.destroy();
});
it('should filter commands by description', async () => {
const getProviderEntries = jest.fn().mockResolvedValue(PROVIDER_ENTRIES);
const d = new SlashCommandDropdown(
containerEl, inputEl, callbacks,
{ providerConfig: CLAUDE_CONFIG, getProviderEntries }
);
inputEl.value = '/pull';
inputEl.selectionStart = 5;
d.handleInputChange();
await new Promise(resolve => setTimeout(resolve, 10));
expect(getRenderedCommandNames(containerEl)).toContain('pr');
d.destroy();
});
it('should hide dropdown when search has no matches', async () => {
inputEl.value = '/xyz123nonexistent';
inputEl.selectionStart = 18;
dropdown.handleInputChange();
await new Promise(resolve => setTimeout(resolve, 10));
expect(callbacks.onHide).toHaveBeenCalled();
});
it('should sort results alphabetically', async () => {
const getProviderEntries = jest.fn().mockResolvedValue(PROVIDER_ENTRIES);
const d = new SlashCommandDropdown(
containerEl, inputEl, callbacks,
{ providerConfig: CLAUDE_CONFIG, getProviderEntries }
);
inputEl.value = '/';
inputEl.selectionStart = 1;
d.handleInputChange();
await new Promise(resolve => setTimeout(resolve, 10));
const names = getRenderedCommandNames(containerEl);
const sortedNames = [...names].sort();
expect(names).toEqual(sortedNames);
d.destroy();
});
});
});