From 0508d52ca9be3c0be65a68cbd3cc3d627f9c0785 Mon Sep 17 00:00:00 2001 From: Jake Strawn Date: Thu, 26 Mar 2026 18:10:39 -0400 Subject: [PATCH] test: add test suites for cdn, composition, framework, story, tokens, typegenerate, typescript, and validate tools Co-Authored-By: Claude Sonnet 4.6 --- tests/tools/cdn.test.ts | 226 +++++++++++++++++++++++++++++ tests/tools/composition.test.ts | 235 ++++++++++++++++++++++++++++++ tests/tools/framework.test.ts | 159 ++++++++++++++++++++ tests/tools/story.test.ts | 218 ++++++++++++++++++++++++++++ tests/tools/tokens.test.ts | 210 +++++++++++++++++++++++++++ tests/tools/typegenerate.test.ts | 181 +++++++++++++++++++++++ tests/tools/typescript.test.ts | 242 +++++++++++++++++++++++++++++++ tests/tools/validate.test.ts | 230 +++++++++++++++++++++++++++++ 8 files changed, 1701 insertions(+) create mode 100644 tests/tools/cdn.test.ts create mode 100644 tests/tools/composition.test.ts create mode 100644 tests/tools/framework.test.ts create mode 100644 tests/tools/story.test.ts create mode 100644 tests/tools/tokens.test.ts create mode 100644 tests/tools/typegenerate.test.ts create mode 100644 tests/tools/typescript.test.ts create mode 100644 tests/tools/validate.test.ts diff --git a/tests/tools/cdn.test.ts b/tests/tools/cdn.test.ts new file mode 100644 index 0000000..c1076c9 --- /dev/null +++ b/tests/tools/cdn.test.ts @@ -0,0 +1,226 @@ +/** + * Tests for the resolve_cdn_cem tool dispatcher. + * Covers isCdnTool, handleCdnCall, argument validation, + * and response formatting. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { isCdnTool, handleCdnCall, CDN_TOOL_DEFINITIONS } from '../../packages/core/src/tools/cdn.js'; +import type { McpWcConfig } from '../../packages/core/src/config.js'; + +// ─── Mocks ──────────────────────────────────────────────────────────────────── + +vi.mock('../../packages/core/src/handlers/cdn.js', () => ({ + resolveCdnCem: vi.fn(async (pkg: string, version: string, registry: string) => ({ + cachePath: `/tmp/cdn-cache/${pkg}@${version}.json`, + componentCount: 5, + formatted: `Resolved ${pkg}@${version} from ${registry}: 5 component(s). Library ID: "shoelace". Cached to .mcp-wc/cdn-cache/shoelace@${version}.json.`, + registered: false, + libraryId: 'shoelace', + })), +})); + +// ─── Fixtures ───────────────────────────────────────────────────────────────── + +const FAKE_CONFIG: McpWcConfig = { + cemPath: 'custom-elements.json', + projectRoot: '/fake/project', + componentPrefix: '', + healthHistoryDir: '.mcp-wc/health', + tsconfigPath: 'tsconfig.json', + tokensPath: null, + cdnBase: null, + watch: false, +}; + +// ─── CDN_TOOL_DEFINITIONS ───────────────────────────────────────────────────── + +describe('CDN_TOOL_DEFINITIONS', () => { + it('exports exactly 1 tool definition', () => { + expect(CDN_TOOL_DEFINITIONS).toHaveLength(1); + }); + + it('defines resolve_cdn_cem', () => { + const names = CDN_TOOL_DEFINITIONS.map((t) => t.name); + expect(names).toContain('resolve_cdn_cem'); + }); + + it('resolve_cdn_cem schema requires package', () => { + const def = CDN_TOOL_DEFINITIONS.find((t) => t.name === 'resolve_cdn_cem')!; + expect(def.inputSchema.required).toContain('package'); + }); +}); + +// ─── isCdnTool ──────────────────────────────────────────────────────────────── + +describe('isCdnTool', () => { + it('returns true for resolve_cdn_cem', () => { + expect(isCdnTool('resolve_cdn_cem')).toBe(true); + }); + + it('returns false for unknown tool names', () => { + expect(isCdnTool('get_component')).toBe(false); + expect(isCdnTool('scaffold_component')).toBe(false); + expect(isCdnTool('')).toBe(false); + }); + + it('returns false for near-matches', () => { + expect(isCdnTool('resolve_cdn')).toBe(false); + expect(isCdnTool('cdn_cem')).toBe(false); + }); +}); + +// ─── handleCdnCall — valid inputs ───────────────────────────────────────────── + +describe('handleCdnCall — valid inputs', () => { + it('returns a success result for resolve_cdn_cem with only package', async () => { + const result = await handleCdnCall( + 'resolve_cdn_cem', + { package: '@shoelace-style/shoelace' }, + FAKE_CONFIG, + ); + expect(result.isError).toBeFalsy(); + }); + + it('result content includes formatted output string', async () => { + const result = await handleCdnCall( + 'resolve_cdn_cem', + { package: '@shoelace-style/shoelace' }, + FAKE_CONFIG, + ); + expect(result.content[0].text).toContain('Resolved'); + }); + + it('accepts optional version', async () => { + const result = await handleCdnCall( + 'resolve_cdn_cem', + { package: '@shoelace-style/shoelace', version: '2.15.0' }, + FAKE_CONFIG, + ); + expect(result.isError).toBeFalsy(); + }); + + it('accepts optional registry: unpkg', async () => { + const result = await handleCdnCall( + 'resolve_cdn_cem', + { package: '@shoelace-style/shoelace', registry: 'unpkg' }, + FAKE_CONFIG, + ); + expect(result.isError).toBeFalsy(); + }); + + it('accepts optional register: true', async () => { + const result = await handleCdnCall( + 'resolve_cdn_cem', + { package: '@shoelace-style/shoelace', register: true }, + FAKE_CONFIG, + ); + expect(result.isError).toBeFalsy(); + }); + + it('accepts optional cemPath', async () => { + const result = await handleCdnCall( + 'resolve_cdn_cem', + { package: '@shoelace-style/shoelace', cemPath: 'dist/custom-elements.json' }, + FAKE_CONFIG, + ); + expect(result.isError).toBeFalsy(); + }); + + it('defaults version to latest when omitted', async () => { + const { resolveCdnCem } = await import('../../packages/core/src/handlers/cdn.js'); + vi.mocked(resolveCdnCem).mockClear(); + await handleCdnCall( + 'resolve_cdn_cem', + { package: '@shoelace-style/shoelace' }, + FAKE_CONFIG, + ); + expect(vi.mocked(resolveCdnCem)).toHaveBeenCalledWith( + '@shoelace-style/shoelace', + 'latest', + 'jsdelivr', + FAKE_CONFIG, + false, + undefined, + ); + }); + + it('defaults registry to jsdelivr when omitted', async () => { + const { resolveCdnCem } = await import('../../packages/core/src/handlers/cdn.js'); + vi.mocked(resolveCdnCem).mockClear(); + await handleCdnCall( + 'resolve_cdn_cem', + { package: '@shoelace-style/shoelace' }, + FAKE_CONFIG, + ); + const [, , registry] = vi.mocked(resolveCdnCem).mock.calls[0]; + expect(registry).toBe('jsdelivr'); + }); +}); + +// ─── handleCdnCall — error cases ────────────────────────────────────────────── + +describe('handleCdnCall — error cases', () => { + it('returns error for unknown tool name', async () => { + const result = await handleCdnCall('nonexistent_tool', {}, FAKE_CONFIG); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Unknown CDN tool'); + }); + + it('returns error for empty tool name', async () => { + const result = await handleCdnCall('', {}, FAKE_CONFIG); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Unknown CDN tool'); + }); + + it('returns error when package is missing', async () => { + const result = await handleCdnCall('resolve_cdn_cem', {}, FAKE_CONFIG); + expect(result.isError).toBe(true); + }); + + it('returns error for invalid registry value', async () => { + const result = await handleCdnCall( + 'resolve_cdn_cem', + { package: '@shoelace-style/shoelace', registry: 'invalid-cdn' }, + FAKE_CONFIG, + ); + expect(result.isError).toBe(true); + }); +}); + +// ─── handleCdnCall — handler error propagation ──────────────────────────────── + +describe('handleCdnCall — handler error propagation', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns error when resolveCdnCem handler throws a network error', async () => { + const { resolveCdnCem } = await import('../../packages/core/src/handlers/cdn.js'); + vi.mocked(resolveCdnCem).mockImplementationOnce(async () => { + throw new Error('CDN fetch failed: no CEM found for @shoelace-style/shoelace@latest'); + }); + + const result = await handleCdnCall( + 'resolve_cdn_cem', + { package: '@shoelace-style/shoelace' }, + FAKE_CONFIG, + ); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('CDN fetch failed'); + }); + + it('returns error when resolveCdnCem handler throws a generic error', async () => { + const { resolveCdnCem } = await import('../../packages/core/src/handlers/cdn.js'); + vi.mocked(resolveCdnCem).mockImplementationOnce(async () => { + throw new Error('Unexpected error'); + }); + + const result = await handleCdnCall( + 'resolve_cdn_cem', + { package: '@shoelace-style/shoelace' }, + FAKE_CONFIG, + ); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Unexpected error'); + }); +}); diff --git a/tests/tools/composition.test.ts b/tests/tools/composition.test.ts new file mode 100644 index 0000000..8e8ef81 --- /dev/null +++ b/tests/tools/composition.test.ts @@ -0,0 +1,235 @@ +/** + * Tests for the get_composition_example tool dispatcher. + * Covers isCompositionTool, handleCompositionCall, argument validation, + * and response formatting. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + isCompositionTool, + handleCompositionCall, + COMPOSITION_TOOL_DEFINITIONS, +} from '../../packages/core/src/tools/composition.js'; +import type { Cem } from '../../packages/core/src/handlers/cem.js'; + +// ─── Mocks ──────────────────────────────────────────────────────────────────── + +vi.mock('../../packages/core/src/handlers/composition.js', () => ({ + getCompositionExample: vi.fn((cem: unknown, tagNames: string[]) => ({ + components: tagNames.map((t) => ({ tagName: t, found: true })), + html: tagNames.map((t) => `<${t}>`).join('\n'), + description: `Composition of ${tagNames.join(' + ')}`, + })), +})); + +// ─── Fixtures ───────────────────────────────────────────────────────────────── + +const FAKE_CEM: Cem = { schemaVersion: '1.0.0', modules: [] }; + +const RICH_CEM: Cem = { + schemaVersion: '1.0.0', + modules: [ + { + kind: 'javascript-module', + path: 'src/hx-button.ts', + declarations: [ + { + kind: 'class', + name: 'HxButton', + tagName: 'hx-button', + members: [], + slots: [{ name: '' }, { name: 'prefix' }], + }, + ], + }, + { + kind: 'javascript-module', + path: 'src/hx-card.ts', + declarations: [ + { + kind: 'class', + name: 'HxCard', + tagName: 'hx-card', + members: [], + slots: [{ name: '' }, { name: 'header' }, { name: 'footer' }], + }, + ], + }, + ], +}; + +// ─── COMPOSITION_TOOL_DEFINITIONS ───────────────────────────────────────────── + +describe('COMPOSITION_TOOL_DEFINITIONS', () => { + it('exports exactly 1 tool definition', () => { + expect(COMPOSITION_TOOL_DEFINITIONS).toHaveLength(1); + }); + + it('defines get_composition_example', () => { + const names = COMPOSITION_TOOL_DEFINITIONS.map((t) => t.name); + expect(names).toContain('get_composition_example'); + }); + + it('get_composition_example schema requires tagNames', () => { + const def = COMPOSITION_TOOL_DEFINITIONS.find((t) => t.name === 'get_composition_example')!; + expect(def.inputSchema.required).toContain('tagNames'); + }); +}); + +// ─── isCompositionTool ──────────────────────────────────────────────────────── + +describe('isCompositionTool', () => { + it('returns true for get_composition_example', () => { + expect(isCompositionTool('get_composition_example')).toBe(true); + }); + + it('returns false for unknown tool names', () => { + expect(isCompositionTool('scaffold_component')).toBe(false); + expect(isCompositionTool('get_component')).toBe(false); + expect(isCompositionTool('')).toBe(false); + }); + + it('returns false for near-matches', () => { + expect(isCompositionTool('composition')).toBe(false); + expect(isCompositionTool('get_composition')).toBe(false); + }); +}); + +// ─── handleCompositionCall — valid inputs ───────────────────────────────────── + +describe('handleCompositionCall — valid inputs', () => { + it('returns a success result for a single tagName', () => { + const result = handleCompositionCall( + 'get_composition_example', + { tagNames: ['hx-button'] }, + FAKE_CEM, + ); + expect(result.isError).toBeFalsy(); + }); + + it('returns JSON-parseable content', () => { + const result = handleCompositionCall( + 'get_composition_example', + { tagNames: ['hx-button'] }, + FAKE_CEM, + ); + expect(() => JSON.parse(result.content[0].text)).not.toThrow(); + }); + + it('accepts 2 tagNames', () => { + const result = handleCompositionCall( + 'get_composition_example', + { tagNames: ['hx-card', 'hx-button'] }, + RICH_CEM, + ); + expect(result.isError).toBeFalsy(); + }); + + it('accepts 3 tagNames', () => { + const result = handleCompositionCall( + 'get_composition_example', + { tagNames: ['hx-card', 'hx-button', 'hx-badge'] }, + RICH_CEM, + ); + expect(result.isError).toBeFalsy(); + }); + + it('accepts 4 tagNames (maximum)', () => { + const result = handleCompositionCall( + 'get_composition_example', + { tagNames: ['hx-card', 'hx-button', 'hx-badge', 'hx-icon'] }, + RICH_CEM, + ); + expect(result.isError).toBeFalsy(); + }); + + it('result includes html field', () => { + const result = handleCompositionCall( + 'get_composition_example', + { tagNames: ['hx-button'] }, + FAKE_CEM, + ); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.html).toBeDefined(); + }); + + it('result includes description field', () => { + const result = handleCompositionCall( + 'get_composition_example', + { tagNames: ['hx-button', 'hx-card'] }, + RICH_CEM, + ); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.description).toBeDefined(); + }); +}); + +// ─── handleCompositionCall — error cases ────────────────────────────────────── + +describe('handleCompositionCall — error cases', () => { + it('returns error for unknown tool name', () => { + const result = handleCompositionCall('nonexistent_tool', {}, FAKE_CEM); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Unknown composition tool'); + }); + + it('returns error for empty tool name', () => { + const result = handleCompositionCall('', {}, FAKE_CEM); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Unknown composition tool'); + }); + + it('returns error when tagNames is missing', () => { + const result = handleCompositionCall('get_composition_example', {}, FAKE_CEM); + expect(result.isError).toBe(true); + }); + + it('returns error when tagNames is empty array', () => { + const result = handleCompositionCall( + 'get_composition_example', + { tagNames: [] }, + FAKE_CEM, + ); + expect(result.isError).toBe(true); + }); + + it('returns error when tagNames exceeds 4 items', () => { + const result = handleCompositionCall( + 'get_composition_example', + { tagNames: ['a-one', 'a-two', 'a-three', 'a-four', 'a-five'] }, + FAKE_CEM, + ); + expect(result.isError).toBe(true); + }); + + it('returns error when tagNames is not an array', () => { + const result = handleCompositionCall( + 'get_composition_example', + { tagNames: 'hx-button' }, + FAKE_CEM, + ); + expect(result.isError).toBe(true); + }); +}); + +// ─── handleCompositionCall — handler error propagation ──────────────────────── + +describe('handleCompositionCall — handler error propagation', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns error when getCompositionExample handler throws', async () => { + const { getCompositionExample } = await import('../../packages/core/src/handlers/composition.js'); + vi.mocked(getCompositionExample).mockImplementationOnce(() => { + throw new Error('Component not found in CEM'); + }); + + const result = handleCompositionCall( + 'get_composition_example', + { tagNames: ['unknown-element'] }, + FAKE_CEM, + ); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Component not found in CEM'); + }); +}); diff --git a/tests/tools/framework.test.ts b/tests/tools/framework.test.ts new file mode 100644 index 0000000..0051fc6 --- /dev/null +++ b/tests/tools/framework.test.ts @@ -0,0 +1,159 @@ +/** + * Tests for the detect_framework tool dispatcher. + * Covers isFrameworkTool, handleFrameworkCall, argument validation, + * and response formatting. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + isFrameworkTool, + handleFrameworkCall, + FRAMEWORK_TOOL_DEFINITIONS, +} from '../../packages/core/src/tools/framework.js'; +import type { McpWcConfig } from '../../packages/core/src/config.js'; + +// ─── Mocks ──────────────────────────────────────────────────────────────────── + +vi.mock('../../packages/core/src/handlers/framework.js', () => ({ + detectFramework: vi.fn(async () => ({ + framework: 'lit', + version: '3.2.0', + cemGenerator: '@custom-elements-manifest/analyzer', + regenerationNotes: 'Run: npx cem analyze --globs "src/**/*.ts"', + formatted: + '## Framework Detection\n\n**Framework:** lit\n**Version:** 3.2.0\n**CEM Generator:** @custom-elements-manifest/analyzer\n\n### Regeneration Notes\nRun: npx cem analyze --globs "src/**/*.ts"', + })), +})); + +// ─── Fixtures ───────────────────────────────────────────────────────────────── + +const FAKE_CONFIG: McpWcConfig = { + cemPath: 'custom-elements.json', + projectRoot: '/fake/project', + componentPrefix: 'hx-', + healthHistoryDir: '.mcp-wc/health', + tsconfigPath: 'tsconfig.json', + tokensPath: null, + cdnBase: null, + watch: false, +}; + +// ─── FRAMEWORK_TOOL_DEFINITIONS ─────────────────────────────────────────────── + +describe('FRAMEWORK_TOOL_DEFINITIONS', () => { + it('exports exactly 1 tool definition', () => { + expect(FRAMEWORK_TOOL_DEFINITIONS).toHaveLength(1); + }); + + it('defines detect_framework', () => { + const names = FRAMEWORK_TOOL_DEFINITIONS.map((t) => t.name); + expect(names).toContain('detect_framework'); + }); + + it('detect_framework schema has no required fields', () => { + const def = FRAMEWORK_TOOL_DEFINITIONS.find((t) => t.name === 'detect_framework')!; + expect(def.inputSchema.required).toBeUndefined(); + }); +}); + +// ─── isFrameworkTool ────────────────────────────────────────────────────────── + +describe('isFrameworkTool', () => { + it('returns true for detect_framework', () => { + expect(isFrameworkTool('detect_framework')).toBe(true); + }); + + it('returns false for unknown tool names', () => { + expect(isFrameworkTool('scaffold_component')).toBe(false); + expect(isFrameworkTool('get_component')).toBe(false); + expect(isFrameworkTool('')).toBe(false); + }); + + it('returns false for near-matches', () => { + expect(isFrameworkTool('framework')).toBe(false); + expect(isFrameworkTool('detect_frameworks')).toBe(false); + }); +}); + +// ─── handleFrameworkCall — valid inputs ─────────────────────────────────────── + +describe('handleFrameworkCall — valid inputs', () => { + it('returns a success result for detect_framework with empty args', async () => { + const result = await handleFrameworkCall('detect_framework', {}, FAKE_CONFIG); + expect(result.isError).toBeFalsy(); + }); + + it('returns text content with framework info', async () => { + const result = await handleFrameworkCall('detect_framework', {}, FAKE_CONFIG); + expect(result.content[0].text).toContain('Framework Detection'); + }); + + it('result contains framework name', async () => { + const result = await handleFrameworkCall('detect_framework', {}, FAKE_CONFIG); + expect(result.content[0].text).toContain('lit'); + }); + + it('result contains version info', async () => { + const result = await handleFrameworkCall('detect_framework', {}, FAKE_CONFIG); + expect(result.content[0].text).toContain('3.2.0'); + }); + + it('result contains regeneration notes', async () => { + const result = await handleFrameworkCall('detect_framework', {}, FAKE_CONFIG); + expect(result.content[0].text).toContain('Regeneration Notes'); + }); + + it('ignores any extra args passed in (no schema fields)', async () => { + const result = await handleFrameworkCall( + 'detect_framework', + { unknownProp: 'ignored' }, + FAKE_CONFIG, + ); + expect(result.isError).toBeFalsy(); + }); +}); + +// ─── handleFrameworkCall — error cases ──────────────────────────────────────── + +describe('handleFrameworkCall — error cases', () => { + it('returns error for unknown tool name', async () => { + const result = await handleFrameworkCall('nonexistent_tool', {}, FAKE_CONFIG); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Unknown framework tool'); + }); + + it('returns error for empty tool name', async () => { + const result = await handleFrameworkCall('', {}, FAKE_CONFIG); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Unknown framework tool'); + }); +}); + +// ─── handleFrameworkCall — handler error propagation ───────────────────────── + +describe('handleFrameworkCall — handler error propagation', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns error when detectFramework handler throws', async () => { + const { detectFramework } = await import('../../packages/core/src/handlers/framework.js'); + vi.mocked(detectFramework).mockImplementationOnce(async () => { + throw new Error('package.json not found in project root'); + }); + + const result = await handleFrameworkCall('detect_framework', {}, FAKE_CONFIG); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('package.json not found'); + }); + + it('returns error when detectFramework handler throws generic error', async () => { + const { detectFramework } = await import('../../packages/core/src/handlers/framework.js'); + vi.mocked(detectFramework).mockImplementationOnce(async () => { + throw new Error('Permission denied'); + }); + + const result = await handleFrameworkCall('detect_framework', {}, FAKE_CONFIG); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Permission denied'); + }); +}); diff --git a/tests/tools/story.test.ts b/tests/tools/story.test.ts new file mode 100644 index 0000000..cab805a --- /dev/null +++ b/tests/tools/story.test.ts @@ -0,0 +1,218 @@ +/** + * Tests for the generate_story tool dispatcher. + * Covers isStoryTool, handleStoryCall, argument validation, + * and response formatting. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + isStoryTool, + handleStoryCall, + STORY_TOOL_DEFINITIONS, +} from '../../packages/core/src/tools/story.js'; +import type { Cem } from '../../packages/core/src/handlers/cem.js'; + +// ─── Mocks ──────────────────────────────────────────────────────────────────── + +vi.mock('../../packages/core/src/handlers/story.js', () => ({ + generateStory: vi.fn((decl: { tagName?: string; name?: string }) => { + const tag = decl.tagName ?? 'unknown-element'; + return `import type { Meta, StoryObj } from '@storybook/web-components';\n\nconst meta: Meta = {\n title: 'Components/${tag}',\n component: '${tag}',\n};\n\nexport default meta;\n`; + }), +})); + +// ─── Fixtures ───────────────────────────────────────────────────────────────── + +const EMPTY_CEM: Cem = { schemaVersion: '1.0.0', modules: [] }; + +const BUTTON_CEM: Cem = { + schemaVersion: '1.0.0', + modules: [ + { + kind: 'javascript-module', + path: 'src/hx-button.ts', + declarations: [ + { + kind: 'class', + name: 'HxButton', + tagName: 'hx-button', + members: [ + { kind: 'field', name: 'variant', type: { text: 'string' }, default: '"primary"' }, + { kind: 'field', name: 'disabled', type: { text: 'boolean' }, default: 'false' }, + ], + attributes: [ + { name: 'variant', type: { text: "'primary' | 'secondary' | 'danger'" } }, + { name: 'disabled', type: { text: 'boolean' } }, + ], + slots: [{ name: '' }], + }, + ], + }, + ], +}; + +const MULTI_CEM: Cem = { + schemaVersion: '1.0.0', + modules: [ + { + kind: 'javascript-module', + path: 'src/hx-button.ts', + declarations: [ + { + kind: 'class', + name: 'HxButton', + tagName: 'hx-button', + members: [], + }, + ], + }, + { + kind: 'javascript-module', + path: 'src/hx-card.ts', + declarations: [ + { + kind: 'class', + name: 'HxCard', + tagName: 'hx-card', + members: [], + }, + ], + }, + ], +}; + +// ─── STORY_TOOL_DEFINITIONS ─────────────────────────────────────────────────── + +describe('STORY_TOOL_DEFINITIONS', () => { + it('exports exactly 1 tool definition', () => { + expect(STORY_TOOL_DEFINITIONS).toHaveLength(1); + }); + + it('defines generate_story', () => { + const names = STORY_TOOL_DEFINITIONS.map((t) => t.name); + expect(names).toContain('generate_story'); + }); + + it('generate_story schema requires tagName', () => { + const def = STORY_TOOL_DEFINITIONS.find((t) => t.name === 'generate_story')!; + expect(def.inputSchema.required).toContain('tagName'); + }); +}); + +// ─── isStoryTool ────────────────────────────────────────────────────────────── + +describe('isStoryTool', () => { + it('returns true for generate_story', () => { + expect(isStoryTool('generate_story')).toBe(true); + }); + + it('returns false for unknown tool names', () => { + expect(isStoryTool('scaffold_component')).toBe(false); + expect(isStoryTool('get_component')).toBe(false); + expect(isStoryTool('')).toBe(false); + }); + + it('returns false for near-matches', () => { + expect(isStoryTool('story')).toBe(false); + expect(isStoryTool('generate_stories')).toBe(false); + }); +}); + +// ─── handleStoryCall — valid inputs ─────────────────────────────────────────── + +describe('handleStoryCall — valid inputs', () => { + it('returns a success result for a known component', async () => { + const result = await handleStoryCall('generate_story', { tagName: 'hx-button' }, BUTTON_CEM); + expect(result.isError).toBeFalsy(); + }); + + it('result contains Storybook Meta import', async () => { + const result = await handleStoryCall('generate_story', { tagName: 'hx-button' }, BUTTON_CEM); + expect(result.content[0].text).toContain("'@storybook/web-components'"); + }); + + it('result contains the component tag name', async () => { + const result = await handleStoryCall('generate_story', { tagName: 'hx-button' }, BUTTON_CEM); + expect(result.content[0].text).toContain('hx-button'); + }); + + it('works for a second component in a multi-module CEM', async () => { + const result = await handleStoryCall('generate_story', { tagName: 'hx-card' }, MULTI_CEM); + expect(result.isError).toBeFalsy(); + expect(result.content[0].text).toContain('hx-card'); + }); + + it('returns story source as plain text (not JSON)', async () => { + const result = await handleStoryCall('generate_story', { tagName: 'hx-button' }, BUTTON_CEM); + expect(() => JSON.parse(result.content[0].text)).toThrow(); + }); +}); + +// ─── handleStoryCall — error cases ──────────────────────────────────────────── + +describe('handleStoryCall — error cases', () => { + it('returns error for unknown tool name', async () => { + const result = await handleStoryCall('nonexistent_tool', {}, EMPTY_CEM); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Unknown story tool'); + }); + + it('returns error for empty tool name', async () => { + const result = await handleStoryCall('', {}, EMPTY_CEM); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Unknown story tool'); + }); + + it('returns error when tagName is missing', async () => { + const result = await handleStoryCall('generate_story', {}, BUTTON_CEM); + expect(result.isError).toBe(true); + }); + + it('returns error when tagName not found in CEM', async () => { + const result = await handleStoryCall( + 'generate_story', + { tagName: 'nonexistent-element' }, + BUTTON_CEM, + ); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('nonexistent-element'); + expect(result.content[0].text).toContain('not found in CEM'); + }); + + it('returns error with known components list when tagName not found', async () => { + const result = await handleStoryCall( + 'generate_story', + { tagName: 'missing-component' }, + BUTTON_CEM, + ); + expect(result.content[0].text).toContain('hx-button'); + }); + + it('returns error with (none) when CEM has no components', async () => { + const result = await handleStoryCall( + 'generate_story', + { tagName: 'hx-button' }, + EMPTY_CEM, + ); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('(none)'); + }); +}); + +// ─── handleStoryCall — handler error propagation ────────────────────────────── + +describe('handleStoryCall — handler error propagation', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns error when generateStory handler throws', async () => { + const { generateStory } = await import('../../packages/core/src/handlers/story.js'); + vi.mocked(generateStory).mockImplementationOnce(() => { + throw new Error('Failed to generate story template'); + }); + + const result = await handleStoryCall('generate_story', { tagName: 'hx-button' }, BUTTON_CEM); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Failed to generate story template'); + }); +}); diff --git a/tests/tools/tokens.test.ts b/tests/tools/tokens.test.ts new file mode 100644 index 0000000..aa2346e --- /dev/null +++ b/tests/tools/tokens.test.ts @@ -0,0 +1,210 @@ +/** + * Tests for the get_design_tokens and find_token tool dispatchers. + * Covers isTokenTool, handleTokenCall, argument validation, + * and response formatting. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + isTokenTool, + handleTokenCall, + TOKEN_TOOL_DEFINITIONS, +} from '../../packages/core/src/tools/tokens.js'; +import type { McpWcConfig } from '../../packages/core/src/config.js'; + +// ─── Mocks ──────────────────────────────────────────────────────────────────── + +vi.mock('../../packages/core/src/handlers/tokens.js', () => ({ + getDesignTokens: vi.fn(async (_config: unknown, category?: string) => ({ + tokens: [ + { name: '--color-primary', value: '#0066cc', category: 'color' }, + { name: '--color-secondary', value: '#666', category: 'color' }, + { name: '--spacing-md', value: '1rem', category: 'spacing' }, + ].filter((t) => !category || t.category === category), + count: category ? 2 : 3, + categories: ['color', 'spacing'], + })), + findToken: vi.fn(async (_config: unknown, query: string) => ({ + tokens: [ + { name: '--color-primary', value: '#0066cc', category: 'color' }, + ].filter((t) => t.name.includes(query) || t.value.includes(query)), + count: 1, + query, + })), +})); + +// ─── Fixtures ───────────────────────────────────────────────────────────────── + +const FAKE_CONFIG: McpWcConfig = { + cemPath: 'custom-elements.json', + projectRoot: '/fake/project', + componentPrefix: '', + healthHistoryDir: '.mcp-wc/health', + tsconfigPath: 'tsconfig.json', + tokensPath: '/fake/project/tokens.json', + cdnBase: null, + watch: false, +}; + +const CONFIG_NO_TOKENS: McpWcConfig = { + ...FAKE_CONFIG, + tokensPath: null, +}; + +// ─── TOKEN_TOOL_DEFINITIONS ─────────────────────────────────────────────────── + +describe('TOKEN_TOOL_DEFINITIONS', () => { + it('exports exactly 2 tool definitions', () => { + expect(TOKEN_TOOL_DEFINITIONS).toHaveLength(2); + }); + + it('defines get_design_tokens and find_token', () => { + const names = TOKEN_TOOL_DEFINITIONS.map((t) => t.name); + expect(names).toContain('get_design_tokens'); + expect(names).toContain('find_token'); + }); + + it('find_token schema requires query', () => { + const def = TOKEN_TOOL_DEFINITIONS.find((t) => t.name === 'find_token')!; + expect(def.inputSchema.required).toContain('query'); + }); + + it('get_design_tokens schema has no required fields', () => { + const def = TOKEN_TOOL_DEFINITIONS.find((t) => t.name === 'get_design_tokens')!; + expect(def.inputSchema.required).toBeUndefined(); + }); +}); + +// ─── isTokenTool ────────────────────────────────────────────────────────────── + +describe('isTokenTool', () => { + it('returns true for get_design_tokens', () => { + expect(isTokenTool('get_design_tokens')).toBe(true); + }); + + it('returns true for find_token', () => { + expect(isTokenTool('find_token')).toBe(true); + }); + + it('returns false for unknown tool names', () => { + expect(isTokenTool('scaffold_component')).toBe(false); + expect(isTokenTool('get_component')).toBe(false); + expect(isTokenTool('')).toBe(false); + }); + + it('returns false for near-matches', () => { + expect(isTokenTool('design_tokens')).toBe(false); + expect(isTokenTool('get_tokens')).toBe(false); + }); +}); + +// ─── handleTokenCall — get_design_tokens ────────────────────────────────────── + +describe('handleTokenCall — get_design_tokens', () => { + it('returns success result with no args', async () => { + const result = await handleTokenCall('get_design_tokens', {}, FAKE_CONFIG); + expect(result.isError).toBeFalsy(); + }); + + it('returns JSON-parseable content', async () => { + const result = await handleTokenCall('get_design_tokens', {}, FAKE_CONFIG); + expect(() => JSON.parse(result.content[0].text)).not.toThrow(); + }); + + it('accepts optional category filter', async () => { + const result = await handleTokenCall( + 'get_design_tokens', + { category: 'color' }, + FAKE_CONFIG, + ); + expect(result.isError).toBeFalsy(); + }); + + it('result contains tokens array', async () => { + const result = await handleTokenCall('get_design_tokens', {}, FAKE_CONFIG); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.tokens).toBeDefined(); + }); + + it('result contains categories list', async () => { + const result = await handleTokenCall('get_design_tokens', {}, FAKE_CONFIG); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.categories).toBeDefined(); + }); +}); + +// ─── handleTokenCall — find_token ───────────────────────────────────────────── + +describe('handleTokenCall — find_token', () => { + it('returns success result with valid query', async () => { + const result = await handleTokenCall('find_token', { query: 'color' }, FAKE_CONFIG); + expect(result.isError).toBeFalsy(); + }); + + it('returns JSON-parseable content', async () => { + const result = await handleTokenCall('find_token', { query: 'primary' }, FAKE_CONFIG); + expect(() => JSON.parse(result.content[0].text)).not.toThrow(); + }); + + it('result contains query field', async () => { + const result = await handleTokenCall('find_token', { query: 'primary' }, FAKE_CONFIG); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.query).toBe('primary'); + }); + + it('result contains tokens array', async () => { + const result = await handleTokenCall('find_token', { query: 'color' }, FAKE_CONFIG); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.tokens).toBeDefined(); + }); +}); + +// ─── handleTokenCall — error cases ──────────────────────────────────────────── + +describe('handleTokenCall — error cases', () => { + it('returns error for unknown tool name', async () => { + const result = await handleTokenCall('nonexistent_tool', {}, FAKE_CONFIG); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Unknown token tool'); + }); + + it('returns error for empty tool name', async () => { + const result = await handleTokenCall('', {}, FAKE_CONFIG); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Unknown token tool'); + }); + + it('returns error when find_token query is missing', async () => { + const result = await handleTokenCall('find_token', {}, FAKE_CONFIG); + expect(result.isError).toBe(true); + }); +}); + +// ─── handleTokenCall — handler error propagation ────────────────────────────── + +describe('handleTokenCall — handler error propagation', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns error when getDesignTokens handler throws (no tokensPath)', async () => { + const { getDesignTokens } = await import('../../packages/core/src/handlers/tokens.js'); + vi.mocked(getDesignTokens).mockImplementationOnce(async () => { + throw new Error('tokensPath is not configured'); + }); + + const result = await handleTokenCall('get_design_tokens', {}, CONFIG_NO_TOKENS); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('tokensPath is not configured'); + }); + + it('returns error when findToken handler throws', async () => { + const { findToken } = await import('../../packages/core/src/handlers/tokens.js'); + vi.mocked(findToken).mockImplementationOnce(async () => { + throw new Error('Tokens file not found'); + }); + + const result = await handleTokenCall('find_token', { query: 'primary' }, FAKE_CONFIG); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Tokens file not found'); + }); +}); diff --git a/tests/tools/typegenerate.test.ts b/tests/tools/typegenerate.test.ts new file mode 100644 index 0000000..01502b2 --- /dev/null +++ b/tests/tools/typegenerate.test.ts @@ -0,0 +1,181 @@ +/** + * Tests for the generate_types tool dispatcher. + * Covers isTypegenerateTool, handleTypegenerateCall, argument validation, + * and response formatting. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + isTypegenerateTool, + handleTypegenerateCall, + TYPEGENERATE_TOOL_DEFINITIONS, +} from '../../packages/core/src/tools/typegenerate.js'; +import type { Cem } from '../../packages/core/src/handlers/cem.js'; + +// ─── Mocks ──────────────────────────────────────────────────────────────────── + +vi.mock('../../packages/core/src/handlers/typegenerate.js', () => ({ + generateTypes: vi.fn((cem: { modules: unknown[] }) => { + const count = cem.modules.length; + return { + componentCount: count, + content: count === 0 + ? '// No components found\n' + : 'declare global {\n interface HTMLElementTagNameMap {\n "hx-button": HxButton;\n }\n}\nexport interface HxButton {\n variant?: string;\n disabled?: boolean;\n}\n', + }; + }), +})); + +// ─── Fixtures ───────────────────────────────────────────────────────────────── + +const EMPTY_CEM: Cem = { schemaVersion: '1.0.0', modules: [] }; + +const BUTTON_CEM: Cem = { + schemaVersion: '1.0.0', + modules: [ + { + kind: 'javascript-module', + path: 'src/hx-button.ts', + declarations: [ + { + kind: 'class', + name: 'HxButton', + tagName: 'hx-button', + members: [ + { kind: 'field', name: 'variant', type: { text: 'string' } }, + { kind: 'field', name: 'disabled', type: { text: 'boolean' } }, + ], + attributes: [ + { name: 'variant', type: { text: 'string' } }, + ], + }, + ], + }, + ], +}; + +const MULTI_CEM: Cem = { + schemaVersion: '1.0.0', + modules: [ + { + kind: 'javascript-module', + path: 'src/hx-button.ts', + declarations: [ + { kind: 'class', name: 'HxButton', tagName: 'hx-button', members: [] }, + ], + }, + { + kind: 'javascript-module', + path: 'src/hx-card.ts', + declarations: [ + { kind: 'class', name: 'HxCard', tagName: 'hx-card', members: [] }, + ], + }, + ], +}; + +// ─── TYPEGENERATE_TOOL_DEFINITIONS ──────────────────────────────────────────── + +describe('TYPEGENERATE_TOOL_DEFINITIONS', () => { + it('exports exactly 1 tool definition', () => { + expect(TYPEGENERATE_TOOL_DEFINITIONS).toHaveLength(1); + }); + + it('defines generate_types', () => { + const names = TYPEGENERATE_TOOL_DEFINITIONS.map((t) => t.name); + expect(names).toContain('generate_types'); + }); + + it('generate_types schema has no required fields', () => { + const def = TYPEGENERATE_TOOL_DEFINITIONS.find((t) => t.name === 'generate_types')!; + expect(def.inputSchema.required).toBeUndefined(); + }); +}); + +// ─── isTypegenerateTool ─────────────────────────────────────────────────────── + +describe('isTypegenerateTool', () => { + it('returns true for generate_types', () => { + expect(isTypegenerateTool('generate_types')).toBe(true); + }); + + it('returns false for unknown tool names', () => { + expect(isTypegenerateTool('scaffold_component')).toBe(false); + expect(isTypegenerateTool('get_component')).toBe(false); + expect(isTypegenerateTool('')).toBe(false); + }); + + it('returns false for near-matches', () => { + expect(isTypegenerateTool('generate')).toBe(false); + expect(isTypegenerateTool('generate_type')).toBe(false); + }); +}); + +// ─── handleTypegenerateCall — valid inputs ──────────────────────────────────── + +describe('handleTypegenerateCall — valid inputs', () => { + it('returns a success result for generate_types with empty args', () => { + const result = handleTypegenerateCall('generate_types', {}, BUTTON_CEM); + expect(result.isError).toBeFalsy(); + }); + + it('output includes component count comment', () => { + const result = handleTypegenerateCall('generate_types', {}, BUTTON_CEM); + expect(result.content[0].text).toContain('component(s) generated'); + }); + + it('output contains TypeScript declarations', () => { + const result = handleTypegenerateCall('generate_types', {}, BUTTON_CEM); + expect(result.content[0].text).toContain('HTMLElementTagNameMap'); + }); + + it('works with a multi-module CEM', () => { + const result = handleTypegenerateCall('generate_types', {}, MULTI_CEM); + expect(result.isError).toBeFalsy(); + }); + + it('works with an empty CEM', () => { + const result = handleTypegenerateCall('generate_types', {}, EMPTY_CEM); + expect(result.isError).toBeFalsy(); + expect(result.content[0].text).toContain('// 0 component(s) generated'); + }); + + it('accepts optional libraryId argument without error', () => { + const result = handleTypegenerateCall('generate_types', { libraryId: 'shoelace' }, BUTTON_CEM); + expect(result.isError).toBeFalsy(); + }); +}); + +// ─── handleTypegenerateCall — error cases ───────────────────────────────────── + +describe('handleTypegenerateCall — error cases', () => { + it('returns error for unknown tool name', () => { + const result = handleTypegenerateCall('nonexistent_tool', {}, EMPTY_CEM); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Unknown typegenerate tool'); + }); + + it('returns error for empty tool name', () => { + const result = handleTypegenerateCall('', {}, EMPTY_CEM); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Unknown typegenerate tool'); + }); +}); + +// ─── handleTypegenerateCall — handler error propagation ─────────────────────── + +describe('handleTypegenerateCall — handler error propagation', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns error when generateTypes handler throws', async () => { + const { generateTypes } = await import('../../packages/core/src/handlers/typegenerate.js'); + vi.mocked(generateTypes).mockImplementationOnce(() => { + throw new Error('CEM schema version not supported'); + }); + + const result = handleTypegenerateCall('generate_types', {}, BUTTON_CEM); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('CEM schema version not supported'); + }); +}); diff --git a/tests/tools/typescript.test.ts b/tests/tools/typescript.test.ts new file mode 100644 index 0000000..7726ae5 --- /dev/null +++ b/tests/tools/typescript.test.ts @@ -0,0 +1,242 @@ +/** + * Tests for the get_file_diagnostics and get_project_diagnostics tool dispatchers. + * Covers isTypeScriptTool, handleTypeScriptCall, argument validation, + * and response formatting. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + isTypeScriptTool, + handleTypeScriptCall, + TYPESCRIPT_TOOL_DEFINITIONS, +} from '../../packages/core/src/tools/typescript.js'; +import type { McpWcConfig } from '../../packages/core/src/config.js'; + +// ─── Mocks ──────────────────────────────────────────────────────────────────── + +vi.mock('../../packages/core/src/handlers/typescript.js', () => ({ + getFileDiagnostics: vi.fn((_config: unknown, filePath: string) => ({ + filePath, + diagnostics: [], + errorCount: 0, + warningCount: 0, + })), + getProjectDiagnostics: vi.fn((_config: unknown) => ({ + errorCount: 0, + warningCount: 2, + files: 15, + diagnostics: [], + })), +})); + +// ─── Fixtures ───────────────────────────────────────────────────────────────── + +const FAKE_CONFIG: McpWcConfig = { + cemPath: 'custom-elements.json', + projectRoot: '/fake/project', + componentPrefix: '', + healthHistoryDir: '.mcp-wc/health', + tsconfigPath: 'tsconfig.json', + tokensPath: null, + cdnBase: null, + watch: false, +}; + +// ─── TYPESCRIPT_TOOL_DEFINITIONS ────────────────────────────────────────────── + +describe('TYPESCRIPT_TOOL_DEFINITIONS', () => { + it('exports exactly 2 tool definitions', () => { + expect(TYPESCRIPT_TOOL_DEFINITIONS).toHaveLength(2); + }); + + it('defines get_file_diagnostics and get_project_diagnostics', () => { + const names = TYPESCRIPT_TOOL_DEFINITIONS.map((t) => t.name); + expect(names).toContain('get_file_diagnostics'); + expect(names).toContain('get_project_diagnostics'); + }); + + it('get_file_diagnostics schema requires filePath', () => { + const def = TYPESCRIPT_TOOL_DEFINITIONS.find((t) => t.name === 'get_file_diagnostics')!; + expect(def.inputSchema.required).toContain('filePath'); + }); + + it('get_project_diagnostics schema has no required fields', () => { + const def = TYPESCRIPT_TOOL_DEFINITIONS.find((t) => t.name === 'get_project_diagnostics')!; + expect(def.inputSchema.required).toBeUndefined(); + }); +}); + +// ─── isTypeScriptTool ───────────────────────────────────────────────────────── + +describe('isTypeScriptTool', () => { + it('returns true for get_file_diagnostics', () => { + expect(isTypeScriptTool('get_file_diagnostics')).toBe(true); + }); + + it('returns true for get_project_diagnostics', () => { + expect(isTypeScriptTool('get_project_diagnostics')).toBe(true); + }); + + it('returns false for unknown tool names', () => { + expect(isTypeScriptTool('scaffold_component')).toBe(false); + expect(isTypeScriptTool('get_component')).toBe(false); + expect(isTypeScriptTool('')).toBe(false); + }); + + it('returns false for near-matches', () => { + expect(isTypeScriptTool('file_diagnostics')).toBe(false); + expect(isTypeScriptTool('get_diagnostics')).toBe(false); + }); +}); + +// ─── handleTypeScriptCall — get_file_diagnostics ────────────────────────────── + +describe('handleTypeScriptCall — get_file_diagnostics', () => { + it('returns success result for a valid file path', () => { + const result = handleTypeScriptCall( + 'get_file_diagnostics', + { filePath: 'src/hx-button.ts' }, + FAKE_CONFIG, + ); + expect(result.isError).toBeFalsy(); + }); + + it('returns JSON-parseable content', () => { + const result = handleTypeScriptCall( + 'get_file_diagnostics', + { filePath: 'src/hx-button.ts' }, + FAKE_CONFIG, + ); + expect(() => JSON.parse(result.content[0].text)).not.toThrow(); + }); + + it('result includes filePath field', () => { + const result = handleTypeScriptCall( + 'get_file_diagnostics', + { filePath: 'src/hx-button.ts' }, + FAKE_CONFIG, + ); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.filePath).toBe('src/hx-button.ts'); + }); + + it('result includes diagnostics array', () => { + const result = handleTypeScriptCall( + 'get_file_diagnostics', + { filePath: 'src/hx-button.ts' }, + FAKE_CONFIG, + ); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.diagnostics).toBeDefined(); + expect(Array.isArray(parsed.diagnostics)).toBe(true); + }); + + it('result includes errorCount', () => { + const result = handleTypeScriptCall( + 'get_file_diagnostics', + { filePath: 'src/hx-button.ts' }, + FAKE_CONFIG, + ); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.errorCount).toBeDefined(); + }); +}); + +// ─── handleTypeScriptCall — get_project_diagnostics ─────────────────────────── + +describe('handleTypeScriptCall — get_project_diagnostics', () => { + it('returns success result with empty args', () => { + const result = handleTypeScriptCall('get_project_diagnostics', {}, FAKE_CONFIG); + expect(result.isError).toBeFalsy(); + }); + + it('returns JSON-parseable content', () => { + const result = handleTypeScriptCall('get_project_diagnostics', {}, FAKE_CONFIG); + expect(() => JSON.parse(result.content[0].text)).not.toThrow(); + }); + + it('result includes errorCount and warningCount', () => { + const result = handleTypeScriptCall('get_project_diagnostics', {}, FAKE_CONFIG); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.errorCount).toBeDefined(); + expect(parsed.warningCount).toBeDefined(); + }); + + it('result includes files count', () => { + const result = handleTypeScriptCall('get_project_diagnostics', {}, FAKE_CONFIG); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.files).toBeDefined(); + }); +}); + +// ─── handleTypeScriptCall — error cases ─────────────────────────────────────── + +describe('handleTypeScriptCall — error cases', () => { + it('returns error for unknown tool name', () => { + const result = handleTypeScriptCall('nonexistent_tool', {}, FAKE_CONFIG); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Unknown TypeScript tool'); + }); + + it('returns error for empty tool name', () => { + const result = handleTypeScriptCall('', {}, FAKE_CONFIG); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Unknown TypeScript tool'); + }); + + it('returns error when filePath is missing for get_file_diagnostics', () => { + const result = handleTypeScriptCall('get_file_diagnostics', {}, FAKE_CONFIG); + expect(result.isError).toBe(true); + }); + + it('returns error for absolute filePath (path traversal)', () => { + const result = handleTypeScriptCall( + 'get_file_diagnostics', + { filePath: '/etc/passwd' }, + FAKE_CONFIG, + ); + expect(result.isError).toBe(true); + }); + + it('returns error for path traversal attempt in filePath', () => { + const result = handleTypeScriptCall( + 'get_file_diagnostics', + { filePath: '../../etc/passwd' }, + FAKE_CONFIG, + ); + expect(result.isError).toBe(true); + }); +}); + +// ─── handleTypeScriptCall — handler error propagation ───────────────────────── + +describe('handleTypeScriptCall — handler error propagation', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns error when getFileDiagnostics handler throws', async () => { + const { getFileDiagnostics } = await import('../../packages/core/src/handlers/typescript.js'); + vi.mocked(getFileDiagnostics).mockImplementationOnce(() => { + throw new Error('tsconfig.json not found'); + }); + + const result = handleTypeScriptCall( + 'get_file_diagnostics', + { filePath: 'src/hx-button.ts' }, + FAKE_CONFIG, + ); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('tsconfig.json not found'); + }); + + it('returns error when getProjectDiagnostics handler throws', async () => { + const { getProjectDiagnostics } = await import('../../packages/core/src/handlers/typescript.js'); + vi.mocked(getProjectDiagnostics).mockImplementationOnce(() => { + throw new Error('Project root does not exist'); + }); + + const result = handleTypeScriptCall('get_project_diagnostics', {}, FAKE_CONFIG); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Project root does not exist'); + }); +}); diff --git a/tests/tools/validate.test.ts b/tests/tools/validate.test.ts new file mode 100644 index 0000000..7da73d2 --- /dev/null +++ b/tests/tools/validate.test.ts @@ -0,0 +1,230 @@ +/** + * Tests for the validate_usage tool dispatcher. + * Covers isValidateTool, handleValidateCall, argument validation, + * and response formatting. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + isValidateTool, + handleValidateCall, + VALIDATE_TOOL_DEFINITIONS, +} from '../../packages/core/src/tools/validate.js'; +import type { Cem } from '../../packages/core/src/handlers/cem.js'; + +// ─── Mocks ──────────────────────────────────────────────────────────────────── + +vi.mock('../../packages/core/src/handlers/validate.js', () => ({ + validateUsage: vi.fn( + (tagName: string, html: string, _cem: unknown) => ({ + tagName, + html, + valid: true, + issues: [], + issueCount: 0, + formatted: `## Validation: ${tagName}\n\n**Result:** PASS\n\nNo issues found.`, + }), + ), +})); + +// ─── Fixtures ───────────────────────────────────────────────────────────────── + +const EMPTY_CEM: Cem = { schemaVersion: '1.0.0', modules: [] }; + +const BUTTON_CEM: Cem = { + schemaVersion: '1.0.0', + modules: [ + { + kind: 'javascript-module', + path: 'src/hx-button.ts', + declarations: [ + { + kind: 'class', + name: 'HxButton', + tagName: 'hx-button', + members: [], + attributes: [ + { name: 'variant', type: { text: "'primary' | 'secondary' | 'danger'" } }, + { name: 'disabled', type: { text: 'boolean' } }, + ], + slots: [{ name: '' }], + }, + ], + }, + ], +}; + +// ─── VALIDATE_TOOL_DEFINITIONS ──────────────────────────────────────────────── + +describe('VALIDATE_TOOL_DEFINITIONS', () => { + it('exports exactly 1 tool definition', () => { + expect(VALIDATE_TOOL_DEFINITIONS).toHaveLength(1); + }); + + it('defines validate_usage', () => { + const names = VALIDATE_TOOL_DEFINITIONS.map((t) => t.name); + expect(names).toContain('validate_usage'); + }); + + it('validate_usage schema requires tagName and html', () => { + const def = VALIDATE_TOOL_DEFINITIONS.find((t) => t.name === 'validate_usage')!; + expect(def.inputSchema.required).toContain('tagName'); + expect(def.inputSchema.required).toContain('html'); + }); +}); + +// ─── isValidateTool ─────────────────────────────────────────────────────────── + +describe('isValidateTool', () => { + it('returns true for validate_usage', () => { + expect(isValidateTool('validate_usage')).toBe(true); + }); + + it('returns false for unknown tool names', () => { + expect(isValidateTool('scaffold_component')).toBe(false); + expect(isValidateTool('get_component')).toBe(false); + expect(isValidateTool('')).toBe(false); + }); + + it('returns false for near-matches', () => { + expect(isValidateTool('validate')).toBe(false); + expect(isValidateTool('usage')).toBe(false); + }); +}); + +// ─── handleValidateCall — valid inputs ──────────────────────────────────────── + +describe('handleValidateCall — valid inputs', () => { + it('returns success result for valid HTML snippet', () => { + const result = handleValidateCall( + 'validate_usage', + { tagName: 'hx-button', html: 'Click' }, + BUTTON_CEM, + ); + expect(result.isError).toBeFalsy(); + }); + + it('result content includes formatted output', () => { + const result = handleValidateCall( + 'validate_usage', + { tagName: 'hx-button', html: 'Click' }, + BUTTON_CEM, + ); + expect(result.content[0].text).toContain('Validation'); + }); + + it('result includes PASS/FAIL result', () => { + const result = handleValidateCall( + 'validate_usage', + { tagName: 'hx-button', html: 'Click' }, + BUTTON_CEM, + ); + expect(result.content[0].text).toMatch(/PASS|FAIL/); + }); + + it('works with empty CEM (no declaration to check against)', () => { + const result = handleValidateCall( + 'validate_usage', + { tagName: 'hx-button', html: 'Click' }, + EMPTY_CEM, + ); + expect(result.isError).toBeFalsy(); + }); + + it('accepts self-closing HTML snippet', () => { + const result = handleValidateCall( + 'validate_usage', + { tagName: 'hx-button', html: '' }, + BUTTON_CEM, + ); + expect(result.isError).toBeFalsy(); + }); + + it('accepts multi-attribute HTML snippet', () => { + const result = handleValidateCall( + 'validate_usage', + { + tagName: 'hx-button', + html: 'Submit', + }, + BUTTON_CEM, + ); + expect(result.isError).toBeFalsy(); + }); + + it('accepts html up to 50000 characters', () => { + const longHtml = '' + 'x'.repeat(49_980) + ''; + const result = handleValidateCall( + 'validate_usage', + { tagName: 'hx-button', html: longHtml }, + BUTTON_CEM, + ); + expect(result.isError).toBeFalsy(); + }); +}); + +// ─── handleValidateCall — error cases ───────────────────────────────────────── + +describe('handleValidateCall — error cases', () => { + it('returns error for unknown tool name', () => { + const result = handleValidateCall('nonexistent_tool', {}, EMPTY_CEM); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Unknown validate tool'); + }); + + it('returns error for empty tool name', () => { + const result = handleValidateCall('', {}, EMPTY_CEM); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Unknown validate tool'); + }); + + it('returns error when tagName is missing', () => { + const result = handleValidateCall( + 'validate_usage', + { html: 'Click' }, + BUTTON_CEM, + ); + expect(result.isError).toBe(true); + }); + + it('returns error when html is missing', () => { + const result = handleValidateCall( + 'validate_usage', + { tagName: 'hx-button' }, + BUTTON_CEM, + ); + expect(result.isError).toBe(true); + }); + + it('returns error when html exceeds 50000 characters', () => { + const tooLongHtml = '' + 'x'.repeat(50_000) + ''; + const result = handleValidateCall( + 'validate_usage', + { tagName: 'hx-button', html: tooLongHtml }, + BUTTON_CEM, + ); + expect(result.isError).toBe(true); + }); +}); + +// ─── handleValidateCall — handler error propagation ─────────────────────────── + +describe('handleValidateCall — handler error propagation', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns error when validateUsage handler throws', async () => { + const { validateUsage } = await import('../../packages/core/src/handlers/validate.js'); + vi.mocked(validateUsage).mockImplementationOnce(() => { + throw new Error('HTML parse error: unexpected token'); + }); + + const result = handleValidateCall( + 'validate_usage', + { tagName: 'hx-button', html: 'Click' }, + BUTTON_CEM, + ); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('HTML parse error'); + }); +});