import { describe, it, before, after } from 'node:test';
import assert from 'node:assert';
import fs from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
/**
* BasecoatMCPServer Test Suite
*
* Tests all available MCP tools:
* 1. get_component - Get HTML code for a specific component
* 2. list_components - List all components by category
* 3. get_usage - Get usage documentation for a component
* 4. get_setup - Get Basecoat CSS setup code
* 5. get_theme_script - Get theme switcher script
* 6. search_components - Search components by name or category
* 7. get_category - Get all components in a category
*/
// Helper class that mirrors the server's internal methods for testing
class TestableBasecoatServer {
constructor() {
this.componentsDir = path.join(__dirname, 'components');
this.usageDir = path.join(__dirname, 'usage');
this.scriptsDir = path.join(__dirname, 'scripts');
}
async getComponentsList() {
const categories = ['forms', 'navigation', 'feedback', 'interactive', 'layout'];
const components = {};
for (const category of categories) {
const categoryPath = path.join(this.componentsDir, category);
try {
const files = await fs.readdir(categoryPath);
const htmlFiles = files
.filter(file => file.endsWith('.html'))
.map(file => ({
name: file.replace('.html', ''),
category: category,
file: file
}));
if (htmlFiles.length > 0) {
components[category] = htmlFiles;
}
} catch (error) {
// Category doesn't exist
}
}
return components;
}
async getComponent(componentName) {
const components = await this.getComponentsList();
for (const [category, categoryComponents] of Object.entries(components)) {
const component = categoryComponents.find(comp => comp.name === componentName);
if (component) {
const filePath = path.join(this.componentsDir, category, component.file);
try {
const content = await fs.readFile(filePath, 'utf-8');
return {
name: componentName,
category: category,
html: content.trim(),
file: component.file
};
} catch (error) {
throw new Error(`Failed to read component file: ${error.message}`);
}
}
}
throw new Error(`Component '${componentName}' not found`);
}
async getUsageDocumentation(componentName) {
const usageCategories = ['forms', 'navigation', 'feedback', 'interactive', 'layout'];
for (const category of usageCategories) {
const usageFile = `${componentName}-usage.md`;
const usagePath = path.join(this.usageDir, category, usageFile);
try {
const content = await fs.readFile(usagePath, 'utf-8');
return {
component: componentName,
category: category,
documentation: content.trim()
};
} catch (error) {
continue;
}
}
throw new Error(`Usage documentation for '${componentName}' not found`);
}
async getSetupCode() {
const setupPath = path.join(this.scriptsDir, 'setup.html');
try {
const content = await fs.readFile(setupPath, 'utf-8');
return content.trim();
} catch (error) {
throw new Error(`Failed to read setup code: ${error.message}`);
}
}
async getThemeScript() {
const themePath = path.join(this.scriptsDir, 'theme-script.html');
try {
const content = await fs.readFile(themePath, 'utf-8');
return content.trim();
} catch (error) {
throw new Error(`Failed to read theme script: ${error.message}`);
}
}
async searchComponents(query) {
const components = await this.getComponentsList();
const results = [];
const queryLower = query.toLowerCase();
for (const [category, categoryComponents] of Object.entries(components)) {
for (const component of categoryComponents) {
if (component.name.toLowerCase().includes(queryLower) ||
category.toLowerCase().includes(queryLower)) {
results.push({
name: component.name,
category: category,
file: component.file,
match: component.name.toLowerCase().includes(queryLower) ? 'name' : 'category'
});
}
}
}
return results;
}
async getComponentsByCategory(category) {
const components = await this.getComponentsList();
if (!components[category]) {
throw new Error(`Category '${category}' not found. Available categories: ${Object.keys(components).join(', ')}`);
}
return {
category: category,
components: components[category]
};
}
}
// Test Suite
describe('Basecoat MCP Server Tools', () => {
let server;
before(() => {
server = new TestableBasecoatServer();
});
// ============================================
// Tool 1: list_components
// ============================================
describe('list_components', () => {
it('should return components organized by category', async () => {
const components = await server.getComponentsList();
assert.ok(typeof components === 'object', 'Should return an object');
assert.ok(Object.keys(components).length > 0, 'Should have at least one category');
});
it('should include all expected categories', async () => {
const components = await server.getComponentsList();
const expectedCategories = ['forms', 'navigation', 'feedback', 'interactive', 'layout'];
for (const category of expectedCategories) {
assert.ok(components[category], `Should include category: ${category}`);
}
});
it('should have components in each category', async () => {
const components = await server.getComponentsList();
for (const [category, categoryComponents] of Object.entries(components)) {
assert.ok(Array.isArray(categoryComponents), `${category} should be an array`);
assert.ok(categoryComponents.length > 0, `${category} should have components`);
}
});
it('should have correct component structure', async () => {
const components = await server.getComponentsList();
for (const [category, categoryComponents] of Object.entries(components)) {
for (const component of categoryComponents) {
assert.ok(component.name, 'Component should have a name');
assert.ok(component.category, 'Component should have a category');
assert.ok(component.file, 'Component should have a file');
assert.ok(component.file.endsWith('.html'), 'File should be an HTML file');
}
}
});
it('should include newly added components', async () => {
const components = await server.getComponentsList();
// Check for new components added during enrichment
const layoutComponents = components.layout.map(c => c.name);
assert.ok(layoutComponents.includes('empty'), 'Should include empty component');
assert.ok(layoutComponents.includes('spinner'), 'Should include spinner component');
assert.ok(layoutComponents.includes('kbd'), 'Should include kbd component');
assert.ok(layoutComponents.includes('item'), 'Should include item component');
const formsComponents = components.forms.map(c => c.name);
assert.ok(formsComponents.includes('button-group'), 'Should include button-group component');
assert.ok(formsComponents.includes('input-group'), 'Should include input-group component');
const feedbackComponents = components.feedback.map(c => c.name);
assert.ok(feedbackComponents.includes('progress'), 'Should include progress component');
const navComponents = components.navigation.map(c => c.name);
assert.ok(navComponents.includes('command'), 'Should include command component');
});
});
// ============================================
// Tool 2: get_component
// ============================================
describe('get_component', () => {
it('should return a valid component by name', async () => {
const component = await server.getComponent('button-primary');
assert.ok(component, 'Should return a component');
assert.strictEqual(component.name, 'button-primary');
assert.strictEqual(component.category, 'forms');
assert.ok(component.html, 'Should have HTML content');
assert.ok(component.html.includes('<button'), 'HTML should contain button element');
});
it('should throw error for non-existent component', async () => {
await assert.rejects(
async () => await server.getComponent('non-existent-component'),
/Component 'non-existent-component' not found/
);
});
it('should return correct category for component', async () => {
const formsComponent = await server.getComponent('input');
assert.strictEqual(formsComponent.category, 'forms');
const layoutComponent = await server.getComponent('card');
assert.strictEqual(layoutComponent.category, 'layout');
const feedbackComponent = await server.getComponent('alert');
assert.strictEqual(feedbackComponent.category, 'feedback');
});
it('should return HTML content for new components', async () => {
const progress = await server.getComponent('progress');
assert.ok(progress.html.includes('progress'), 'Progress should have progress element');
const spinner = await server.getComponent('spinner');
assert.ok(spinner.html.includes('animate-spin'), 'Spinner should have animation class');
const kbd = await server.getComponent('kbd');
assert.ok(kbd.html.includes('<kbd'), 'Kbd should have kbd element');
const empty = await server.getComponent('empty');
assert.ok(empty.html.includes('empty') || empty.html.includes('Empty'), 'Empty state should have empty content');
});
it('should handle components with dashes in name', async () => {
const component = await server.getComponent('button-outline');
assert.strictEqual(component.name, 'button-outline');
assert.ok(component.html, 'Should have HTML content');
});
});
// ============================================
// Tool 3: get_usage
// ============================================
describe('get_usage', () => {
it('should return usage documentation for button', async () => {
const usage = await server.getUsageDocumentation('button');
assert.ok(usage, 'Should return usage documentation');
assert.strictEqual(usage.component, 'button');
assert.strictEqual(usage.category, 'forms');
assert.ok(usage.documentation, 'Should have documentation content');
assert.ok(usage.documentation.includes('#'), 'Should have markdown headers');
});
it('should throw error for non-existent usage documentation', async () => {
await assert.rejects(
async () => await server.getUsageDocumentation('non-existent'),
/Usage documentation for 'non-existent' not found/
);
});
it('should return usage docs from correct categories', async () => {
const buttonUsage = await server.getUsageDocumentation('button');
assert.strictEqual(buttonUsage.category, 'forms');
const cardUsage = await server.getUsageDocumentation('card');
assert.strictEqual(cardUsage.category, 'layout');
const alertUsage = await server.getUsageDocumentation('alert');
assert.strictEqual(alertUsage.category, 'feedback');
const tabsUsage = await server.getUsageDocumentation('tabs');
assert.strictEqual(tabsUsage.category, 'navigation');
});
it('should return usage documentation for new components', async () => {
// Forms
const checkboxUsage = await server.getUsageDocumentation('checkbox');
assert.ok(checkboxUsage.documentation.length > 0, 'Checkbox usage should have content');
const switchUsage = await server.getUsageDocumentation('switch');
assert.ok(switchUsage.documentation.length > 0, 'Switch usage should have content');
const radioUsage = await server.getUsageDocumentation('radio');
assert.ok(radioUsage.documentation.length > 0, 'Radio usage should have content');
const sliderUsage = await server.getUsageDocumentation('slider');
assert.ok(sliderUsage.documentation.length > 0, 'Slider usage should have content');
// Navigation
const accordionUsage = await server.getUsageDocumentation('accordion');
assert.ok(accordionUsage.documentation.length > 0, 'Accordion usage should have content');
const sidebarUsage = await server.getUsageDocumentation('sidebar');
assert.ok(sidebarUsage.documentation.length > 0, 'Sidebar usage should have content');
// Interactive
const tooltipUsage = await server.getUsageDocumentation('tooltip');
assert.ok(tooltipUsage.documentation.length > 0, 'Tooltip usage should have content');
const comboboxUsage = await server.getUsageDocumentation('combobox');
assert.ok(comboboxUsage.documentation.length > 0, 'Combobox usage should have content');
// Layout
const avatarUsage = await server.getUsageDocumentation('avatar');
assert.ok(avatarUsage.documentation.length > 0, 'Avatar usage should have content');
const tableUsage = await server.getUsageDocumentation('table');
assert.ok(tableUsage.documentation.length > 0, 'Table usage should have content');
});
it('should include JavaScript requirements in documentation', async () => {
const tooltipUsage = await server.getUsageDocumentation('tooltip');
assert.ok(
tooltipUsage.documentation.includes('JavaScript') ||
tooltipUsage.documentation.includes('script'),
'Tooltip usage should mention JavaScript requirements'
);
});
});
// ============================================
// Tool 4: get_setup
// ============================================
describe('get_setup', () => {
it('should return setup code', async () => {
const setupCode = await server.getSetupCode();
assert.ok(setupCode, 'Should return setup code');
assert.ok(typeof setupCode === 'string', 'Should be a string');
});
it('should include CDN links', async () => {
const setupCode = await server.getSetupCode();
assert.ok(
setupCode.includes('cdn') || setupCode.includes('CDN') || setupCode.includes('http'),
'Should include CDN references'
);
});
it('should include Tailwind or Basecoat references', async () => {
const setupCode = await server.getSetupCode();
assert.ok(
setupCode.includes('tailwind') ||
setupCode.includes('Tailwind') ||
setupCode.includes('basecoat') ||
setupCode.includes('Basecoat'),
'Should include Tailwind or Basecoat references'
);
});
it('should be valid HTML', async () => {
const setupCode = await server.getSetupCode();
// Basic HTML validation - should have script or link tags
assert.ok(
setupCode.includes('<script') || setupCode.includes('<link'),
'Should contain script or link tags'
);
});
});
// ============================================
// Tool 5: get_theme_script
// ============================================
describe('get_theme_script', () => {
it('should return theme script', async () => {
const themeScript = await server.getThemeScript();
assert.ok(themeScript, 'Should return theme script');
assert.ok(typeof themeScript === 'string', 'Should be a string');
});
it('should include script tag', async () => {
const themeScript = await server.getThemeScript();
assert.ok(themeScript.includes('<script'), 'Should include script tag');
});
it('should include theme-related code', async () => {
const themeScript = await server.getThemeScript();
assert.ok(
themeScript.includes('theme') ||
themeScript.includes('dark') ||
themeScript.includes('light'),
'Should include theme-related code'
);
});
it('should include localStorage or class manipulation', async () => {
const themeScript = await server.getThemeScript();
assert.ok(
themeScript.includes('localStorage') ||
themeScript.includes('classList') ||
themeScript.includes('class'),
'Should include storage or class manipulation'
);
});
});
// ============================================
// Tool 6: search_components
// ============================================
describe('search_components', () => {
it('should find components by name', async () => {
const results = await server.searchComponents('button');
assert.ok(Array.isArray(results), 'Should return an array');
assert.ok(results.length > 0, 'Should find at least one button component');
const buttonResults = results.filter(r => r.name.includes('button'));
assert.ok(buttonResults.length > 0, 'Should find button components');
});
it('should find components by category', async () => {
const results = await server.searchComponents('forms');
assert.ok(results.length > 0, 'Should find form components');
assert.ok(results.every(r => r.category === 'forms'), 'All results should be from forms category');
});
it('should return empty array for no matches', async () => {
const results = await server.searchComponents('xyznonexistent');
assert.ok(Array.isArray(results), 'Should return an array');
assert.strictEqual(results.length, 0, 'Should return empty array');
});
it('should be case-insensitive', async () => {
const lowerResults = await server.searchComponents('button');
const upperResults = await server.searchComponents('BUTTON');
const mixedResults = await server.searchComponents('BuTtOn');
assert.strictEqual(lowerResults.length, upperResults.length, 'Should find same results regardless of case');
assert.strictEqual(lowerResults.length, mixedResults.length, 'Should find same results regardless of case');
});
it('should include match type in results', async () => {
const nameResults = await server.searchComponents('button');
const categoryResults = await server.searchComponents('layout');
const nameMatch = nameResults.find(r => r.match === 'name');
assert.ok(nameMatch, 'Should have name matches');
const categoryMatch = categoryResults.find(r => r.match === 'category');
assert.ok(categoryMatch, 'Should have category matches');
});
it('should find new components', async () => {
const spinnerResults = await server.searchComponents('spinner');
assert.ok(spinnerResults.length > 0, 'Should find spinner component');
const progressResults = await server.searchComponents('progress');
assert.ok(progressResults.length > 0, 'Should find progress component');
const commandResults = await server.searchComponents('command');
assert.ok(commandResults.length > 0, 'Should find command component');
});
it('should find partial matches', async () => {
const results = await server.searchComponents('btn');
// Should find nothing since components are named 'button-*' not 'btn-*'
// This tests that partial matching works correctly
assert.ok(Array.isArray(results), 'Should return an array');
});
});
// ============================================
// Tool 7: get_category
// ============================================
describe('get_category', () => {
it('should return components for forms category', async () => {
const result = await server.getComponentsByCategory('forms');
assert.strictEqual(result.category, 'forms');
assert.ok(Array.isArray(result.components), 'Should have components array');
assert.ok(result.components.length > 0, 'Should have components');
});
it('should return components for all valid categories', async () => {
const categories = ['forms', 'navigation', 'feedback', 'interactive', 'layout'];
for (const category of categories) {
const result = await server.getComponentsByCategory(category);
assert.strictEqual(result.category, category);
assert.ok(result.components.length > 0, `${category} should have components`);
}
});
it('should throw error for invalid category', async () => {
await assert.rejects(
async () => await server.getComponentsByCategory('invalid-category'),
/Category 'invalid-category' not found/
);
});
it('should include available categories in error message', async () => {
try {
await server.getComponentsByCategory('invalid');
assert.fail('Should have thrown an error');
} catch (error) {
assert.ok(error.message.includes('forms'), 'Error should mention forms');
assert.ok(error.message.includes('layout'), 'Error should mention layout');
}
});
it('should return correct structure for each component', async () => {
const result = await server.getComponentsByCategory('layout');
for (const component of result.components) {
assert.ok(component.name, 'Component should have name');
assert.ok(component.category, 'Component should have category');
assert.ok(component.file, 'Component should have file');
assert.strictEqual(component.category, 'layout', 'Category should match');
}
});
it('should include newly added components in categories', async () => {
const layoutResult = await server.getComponentsByCategory('layout');
const layoutNames = layoutResult.components.map(c => c.name);
assert.ok(layoutNames.includes('spinner'), 'Layout should include spinner');
assert.ok(layoutNames.includes('empty'), 'Layout should include empty');
assert.ok(layoutNames.includes('kbd'), 'Layout should include kbd');
const formsResult = await server.getComponentsByCategory('forms');
const formsNames = formsResult.components.map(c => c.name);
assert.ok(formsNames.includes('button-group'), 'Forms should include button-group');
const feedbackResult = await server.getComponentsByCategory('feedback');
const feedbackNames = feedbackResult.components.map(c => c.name);
assert.ok(feedbackNames.includes('progress'), 'Feedback should include progress');
});
});
// ============================================
// Integration Tests
// ============================================
describe('Integration Tests', () => {
it('should be able to list, search, and get a component', async () => {
// List all components
const allComponents = await server.getComponentsList();
assert.ok(Object.keys(allComponents).length > 0);
// Search for button
const searchResults = await server.searchComponents('button');
assert.ok(searchResults.length > 0);
// Get the first button component
const firstButton = searchResults[0];
const component = await server.getComponent(firstButton.name);
assert.ok(component.html);
});
it('should have consistent component data across methods', async () => {
// Get component via get_component
const directComponent = await server.getComponent('button-primary');
// Get component via get_category
const categoryResult = await server.getComponentsByCategory('forms');
const categoryComponent = categoryResult.components.find(c => c.name === 'button-primary');
// Get component via search
const searchResults = await server.searchComponents('button-primary');
const searchComponent = searchResults.find(c => c.name === 'button-primary');
// All should have consistent data
assert.strictEqual(directComponent.name, categoryComponent.name);
assert.strictEqual(directComponent.category, categoryComponent.category);
assert.strictEqual(directComponent.name, searchComponent.name);
});
it('should have usage docs for components that need them', async () => {
const componentsWithUsage = [
'button', 'input', 'card', 'tabs', 'select', 'dialog',
'toast', 'alert', 'checkbox', 'switch', 'radio', 'slider',
'accordion', 'sidebar', 'breadcrumb', 'badge', 'tooltip',
'popover', 'dropdown', 'combobox', 'avatar', 'skeleton', 'table'
];
for (const componentName of componentsWithUsage) {
try {
const usage = await server.getUsageDocumentation(componentName);
assert.ok(usage.documentation, `${componentName} should have usage documentation`);
} catch (error) {
assert.fail(`${componentName} should have usage documentation: ${error.message}`);
}
}
});
});
// ============================================
// Edge Cases
// ============================================
describe('Edge Cases', () => {
it('should handle empty search query', async () => {
const results = await server.searchComponents('');
// Empty string should match everything by category
assert.ok(Array.isArray(results), 'Should return an array');
});
it('should handle special characters in search', async () => {
const results = await server.searchComponents('button-');
assert.ok(Array.isArray(results), 'Should handle dashes');
const results2 = await server.searchComponents('alert_');
assert.ok(Array.isArray(results2), 'Should handle underscores');
});
it('should handle whitespace in search', async () => {
const results = await server.searchComponents(' button ');
// Should still work but may not find exact matches due to trimming
assert.ok(Array.isArray(results), 'Should handle whitespace');
});
it('should handle component names with multiple dashes', async () => {
const component = await server.getComponent('checkbox-with-label');
assert.ok(component.html, 'Should handle multi-dash names');
});
});
// ============================================
// Performance Tests
// ============================================
describe('Performance', () => {
it('should list components quickly', async () => {
const start = Date.now();
await server.getComponentsList();
const duration = Date.now() - start;
assert.ok(duration < 1000, `Should complete in under 1 second, took ${duration}ms`);
});
it('should search components quickly', async () => {
const start = Date.now();
await server.searchComponents('button');
const duration = Date.now() - start;
assert.ok(duration < 500, `Should complete in under 500ms, took ${duration}ms`);
});
it('should get component quickly', async () => {
const start = Date.now();
await server.getComponent('button-primary');
const duration = Date.now() - start;
assert.ok(duration < 500, `Should complete in under 500ms, took ${duration}ms`);
});
});
// ============================================
// File Integrity Tests
// ============================================
describe('File Integrity', () => {
it('should have valid HTML in all component files', async () => {
const components = await server.getComponentsList();
for (const [category, categoryComponents] of Object.entries(components)) {
for (const comp of categoryComponents) {
const component = await server.getComponent(comp.name);
// Basic HTML validation - should have at least one HTML tag
assert.ok(
component.html.includes('<') && component.html.includes('>'),
`${comp.name} should contain valid HTML`
);
}
}
});
it('should have valid markdown in usage documentation', async () => {
const usageFiles = ['button', 'input', 'card', 'alert', 'tabs'];
for (const file of usageFiles) {
try {
const usage = await server.getUsageDocumentation(file);
// Basic markdown validation - should have headers
assert.ok(
usage.documentation.includes('#'),
`${file} usage should have markdown headers`
);
} catch (error) {
// Skip if usage doc doesn't exist
}
}
});
it('should have setup.html file', async () => {
const setupCode = await server.getSetupCode();
assert.ok(setupCode.length > 0, 'Setup file should not be empty');
});
it('should have theme-script.html file', async () => {
const themeScript = await server.getThemeScript();
assert.ok(themeScript.length > 0, 'Theme script file should not be empty');
});
});
});
// Run summary
describe('Test Summary', () => {
it('should have tested all 7 MCP tools', async () => {
const tools = [
'list_components',
'get_component',
'get_usage',
'get_setup',
'get_theme_script',
'search_components',
'get_category'
];
// This test just confirms we have coverage for all tools
assert.strictEqual(tools.length, 7, 'Should test all 7 tools');
});
});