/** * E2E integration tests for the autocomplete workflow. * * Validates the complete flow from typing a trigger character through * skill selection and insertion, using a mock HTTP server that serves * both dashboard files and the /api/skills endpoint. * * Test scenarios from bd-3cc: * - Server serves /api/skills correctly * - Dashboard loads skills on session open * - Trigger character shows dropdown * - Keyboard navigation works * - Selection inserts skill * - Edge cases (wrong trigger, empty skills, backspace, etc.) */ import { describe, it, before, after } from 'node:test'; import assert from 'node:assert/strict'; import { createServer } from 'node:http'; import { getTriggerInfo, filteredSkills } from '../../dashboard/utils/autocomplete.js'; // -- Mock server for /api/skills -- const CLAUDE_SKILLS_RESPONSE = { trigger: '/', skills: [ { name: 'commit', description: 'Create a git commit' }, { name: 'comment', description: 'Add a comment' }, { name: 'review-pr', description: 'Review a pull request' }, { name: 'help', description: 'Get help' }, { name: 'init', description: 'Initialize project' }, ], }; const CODEX_SKILLS_RESPONSE = { trigger: '$', skills: [ { name: 'lint', description: 'Lint code' }, { name: 'deploy', description: 'Deploy to prod' }, { name: 'test', description: 'Run tests' }, ], }; const EMPTY_SKILLS_RESPONSE = { trigger: '/', skills: [], }; let server; let serverUrl; function startMockServer() { return new Promise((resolve) => { server = createServer((req, res) => { const url = new URL(req.url, `http://${req.headers.host}`); if (url.pathname === '/api/skills') { const agent = url.searchParams.get('agent') || 'claude'; res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }); if (agent === 'codex') { res.end(JSON.stringify(CODEX_SKILLS_RESPONSE)); } else if (agent === 'empty') { res.end(JSON.stringify(EMPTY_SKILLS_RESPONSE)); } else { res.end(JSON.stringify(CLAUDE_SKILLS_RESPONSE)); } } else { res.writeHead(404); res.end('Not found'); } }); server.listen(0, '127.0.0.1', () => { const { port } = server.address(); serverUrl = `http://127.0.0.1:${port}`; resolve(); }); }); } function stopMockServer() { return new Promise((resolve) => { if (server) server.close(resolve); else resolve(); }); } // -- Helper: simulate fetching skills like the dashboard does -- async function fetchSkills(agent) { const url = `${serverUrl}/api/skills?agent=${encodeURIComponent(agent)}`; const response = await fetch(url); if (!response.ok) return null; return response.json(); } // ============================================================= // Test Suite: Server -> Client Skills Fetch // ============================================================= describe('E2E: Server serves /api/skills correctly', () => { before(startMockServer); after(stopMockServer); it('fetches Claude skills with / trigger', async () => { const config = await fetchSkills('claude'); assert.equal(config.trigger, '/'); assert.ok(config.skills.length > 0, 'should have skills'); assert.ok(config.skills.some(s => s.name === 'commit')); }); it('fetches Codex skills with $ trigger', async () => { const config = await fetchSkills('codex'); assert.equal(config.trigger, '$'); assert.ok(config.skills.some(s => s.name === 'lint')); }); it('returns empty skills list when none exist', async () => { const config = await fetchSkills('empty'); assert.equal(config.trigger, '/'); assert.deepEqual(config.skills, []); }); it('each skill has name and description', async () => { const config = await fetchSkills('claude'); for (const skill of config.skills) { assert.ok(skill.name, 'skill should have name'); assert.ok(skill.description, 'skill should have description'); } }); }); // ============================================================= // Test Suite: Dashboard loads skills on session open // ============================================================= describe('E2E: Dashboard loads skills on session open', () => { before(startMockServer); after(stopMockServer); it('loads Claude skills config matching server response', async () => { const config = await fetchSkills('claude'); assert.equal(config.trigger, '/'); // Verify the config is usable by autocomplete functions const info = getTriggerInfo('/com', 4, config); assert.ok(info, 'should detect trigger in loaded config'); assert.equal(info.filterText, 'com'); }); it('loads Codex skills config matching server response', async () => { const config = await fetchSkills('codex'); assert.equal(config.trigger, '$'); const info = getTriggerInfo('$li', 3, config); assert.ok(info, 'should detect $ trigger'); assert.equal(info.filterText, 'li'); }); it('handles null/missing config gracefully', async () => { // Simulate network failure const info = getTriggerInfo('/test', 5, null); assert.equal(info, null); const skills = filteredSkills(null, { filterText: '' }); assert.deepEqual(skills, []); }); }); // ============================================================= // Test Suite: Trigger character shows dropdown // ============================================================= describe('E2E: Trigger character shows dropdown', () => { const config = CLAUDE_SKILLS_RESPONSE; it('Claude session: Type "/" -> dropdown appears with Claude skills', () => { const info = getTriggerInfo('/', 1, config); assert.ok(info, 'trigger should be detected'); assert.equal(info.trigger, '/'); const skills = filteredSkills(config, info); assert.ok(skills.length > 0, 'should show skills'); }); it('Codex session: Type "$" -> dropdown appears with Codex skills', () => { const codexConfig = CODEX_SKILLS_RESPONSE; const info = getTriggerInfo('$', 1, codexConfig); assert.ok(info, 'trigger should be detected'); assert.equal(info.trigger, '$'); const skills = filteredSkills(codexConfig, info); assert.ok(skills.length > 0); }); it('Claude session: Type "$" -> nothing happens (wrong trigger)', () => { const info = getTriggerInfo('$', 1, config); assert.equal(info, null, 'wrong trigger should not activate'); }); it('Type "/com" -> list filters to skills containing "com"', () => { const info = getTriggerInfo('/com', 4, config); assert.ok(info); assert.equal(info.filterText, 'com'); const skills = filteredSkills(config, info); const names = skills.map(s => s.name); assert.ok(names.includes('commit'), 'should include commit'); assert.ok(names.includes('comment'), 'should include comment'); assert.ok(!names.includes('review-pr'), 'should not include review-pr'); assert.ok(!names.includes('help'), 'should not include help'); }); it('Mid-message: Type "please run /commit" -> autocomplete triggers on "/"', () => { const input = 'please run /commit'; const info = getTriggerInfo(input, input.length, config); assert.ok(info, 'should detect trigger mid-message'); assert.equal(info.trigger, '/'); assert.equal(info.filterText, 'commit'); assert.equal(info.replaceStart, 11); assert.equal(info.replaceEnd, 18); }); it('Trigger at start of line after newline', () => { const input = 'first line\n/rev'; const info = getTriggerInfo(input, input.length, config); assert.ok(info); assert.equal(info.filterText, 'rev'); }); }); // ============================================================= // Test Suite: Keyboard navigation works // ============================================================= describe('E2E: Keyboard navigation simulation', () => { const config = CLAUDE_SKILLS_RESPONSE; it('Arrow keys navigate through filtered list', () => { const info = getTriggerInfo('/', 1, config); const skills = filteredSkills(config, info); // Simulate state: selectedIndex starts at 0 let selectedIndex = 0; // ArrowDown moves to next selectedIndex = Math.min(selectedIndex + 1, skills.length - 1); assert.equal(selectedIndex, 1); // ArrowDown again selectedIndex = Math.min(selectedIndex + 1, skills.length - 1); assert.equal(selectedIndex, 2); // ArrowUp moves back selectedIndex = Math.max(selectedIndex - 1, 0); assert.equal(selectedIndex, 1); // ArrowUp back to start selectedIndex = Math.max(selectedIndex - 1, 0); assert.equal(selectedIndex, 0); // ArrowUp at top doesn't go negative selectedIndex = Math.max(selectedIndex - 1, 0); assert.equal(selectedIndex, 0); }); it('ArrowDown clamps at list end', () => { const info = getTriggerInfo('/com', 4, config); const skills = filteredSkills(config, info); // "com" matches commit and comment -> 2 skills assert.equal(skills.length, 2); let selectedIndex = 0; // Down to 1 selectedIndex = Math.min(selectedIndex + 1, skills.length - 1); assert.equal(selectedIndex, 1); // Down again - clamped at 1 selectedIndex = Math.min(selectedIndex + 1, skills.length - 1); assert.equal(selectedIndex, 1, 'should clamp at list end'); }); it('Enter selects the current skill', () => { const info = getTriggerInfo('/', 1, config); const skills = filteredSkills(config, info); const selectedIndex = 0; // Simulate Enter: select skill at selectedIndex const selected = skills[selectedIndex]; assert.ok(selected, 'should have a skill to select'); assert.equal(selected.name, skills[0].name); }); it('Escape dismisses without selection', () => { // Simulate Escape: set showAutocomplete = false, no insertion let showAutocomplete = true; // Escape handler showAutocomplete = false; assert.equal(showAutocomplete, false, 'dropdown should close on Escape'); }); }); // ============================================================= // Test Suite: Selection inserts skill // ============================================================= describe('E2E: Selection inserts skill', () => { const config = CLAUDE_SKILLS_RESPONSE; /** * Simulate insertSkill logic from SimpleInput.js */ function simulateInsertSkill(text, triggerInfo, skill, trigger) { const { replaceStart, replaceEnd } = triggerInfo; const before = text.slice(0, replaceStart); const after = text.slice(replaceEnd); const inserted = `${trigger}${skill.name} `; return { newText: before + inserted + after, newCursorPos: replaceStart + inserted.length, }; } it('Selected skill shows as "{trigger}skill-name " in input', () => { const text = '/com'; const info = getTriggerInfo(text, 4, config); const skills = filteredSkills(config, info); const skill = skills.find(s => s.name === 'commit'); const { newText, newCursorPos } = simulateInsertSkill(text, info, skill, config.trigger); assert.equal(newText, '/commit ', 'should insert trigger + skill name + space'); assert.equal(newCursorPos, 8, 'cursor should be after inserted text'); }); it('Inserting mid-message preserves surrounding text', () => { const text = 'please run /com and continue'; const info = getTriggerInfo(text, 15, config); // cursor at end of "/com" assert.ok(info); const skill = { name: 'commit' }; const { newText } = simulateInsertSkill(text, info, skill, config.trigger); assert.equal(newText, 'please run /commit and continue'); // Note: there's a double space because "and" was after the cursor position // In real use, the cursor was at position 15 which is after "/com" }); it('Inserting at start of input', () => { const text = '/'; const info = getTriggerInfo(text, 1, config); const skill = { name: 'help' }; const { newText, newCursorPos } = simulateInsertSkill(text, info, skill, config.trigger); assert.equal(newText, '/help '); assert.equal(newCursorPos, 6); }); it('Inserting with filter text replaces trigger+filter', () => { const text = '/review'; const info = getTriggerInfo(text, 7, config); const skill = { name: 'review-pr' }; const { newText } = simulateInsertSkill(text, info, skill, config.trigger); assert.equal(newText, '/review-pr '); }); }); // ============================================================= // Test Suite: Verify alphabetical ordering // ============================================================= describe('E2E: Verify alphabetical ordering of skills', () => { it('Skills are returned sorted alphabetically', () => { const config = CLAUDE_SKILLS_RESPONSE; const info = getTriggerInfo('/', 1, config); const skills = filteredSkills(config, info); const names = skills.map(s => s.name); for (let i = 1; i < names.length; i++) { assert.ok( names[i].localeCompare(names[i - 1]) >= 0, `${names[i]} should come after ${names[i - 1]}` ); } }); it('Filtered results maintain alphabetical order', () => { const config = CLAUDE_SKILLS_RESPONSE; const info = getTriggerInfo('/com', 4, config); const skills = filteredSkills(config, info); const names = skills.map(s => s.name); assert.deepEqual(names, ['comment', 'commit']); }); }); // ============================================================= // Test Suite: Edge Cases // ============================================================= describe('E2E: Edge cases', () => { it('Session without skills shows empty list', () => { const emptyConfig = EMPTY_SKILLS_RESPONSE; const info = getTriggerInfo('/', 1, emptyConfig); assert.ok(info, 'trigger still detected'); const skills = filteredSkills(emptyConfig, info); assert.equal(skills.length, 0); }); it('Single skill still shows in dropdown', () => { const singleConfig = { trigger: '/', skills: [{ name: 'only-skill', description: 'The only one' }], }; const info = getTriggerInfo('/', 1, singleConfig); const skills = filteredSkills(singleConfig, info); assert.equal(skills.length, 1); assert.equal(skills[0].name, 'only-skill'); }); it('Multiple triggers in one message work independently', () => { const config = CLAUDE_SKILLS_RESPONSE; // User types: "first /commit then /review-pr finally" // After first insertion, simulating second trigger const text = 'first /commit then /rev'; // Cursor at end - should detect second trigger const info = getTriggerInfo(text, text.length, config); assert.ok(info, 'should detect second trigger'); assert.equal(info.filterText, 'rev'); assert.equal(info.replaceStart, 19); // position of second "/" assert.equal(info.replaceEnd, text.length); const skills = filteredSkills(config, info); assert.ok(skills.some(s => s.name === 'review-pr')); }); it('Backspace over trigger dismisses autocomplete', () => { const config = CLAUDE_SKILLS_RESPONSE; // Type "/" - trigger detected let info = getTriggerInfo('/', 1, config); assert.ok(info, 'trigger detected'); // Backspace - text is now empty info = getTriggerInfo('', 0, config); assert.equal(info, null, 'trigger dismissed after backspace'); }); it('Trigger embedded in word does not activate', () => { const config = CLAUDE_SKILLS_RESPONSE; const info = getTriggerInfo('path/to/file', 5, config); assert.equal(info, null, 'should not trigger on path separator'); }); it('No matching skills after filtering shows empty list', () => { const config = CLAUDE_SKILLS_RESPONSE; const info = getTriggerInfo('/zzz', 4, config); assert.ok(info, 'trigger still detected'); const skills = filteredSkills(config, info); assert.equal(skills.length, 0, 'no skills match "zzz"'); }); it('Case-insensitive filtering works', () => { const config = CLAUDE_SKILLS_RESPONSE; const info = getTriggerInfo('/COM', 4, config); assert.ok(info); assert.equal(info.filterText, 'com'); // lowercased const skills = filteredSkills(config, info); assert.ok(skills.length >= 2, 'should match commit and comment'); }); it('Click outside dismisses (state simulation)', () => { // Simulate: showAutocomplete=true, click outside sets it to false let showAutocomplete = true; // Simulate click outside handler const clickTarget = { contains: () => false }; const textareaRef = { contains: () => false }; if (!clickTarget.contains('event') && !textareaRef.contains('event')) { showAutocomplete = false; } assert.equal(showAutocomplete, false, 'click outside should dismiss'); }); }); // ============================================================= // Test Suite: Cross-agent isolation // ============================================================= describe('E2E: Cross-agent trigger isolation', () => { it('Claude trigger / does not activate in Codex config', () => { const codexConfig = CODEX_SKILLS_RESPONSE; const info = getTriggerInfo('/', 1, codexConfig); assert.equal(info, null, '/ should not trigger for Codex'); }); it('Codex trigger $ does not activate in Claude config', () => { const claudeConfig = CLAUDE_SKILLS_RESPONSE; const info = getTriggerInfo('$', 1, claudeConfig); assert.equal(info, null, '$ should not trigger for Claude'); }); it('Each agent gets its own skills list', async () => { // This requires the mock server await startMockServer(); try { const claude = await fetchSkills('claude'); const codex = await fetchSkills('codex'); assert.equal(claude.trigger, '/'); assert.equal(codex.trigger, '$'); const claudeNames = claude.skills.map(s => s.name); const codexNames = codex.skills.map(s => s.name); // No overlap in default test data assert.ok(!claudeNames.includes('lint'), 'Claude should not have Codex skills'); assert.ok(!codexNames.includes('commit'), 'Codex should not have Claude skills'); } finally { await stopMockServer(); } }); }); // ============================================================= // Test Suite: Full workflow simulation // ============================================================= describe('E2E: Full autocomplete workflow', () => { before(startMockServer); after(stopMockServer); it('complete flow: fetch -> type -> filter -> navigate -> select -> verify', async () => { // Step 1: Fetch skills from server (like Modal.js does on session open) const config = await fetchSkills('claude'); assert.equal(config.trigger, '/'); assert.ok(config.skills.length > 0); // Step 2: User starts typing - no trigger yet let text = 'hello '; let cursorPos = text.length; let info = getTriggerInfo(text, cursorPos, config); assert.equal(info, null, 'no trigger yet'); // Step 3: User types trigger character text = 'hello /'; cursorPos = text.length; info = getTriggerInfo(text, cursorPos, config); assert.ok(info, 'trigger detected'); let skills = filteredSkills(config, info); assert.ok(skills.length === 5, 'all 5 skills shown'); // Step 4: User types filter text text = 'hello /com'; cursorPos = text.length; info = getTriggerInfo(text, cursorPos, config); assert.ok(info); assert.equal(info.filterText, 'com'); skills = filteredSkills(config, info); assert.equal(skills.length, 2, 'filtered to 2 skills'); assert.deepEqual(skills.map(s => s.name), ['comment', 'commit']); // Step 5: Arrow down to select "commit" (index 1) let selectedIndex = 0; // starts on "comment" selectedIndex = Math.min(selectedIndex + 1, skills.length - 1); // ArrowDown assert.equal(selectedIndex, 1); assert.equal(skills[selectedIndex].name, 'commit'); // Step 6: Press Enter to insert const selected = skills[selectedIndex]; const { replaceStart, replaceEnd } = info; const before = text.slice(0, replaceStart); const after = text.slice(replaceEnd); const inserted = `${config.trigger}${selected.name} `; const newText = before + inserted + after; const newCursorPos = replaceStart + inserted.length; // Step 7: Verify insertion assert.equal(newText, 'hello /commit '); assert.equal(newCursorPos, 14); // Step 8: Verify autocomplete closed (trigger info should be null for the new text) // After insertion, cursor is at 14, no active trigger word const postInfo = getTriggerInfo(newText, newCursorPos, config); assert.equal(postInfo, null, 'autocomplete should be dismissed after selection'); }); it('complete flow with second trigger after first insertion', async () => { const config = await fetchSkills('claude'); // After first insertion: "hello /commit " let text = 'hello /commit '; let cursorPos = text.length; // User types more text and another trigger text = 'hello /commit then /'; cursorPos = text.length; let info = getTriggerInfo(text, cursorPos, config); assert.ok(info, 'second trigger detected'); assert.equal(info.replaceStart, 19); // Filter the second trigger text = 'hello /commit then /rev'; cursorPos = text.length; info = getTriggerInfo(text, cursorPos, config); assert.ok(info); assert.equal(info.filterText, 'rev'); const skills = filteredSkills(config, info); assert.ok(skills.some(s => s.name === 'review-pr')); // Select review-pr const skill = skills.find(s => s.name === 'review-pr'); const before = text.slice(0, info.replaceStart); const after = text.slice(info.replaceEnd); const newText = before + `${config.trigger}${skill.name} ` + after; assert.equal(newText, 'hello /commit then /review-pr '); }); });