test(e2e): add autocomplete workflow tests
This commit is contained in:
0
tests/e2e/__init__.py
Normal file
0
tests/e2e/__init__.py
Normal file
614
tests/e2e/test_autocomplete_workflow.js
Normal file
614
tests/e2e/test_autocomplete_workflow.js
Normal file
@@ -0,0 +1,614 @@
|
|||||||
|
/**
|
||||||
|
* 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 ');
|
||||||
|
});
|
||||||
|
});
|
||||||
250
tests/e2e/test_skills_endpoint.py
Normal file
250
tests/e2e/test_skills_endpoint.py
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
"""E2E tests for the /api/skills endpoint.
|
||||||
|
|
||||||
|
Spins up a real AMC server on a random port and verifies the skills API
|
||||||
|
returns correct data for Claude and Codex agents, including trigger
|
||||||
|
characters, alphabetical sorting, and response format.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
import urllib.request
|
||||||
|
from http.server import ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from amc_server.handler import AMCHandler
|
||||||
|
|
||||||
|
|
||||||
|
def _find_free_port():
|
||||||
|
"""Find an available port for the test server."""
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||||
|
s.bind(("127.0.0.1", 0))
|
||||||
|
return s.getsockname()[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_json(url):
|
||||||
|
"""Fetch JSON from a URL, returning (status_code, parsed_json)."""
|
||||||
|
req = urllib.request.Request(url)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||||
|
return resp.status, json.loads(resp.read())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return e.code, json.loads(e.read())
|
||||||
|
|
||||||
|
|
||||||
|
class TestSkillsEndpointE2E(unittest.TestCase):
|
||||||
|
"""E2E tests: start a real server and hit /api/skills over HTTP."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
"""Start a test server on a random port with mock skill data."""
|
||||||
|
cls.port = _find_free_port()
|
||||||
|
cls.base_url = f"http://127.0.0.1:{cls.port}"
|
||||||
|
|
||||||
|
# Create temp directories for skill data
|
||||||
|
cls.tmpdir = tempfile.mkdtemp()
|
||||||
|
cls.home = Path(cls.tmpdir)
|
||||||
|
|
||||||
|
# Claude skills
|
||||||
|
for name, desc in [
|
||||||
|
("commit", "Create a git commit"),
|
||||||
|
("review-pr", "Review a pull request"),
|
||||||
|
("comment", "Add a comment"),
|
||||||
|
]:
|
||||||
|
skill_dir = cls.home / ".claude/skills" / name
|
||||||
|
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(skill_dir / "SKILL.md").write_text(desc)
|
||||||
|
|
||||||
|
# Codex curated skills
|
||||||
|
cache_dir = cls.home / ".codex/vendor_imports"
|
||||||
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
cache = {
|
||||||
|
"skills": [
|
||||||
|
{"id": "lint", "shortDescription": "Lint code"},
|
||||||
|
{"id": "deploy", "shortDescription": "Deploy to prod"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
(cache_dir / "skills-curated-cache.json").write_text(json.dumps(cache))
|
||||||
|
|
||||||
|
# Codex user skill
|
||||||
|
codex_skill = cls.home / ".codex/skills/my-script"
|
||||||
|
codex_skill.mkdir(parents=True, exist_ok=True)
|
||||||
|
(codex_skill / "SKILL.md").write_text("Run my custom script")
|
||||||
|
|
||||||
|
# Patch Path.home() for the skills enumeration
|
||||||
|
cls.home_patcher = patch.object(Path, "home", return_value=cls.home)
|
||||||
|
cls.home_patcher.start()
|
||||||
|
|
||||||
|
# Start server in background thread
|
||||||
|
cls.server = ThreadingHTTPServer(("127.0.0.1", cls.port), AMCHandler)
|
||||||
|
cls.server_thread = threading.Thread(target=cls.server.serve_forever)
|
||||||
|
cls.server_thread.daemon = True
|
||||||
|
cls.server_thread.start()
|
||||||
|
|
||||||
|
# Wait for server to be ready
|
||||||
|
for _ in range(50):
|
||||||
|
try:
|
||||||
|
with socket.create_connection(("127.0.0.1", cls.port), timeout=0.1):
|
||||||
|
break
|
||||||
|
except OSError:
|
||||||
|
time.sleep(0.05)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
"""Shut down the test server."""
|
||||||
|
cls.server.shutdown()
|
||||||
|
cls.server_thread.join(timeout=5)
|
||||||
|
cls.home_patcher.stop()
|
||||||
|
|
||||||
|
# -- Core: /api/skills serves correctly --
|
||||||
|
|
||||||
|
def test_skills_default_is_claude(self):
|
||||||
|
"""GET /api/skills without ?agent defaults to claude (/ trigger)."""
|
||||||
|
status, data = _get_json(f"{self.base_url}/api/skills")
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertEqual(data["trigger"], "/")
|
||||||
|
self.assertIsInstance(data["skills"], list)
|
||||||
|
|
||||||
|
def test_claude_skills_returned(self):
|
||||||
|
"""GET /api/skills?agent=claude returns Claude skills."""
|
||||||
|
status, data = _get_json(f"{self.base_url}/api/skills?agent=claude")
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertEqual(data["trigger"], "/")
|
||||||
|
names = [s["name"] for s in data["skills"]]
|
||||||
|
self.assertIn("commit", names)
|
||||||
|
self.assertIn("review-pr", names)
|
||||||
|
self.assertIn("comment", names)
|
||||||
|
|
||||||
|
def test_codex_skills_returned(self):
|
||||||
|
"""GET /api/skills?agent=codex returns Codex skills with $ trigger."""
|
||||||
|
status, data = _get_json(f"{self.base_url}/api/skills?agent=codex")
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertEqual(data["trigger"], "$")
|
||||||
|
names = [s["name"] for s in data["skills"]]
|
||||||
|
self.assertIn("lint", names)
|
||||||
|
self.assertIn("deploy", names)
|
||||||
|
self.assertIn("my-script", names)
|
||||||
|
|
||||||
|
def test_unknown_agent_defaults_to_claude(self):
|
||||||
|
"""Unknown agent type defaults to claude behavior."""
|
||||||
|
status, data = _get_json(f"{self.base_url}/api/skills?agent=unknown-agent")
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertEqual(data["trigger"], "/")
|
||||||
|
|
||||||
|
# -- Response format --
|
||||||
|
|
||||||
|
def test_response_has_trigger_and_skills_keys(self):
|
||||||
|
"""Response JSON has exactly trigger and skills keys."""
|
||||||
|
_, data = _get_json(f"{self.base_url}/api/skills?agent=claude")
|
||||||
|
self.assertIn("trigger", data)
|
||||||
|
self.assertIn("skills", data)
|
||||||
|
|
||||||
|
def test_each_skill_has_name_and_description(self):
|
||||||
|
"""Each skill object has name and description fields."""
|
||||||
|
_, data = _get_json(f"{self.base_url}/api/skills?agent=claude")
|
||||||
|
for skill in data["skills"]:
|
||||||
|
self.assertIn("name", skill)
|
||||||
|
self.assertIn("description", skill)
|
||||||
|
self.assertIsInstance(skill["name"], str)
|
||||||
|
self.assertIsInstance(skill["description"], str)
|
||||||
|
|
||||||
|
# -- Alphabetical sorting --
|
||||||
|
|
||||||
|
def test_claude_skills_alphabetically_sorted(self):
|
||||||
|
"""Claude skills are returned in alphabetical order."""
|
||||||
|
_, data = _get_json(f"{self.base_url}/api/skills?agent=claude")
|
||||||
|
names = [s["name"] for s in data["skills"]]
|
||||||
|
self.assertEqual(names, sorted(names, key=str.lower))
|
||||||
|
|
||||||
|
def test_codex_skills_alphabetically_sorted(self):
|
||||||
|
"""Codex skills are returned in alphabetical order."""
|
||||||
|
_, data = _get_json(f"{self.base_url}/api/skills?agent=codex")
|
||||||
|
names = [s["name"] for s in data["skills"]]
|
||||||
|
self.assertEqual(names, sorted(names, key=str.lower))
|
||||||
|
|
||||||
|
# -- Descriptions --
|
||||||
|
|
||||||
|
def test_claude_skill_descriptions(self):
|
||||||
|
"""Claude skills have correct descriptions from SKILL.md."""
|
||||||
|
_, data = _get_json(f"{self.base_url}/api/skills?agent=claude")
|
||||||
|
by_name = {s["name"]: s["description"] for s in data["skills"]}
|
||||||
|
self.assertEqual(by_name["commit"], "Create a git commit")
|
||||||
|
self.assertEqual(by_name["review-pr"], "Review a pull request")
|
||||||
|
|
||||||
|
def test_codex_curated_descriptions(self):
|
||||||
|
"""Codex curated skills have correct descriptions from cache."""
|
||||||
|
_, data = _get_json(f"{self.base_url}/api/skills?agent=codex")
|
||||||
|
by_name = {s["name"]: s["description"] for s in data["skills"]}
|
||||||
|
self.assertEqual(by_name["lint"], "Lint code")
|
||||||
|
self.assertEqual(by_name["deploy"], "Deploy to prod")
|
||||||
|
|
||||||
|
def test_codex_user_skill_description(self):
|
||||||
|
"""Codex user-installed skills have descriptions from SKILL.md."""
|
||||||
|
_, data = _get_json(f"{self.base_url}/api/skills?agent=codex")
|
||||||
|
by_name = {s["name"]: s["description"] for s in data["skills"]}
|
||||||
|
self.assertEqual(by_name["my-script"], "Run my custom script")
|
||||||
|
|
||||||
|
# -- CORS --
|
||||||
|
|
||||||
|
def test_cors_header_present(self):
|
||||||
|
"""Response includes Access-Control-Allow-Origin header."""
|
||||||
|
url = f"{self.base_url}/api/skills?agent=claude"
|
||||||
|
with urllib.request.urlopen(url, timeout=5) as resp:
|
||||||
|
cors = resp.headers.get("Access-Control-Allow-Origin")
|
||||||
|
self.assertEqual(cors, "*")
|
||||||
|
|
||||||
|
|
||||||
|
class TestSkillsEndpointEmptyE2E(unittest.TestCase):
|
||||||
|
"""E2E tests: server with no skills data."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.port = _find_free_port()
|
||||||
|
cls.base_url = f"http://127.0.0.1:{cls.port}"
|
||||||
|
|
||||||
|
# Empty home directory - no skills at all
|
||||||
|
cls.tmpdir = tempfile.mkdtemp()
|
||||||
|
cls.home = Path(cls.tmpdir)
|
||||||
|
|
||||||
|
cls.home_patcher = patch.object(Path, "home", return_value=cls.home)
|
||||||
|
cls.home_patcher.start()
|
||||||
|
|
||||||
|
cls.server = ThreadingHTTPServer(("127.0.0.1", cls.port), AMCHandler)
|
||||||
|
cls.server_thread = threading.Thread(target=cls.server.serve_forever)
|
||||||
|
cls.server_thread.daemon = True
|
||||||
|
cls.server_thread.start()
|
||||||
|
|
||||||
|
for _ in range(50):
|
||||||
|
try:
|
||||||
|
with socket.create_connection(("127.0.0.1", cls.port), timeout=0.1):
|
||||||
|
break
|
||||||
|
except OSError:
|
||||||
|
time.sleep(0.05)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
cls.server.shutdown()
|
||||||
|
cls.server_thread.join(timeout=5)
|
||||||
|
cls.home_patcher.stop()
|
||||||
|
|
||||||
|
def test_empty_claude_skills(self):
|
||||||
|
"""Server with no Claude skills returns empty list."""
|
||||||
|
status, data = _get_json(f"{self.base_url}/api/skills?agent=claude")
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertEqual(data["trigger"], "/")
|
||||||
|
self.assertEqual(data["skills"], [])
|
||||||
|
|
||||||
|
def test_empty_codex_skills(self):
|
||||||
|
"""Server with no Codex skills returns empty list."""
|
||||||
|
status, data = _get_json(f"{self.base_url}/api/skills?agent=codex")
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertEqual(data["trigger"], "$")
|
||||||
|
self.assertEqual(data["skills"], [])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user