test(e2e): add autocomplete workflow tests

This commit is contained in:
teernisse
2026-02-26 17:03:20 -05:00
parent 9695e9b08a
commit 37748cb99c
3 changed files with 864 additions and 0 deletions

0
tests/e2e/__init__.py Normal file
View File

View 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 ');
});
});

View 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()