From 37748cb99cc2e410880d962e66fbce9b9ccb0f3a Mon Sep 17 00:00:00 2001 From: teernisse Date: Thu, 26 Feb 2026 17:03:20 -0500 Subject: [PATCH] test(e2e): add autocomplete workflow tests --- tests/e2e/__init__.py | 0 tests/e2e/test_autocomplete_workflow.js | 614 ++++++++++++++++++++++++ tests/e2e/test_skills_endpoint.py | 250 ++++++++++ 3 files changed, 864 insertions(+) create mode 100644 tests/e2e/__init__.py create mode 100644 tests/e2e/test_autocomplete_workflow.js create mode 100644 tests/e2e/test_skills_endpoint.py diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e/test_autocomplete_workflow.js b/tests/e2e/test_autocomplete_workflow.js new file mode 100644 index 0000000..69d8061 --- /dev/null +++ b/tests/e2e/test_autocomplete_workflow.js @@ -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 '); + }); +}); diff --git a/tests/e2e/test_skills_endpoint.py b/tests/e2e/test_skills_endpoint.py new file mode 100644 index 0000000..2519589 --- /dev/null +++ b/tests/e2e/test_skills_endpoint.py @@ -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()