diff --git a/README.md b/README.md index de39877..b59f0fa 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@
+HELiXiR — MCP Server for Web Component Libraries + # HELiXiR **Give AI agents full situational awareness of any web component library.** @@ -13,6 +15,9 @@ Stop AI hallucinations. Ground every component suggestion in your actual Custom [![Node 20+](https://img.shields.io/badge/node-%3E%3D20-brightgreen)](https://nodejs.org) [![Build](https://img.shields.io/github/actions/workflow/status/bookedsolidtech/helixir/build.yml?branch=main&label=build)](https://github.com/bookedsolidtech/helixir/actions/workflows/build.yml) [![Tests](https://img.shields.io/github/actions/workflow/status/bookedsolidtech/helixir/test.yml?branch=main&label=tests)](https://github.com/bookedsolidtech/helixir/actions/workflows/test.yml) +[![MCP Protocol](https://img.shields.io/badge/MCP-protocol-purple)](https://modelcontextprotocol.io) +[![TypeScript](https://img.shields.io/badge/TypeScript-strict-blue?logo=typescript)](https://www.typescriptlang.org) +[![Tools](https://img.shields.io/badge/tools-73-purple)](https://www.npmjs.com/package/helixir) [Quick Start](#quick-start) · [Why HELiXiR](#why-helixir) · [Tools Reference](#tools-reference) · [Configuration](#configuration) · [AI Tool Configs](#ai-tool-configs) @@ -23,7 +28,7 @@ Stop AI hallucinations. Ground every component suggestion in your actual Custom ## Why HELiXiR - **No more hallucinations** — AI reads your real component API from the Custom Elements Manifest, not from training data. Every attribute, event, slot, and CSS part is sourced directly from your library. -- **30+ MCP tools out of the box** — Component discovery, health scoring, design token lookup, TypeScript diagnostics, breaking-change detection, and Storybook story generation — all callable by any MCP-compatible AI agent. +- **87+ MCP tools out of the box** — Component discovery, health scoring, design token lookup, TypeScript diagnostics, breaking-change detection, Storybook story generation, Shadow DOM styling validators, theme scaffolding, and scaffold/extend tools — all callable by any MCP-compatible AI agent. - **Works with any web component framework** — Shoelace, Lit, Stencil, FAST, Spectrum, Vaadin, and any library that produces a `custom-elements.json` CEM file. - **Any AI editor, zero lock-in** — Claude Code, Claude Desktop, Cursor, VS Code (Cline/Continue), Zed — one config, any tool. @@ -281,14 +286,15 @@ All tools are exposed over the [Model Context Protocol](https://modelcontextprot ### Health -| Tool | Description | Required Args | -| ----------------------- | ----------------------------------------------------------------------------------- | ---------------------- | -| `score_component` | Latest health score for a component: grade (A–F), dimension scores, and issues | `tagName` | -| `score_all_components` | Health scores for every component in the library | — | -| `get_health_trend` | Health trend for a component over the last N days with trend direction | `tagName` | -| `get_health_diff` | Before/after health comparison between current branch and a base branch | `tagName` | -| `get_health_summary` | Aggregate health stats for all components: average score, grade distribution | — | -| `analyze_accessibility` | Accessibility profile: ARIA roles, keyboard events, focus management, label support | `tagName` _(optional)_ | +| Tool | Description | Required Args | +| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ---------------------- | +| `score_component` | Latest health score for a component: grade (A–F), dimension scores, and issues | `tagName` | +| `score_all_components` | Health scores for every component in the library | — | +| `get_health_trend` | Health trend for a component over the last N days with trend direction | `tagName` | +| `get_health_diff` | Before/after health comparison between current branch and a base branch | `tagName` | +| `get_health_summary` | Aggregate health stats for all components: average score, grade distribution | — | +| `analyze_accessibility` | Accessibility profile: ARIA roles, keyboard events, focus management, label support | `tagName` _(optional)_ | +| `audit_library` | Generates a JSONL audit report scoring every component across 11 dimensions; returns file path (if outputPath given) and summary stats | — | ### Library @@ -368,6 +374,67 @@ _(Requires `tokensPath` to be configured)_ | `get_design_tokens` | List all design tokens, optionally filtered by category (e.g. `"color"`, `"spacing"`) | — | | `find_token` | Search for a design token by name or value (case-insensitive substring match) | `query` | +### TypeGenerate + +| Tool | Description | Required Args | +| ---------------- | ---------------------------------------------------------------------------------------- | ------------- | +| `generate_types` | Generates TypeScript type definitions (.d.ts content) for all custom elements in the CEM | — | + +### Theme + +| Tool | Description | Required Args | +| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | +| `create_theme` | Scaffold a complete enterprise CSS theme from the component library's design tokens with light/dark mode variables and color-scheme support | — | +| `apply_theme_tokens` | Map a theme token definition to specific components, generating per-component CSS blocks and a global `:root` block | `themeTokens` | + +### Scaffold + +| Tool | Description | Required Args | +| -------------------- | ------------------------------------------------------------------------------------------------- | ------------- | +| `scaffold_component` | Scaffold a new web component with boilerplate code based on an existing component's CEM structure | `tagName` | + +### Extend + +| Tool | Description | Required Args | +| ------------------ | ---------------------------------------------------------------------------------------------------------------- | ------------- | +| `extend_component` | Generate extension boilerplate for a web component, providing a subclass with overridable methods and properties | `tagName` | + +### Styling + +29 anti-hallucination validators that ground every component styling decision in real CEM data. Run `validate_component_code` as the all-in-one final check, or use individual tools for targeted validation. + +| Tool | Description | Required Args | +| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------- | +| `diagnose_styling` | Generates a Shadow DOM styling guide for a component — token prefix, theming approach, dark mode support, anti-pattern warnings, and correct CSS usage snippets | `tagName` | +| `get_component_quick_ref` | Complete quick reference for a component — attributes, methods, events, slots, CSS custom properties, CSS parts, Shadow DOM warnings, and anti-patterns. Use as the FIRST call when working with any component | `tagName` | +| `validate_component_code` | ALL-IN-ONE validator — runs 19 anti-hallucination sub-validators (HTML, CSS, JS, a11y, events, methods, composition) in a single call. Use as the FINAL check before submitting any code | `html`, `tagName` | +| `styling_preflight` | Single-call styling validation combining API discovery, CSS reference resolution, and anti-pattern detection with inline fix suggestions. Call ONCE before finalizing component CSS | `cssText`, `tagName` | +| `validate_css_file` | Validates an entire CSS file targeting multiple components — auto-detects component tags, runs per-component and global validation with inline fixes | `cssText` | +| `check_shadow_dom_usage` | Scans CSS for Shadow DOM anti-patterns: descendant selectors piercing shadow boundaries, `::slotted()` misuse, invalid `::part()` chaining, `!important` on tokens, unknown part names | `cssText` | +| `check_html_usage` | Validates consumer HTML against a component CEM — catches invalid slot names, wrong enum values, boolean attribute misuse, and unknown attributes with typo suggestions | `htmlText`, `tagName` | +| `check_event_usage` | Validates event listener patterns against a component CEM — catches React `onXxx` props for custom events, unknown event names, and framework-specific binding mistakes | `codeText`, `tagName` | +| `check_component_imports` | Scans HTML/JSX/template code for all custom element tags and verifies they exist in the loaded CEM; catches non-existent components with fuzzy suggestions | `codeText` | +| `check_slot_children` | Validates that children placed inside slots match expected element types from the CEM — catches wrong child elements in constrained slots (e.g. `
` inside ``) | `htmlText`, `tagName` | +| `check_attribute_conflicts` | Detects conditional attributes used without their guard conditions — catches `target` without `href`, `min`/`max` on non-number inputs, and other attribute interaction mistakes | `htmlText`, `tagName` | +| `check_a11y_usage` | Validates consumer HTML for accessibility mistakes — catches missing accessible labels on icon buttons/dialogs/selects, and manual role overrides on components that self-assign ARIA roles | `htmlText`, `tagName` | +| `check_css_vars` | Validates CSS for custom property usage against a component CEM — catches unknown CSS custom properties with typo suggestions and `!important` on design tokens | `cssText`, `tagName` | +| `check_token_fallbacks` | Validates CSS for proper `var()` fallback chains and detects hardcoded colors that break theme switching | `cssText`, `tagName` | +| `check_composition` | Validates cross-component composition patterns — catches tab/panel count mismatches, unlinked cross-references, and empty containers | `htmlText` | +| `check_method_calls` | Validates JS/TS code for correct method and property usage — catches hallucinated API calls, properties called as methods, and methods assigned as properties | `codeText`, `tagName` | +| `check_theme_compatibility` | Validates CSS for dark mode and theme compatibility — catches hardcoded colors on background/color/border properties and potential contrast issues | `cssText` | +| `check_css_specificity` | Detects CSS specificity anti-patterns — catches `!important` usage, ID selectors, deeply nested selectors (4+ levels), and inline style attributes | `code` | +| `check_layout_patterns` | Detects layout anti-patterns when styling web component host elements — catches display overrides, fixed dimensions, absolute/fixed positioning, and `overflow: hidden` | `cssText` | +| `check_css_scope` | Detects component-scoped CSS custom properties set at the wrong scope (e.g. on `:root` instead of the component host) | `cssText`, `tagName` | +| `check_css_shorthand` | Detects risky CSS shorthand + `var()` combinations that can fail silently when any token is undefined | `cssText` | +| `check_color_contrast` | Detects color contrast issues: low-contrast hardcoded color pairs, mixed color sources (token + hardcoded), and low opacity on text | `cssText` | +| `check_transition_animation` | Detects CSS transitions and animations on component hosts targeting properties that cannot cross Shadow DOM boundaries | `cssText`, `tagName` | +| `check_shadow_dom_js` | Detects JavaScript anti-patterns that violate Shadow DOM encapsulation — catches `.shadowRoot.querySelector()`, `attachShadow()` on existing components, and `innerHTML` overwriting slot content | `codeText` | +| `check_dark_mode_patterns` | Detects dark mode styling anti-patterns — catches theme-scoped selectors setting standard CSS properties that won't reach shadow DOM internals | `cssText` | +| `resolve_css_api` | Resolves every `::part()`, CSS custom property, and slot reference in agent-generated code against actual CEM data — reports valid/hallucinated references with closest valid alternatives | `cssText`, `tagName` | +| `detect_theme_support` | Analyzes a component library for theming capabilities — token categories, semantic naming patterns, dark mode readiness, and coverage score | — | +| `recommend_checks` | Analyzes code to determine which validation tools are most relevant — returns a prioritized list of tool names without running them all | `codeText` | +| `suggest_fix` | Generates concrete, copy-pasteable code fixes for validation issues by type (shadow-dom, token-fallback, theme-compat, method-call, event-usage, specificity, layout) | `type`, `issue`, `original` | + --- ## Configuration diff --git a/assets/social-card.png b/assets/social-card.png new file mode 100644 index 0000000..8e9bdcf Binary files /dev/null and b/assets/social-card.png differ diff --git a/assets/social-card.webp b/assets/social-card.webp new file mode 100644 index 0000000..8b8a772 Binary files /dev/null and b/assets/social-card.webp differ diff --git a/build b/build new file mode 120000 index 0000000..5df8d8b --- /dev/null +++ b/build @@ -0,0 +1 @@ +/Volumes/Development/booked/helixir/build \ No newline at end of file diff --git a/node_modules b/node_modules new file mode 120000 index 0000000..07c009a --- /dev/null +++ b/node_modules @@ -0,0 +1 @@ +/Volumes/Development/booked/helixir/node_modules \ No newline at end of file diff --git a/package.json b/package.json index b490e16..2ab2ac8 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "test:coverage": "vitest run --coverage", "test:watch": "vitest", "type-check": "tsc --noEmit", + "typecheck": "pnpm run type-check", "lint": "eslint src packages/core/src", "lint:fix": "eslint src packages/core/src --fix", "format": "prettier --write .", diff --git a/packages/core/package.json b/packages/core/package.json index 11856c5..c0c49ba 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -16,7 +16,7 @@ }, "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.26.0", + "@modelcontextprotocol/sdk": "^1.27.1", "zod": "^3.22.0" }, "peerDependencies": { diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index d56a547..b35efec 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -32,7 +32,6 @@ const defaults: McpWcConfig = { }; function readConfigFile(projectRoot: string): Partial { - // Primary config file name const primaryPath = resolve(projectRoot, 'helixir.mcp.json'); if (existsSync(primaryPath)) { try { @@ -44,21 +43,6 @@ function readConfigFile(projectRoot: string): Partial { } } - // Backward-compatible fallback to legacy config file name - const legacyPath = resolve(projectRoot, 'mcpwc.config.json'); - if (existsSync(legacyPath)) { - process.stderr.write( - `[helixir] Warning: mcpwc.config.json is deprecated. Rename to helixir.mcp.json.\n`, - ); - try { - const raw = readFileSync(legacyPath, 'utf-8'); - return JSON.parse(raw) as Partial; - } catch { - process.stderr.write(`[helixir] Warning: mcpwc.config.json is malformed. Using defaults.\n`); - return {}; - } - } - return {}; } @@ -92,36 +76,32 @@ export function loadConfig(): Readonly { } // Apply env vars (highest priority) - if (process.env['MCP_WC_CEM_PATH'] !== undefined) { - config.cemPath = process.env['MCP_WC_CEM_PATH']; - } - if (process.env['MCP_WC_PROJECT_ROOT'] !== undefined) { - config.projectRoot = process.env['MCP_WC_PROJECT_ROOT']; - } - if (process.env['MCP_WC_COMPONENT_PREFIX'] !== undefined) { - config.componentPrefix = process.env['MCP_WC_COMPONENT_PREFIX']; - } - if (process.env['MCP_WC_HEALTH_HISTORY_DIR'] !== undefined) { - config.healthHistoryDir = process.env['MCP_WC_HEALTH_HISTORY_DIR']; - } - if (process.env['MCP_WC_TSCONFIG_PATH'] !== undefined) { - config.tsconfigPath = process.env['MCP_WC_TSCONFIG_PATH']; - } - if (process.env['MCP_WC_TOKENS_PATH'] !== undefined) { - const val = process.env['MCP_WC_TOKENS_PATH']; - config.tokensPath = val === 'null' ? null : val; - } - if (process.env['MCP_WC_CDN_BASE'] !== undefined) { - const val = process.env['MCP_WC_CDN_BASE']; - config.cdnBase = val === 'null' ? null : val; - } - if (process.env['MCP_WC_CDN_AUTOLOADER'] !== undefined) { - const val = process.env['MCP_WC_CDN_AUTOLOADER']; - config.cdnAutoloader = val === 'null' ? null : val; + // String keys map directly; nullable keys treat the literal string 'null' as null. + const ENV_MAP_STRING: Readonly> = { + MCP_WC_CEM_PATH: 'cemPath', + MCP_WC_PROJECT_ROOT: 'projectRoot', + MCP_WC_COMPONENT_PREFIX: 'componentPrefix', + MCP_WC_HEALTH_HISTORY_DIR: 'healthHistoryDir', + MCP_WC_TSCONFIG_PATH: 'tsconfigPath', + }; + const ENV_MAP_NULLABLE: Readonly> = { + MCP_WC_TOKENS_PATH: 'tokensPath', + MCP_WC_CDN_BASE: 'cdnBase', + MCP_WC_CDN_AUTOLOADER: 'cdnAutoloader', + MCP_WC_CDN_STYLESHEET: 'cdnStylesheet', + }; + + for (const [envKey, configKey] of Object.entries(ENV_MAP_STRING)) { + const val = process.env[envKey]; + if (val !== undefined) { + (config as Record)[configKey] = val; + } } - if (process.env['MCP_WC_CDN_STYLESHEET'] !== undefined) { - const val = process.env['MCP_WC_CDN_STYLESHEET']; - config.cdnStylesheet = val === 'null' ? null : val; + for (const [envKey, configKey] of Object.entries(ENV_MAP_NULLABLE)) { + const val = process.env[envKey]; + if (val !== undefined) { + (config as Record)[configKey] = val === 'null' ? null : val; + } } // --watch CLI flag overrides config file value diff --git a/packages/core/src/handlers/cem.ts b/packages/core/src/handlers/cem.ts index b13b3fe..17bbafc 100644 --- a/packages/core/src/handlers/cem.ts +++ b/packages/core/src/handlers/cem.ts @@ -725,7 +725,10 @@ export function findComponentsByToken( cem: Cem, ): FindComponentsByTokenResult { if (!token.startsWith('--')) { - throw new Error(`CSS custom property name must start with "--": "${token}"`); + throw new MCPError( + `CSS custom property name must start with "--": "${token}"`, + ErrorCategory.VALIDATION, + ); } const components: TokenComponentMatch[] = []; diff --git a/packages/core/src/handlers/dependencies.ts b/packages/core/src/handlers/dependencies.ts index df84ce4..d76417f 100644 --- a/packages/core/src/handlers/dependencies.ts +++ b/packages/core/src/handlers/dependencies.ts @@ -1,4 +1,5 @@ import type { Cem } from './cem.js'; +import { MCPError, ErrorCategory } from '../shared/error-handling.js'; export interface ComponentDependencyResult { tagName: string; @@ -105,7 +106,7 @@ export function getComponentDependencies( } if (!found) { - throw new Error(`Component "${tagName}" not found in CEM.`); + throw new MCPError(`Component "${tagName}" not found in CEM.`, ErrorCategory.NOT_FOUND); } const depMap = buildDependencyMap(cem); diff --git a/packages/core/src/handlers/extend.ts b/packages/core/src/handlers/extend.ts index 8149c92..d62e579 100644 --- a/packages/core/src/handlers/extend.ts +++ b/packages/core/src/handlers/extend.ts @@ -1,4 +1,5 @@ import type { Cem, CemDeclaration } from './cem.js'; +import { MCPError, ErrorCategory } from '../shared/error-handling.js'; // --- Helpers --- @@ -66,7 +67,7 @@ export function extendComponent( ): ExtendComponentResult { const parentDecl = findDeclaration(cem, parentTagName); if (!parentDecl) { - throw new Error(`Component "${parentTagName}" not found in CEM.`); + throw new MCPError(`Component "${parentTagName}" not found in CEM.`, ErrorCategory.NOT_FOUND); } const parentClassName = tagNameToClassName(parentTagName); diff --git a/packages/core/src/handlers/theme.ts b/packages/core/src/handlers/theme.ts index d10eaf8..55a6e94 100644 --- a/packages/core/src/handlers/theme.ts +++ b/packages/core/src/handlers/theme.ts @@ -145,7 +145,7 @@ function lightPlaceholder(tokenName: string, category: string): string { return '200ms'; default: - return '/* TODO: set value */'; + return `var(${tokenName})`; } } diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 839f973..5ce658c 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -46,7 +46,7 @@ "homepage": "https://github.com/bookedsolidtech/helixir/tree/main/packages/mcp#readme", "peerDependencies": { "helixir": ">=0.5.0", - "@modelcontextprotocol/sdk": "^1.26.0", + "@modelcontextprotocol/sdk": "^1.27.1", "zod": "^3.22.0" }, "devDependencies": { diff --git a/packages/vscode/.vscodeignore b/packages/vscode/.vscodeignore new file mode 100644 index 0000000..aa8e76e --- /dev/null +++ b/packages/vscode/.vscodeignore @@ -0,0 +1,30 @@ +# Source files — not needed in the packaged extension +src/ +tsconfig.json +esbuild.config.mjs + +# Development dependencies and lock files +node_modules/ +.pnpm-store/ +pnpm-lock.yaml +package-lock.json + +# Test artefacts +coverage/ +*.test.ts +*.spec.ts + +# Build intermediates (keep dist/) +*.map + +# Editor and OS artefacts +.vscode/ +.DS_Store +*.log + +# Root-level workspace files that should not be bundled +../../node_modules/ +../../src/ +../../build/ +../../packages/ +../../.github/ diff --git a/packages/vscode/README.md b/packages/vscode/README.md new file mode 100644 index 0000000..689b2fb --- /dev/null +++ b/packages/vscode/README.md @@ -0,0 +1,84 @@ +# Helixir — VS Code Extension + +**AI-powered web component intelligence for VS Code.** + +Helixir gives AI assistants full situational awareness of any web component library by wiring the [helixir MCP server](https://github.com/bookedsolidtech/helixir) directly into VS Code's MCP layer. + +## Features + +- **MCP server auto-registration** — the helixir MCP server starts automatically with VS Code, no manual configuration required +- **30+ MCP tools** — component discovery, health scoring, breaking-change detection, TypeScript diagnostics, design token lookup, and more +- **Zero hallucinations** — every AI component suggestion is grounded in your actual `custom-elements.json` +- **Framework-agnostic** — works with Lit, Stencil, FAST, Spectrum, Shoelace, or any library that produces a Custom Elements Manifest + +## Requirements + +- VS Code **≥ 1.99.0** +- A component library with a `custom-elements.json` (Custom Elements Manifest) +- Node.js **≥ 20** on `PATH` + +## Getting Started + +1. Install the extension from the VS Code Marketplace +2. Open your component library folder in VS Code +3. The Helixir MCP server will register automatically with AI assistants that support MCP (e.g., GitHub Copilot, Claude) + +### Optional: Configure the Config Path + +If your `mcpwc.config.json` is not at the workspace root, set the path via VS Code settings: + +```json +// .vscode/settings.json +{ + "helixir.configPath": "packages/web-components/mcpwc.config.json" +} +``` + +The path can be relative to the workspace root or absolute. + +## Commands + +| Command | Description | +|---------|-------------| +| `Helixir: Run Health Check` | Guides you to run a health check via your AI assistant | + +## Extension Settings + +| Setting | Type | Default | Description | +|---------|------|---------|-------------| +| `helixir.configPath` | `string` | `""` | Path to `mcpwc.config.json`. Empty = workspace root. | + +## How It Works + +When the extension activates, it registers a **MCP server definition provider** (`helixir`) with VS Code's language model API (`vscode.lm`). VS Code spawns the bundled helixir MCP server (`dist/mcp-server.js`) as a child process over stdio. + +The server reads your `custom-elements.json` and exposes 30+ tools that AI models can call to look up component APIs, run health scans, generate type declarations, and more. + +## Configuration Reference + +The helixir server is configured via environment variables passed by the extension: + +| Variable | Description | +|----------|-------------| +| `MCP_WC_PROJECT_ROOT` | Set to your workspace folder automatically | +| `MCP_WC_CONFIG_PATH` | Set when `helixir.configPath` is configured | + +Additional configuration (token path, component prefix, health history dir) belongs in `mcpwc.config.json`. See the [helixir documentation](https://github.com/bookedsolidtech/helixir) for the full config reference. + +## Troubleshooting + +**MCP server not appearing in AI assistant tools** +- Verify VS Code ≥ 1.99.0 is installed +- Confirm your workspace contains a `custom-elements.json` +- Check the Output panel → Helixir for error messages + +**"No workspace folder" error from Run Health Check** +- Open a folder (not just a file) in VS Code — the extension uses the workspace folder as the project root + +**Server starts but returns no components** +- Ensure `custom-elements.json` exists at the workspace root or configure `helixir.configPath` +- Regenerate the manifest: `npm run analyze:cem` (or your CEM generation script) + +## License + +MIT — see [LICENSE](../../LICENSE) diff --git a/packages/vscode/esbuild.config.mjs b/packages/vscode/esbuild.config.mjs new file mode 100644 index 0000000..85e5030 --- /dev/null +++ b/packages/vscode/esbuild.config.mjs @@ -0,0 +1,69 @@ +/** + * esbuild configuration for the Helixir VS Code extension. + * + * Produces two bundles: + * dist/extension.js — VS Code extension host entry (CJS, externalizes 'vscode') + * dist/mcp-server.js — Helixir MCP server entry (ESM, bundles helixir) + */ + +import * as esbuild from 'esbuild'; + +const isProduction = process.argv.includes('--production'); +const isWatch = process.argv.includes('--watch'); + +const sharedOptions = { + bundle: true, + sourcemap: !isProduction, + minify: isProduction, + logLevel: 'info', + platform: 'node', + target: 'node20', +}; + +/** + * Bundle 1: VS Code extension host entry + * - CommonJS (VS Code extension host requires CJS) + * - 'vscode' is externalized — provided by the VS Code runtime + */ +const extensionConfig = { + ...sharedOptions, + entryPoints: ['src/extension.ts'], + outfile: 'dist/extension.js', + format: 'cjs', + external: ['vscode'], +}; + +/** + * Bundle 2: Helixir MCP server + * - ESM format (helixir is an ES module) + * - Bundles helixir and its dependencies so the extension is self-contained + * - Spawned as a child process via stdio by the VS Code extension + */ +const mcpServerConfig = { + ...sharedOptions, + entryPoints: ['src/mcp-server-entry.ts'], + outfile: 'dist/mcp-server.js', + format: 'esm', + banner: { + js: '#!/usr/bin/env node\n// Helixir MCP Server — bundled by esbuild', + }, +}; + +async function build() { + const extensionCtx = await esbuild.context(extensionConfig); + const mcpServerCtx = await esbuild.context(mcpServerConfig); + + if (isWatch) { + await Promise.all([extensionCtx.watch(), mcpServerCtx.watch()]); + console.log('[helixir-vscode] Watching for changes...'); + } else { + await Promise.all([extensionCtx.rebuild(), mcpServerCtx.rebuild()]); + await Promise.all([extensionCtx.dispose(), mcpServerCtx.dispose()]); + console.log('[helixir-vscode] Build complete.'); + } +} + +build().catch((err) => { + console.error('[helixir-vscode] Build failed:', err); + process.exit(1); +}); diff --git a/packages/vscode/package.json b/packages/vscode/package.json new file mode 100644 index 0000000..b33a626 --- /dev/null +++ b/packages/vscode/package.json @@ -0,0 +1,72 @@ +{ + "name": "helixir-vscode", + "displayName": "Helixir", + "description": "AI-powered web component intelligence for VS Code — powered by helixir MCP", + "version": "0.1.0", + "publisher": "bookedsolidtech", + "private": true, + "engines": { + "vscode": "^1.99.0" + }, + "categories": [ + "Other", + "AI" + ], + "keywords": [ + "mcp", + "web components", + "helixir", + "ai", + "custom elements" + ], + "activationEvents": [ + "onStartupFinished" + ], + "main": "./dist/extension.js", + "contributes": { + "mcpServerDefinitionProviders": [ + { + "id": "helixir", + "label": "Helixir" + } + ], + "commands": [ + { + "command": "helixir.runHealthCheck", + "title": "Helixir: Run Health Check", + "category": "Helixir" + }, + { + "command": "helixir.configureCursorWindsurf", + "title": "Helixir: Configure for Cursor/Windsurf", + "category": "Helixir" + } + ], + "configuration": { + "title": "Helixir", + "properties": { + "helixir.configPath": { + "type": "string", + "default": "", + "description": "Path to mcpwc.config.json (relative to workspace root or absolute). Leave empty to use workspace root defaults." + } + } + } + }, + "scripts": { + "vscode:prepublish": "node esbuild.config.mjs --production", + "build": "node esbuild.config.mjs", + "watch": "node esbuild.config.mjs --watch", + "package": "vsce package --no-dependencies", + "publish": "vsce publish --no-dependencies" + }, + "dependencies": { + "helixir": "workspace:*" + }, + "devDependencies": { + "@types/vscode": "^1.99.0", + "@vscode/vsce": "^3.0.0", + "esbuild": "^0.25.0", + "ovsx": "^0.9.0" + } +} diff --git a/packages/vscode/src/commands/configureCursorWindsurf.ts b/packages/vscode/src/commands/configureCursorWindsurf.ts new file mode 100644 index 0000000..ffb6ab9 --- /dev/null +++ b/packages/vscode/src/commands/configureCursorWindsurf.ts @@ -0,0 +1,114 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as vscode from 'vscode'; + +/** + * Detects whether the current host is Cursor editor by inspecting the + * application name reported by the VS Code API and common environment + * variables set by the Cursor process. + */ +function isCursor(): boolean { + const appName = vscode.env.appName ?? ''; + return ( + appName.toLowerCase().includes('cursor') || + (process.env['CURSOR_TRACE_ID'] !== undefined) || + (process.env['CURSOR_APP_PATH'] !== undefined) + ); +} + +/** + * Returns the directory name (.cursor or .windsurf) and a human-readable + * editor label based on the detected editor. + */ +function resolveEditorConfig(): { dirName: string; label: string } { + if (isCursor()) { + return { dirName: '.cursor', label: 'Cursor' }; + } + return { dirName: '.windsurf', label: 'Windsurf' }; +} + +interface McpServerEntry { + command: string; + args: string[]; + env: Record; +} + +interface McpJson { + mcpServers: Record; +} + +/** + * Registers the "Helixir: Configure for Cursor/Windsurf" command. + * + * When invoked the command: + * 1. Detects whether the host is Cursor or Windsurf/other. + * 2. Resolves the target mcp.json path inside the workspace root (or $HOME as + * fallback when no workspace is open). + * 3. Reads any existing mcp.json so that pre-existing server entries are + * preserved. + * 4. Upserts the "helixir" entry pointing at the bundled mcp-server.js. + * 5. Writes the file and shows an information notification. + */ +export function registerConfigureCursorWindsurfCommand( + context: vscode.ExtensionContext +): void { + const command = vscode.commands.registerCommand( + 'helixir.configureCursorWindsurf', + async () => { + const { dirName, label } = resolveEditorConfig(); + + // Resolve the base directory (workspace root or home directory). + const baseDir = + vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? os.homedir(); + + const configDir = path.join(baseDir, dirName); + const configFilePath = path.join(configDir, 'mcp.json'); + + // Path to the bundled MCP server shipped with this extension. + const serverScriptPath = path.join( + context.extensionPath, + 'dist', + 'mcp-server.js' + ); + + // Read existing config (if any) so we don't stomp other servers. + let existing: McpJson = { mcpServers: {} }; + if (fs.existsSync(configFilePath)) { + try { + const raw = fs.readFileSync(configFilePath, 'utf8'); + const parsed = JSON.parse(raw) as Partial; + existing = { + mcpServers: parsed.mcpServers ?? {}, + }; + } catch { + // If the file is malformed, start fresh but preserve the attempt. + existing = { mcpServers: {} }; + } + } + + // Upsert the helixir entry. + existing.mcpServers['helixir'] = { + command: 'node', + args: [serverScriptPath], + env: {}, + }; + + // Ensure the config directory exists. + fs.mkdirSync(configDir, { recursive: true }); + + // Write the updated config. + fs.writeFileSync( + configFilePath, + JSON.stringify(existing, null, 2) + '\n', + 'utf8' + ); + + await vscode.window.showInformationMessage( + `Helixir: MCP server entry written to ${configFilePath} (${label}).` + ); + } + ); + + context.subscriptions.push(command); +} diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts new file mode 100644 index 0000000..7480d62 --- /dev/null +++ b/packages/vscode/src/extension.ts @@ -0,0 +1,41 @@ +import * as vscode from 'vscode'; +import { registerConfigureCursorWindsurfCommand } from './commands/configureCursorWindsurf.js'; +import { registerMcpProvider } from './mcpProvider.js'; + +/** + * Called when the extension is activated. + * Registers the Helixir MCP server definition provider and the + * "Helixir: Run Health Check" command. + */ +export function activate(context: vscode.ExtensionContext): void { + registerMcpProvider(context); + registerConfigureCursorWindsurfCommand(context); + + const healthCheckCommand = vscode.commands.registerCommand( + 'helixir.runHealthCheck', + async () => { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders || workspaceFolders.length === 0) { + await vscode.window.showErrorMessage( + 'Helixir: No workspace folder is open. ' + + 'Open a component library folder to run a health check.' + ); + return; + } + + await vscode.window.showInformationMessage( + 'Helixir: MCP server is active. ' + + 'Ask your AI assistant to call score_all_components via the Helixir MCP server.' + ); + } + ); + + context.subscriptions.push(healthCheckCommand); +} + +/** + * Called when the extension is deactivated. + */ +export function deactivate(): void { + // Subscriptions are disposed automatically via context.subscriptions. +} diff --git a/packages/vscode/src/mcp-server-entry.ts b/packages/vscode/src/mcp-server-entry.ts new file mode 100644 index 0000000..ef0068d --- /dev/null +++ b/packages/vscode/src/mcp-server-entry.ts @@ -0,0 +1,16 @@ +/** + * Helixir MCP Server — entry point for the bundled server. + * + * This file is bundled by esbuild into dist/mcp-server.js (ESM format). + * It is spawned as a child process by the VS Code extension (mcpProvider.ts) + * using stdio transport. + * + * The helixir/mcp module exports a `main()` function that initialises and + * starts the MCP server, listening on stdin/stdout. + */ +import { main } from 'helixir/mcp'; + +main().catch((err: unknown) => { + process.stderr.write(`[helixir-mcp] Fatal: ${String(err)}\n`); + process.exit(1); +}); diff --git a/packages/vscode/src/mcpProvider.ts b/packages/vscode/src/mcpProvider.ts new file mode 100644 index 0000000..0bf1e95 --- /dev/null +++ b/packages/vscode/src/mcpProvider.ts @@ -0,0 +1,68 @@ +import * as path from 'path'; +import * as vscode from 'vscode'; + +/** + * Registers helixir as an MCP server definition provider with VS Code. + * + * The provider spawns dist/mcp-server.js (the bundled helixir MCP server) + * as a child process via stdio. VS Code passes it to connected AI models + * (e.g., GitHub Copilot, Claude) automatically. + * + * The server is configured with the workspace folder as MCP_WC_PROJECT_ROOT + * so helixir reads the correct custom-elements.json. + * + * Requires VS Code ≥ 1.99.0 (MCP server definition provider API). + */ +export function registerMcpProvider(context: vscode.ExtensionContext): void { + const provider = { + provideMcpServerDefinitions() { + const serverScriptPath = path.join( + context.extensionPath, + 'dist', + 'mcp-server.js' + ); + + const workspaceFolder = + vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? process.cwd(); + + const configPath = vscode.workspace + .getConfiguration('helixir') + .get('configPath', ''); + + const env: Record = { + MCP_WC_PROJECT_ROOT: workspaceFolder, + }; + + // If the user specified a custom config path, resolve it and pass it on. + if (configPath && configPath.trim() !== '') { + env['MCP_WC_CONFIG_PATH'] = path.isAbsolute(configPath) + ? configPath + : path.join(workspaceFolder, configPath); + } + + return [ + { + label: 'Helixir', + command: 'node', + args: [serverScriptPath], + env, + }, + ]; + }, + }; + + // vscode.lm.registerMcpServerDefinitionProvider was introduced in VS Code 1.99. + // We guard the call so the extension degrades gracefully on older hosts. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const lm = vscode.lm as any; + if (typeof lm?.registerMcpServerDefinitionProvider === 'function') { + context.subscriptions.push( + lm.registerMcpServerDefinitionProvider('helixir', provider) as vscode.Disposable + ); + } else { + console.warn( + '[helixir-vscode] vscode.lm.registerMcpServerDefinitionProvider is not available. ' + + 'Upgrade to VS Code ≥ 1.99.0 to enable MCP server support.' + ); + } +} diff --git a/packages/vscode/tsconfig.json b/packages/vscode/tsconfig.json new file mode 100644 index 0000000..cb042b5 --- /dev/null +++ b/packages/vscode/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "lib": ["ES2022"], + "strict": true, + "noEmit": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 91579f2..f74cde2 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -3,6 +3,7 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { parseArgs } from 'node:util'; import { loadConfig } from '../../packages/core/src/config.js'; +import { handleToolError } from '../../packages/core/src/shared/error-handling.js'; import { CemSchema } from '../../packages/core/src/handlers/cem.js'; import type { Cem, CemDeclaration } from '../../packages/core/src/handlers/cem.js'; import { parseCem, diffCem, listAllComponents } from '../../packages/core/src/handlers/cem.js'; @@ -179,11 +180,11 @@ async function cmdHealth(args: string[], opts: CliOptions): Promise { const config = loadConfig(); if (opts.trend) { - const tag = args[0]; - if (!tag) { + if (args.length < 1) { process.stderr.write('Error: --trend requires a tag name\n'); process.exit(1); } + const tag = args[0] as string; const trend = await getHealthTrend(config, tag); if (opts.format === 'json') { output(trend, 'json'); @@ -283,12 +284,12 @@ async function cmdDiff(args: string[], opts: CliOptions): Promise { async function cmdMigrate(args: string[], opts: CliOptions): Promise { const config = loadConfig(); const cem = loadCem(config.cemPath, config.projectRoot); - const tag = args[0]; - if (!tag) { + if (args.length < 1) { process.stderr.write('Error: migrate requires a tag name\n'); process.exit(1); } + const tag = args[0] as string; const guide = await generateMigrationGuide(tag, opts.base, config, cem); @@ -302,12 +303,12 @@ async function cmdMigrate(args: string[], opts: CliOptions): Promise { async function cmdSuggest(args: string[], opts: CliOptions): Promise { const config = loadConfig(); const cem = loadCem(config.cemPath, config.projectRoot); - const tag = args[0]; - if (!tag) { + if (args.length < 1) { process.stderr.write('Error: suggest requires a tag name\n'); process.exit(1); } + const tag = args[0] as string; const result = await suggestUsage(tag, config, cem); @@ -324,12 +325,12 @@ async function cmdSuggest(args: string[], opts: CliOptions): Promise { async function cmdBundle(args: string[], opts: CliOptions): Promise { const config = loadConfig(); - const tag = args[0]; - if (!tag) { + if (args.length < 1) { process.stderr.write('Error: bundle requires a tag name\n'); process.exit(1); } + const tag = args[0] as string; const result = await estimateBundleSize(tag, config); const fp = result.estimates.full_package; @@ -372,13 +373,13 @@ async function cmdTokens(args: string[], opts: CliOptions): Promise { async function cmdCompare(args: string[], opts: CliOptions): Promise { const config = loadConfig(); - const cemA = args[0]; - const cemB = args[1]; - if (!cemA || !cemB) { + if (args.length < 2) { process.stderr.write('Error: compare requires two CEM paths\n'); process.exit(1); } + const cemA = args[0] as string; + const cemB = args[1] as string; const result = await compareLibraries({ cemPathA: cemA, cemPathB: cemB }, config); @@ -419,12 +420,12 @@ async function cmdBenchmark(args: string[], opts: CliOptions): Promise { async function cmdValidate(args: string[], opts: CliOptions): Promise { const config = loadConfig(); - const tag = args[0]; - if (!tag) { + if (args.length < 1) { process.stderr.write('Error: validate requires a tag name\n'); process.exit(1); } + const tag = args[0] as string; if (!opts.html) { process.stderr.write('Error: validate requires --html ""\n'); process.exit(1); @@ -445,14 +446,13 @@ async function cmdValidate(args: string[], opts: CliOptions): Promise { async function cmdCdn(args: string[], opts: CliOptions): Promise { const config = loadConfig(); - const pkg = args[0]; - if (!pkg) { + if (args.length < 1) { process.stderr.write('Error: cdn requires a package name\n'); process.exit(1); } - - const version = args[1] ?? 'latest'; + const pkg = args[0] as string; + const version = args.length >= 2 ? (args[1] as string) : 'latest'; const result = await resolveCdnCem(pkg, version, opts.registry, config); if (opts.format === 'json') { @@ -524,7 +524,7 @@ export async function runCli(): Promise { values = result.values; positionals = result.positionals; } catch (err) { - process.stderr.write(`Error: ${String(err)}\n`); + process.stderr.write(`Error: ${handleToolError(err).message}\n`); process.exit(1); } @@ -612,7 +612,7 @@ export async function runCli(): Promise { process.exit(1); } } catch (err) { - process.stderr.write(`Error: ${String(err)}\n`); + process.stderr.write(`Error: ${handleToolError(err).message}\n`); process.exit(1); } } diff --git a/src/mcp/index.ts b/src/mcp/index.ts index 37aea86..70a331f 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -4,6 +4,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprot import { existsSync, readFileSync, watch as fsWatch } from 'fs'; import { resolve, relative, sep } from 'path'; import { loadConfig } from '../../packages/core/src/config.js'; +import { handleToolError } from '../../packages/core/src/shared/error-handling.js'; import { CemSchema, loadLibrary, resolveCem } from '../../packages/core/src/handlers/cem.js'; import type { Cem } from '../../packages/core/src/handlers/cem.js'; import { @@ -92,6 +93,16 @@ import { handleThemeCall, isThemeTool, } from '../../packages/core/src/tools/theme.js'; +import { + SCAFFOLD_TOOL_DEFINITIONS, + handleScaffoldCall, + isScaffoldTool, +} from '../../packages/core/src/tools/scaffold.js'; +import { + EXTEND_TOOL_DEFINITIONS, + handleExtendCall, + isExtendTool, +} from '../../packages/core/src/tools/extend.js'; import { createErrorResponse } from '../../packages/core/src/shared/mcp-helpers.js'; import type { MCPToolResult } from '../../packages/core/src/shared/mcp-helpers.js'; @@ -138,7 +149,7 @@ function startCemWatcher(cemAbsPath: string): void { ); prevCount = componentCount; } catch (err) { - process.stderr.write(`[helixir] CEM reload failed: ${String(err)}\n`); + process.stderr.write(`[helixir] CEM reload failed: ${handleToolError(err).message}\n`); } finally { cemReloading = false; } @@ -173,7 +184,7 @@ export async function main(): Promise { loadCem(cemAbsPath); } catch (err) { const relPath = relative(resolvedProjectRoot, cemAbsPath); - process.stderr.write(`Fatal: CEM file at ${relPath} is invalid: ${String(err)}\n`); + process.stderr.write(`Fatal: CEM file at ${relPath} is invalid: ${handleToolError(err).message}\n`); process.exit(1); } @@ -199,6 +210,8 @@ export async function main(): Promise { ...TYPEGENERATE_TOOL_DEFINITIONS, ...STYLING_TOOL_DEFINITIONS, ...THEME_TOOL_DEFINITIONS, + ...SCAFFOLD_TOOL_DEFINITIONS, + ...EXTEND_TOOL_DEFINITIONS, ...tsTools, ]; @@ -290,6 +303,20 @@ export async function main(): Promise { ); return handleThemeCall(name, typedArgs, resolveCem(libraryId, cemCache)); } + if (isScaffoldTool(name)) { + if (cemCache === null || cemReloading) + return createErrorResponse( + 'CEM not yet loaded — server is still initializing. Please retry.', + ); + return handleScaffoldCall(name, typedArgs, config, resolveCem(libraryId, cemCache)); + } + if (isExtendTool(name)) { + if (cemCache === null || cemReloading) + return createErrorResponse( + 'CEM not yet loaded — server is still initializing. Please retry.', + ); + return handleExtendCall(name, typedArgs, resolveCem(libraryId, cemCache)); + } if (isBenchmarkTool(name)) return handleBenchmarkCall(name, typedArgs, config); if (isTypegenerateTool(name)) { if (cemCache === null || cemReloading) diff --git a/tests/handlers/analyzers/api-surface.test.ts b/tests/handlers/analyzers/api-surface.test.ts new file mode 100644 index 0000000..8e00bb6 --- /dev/null +++ b/tests/handlers/analyzers/api-surface.test.ts @@ -0,0 +1,360 @@ +/** + * API Surface Quality Analyzer — unit tests + * + * Tests analyzeApiSurface() covering: + * - Method documentation scoring (30 pts) + * - Attribute reflection scoring (25 pts) + * - Default values documented scoring (25 pts) + * - Property descriptions scoring (20 pts) + * - Null return for empty components + * - Proportional normalization when some categories absent + */ + +import { describe, it, expect } from 'vitest'; +import { analyzeApiSurface } from '../../../packages/core/src/handlers/analyzers/api-surface.js'; +import type { CemDeclaration } from '../../../packages/core/src/handlers/cem.js'; + +// ─── Fixtures ───────────────────────────────────────────────────────────────── + +const FULLY_DOCUMENTED: CemDeclaration = { + kind: 'class', + name: 'FullyDocumented', + tagName: 'fully-documented', + members: [ + { + kind: 'field', + name: 'value', + type: { text: 'string' }, + description: 'The current value.', + default: '"hello"', + attribute: 'value', + reflects: true, + }, + { + kind: 'field', + name: 'disabled', + type: { text: 'boolean' }, + description: 'Disables the component.', + default: 'false', + attribute: 'disabled', + }, + { + kind: 'method', + name: 'reset', + description: 'Resets to initial state.', + return: { type: { text: 'void' } }, + }, + { + kind: 'method', + name: 'validate', + description: 'Validates the current value.', + return: { type: { text: 'boolean' } }, + }, + ], +}; + +const UNDOCUMENTED: CemDeclaration = { + kind: 'class', + name: 'Undocumented', + tagName: 'undocumented', + members: [ + { kind: 'field', name: 'value' }, + { kind: 'field', name: 'count' }, + { kind: 'method', name: 'reset' }, + { kind: 'method', name: 'update' }, + ], +}; + +const EMPTY_COMPONENT: CemDeclaration = { + kind: 'class', + name: 'Empty', + tagName: 'empty-thing', +}; + +const METHODS_ONLY: CemDeclaration = { + kind: 'class', + name: 'MethodsOnly', + tagName: 'methods-only', + members: [ + { kind: 'method', name: 'open', description: 'Opens the panel.' }, + { kind: 'method', name: 'close', description: 'Closes the panel.' }, + { kind: 'method', name: 'toggle', description: 'Toggles open state.' }, + ], +}; + +const FIELDS_ONLY: CemDeclaration = { + kind: 'class', + name: 'FieldsOnly', + tagName: 'fields-only', + members: [ + { + kind: 'field', + name: 'label', + type: { text: 'string' }, + description: 'Visible label.', + default: '""', + attribute: 'label', + }, + { + kind: 'field', + name: 'placeholder', + type: { text: 'string' }, + description: 'Placeholder text.', + default: '""', + attribute: 'placeholder', + }, + ], +}; + +const PARTIAL_DOCS: CemDeclaration = { + kind: 'class', + name: 'PartialDocs', + tagName: 'partial-docs', + members: [ + { + kind: 'field', + name: 'value', + type: { text: 'string' }, + description: 'The value.', + default: '""', + attribute: 'value', + }, + { kind: 'field', name: 'count', type: { text: 'number' } }, // no description, no default, no attribute + { + kind: 'method', + name: 'reset', + description: 'Resets it.', + }, + { kind: 'method', name: 'update' }, // no description + ], +}; + +// ─── Tests ───────────────────────────────────────────────────────────────────── + +describe('analyzeApiSurface', () => { + describe('null return cases', () => { + it('returns null for component with no members', () => { + const result = analyzeApiSurface(EMPTY_COMPONENT); + expect(result).toBeNull(); + }); + + it('returns null when members is undefined', () => { + const decl: CemDeclaration = { kind: 'class', name: 'NoMembers', tagName: 'no-members' }; + expect(analyzeApiSurface(decl)).toBeNull(); + }); + + it('returns null when members array is empty', () => { + const decl: CemDeclaration = { + kind: 'class', + name: 'EmptyMembers', + tagName: 'empty-members', + members: [], + }; + expect(analyzeApiSurface(decl)).toBeNull(); + }); + }); + + describe('result structure', () => { + it('returns score, confidence, and subMetrics', () => { + const result = analyzeApiSurface(FULLY_DOCUMENTED); + expect(result).not.toBeNull(); + expect(result).toHaveProperty('score'); + expect(result).toHaveProperty('confidence'); + expect(result).toHaveProperty('subMetrics'); + }); + + it('confidence is always heuristic', () => { + expect(analyzeApiSurface(FULLY_DOCUMENTED)!.confidence).toBe('heuristic'); + expect(analyzeApiSurface(UNDOCUMENTED)!.confidence).toBe('heuristic'); + expect(analyzeApiSurface(METHODS_ONLY)!.confidence).toBe('heuristic'); + }); + + it('has 4 sub-metrics', () => { + const result = analyzeApiSurface(FULLY_DOCUMENTED); + expect(result!.subMetrics).toHaveLength(4); + }); + + it('sub-metric names match expected categories', () => { + const result = analyzeApiSurface(FULLY_DOCUMENTED); + const names = result!.subMetrics.map((m) => m.name); + expect(names).toContain('Method documentation'); + expect(names).toContain('Attribute reflection'); + expect(names).toContain('Default values documented'); + expect(names).toContain('Property descriptions'); + }); + }); + + describe('full documentation scoring', () => { + it('scores 100 for a fully-documented component', () => { + const result = analyzeApiSurface(FULLY_DOCUMENTED); + expect(result!.score).toBe(100); + }); + + it('scores method documentation as full when all methods have descriptions', () => { + const result = analyzeApiSurface(FULLY_DOCUMENTED); + const methodMetric = result!.subMetrics.find((m) => m.name === 'Method documentation'); + expect(methodMetric!.score).toBe(methodMetric!.maxScore); + }); + + it('scores attribute reflection as full when all fields have attributes', () => { + const result = analyzeApiSurface(FULLY_DOCUMENTED); + const attrMetric = result!.subMetrics.find((m) => m.name === 'Attribute reflection'); + expect(attrMetric!.score).toBe(attrMetric!.maxScore); + }); + + it('scores default values as full when all fields have defaults', () => { + const result = analyzeApiSurface(FULLY_DOCUMENTED); + const defaultMetric = result!.subMetrics.find((m) => m.name === 'Default values documented'); + expect(defaultMetric!.score).toBe(defaultMetric!.maxScore); + }); + + it('scores property descriptions as full when all fields have descriptions', () => { + const result = analyzeApiSurface(FULLY_DOCUMENTED); + const propMetric = result!.subMetrics.find((m) => m.name === 'Property descriptions'); + expect(propMetric!.score).toBe(propMetric!.maxScore); + }); + }); + + describe('low documentation scoring', () => { + it('scores low for undocumented component', () => { + const result = analyzeApiSurface(UNDOCUMENTED); + expect(result!.score).toBeLessThan(20); + }); + + it('scores 0 for method documentation when no methods have descriptions', () => { + const result = analyzeApiSurface(UNDOCUMENTED); + const methodMetric = result!.subMetrics.find((m) => m.name === 'Method documentation'); + expect(methodMetric!.score).toBe(0); + }); + + it('scores 0 for attribute reflection when no fields have attributes', () => { + const result = analyzeApiSurface(UNDOCUMENTED); + const attrMetric = result!.subMetrics.find((m) => m.name === 'Attribute reflection'); + expect(attrMetric!.score).toBe(0); + }); + + it('scores 0 for default values when no fields have defaults', () => { + const result = analyzeApiSurface(UNDOCUMENTED); + const defaultMetric = result!.subMetrics.find((m) => m.name === 'Default values documented'); + expect(defaultMetric!.score).toBe(0); + }); + + it('scores 0 for property descriptions when no fields have descriptions', () => { + const result = analyzeApiSurface(UNDOCUMENTED); + const propMetric = result!.subMetrics.find((m) => m.name === 'Property descriptions'); + expect(propMetric!.score).toBe(0); + }); + }); + + describe('methods-only component', () => { + it('returns a result for methods-only component', () => { + const result = analyzeApiSurface(METHODS_ONLY); + expect(result).not.toBeNull(); + }); + + it('scores well when all methods are documented', () => { + const result = analyzeApiSurface(METHODS_ONLY); + // Only method dimension applies; field dimensions score 0 (no fields) + // Score is normalized to applicable max + expect(result!.score).toBeGreaterThan(0); + }); + + it('scores field-related sub-metrics as 0 when no fields exist', () => { + const result = analyzeApiSurface(METHODS_ONLY); + const attrMetric = result!.subMetrics.find((m) => m.name === 'Attribute reflection'); + const defaultMetric = result!.subMetrics.find((m) => m.name === 'Default values documented'); + const propMetric = result!.subMetrics.find((m) => m.name === 'Property descriptions'); + expect(attrMetric!.score).toBe(0); + expect(defaultMetric!.score).toBe(0); + expect(propMetric!.score).toBe(0); + }); + }); + + describe('fields-only component', () => { + it('returns a result for fields-only component', () => { + const result = analyzeApiSurface(FIELDS_ONLY); + expect(result).not.toBeNull(); + }); + + it('scores method documentation as 0 when no methods exist', () => { + const result = analyzeApiSurface(FIELDS_ONLY); + const methodMetric = result!.subMetrics.find((m) => m.name === 'Method documentation'); + expect(methodMetric!.score).toBe(0); + }); + + it('normalizes score to 100 for fully-documented fields-only component', () => { + const result = analyzeApiSurface(FIELDS_ONLY); + expect(result!.score).toBe(100); + }); + }); + + describe('partial documentation scoring', () => { + it('scores proportionally for partial documentation', () => { + const result = analyzeApiSurface(PARTIAL_DOCS); + expect(result).not.toBeNull(); + // Not 0 and not 100 + expect(result!.score).toBeGreaterThan(0); + expect(result!.score).toBeLessThan(100); + }); + + it('scores method documentation at 50% when half methods documented', () => { + const result = analyzeApiSurface(PARTIAL_DOCS); + const methodMetric = result!.subMetrics.find((m) => m.name === 'Method documentation'); + // 1 of 2 methods has description → round(1/2 * 30) = 15 + expect(methodMetric!.score).toBe(15); + }); + + it('scores attribute reflection at 50% when half fields have attributes', () => { + const result = analyzeApiSurface(PARTIAL_DOCS); + const attrMetric = result!.subMetrics.find((m) => m.name === 'Attribute reflection'); + // 1 of 2 fields has attribute → round(1/2 * 25) = 13 (or 12) + expect(attrMetric!.score).toBeGreaterThan(0); + expect(attrMetric!.score).toBeLessThan(25); + }); + + it('scores default values at 50% when half fields have defaults', () => { + const result = analyzeApiSurface(PARTIAL_DOCS); + const defaultMetric = result!.subMetrics.find((m) => m.name === 'Default values documented'); + // 1 of 2 fields has default + expect(defaultMetric!.score).toBeGreaterThan(0); + expect(defaultMetric!.score).toBeLessThan(25); + }); + }); + + describe('reflects field for attribute reflection', () => { + it('counts reflects:true as attribute binding', () => { + const decl: CemDeclaration = { + kind: 'class', + name: 'WithReflects', + tagName: 'with-reflects', + members: [ + { kind: 'field', name: 'open', type: { text: 'boolean' }, reflects: true }, + { kind: 'field', name: 'value', type: { text: 'string' }, attribute: 'value' }, + ], + }; + const result = analyzeApiSurface(decl); + const attrMetric = result!.subMetrics.find((m) => m.name === 'Attribute reflection'); + // Both fields qualify: one via reflects, one via attribute + expect(attrMetric!.score).toBe(attrMetric!.maxScore); + }); + }); + + describe('score bounds', () => { + it('score is always in range [0, 100]', () => { + const decls = [FULLY_DOCUMENTED, UNDOCUMENTED, PARTIAL_DOCS, METHODS_ONLY, FIELDS_ONLY]; + for (const decl of decls) { + const result = analyzeApiSurface(decl); + if (result) { + expect(result.score).toBeGreaterThanOrEqual(0); + expect(result.score).toBeLessThanOrEqual(100); + } + } + }); + + it('sub-metric maxScore values sum to 100', () => { + const result = analyzeApiSurface(FULLY_DOCUMENTED); + const maxSum = result!.subMetrics.reduce((acc, m) => acc + m.maxScore, 0); + expect(maxSum).toBe(100); + }); + }); +}); diff --git a/tests/handlers/analyzers/css-architecture.test.ts b/tests/handlers/analyzers/css-architecture.test.ts new file mode 100644 index 0000000..5f86638 --- /dev/null +++ b/tests/handlers/analyzers/css-architecture.test.ts @@ -0,0 +1,323 @@ +/** + * CSS Architecture Analyzer — unit tests + * + * Tests analyzeCssArchitecture() covering: + * - CSS property descriptions scoring (35 pts) + * - Design token naming patterns scoring (30 pts) + * - CSS parts documentation scoring (35 pts) + * - Null return for components with no CSS metadata + * - Proportional normalization when only props OR parts exist + * - Token naming pattern validation (--prefix-name) + */ + +import { describe, it, expect } from 'vitest'; +import { analyzeCssArchitecture } from '../../../packages/core/src/handlers/analyzers/css-architecture.js'; +import type { CemDeclaration } from '../../../packages/core/src/handlers/cem.js'; + +// ─── Fixtures ───────────────────────────────────────────────────────────────── + +const IDEAL_CSS: CemDeclaration = { + kind: 'class', + name: 'IdealCss', + tagName: 'ideal-css', + cssProperties: [ + { name: '--ic-color-primary', default: '#0066cc', description: 'Primary brand color.' }, + { name: '--ic-color-secondary', default: '#666', description: 'Secondary color.' }, + { name: '--ic-spacing-base', default: '16px', description: 'Base spacing unit.' }, + { name: '--ic-border-radius', default: '4px', description: 'Border radius.' }, + ], + cssParts: [ + { name: 'base', description: 'The root element.' }, + { name: 'label', description: 'The label text element.' }, + { name: 'icon', description: 'The leading icon.' }, + ], +}; + +const NO_CSS_METADATA: CemDeclaration = { + kind: 'class', + name: 'NoCss', + tagName: 'no-css', + members: [{ kind: 'field', name: 'value', type: { text: 'string' } }], +}; + +const CSS_PROPS_ONLY: CemDeclaration = { + kind: 'class', + name: 'CssPropsOnly', + tagName: 'css-props-only', + cssProperties: [ + { name: '--cp-color', description: 'Primary color.' }, + { name: '--cp-size', description: 'Size value.' }, + ], +}; + +const CSS_PARTS_ONLY: CemDeclaration = { + kind: 'class', + name: 'CssPartsOnly', + tagName: 'css-parts-only', + cssParts: [ + { name: 'base', description: 'Base element.' }, + { name: 'header', description: 'Header element.' }, + ], +}; + +const BAD_TOKEN_NAMING: CemDeclaration = { + kind: 'class', + name: 'BadTokenNaming', + tagName: 'bad-token-naming', + cssProperties: [ + { name: '--color', description: 'A color (missing prefix).' }, // no prefix + { name: 'noLeadingDash', description: 'Missing dashes.' }, // no -- prefix + { name: '--a', description: 'Too short.' }, // single letter prefix + { name: '--bt-color', description: 'Good naming.' }, // valid + ], +}; + +const MISSING_DESCRIPTIONS: CemDeclaration = { + kind: 'class', + name: 'MissingDescriptions', + tagName: 'missing-descriptions', + cssProperties: [ + { name: '--md-color-primary', description: 'Primary color.' }, + { name: '--md-color-secondary' }, // no description + { name: '--md-spacing-base' }, // no description + ], + cssParts: [ + { name: 'base', description: 'Root element.' }, + { name: 'inner' }, // no description + ], +}; + +const EMPTY_ARRAYS: CemDeclaration = { + kind: 'class', + name: 'EmptyArrays', + tagName: 'empty-arrays', + cssProperties: [], + cssParts: [], +}; + +// ─── Tests ───────────────────────────────────────────────────────────────────── + +describe('analyzeCssArchitecture', () => { + describe('null return cases', () => { + it('returns null for component with no CSS metadata', () => { + const result = analyzeCssArchitecture(NO_CSS_METADATA); + expect(result).toBeNull(); + }); + + it('returns null when cssProperties and cssParts are both empty', () => { + expect(analyzeCssArchitecture(EMPTY_ARRAYS)).toBeNull(); + }); + + it('returns null when both arrays are undefined', () => { + const decl: CemDeclaration = { + kind: 'class', + name: 'NoCssAtAll', + tagName: 'no-css-at-all', + }; + expect(analyzeCssArchitecture(decl)).toBeNull(); + }); + }); + + describe('result structure', () => { + it('returns score, confidence, and subMetrics', () => { + const result = analyzeCssArchitecture(IDEAL_CSS); + expect(result).not.toBeNull(); + expect(result).toHaveProperty('score'); + expect(result).toHaveProperty('confidence'); + expect(result).toHaveProperty('subMetrics'); + }); + + it('confidence is always heuristic', () => { + expect(analyzeCssArchitecture(IDEAL_CSS)!.confidence).toBe('heuristic'); + expect(analyzeCssArchitecture(CSS_PROPS_ONLY)!.confidence).toBe('heuristic'); + }); + + it('has exactly 3 sub-metrics', () => { + const result = analyzeCssArchitecture(IDEAL_CSS); + expect(result!.subMetrics).toHaveLength(3); + }); + + it('sub-metric names match expected categories', () => { + const result = analyzeCssArchitecture(IDEAL_CSS); + const names = result!.subMetrics.map((m) => m.name); + expect(names).toContain('CSS property descriptions'); + expect(names).toContain('Design token naming'); + expect(names).toContain('CSS parts documentation'); + }); + }); + + describe('ideal CSS scoring', () => { + it('scores 100 for fully-compliant CSS architecture', () => { + const result = analyzeCssArchitecture(IDEAL_CSS); + expect(result!.score).toBe(100); + }); + + it('scores CSS property descriptions at max when all have descriptions', () => { + const result = analyzeCssArchitecture(IDEAL_CSS); + const propDescMetric = result!.subMetrics.find((m) => m.name === 'CSS property descriptions'); + expect(propDescMetric!.score).toBe(propDescMetric!.maxScore); + }); + + it('scores design token naming at max when all follow --prefix-name pattern', () => { + const result = analyzeCssArchitecture(IDEAL_CSS); + const tokenMetric = result!.subMetrics.find((m) => m.name === 'Design token naming'); + expect(tokenMetric!.score).toBe(tokenMetric!.maxScore); + }); + + it('scores CSS parts documentation at max when all parts have descriptions', () => { + const result = analyzeCssArchitecture(IDEAL_CSS); + const partsMetric = result!.subMetrics.find((m) => m.name === 'CSS parts documentation'); + expect(partsMetric!.score).toBe(partsMetric!.maxScore); + }); + }); + + describe('design token naming validation', () => { + it('requires --prefix-name pattern (at least 2 segments with -)', () => { + const result = analyzeCssArchitecture(BAD_TOKEN_NAMING); + const tokenMetric = result!.subMetrics.find((m) => m.name === 'Design token naming'); + // Only '--bt-color' is well-named (1 of 4) + // '--color' has no secondary prefix, 'noLeadingDash' fails completely, '--a' is single letter + expect(tokenMetric!.score).toBeLessThan(tokenMetric!.maxScore); + expect(tokenMetric!.score).toBeGreaterThan(0); // 1/4 valid + }); + + it('accepts multi-prefix tokens like --sl-button-color', () => { + const decl: CemDeclaration = { + kind: 'class', + name: 'MultiPrefix', + tagName: 'multi-prefix', + cssProperties: [ + { name: '--sl-button-color', description: 'Shoelace button color.' }, + { name: '--md-sys-color-primary', description: 'Material color token.' }, + { name: '--hx-spacing-md', description: 'Helix spacing medium.' }, + ], + }; + const result = analyzeCssArchitecture(decl); + const tokenMetric = result!.subMetrics.find((m) => m.name === 'Design token naming'); + expect(tokenMetric!.score).toBe(tokenMetric!.maxScore); + }); + + it('rejects properties without -- prefix', () => { + const decl: CemDeclaration = { + kind: 'class', + name: 'NoPrefix', + tagName: 'no-prefix', + cssProperties: [ + { name: 'color', description: 'No prefix.' }, + { name: 'background', description: 'No prefix.' }, + ], + }; + const result = analyzeCssArchitecture(decl); + const tokenMetric = result!.subMetrics.find((m) => m.name === 'Design token naming'); + expect(tokenMetric!.score).toBe(0); + }); + }); + + describe('missing descriptions', () => { + it('scores CSS property descriptions proportionally', () => { + const result = analyzeCssArchitecture(MISSING_DESCRIPTIONS); + const propDescMetric = result!.subMetrics.find((m) => m.name === 'CSS property descriptions'); + // 1 of 3 CSS properties have descriptions → round(1/3 * 35) = 12 (or 11) + expect(propDescMetric!.score).toBeGreaterThan(0); + expect(propDescMetric!.score).toBeLessThan(propDescMetric!.maxScore); + }); + + it('scores CSS parts documentation proportionally', () => { + const result = analyzeCssArchitecture(MISSING_DESCRIPTIONS); + const partsMetric = result!.subMetrics.find((m) => m.name === 'CSS parts documentation'); + // 1 of 2 CSS parts have descriptions → round(1/2 * 35) = 18 (or 17) + expect(partsMetric!.score).toBeGreaterThan(0); + expect(partsMetric!.score).toBeLessThan(partsMetric!.maxScore); + }); + }); + + describe('CSS properties only', () => { + it('returns a result when only cssProperties exist', () => { + const result = analyzeCssArchitecture(CSS_PROPS_ONLY); + expect(result).not.toBeNull(); + }); + + it('scores CSS parts at 0 when no parts exist', () => { + const result = analyzeCssArchitecture(CSS_PROPS_ONLY); + const partsMetric = result!.subMetrics.find((m) => m.name === 'CSS parts documentation'); + expect(partsMetric!.score).toBe(0); + }); + + it('normalizes score based on applicable dimensions', () => { + const result = analyzeCssArchitecture(CSS_PROPS_ONLY); + // cssProperties: all described + all well-named → 65/65 → normalized to 100 + expect(result!.score).toBe(100); + }); + }); + + describe('CSS parts only', () => { + it('returns a result when only cssParts exist', () => { + const result = analyzeCssArchitecture(CSS_PARTS_ONLY); + expect(result).not.toBeNull(); + }); + + it('scores CSS properties at 0 when no properties exist', () => { + const result = analyzeCssArchitecture(CSS_PARTS_ONLY); + const propDescMetric = result!.subMetrics.find((m) => m.name === 'CSS property descriptions'); + const tokenMetric = result!.subMetrics.find((m) => m.name === 'Design token naming'); + expect(propDescMetric!.score).toBe(0); + expect(tokenMetric!.score).toBe(0); + }); + + it('normalizes score based on applicable dimensions', () => { + const result = analyzeCssArchitecture(CSS_PARTS_ONLY); + // cssParts: all described → 35/35 → normalized to 100 + expect(result!.score).toBe(100); + }); + }); + + describe('score bounds', () => { + it('score is always in range [0, 100]', () => { + const decls = [ + IDEAL_CSS, + CSS_PROPS_ONLY, + CSS_PARTS_ONLY, + BAD_TOKEN_NAMING, + MISSING_DESCRIPTIONS, + ]; + for (const decl of decls) { + const result = analyzeCssArchitecture(decl); + if (result) { + expect(result.score).toBeGreaterThanOrEqual(0); + expect(result.score).toBeLessThanOrEqual(100); + } + } + }); + + it('sub-metric maxScore values sum to 100', () => { + const result = analyzeCssArchitecture(IDEAL_CSS); + const maxSum = result!.subMetrics.reduce((acc, m) => acc + m.maxScore, 0); + expect(maxSum).toBe(100); + }); + }); + + describe('whitespace description handling', () => { + it('treats whitespace-only descriptions as missing', () => { + const decl: CemDeclaration = { + kind: 'class', + name: 'WhitespaceDesc', + tagName: 'whitespace-desc', + cssProperties: [ + { name: '--ws-color', description: ' ' }, // whitespace only + { name: '--ws-bg', description: 'Valid description.' }, + ], + cssParts: [ + { name: 'base', description: '' }, // empty string + { name: 'inner', description: 'Inner element.' }, + ], + }; + const result = analyzeCssArchitecture(decl); + const propDescMetric = result!.subMetrics.find((m) => m.name === 'CSS property descriptions'); + const partsMetric = result!.subMetrics.find((m) => m.name === 'CSS parts documentation'); + // 1 of 2 CSS props has valid description + expect(propDescMetric!.score).toBeLessThan(propDescMetric!.maxScore); + // 1 of 2 CSS parts has valid description + expect(partsMetric!.score).toBeLessThan(partsMetric!.maxScore); + }); + }); +}); diff --git a/tests/handlers/analyzers/event-architecture.test.ts b/tests/handlers/analyzers/event-architecture.test.ts new file mode 100644 index 0000000..717fd02 --- /dev/null +++ b/tests/handlers/analyzers/event-architecture.test.ts @@ -0,0 +1,351 @@ +/** + * Event Architecture Analyzer — unit tests + * + * Tests analyzeEventArchitecture() covering: + * - Kebab-case naming convention scoring (35 pts) + * - Typed event payloads scoring (35 pts) + * - Event descriptions scoring (30 pts) + * - Null return for components with no events + * - isKebabCase validation edge cases + * - Mixed convention components + */ + +import { describe, it, expect } from 'vitest'; +import { analyzeEventArchitecture } from '../../../packages/core/src/handlers/analyzers/event-architecture.js'; +import type { CemDeclaration } from '../../../packages/core/src/handlers/cem.js'; + +// ─── Fixtures ───────────────────────────────────────────────────────────────── + +const IDEAL_EVENTS: CemDeclaration = { + kind: 'class', + name: 'IdealEvents', + tagName: 'ideal-events', + events: [ + { + name: 'value-change', + type: { text: 'CustomEvent<{ value: string }>' }, + description: 'Fired when the value changes.', + }, + { + name: 'menu-open', + type: { text: 'CustomEvent' }, + description: 'Fired when the menu opens.', + }, + { + name: 'item-selected', + type: { text: 'CustomEvent<{ item: object }>' }, + description: 'Fired when an item is selected.', + }, + ], +}; + +const POOR_EVENTS: CemDeclaration = { + kind: 'class', + name: 'PoorEvents', + tagName: 'poor-events', + events: [ + { name: 'ValueChange' }, // PascalCase, no type, no desc + { name: 'onUpdate' }, // camelCase with 'on' prefix, no type, no desc + { name: 'CLICK_EVENT' }, // SCREAMING_SNAKE, no type, no desc + ], +}; + +const NO_EVENTS: CemDeclaration = { + kind: 'class', + name: 'NoEvents', + tagName: 'no-events', +}; + +const SINGLE_PERFECT_EVENT: CemDeclaration = { + kind: 'class', + name: 'SinglePerfect', + tagName: 'single-perfect', + events: [ + { + name: 'sl-click', + type: { text: 'CustomEvent<{ originalEvent: MouseEvent }>' }, + description: 'Emitted when the button is clicked.', + }, + ], +}; + +const MIXED_NAMING: CemDeclaration = { + kind: 'class', + name: 'MixedNaming', + tagName: 'mixed-naming', + events: [ + { name: 'value-change', type: { text: 'CustomEvent' }, description: 'Value changed.' }, + { name: 'ItemClick', type: { text: 'Event' }, description: 'Item clicked.' }, // PascalCase + { name: 'focus', type: { text: 'CustomEvent' }, description: 'Focused.' }, // valid single-word + ], +}; + +const BARE_EVENT_TYPES: CemDeclaration = { + kind: 'class', + name: 'BareEventTypes', + tagName: 'bare-event-types', + events: [ + { name: 'change', type: { text: 'Event' }, description: 'Changed.' }, + { name: 'blur', type: { text: 'Event' }, description: 'Blurred.' }, + { name: 'value-change', type: { text: 'CustomEvent' }, description: 'Value changed.' }, + ], +}; + +const NO_DESCRIPTIONS: CemDeclaration = { + kind: 'class', + name: 'NoDescriptions', + tagName: 'no-descriptions', + events: [ + { name: 'click', type: { text: 'CustomEvent' } }, + { name: 'change', type: { text: 'CustomEvent' } }, + { name: 'focus', type: { text: 'CustomEvent' } }, + ], +}; + +// ─── Tests ───────────────────────────────────────────────────────────────────── + +describe('analyzeEventArchitecture', () => { + describe('null return cases', () => { + it('returns null when no events are declared', () => { + const result = analyzeEventArchitecture(NO_EVENTS); + expect(result).toBeNull(); + }); + + it('returns null when events array is empty', () => { + const decl: CemDeclaration = { + kind: 'class', + name: 'EmptyEvents', + tagName: 'empty-events', + events: [], + }; + expect(analyzeEventArchitecture(decl)).toBeNull(); + }); + + it('returns null when events is undefined', () => { + const decl: CemDeclaration = { + kind: 'class', + name: 'UndefinedEvents', + tagName: 'undefined-events', + }; + expect(analyzeEventArchitecture(decl)).toBeNull(); + }); + }); + + describe('result structure', () => { + it('returns score, confidence, and subMetrics', () => { + const result = analyzeEventArchitecture(IDEAL_EVENTS); + expect(result).not.toBeNull(); + expect(result).toHaveProperty('score'); + expect(result).toHaveProperty('confidence'); + expect(result).toHaveProperty('subMetrics'); + }); + + it('confidence is always heuristic', () => { + expect(analyzeEventArchitecture(IDEAL_EVENTS)!.confidence).toBe('heuristic'); + expect(analyzeEventArchitecture(POOR_EVENTS)!.confidence).toBe('heuristic'); + }); + + it('has exactly 3 sub-metrics', () => { + const result = analyzeEventArchitecture(IDEAL_EVENTS); + expect(result!.subMetrics).toHaveLength(3); + }); + + it('sub-metric names match expected categories', () => { + const result = analyzeEventArchitecture(IDEAL_EVENTS); + const names = result!.subMetrics.map((m) => m.name); + expect(names).toContain('Kebab-case naming'); + expect(names).toContain('Typed event payloads'); + expect(names).toContain('Event descriptions'); + }); + }); + + describe('ideal events scoring', () => { + it('scores 100 for fully-compliant events', () => { + const result = analyzeEventArchitecture(IDEAL_EVENTS); + expect(result!.score).toBe(100); + }); + + it('scores kebab-case naming at max when all events use kebab-case', () => { + const result = analyzeEventArchitecture(IDEAL_EVENTS); + const namingMetric = result!.subMetrics.find((m) => m.name === 'Kebab-case naming'); + expect(namingMetric!.score).toBe(namingMetric!.maxScore); + }); + + it('scores typed payloads at max when all events have CustomEvent', () => { + const result = analyzeEventArchitecture(IDEAL_EVENTS); + const typeMetric = result!.subMetrics.find((m) => m.name === 'Typed event payloads'); + expect(typeMetric!.score).toBe(typeMetric!.maxScore); + }); + + it('scores event descriptions at max when all events have descriptions', () => { + const result = analyzeEventArchitecture(IDEAL_EVENTS); + const descMetric = result!.subMetrics.find((m) => m.name === 'Event descriptions'); + expect(descMetric!.score).toBe(descMetric!.maxScore); + }); + }); + + describe('poor events scoring', () => { + it('scores 0 for events with no kebab-case, no types, no descriptions', () => { + const result = analyzeEventArchitecture(POOR_EVENTS); + expect(result!.score).toBe(0); + }); + + it('scores kebab-case naming at 0 for PascalCase events', () => { + const result = analyzeEventArchitecture(POOR_EVENTS); + const namingMetric = result!.subMetrics.find((m) => m.name === 'Kebab-case naming'); + expect(namingMetric!.score).toBe(0); + }); + + it('scores typed payloads at 0 when no events have types', () => { + const result = analyzeEventArchitecture(POOR_EVENTS); + const typeMetric = result!.subMetrics.find((m) => m.name === 'Typed event payloads'); + expect(typeMetric!.score).toBe(0); + }); + + it('scores event descriptions at 0 when no events have descriptions', () => { + const result = analyzeEventArchitecture(POOR_EVENTS); + const descMetric = result!.subMetrics.find((m) => m.name === 'Event descriptions'); + expect(descMetric!.score).toBe(0); + }); + }); + + describe('kebab-case naming validation', () => { + it('accepts single lowercase words as kebab-case', () => { + const decl: CemDeclaration = { + kind: 'class', + name: 'SingleWord', + tagName: 'single-word', + events: [{ name: 'click' }, { name: 'focus' }, { name: 'change' }], + }; + const result = analyzeEventArchitecture(decl); + const namingMetric = result!.subMetrics.find((m) => m.name === 'Kebab-case naming'); + expect(namingMetric!.score).toBe(namingMetric!.maxScore); + }); + + it('accepts multi-segment kebab-case names', () => { + const decl: CemDeclaration = { + kind: 'class', + name: 'MultiSegment', + tagName: 'multi-segment', + events: [ + { name: 'value-change' }, + { name: 'menu-item-click' }, + { name: 'form-submit' }, + ], + }; + const result = analyzeEventArchitecture(decl); + const namingMetric = result!.subMetrics.find((m) => m.name === 'Kebab-case naming'); + expect(namingMetric!.score).toBe(namingMetric!.maxScore); + }); + + it('rejects PascalCase event names', () => { + const decl: CemDeclaration = { + kind: 'class', + name: 'PascalCase', + tagName: 'pascal-case', + events: [{ name: 'ValueChange' }, { name: 'MenuOpen' }], + }; + const result = analyzeEventArchitecture(decl); + const namingMetric = result!.subMetrics.find((m) => m.name === 'Kebab-case naming'); + expect(namingMetric!.score).toBe(0); + }); + + it('rejects camelCase event names', () => { + const decl: CemDeclaration = { + kind: 'class', + name: 'CamelCase', + tagName: 'camel-case', + events: [{ name: 'valueChange' }, { name: 'menuOpen' }], + }; + const result = analyzeEventArchitecture(decl); + const namingMetric = result!.subMetrics.find((m) => m.name === 'Kebab-case naming'); + expect(namingMetric!.score).toBe(0); + }); + + it('allows numbers in kebab-case segments', () => { + const decl: CemDeclaration = { + kind: 'class', + name: 'WithNumbers', + tagName: 'with-numbers', + events: [{ name: 'step2-complete' }, { name: 'item3-click' }], + }; + const result = analyzeEventArchitecture(decl); + const namingMetric = result!.subMetrics.find((m) => m.name === 'Kebab-case naming'); + expect(namingMetric!.score).toBe(namingMetric!.maxScore); + }); + }); + + describe('typed payload validation', () => { + it('excludes bare "Event" type as untyped', () => { + const result = analyzeEventArchitecture(BARE_EVENT_TYPES); + const typeMetric = result!.subMetrics.find((m) => m.name === 'Typed event payloads'); + // 1 of 3 events has proper CustomEvent, 2 have bare 'Event' + expect(typeMetric!.score).toBeGreaterThan(0); + expect(typeMetric!.score).toBeLessThan(typeMetric!.maxScore); + }); + + it('accepts CustomEvent as properly typed', () => { + const result = analyzeEventArchitecture(SINGLE_PERFECT_EVENT); + const typeMetric = result!.subMetrics.find((m) => m.name === 'Typed event payloads'); + expect(typeMetric!.score).toBe(typeMetric!.maxScore); + }); + }); + + describe('no descriptions', () => { + it('scores event descriptions at 0 when no events have descriptions', () => { + const result = analyzeEventArchitecture(NO_DESCRIPTIONS); + const descMetric = result!.subMetrics.find((m) => m.name === 'Event descriptions'); + expect(descMetric!.score).toBe(0); + }); + + it('still scores kebab-case and typed payloads even without descriptions', () => { + const result = analyzeEventArchitecture(NO_DESCRIPTIONS); + const namingMetric = result!.subMetrics.find((m) => m.name === 'Kebab-case naming'); + const typeMetric = result!.subMetrics.find((m) => m.name === 'Typed event payloads'); + expect(namingMetric!.score).toBeGreaterThan(0); + expect(typeMetric!.score).toBeGreaterThan(0); + }); + }); + + describe('mixed naming conventions', () => { + it('scores proportionally for mixed kebab/non-kebab events', () => { + const result = analyzeEventArchitecture(MIXED_NAMING); + const namingMetric = result!.subMetrics.find((m) => m.name === 'Kebab-case naming'); + // 2 of 3 events are kebab-case (value-change, focus); ItemClick is not + // round(2/3 * 35) = 23 + expect(namingMetric!.score).toBe(23); + }); + }); + + describe('single event component', () => { + it('scores 100 for a single perfectly-defined event', () => { + const result = analyzeEventArchitecture(SINGLE_PERFECT_EVENT); + expect(result!.score).toBe(100); + }); + }); + + describe('score bounds', () => { + it('score is always in range [0, 100]', () => { + const decls = [IDEAL_EVENTS, POOR_EVENTS, MIXED_NAMING, BARE_EVENT_TYPES, NO_DESCRIPTIONS]; + for (const decl of decls) { + const result = analyzeEventArchitecture(decl); + if (result) { + expect(result.score).toBeGreaterThanOrEqual(0); + expect(result.score).toBeLessThanOrEqual(100); + } + } + }); + + it('sub-metric maxScore values sum to 100', () => { + const result = analyzeEventArchitecture(IDEAL_EVENTS); + const maxSum = result!.subMetrics.reduce((acc, m) => acc + m.maxScore, 0); + expect(maxSum).toBe(100); + }); + + it('sub-metric scores sum to total score', () => { + const result = analyzeEventArchitecture(IDEAL_EVENTS); + const scoreSum = result!.subMetrics.reduce((acc, m) => acc + m.score, 0); + expect(scoreSum).toBe(result!.score); + }); + }); +}); diff --git a/tests/handlers/analyzers/mixin-resolver.test.ts b/tests/handlers/analyzers/mixin-resolver.test.ts new file mode 100644 index 0000000..449d4af --- /dev/null +++ b/tests/handlers/analyzers/mixin-resolver.test.ts @@ -0,0 +1,363 @@ +/** + * Mixin & Inheritance Chain Resolver — unit tests + * + * Tests resolveInheritanceChain() and related helpers from mixin-resolver.ts. + * This module has async I/O behavior but has testable pure logic via: + * - resolveInheritanceChain() with inline source (no real files needed for component itself) + * - Chain resolution on components with no CEM-declared mixins/superclasses + * - Aggregation logic via the chain result + * - Architecture classification based on chain shape + * + * Key exports tested: + * - resolveInheritanceChain() + * - ResolvedSource type structure + * - InheritanceChainResult type structure + */ + +import { describe, it, expect } from 'vitest'; +import { resolve } from 'node:path'; +import { + resolveInheritanceChain, + type ResolvedSource, + type InheritanceChainResult, +} from '../../../packages/core/src/handlers/analyzers/mixin-resolver.js'; +import type { CemDeclaration } from '../../../packages/core/src/handlers/cem.js'; + +// ─── Fixtures ────────────────────────────────────────────────────────────────── + +const WORKTREE = '/Volumes/Development/booked/helixir/.worktrees/feature-test-add-test-suites-for-8-untested'; + +// A minimal component source with no a11y patterns +const MINIMAL_SOURCE = ` +class MyComponent extends HTMLElement { + connectedCallback() { + this.textContent = 'Hello'; + } +} +customElements.define('my-component', MyComponent); +`; + +// A component source with ARIA patterns +const ARIA_SOURCE = ` +class MyButton extends LitElement { + @property({ type: Boolean }) disabled = false; + render() { + return html\`\`; + } + handleKeyDown(e) { + if (e.key === 'Enter') this.click(); + } +} +`; + +// A component with a form internals + focus management +const FORM_SOURCE = ` +class MyInput extends LitElement { + static formAssociated = true; + constructor() { + super(); + this.#internals = this.attachInternals(); + } + focus() { + this.shadowRoot.querySelector('input').focus(); + } + connectedCallback() { + super.connectedCallback(); + this.setAttribute('tabindex', '0'); + } +} +`; + +// A component that imports an a11y-relevant mixin +const MIXIN_IMPORT_SOURCE = ` +import { FocusMixin } from './focus-mixin.js'; +import { KeyboardMixin } from './keyboard-mixin.js'; + +class MyDropdown extends FocusMixin(KeyboardMixin(HTMLElement)) { + connectedCallback() { + this.setAttribute('role', 'listbox'); + } +} +`; + +// A simple component declaration (no inheritance chain in CEM) +const SIMPLE_DECL: CemDeclaration = { + kind: 'class', + name: 'MyComponent', + tagName: 'my-component', +}; + +const BUTTON_DECL: CemDeclaration = { + kind: 'class', + name: 'MyButton', + tagName: 'my-button', +}; + +const FORM_DECL: CemDeclaration = { + kind: 'class', + name: 'MyInput', + tagName: 'my-input', +}; + +// ─── Tests ───────────────────────────────────────────────────────────────────── + +describe('resolveInheritanceChain', () => { + describe('basic chain resolution', () => { + it('resolves a component with no inheritance chain', async () => { + const chain = await resolveInheritanceChain( + MINIMAL_SOURCE, + resolve(WORKTREE, 'src/my-component.ts'), + SIMPLE_DECL, + WORKTREE, + ); + expect(chain).toBeDefined(); + expect(chain.sources.length).toBeGreaterThanOrEqual(1); + }); + + it('always includes the component itself as first source', async () => { + const chain = await resolveInheritanceChain( + MINIMAL_SOURCE, + resolve(WORKTREE, 'src/my-component.ts'), + SIMPLE_DECL, + WORKTREE, + ); + const first = chain.sources[0]; + expect(first!.type).toBe('component'); + expect(first!.name).toBe('MyComponent'); + }); + + it('includes component source content', async () => { + const chain = await resolveInheritanceChain( + MINIMAL_SOURCE, + resolve(WORKTREE, 'src/my-component.ts'), + SIMPLE_DECL, + WORKTREE, + ); + const componentSource = chain.sources.find((s) => s.type === 'component'); + expect(componentSource!.content).toBe(MINIMAL_SOURCE); + }); + }); + + describe('result structure', () => { + it('returns InheritanceChainResult with all required fields', async () => { + const chain = await resolveInheritanceChain( + MINIMAL_SOURCE, + resolve(WORKTREE, 'src/my-component.ts'), + SIMPLE_DECL, + WORKTREE, + ); + expect(chain).toHaveProperty('sources'); + expect(chain).toHaveProperty('aggregatedMarkers'); + expect(chain).toHaveProperty('resolvedCount'); + expect(chain).toHaveProperty('unresolved'); + expect(chain).toHaveProperty('architecture'); + }); + + it('resolvedCount equals sources array length', async () => { + const chain = await resolveInheritanceChain( + MINIMAL_SOURCE, + resolve(WORKTREE, 'src/my-component.ts'), + SIMPLE_DECL, + WORKTREE, + ); + expect(chain.resolvedCount).toBe(chain.sources.length); + }); + + it('unresolved is an array', async () => { + const chain = await resolveInheritanceChain( + MINIMAL_SOURCE, + resolve(WORKTREE, 'src/my-component.ts'), + SIMPLE_DECL, + WORKTREE, + ); + expect(Array.isArray(chain.unresolved)).toBe(true); + }); + + it('architecture is one of the expected values', async () => { + const chain = await resolveInheritanceChain( + MINIMAL_SOURCE, + resolve(WORKTREE, 'src/my-component.ts'), + SIMPLE_DECL, + WORKTREE, + ); + expect(['inline', 'mixin-heavy', 'controller-based', 'hybrid']).toContain( + chain.architecture, + ); + }); + }); + + describe('aggregated markers', () => { + it('aggregated markers reflect component source patterns', async () => { + const chain = await resolveInheritanceChain( + MINIMAL_SOURCE, + resolve(WORKTREE, 'src/my-component.ts'), + SIMPLE_DECL, + WORKTREE, + ); + // MINIMAL_SOURCE has no a11y patterns + expect(chain.aggregatedMarkers.ariaBindings).toBe(false); + expect(chain.aggregatedMarkers.roleAssignments).toBe(false); + expect(chain.aggregatedMarkers.keyboardHandling).toBe(false); + }); + + it('aggregated markers detect aria patterns in component source', async () => { + const chain = await resolveInheritanceChain( + ARIA_SOURCE, + resolve(WORKTREE, 'src/my-button.ts'), + BUTTON_DECL, + WORKTREE, + ); + expect(chain.aggregatedMarkers.ariaBindings).toBe(true); + expect(chain.aggregatedMarkers.keyboardHandling).toBe(true); + }); + + it('aggregated markers detect form internals and focus in component source', async () => { + const chain = await resolveInheritanceChain( + FORM_SOURCE, + resolve(WORKTREE, 'src/my-input.ts'), + FORM_DECL, + WORKTREE, + ); + expect(chain.aggregatedMarkers.formInternals).toBe(true); + expect(chain.aggregatedMarkers.focusManagement).toBe(true); + }); + + it('aggregated markers have all 7 keys', async () => { + const chain = await resolveInheritanceChain( + MINIMAL_SOURCE, + resolve(WORKTREE, 'src/my-component.ts'), + SIMPLE_DECL, + WORKTREE, + ); + expect(Object.keys(chain.aggregatedMarkers)).toHaveLength(7); + }); + }); + + describe('architecture classification', () => { + it('classifies single-file component as "inline"', async () => { + const chain = await resolveInheritanceChain( + MINIMAL_SOURCE, + resolve(WORKTREE, 'src/my-component.ts'), + SIMPLE_DECL, + WORKTREE, + ); + // No mixins resolved → inline + expect(chain.architecture).toBe('inline'); + }); + + it('classifies component with all a11y inline as "inline"', async () => { + const chain = await resolveInheritanceChain( + ARIA_SOURCE, + resolve(WORKTREE, 'src/my-button.ts'), + BUTTON_DECL, + WORKTREE, + ); + // Component has all patterns, no external mixins resolved + expect(chain.architecture).toBe('inline'); + }); + }); + + describe('each ResolvedSource structure', () => { + it('component source has correct ResolvedSource structure', async () => { + const chain = await resolveInheritanceChain( + MINIMAL_SOURCE, + resolve(WORKTREE, 'src/my-component.ts'), + SIMPLE_DECL, + WORKTREE, + ); + const src = chain.sources[0]!; + expect(src).toHaveProperty('name'); + expect(src).toHaveProperty('type'); + expect(src).toHaveProperty('filePath'); + expect(src).toHaveProperty('content'); + expect(src).toHaveProperty('markers'); + }); + + it('component source markers are a valid SourceA11yMarkers object', async () => { + const chain = await resolveInheritanceChain( + MINIMAL_SOURCE, + resolve(WORKTREE, 'src/my-component.ts'), + SIMPLE_DECL, + WORKTREE, + ); + const markers = chain.sources[0]!.markers; + const expectedKeys = [ + 'ariaBindings', + 'roleAssignments', + 'keyboardHandling', + 'focusManagement', + 'formInternals', + 'liveRegions', + 'screenReaderSupport', + ]; + for (const key of expectedKeys) { + expect(markers).toHaveProperty(key); + expect(typeof markers[key as keyof typeof markers]).toBe('boolean'); + } + }); + }); + + describe('CEM-declared superclass with no module path', () => { + it('silently skips framework base classes like LitElement', async () => { + const decl: CemDeclaration = { + kind: 'class', + name: 'MyButton', + tagName: 'my-button', + superclass: { name: 'LitElement' }, // framework base — skipped by getInheritanceChain + }; + const chain = await resolveInheritanceChain( + MINIMAL_SOURCE, + resolve(WORKTREE, 'src/my-component.ts'), + decl, + WORKTREE, + ); + // LitElement is a framework base class — getInheritanceChain() skips it entirely. + // It does NOT appear in unresolved; the chain simply has no superclass entry. + expect(chain.unresolved).not.toContain('LitElement'); + expect(chain.sources.length).toBeGreaterThanOrEqual(1); + }); + + it('adds unresolved entry when a non-framework superclass has no module path', async () => { + const decl: CemDeclaration = { + kind: 'class', + name: 'MyButton', + tagName: 'my-button', + superclass: { name: 'BaseButton' }, // custom base class, no module path + }; + const chain = await resolveInheritanceChain( + MINIMAL_SOURCE, + resolve(WORKTREE, 'src/my-component.ts'), + decl, + WORKTREE, + ); + // BaseButton has no module path → gets added to chain with modulePath=null → goes to unresolved + expect(chain.unresolved).toContain('BaseButton'); + }); + }); + + describe('maxDepth parameter', () => { + it('accepts maxDepth parameter without error', async () => { + await expect( + resolveInheritanceChain( + MINIMAL_SOURCE, + resolve(WORKTREE, 'src/my-component.ts'), + SIMPLE_DECL, + WORKTREE, + 0, // depth 0 = no import following + ), + ).resolves.toBeDefined(); + }); + + it('depth 0 still resolves the component itself', async () => { + const chain = await resolveInheritanceChain( + MINIMAL_SOURCE, + resolve(WORKTREE, 'src/my-component.ts'), + SIMPLE_DECL, + WORKTREE, + 0, + ); + expect(chain.sources.length).toBeGreaterThanOrEqual(1); + expect(chain.sources[0]!.type).toBe('component'); + }); + }); +}); diff --git a/tests/handlers/analyzers/naming-consistency.test.ts b/tests/handlers/analyzers/naming-consistency.test.ts new file mode 100644 index 0000000..ba02344 --- /dev/null +++ b/tests/handlers/analyzers/naming-consistency.test.ts @@ -0,0 +1,542 @@ +/** + * Naming Consistency Analyzer — unit tests (analyzers/ subdirectory) + * + * Tests all exported functions from naming-consistency.ts covering: + * - detectLibraryEventPrefix() + * - detectLibraryCssPrefix() + * - detectLibraryConventions() + * - scoreEventPrefixCoherence() + * - scorePropertyNamingConsistency() + * - scoreCSSCustomPropertyPrefixing() + * - scoreAttributePropertyCoherence() + * - analyzeNamingConsistency() + * + * Additional edge cases beyond tests/handlers/naming-consistency.test.ts: + * - snake_case properties detected as alternate convention + * - Confidence level logic + * - Normalization when dimensions are excluded + */ + +import { describe, it, expect } from 'vitest'; +import { + analyzeNamingConsistency, + detectLibraryConventions, + detectLibraryEventPrefix, + detectLibraryCssPrefix, + scoreEventPrefixCoherence, + scorePropertyNamingConsistency, + scoreCSSCustomPropertyPrefixing, + scoreAttributePropertyCoherence, + type LibraryNamingConventions, +} from '../../../packages/core/src/handlers/analyzers/naming-consistency.js'; +import type { CemDeclaration } from '../../../packages/core/src/handlers/cem.js'; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function makeDecl(overrides: Partial = {}): CemDeclaration { + return { + kind: 'class', + name: 'TestComponent', + tagName: 'test-component', + ...overrides, + } as CemDeclaration; +} + +const NO_PREFIX_CONVENTIONS: LibraryNamingConventions = { + eventPrefix: null, + eventPrefixConfidence: 0, + cssPrefix: null, + cssPrefixConfidence: 0, +}; + +const HX_CONVENTIONS: LibraryNamingConventions = { + eventPrefix: 'hx-', + eventPrefixConfidence: 1.0, + cssPrefix: '--hx-', + cssPrefixConfidence: 1.0, +}; + +// ─── detectLibraryEventPrefix ───────────────────────────────────────────────── + +describe('detectLibraryEventPrefix', () => { + it('returns null prefix for empty declarations array', () => { + const result = detectLibraryEventPrefix([]); + expect(result.prefix).toBeNull(); + expect(result.confidence).toBe(0); + }); + + it('returns null prefix when no events exist across library', () => { + const decls = [makeDecl({ events: [] }), makeDecl({ events: [] })]; + const result = detectLibraryEventPrefix(decls); + expect(result.prefix).toBeNull(); + }); + + it('detects prefix when majority of events share it', () => { + const decls = [ + makeDecl({ events: [{ name: 'sl-click' }, { name: 'sl-focus' }] }), + makeDecl({ events: [{ name: 'sl-change' }, { name: 'sl-blur' }] }), + ]; + const result = detectLibraryEventPrefix(decls); + expect(result.prefix).toBe('sl-'); + expect(result.confidence).toBeGreaterThanOrEqual(0.5); + }); + + it('returns null when events have no common prefix (below 50% threshold)', () => { + const decls = [ + makeDecl({ events: [{ name: 'click' }, { name: 'change' }, { name: 'sl-focus' }] }), + ]; + const result = detectLibraryEventPrefix(decls); + // Only 1 of 3 events has 'sl-' prefix → below 50% → null + expect(result.prefix).toBeNull(); + }); + + it('aggregates events across multiple declarations', () => { + const decls = [ + makeDecl({ events: [{ name: 'ion-click' }] }), + makeDecl({ events: [{ name: 'ion-change' }] }), + makeDecl({ events: [{ name: 'ion-focus' }] }), + ]; + const result = detectLibraryEventPrefix(decls); + expect(result.prefix).toBe('ion-'); + }); +}); + +// ─── detectLibraryCssPrefix ─────────────────────────────────────────────────── + +describe('detectLibraryCssPrefix', () => { + it('returns null prefix for empty declarations array', () => { + const result = detectLibraryCssPrefix([]); + expect(result.prefix).toBeNull(); + }); + + it('returns null prefix when no CSS properties exist', () => { + const decls = [makeDecl({ cssProperties: [] })]; + const result = detectLibraryCssPrefix(decls); + expect(result.prefix).toBeNull(); + }); + + it('detects -- prefix from CSS properties', () => { + const decls = [ + makeDecl({ cssProperties: [{ name: '--sl-color-primary' }, { name: '--sl-spacing-base' }] }), + makeDecl({ cssProperties: [{ name: '--sl-font-size' }, { name: '--sl-border-radius' }] }), + ]; + const result = detectLibraryCssPrefix(decls); + expect(result.prefix).toBe('--sl-'); + }); + + it('adds -- prefix back to detected prefix', () => { + const decls = [ + makeDecl({ + cssProperties: [{ name: '--hx-button-color' }, { name: '--hx-button-bg' }], + }), + ]; + const result = detectLibraryCssPrefix(decls); + expect(result.prefix?.startsWith('--')).toBe(true); + }); +}); + +// ─── detectLibraryConventions ──────────────────────────────────────────────── + +describe('detectLibraryConventions', () => { + it('detects both event and CSS prefixes together', () => { + const decls = [ + makeDecl({ + events: [{ name: 'md-click' }, { name: 'md-change' }], + cssProperties: [{ name: '--md-color-primary' }, { name: '--md-color-secondary' }], + }), + makeDecl({ + events: [{ name: 'md-focus' }, { name: 'md-blur' }], + cssProperties: [{ name: '--md-spacing-md' }], + }), + ]; + const result = detectLibraryConventions(decls); + expect(result.eventPrefix).toBe('md-'); + expect(result.cssPrefix).toBe('--md-'); + expect(result.eventPrefixConfidence).toBeGreaterThanOrEqual(0.5); + expect(result.cssPrefixConfidence).toBeGreaterThanOrEqual(0.5); + }); + + it('returns null prefixes when library has no consistent conventions', () => { + const decls = [ + makeDecl({ + events: [{ name: 'click' }, { name: 'change' }], + cssProperties: [], + }), + ]; + const result = detectLibraryConventions(decls); + expect(result.eventPrefix).toBeNull(); + expect(result.cssPrefix).toBeNull(); + }); +}); + +// ─── scoreEventPrefixCoherence ──────────────────────────────────────────────── + +describe('scoreEventPrefixCoherence', () => { + it('returns null for component with no events', () => { + const decl = makeDecl({ events: [] }); + expect(scoreEventPrefixCoherence(decl, 'sl-')).toBeNull(); + }); + + it('returns null when events is undefined', () => { + const decl = makeDecl({}); + // Default events are undefined → treated as empty + expect(scoreEventPrefixCoherence(decl, 'sl-')).toBeNull(); + }); + + it('gives full 30 points when all events match prefix', () => { + const decl = makeDecl({ + events: [{ name: 'hx-click' }, { name: 'hx-focus' }, { name: 'hx-change' }], + }); + const result = scoreEventPrefixCoherence(decl, 'hx-'); + expect(result!.score).toBe(30); + expect(result!.subMetric.maxScore).toBe(30); + }); + + it('gives 0 points when no events match prefix', () => { + const decl = makeDecl({ + events: [{ name: 'click' }, { name: 'focus' }], + }); + const result = scoreEventPrefixCoherence(decl, 'hx-'); + expect(result!.score).toBe(0); + }); + + it('scores proportionally for partial prefix match', () => { + const decl = makeDecl({ + events: [ + { name: 'sl-click' }, + { name: 'sl-focus' }, + { name: 'custom-event' }, // doesn't match + ], + }); + const result = scoreEventPrefixCoherence(decl, 'sl-'); + // 2 of 3 match → round(2/3 * 30) = 20 + expect(result!.score).toBe(20); + }); + + it('gives full marks when no library prefix is detected (no penalty)', () => { + const decl = makeDecl({ events: [{ name: 'click' }, { name: 'change' }] }); + const result = scoreEventPrefixCoherence(decl, null); + expect(result!.score).toBe(30); + expect(result!.subMetric.note).toContain('not scored'); + }); + + it('subMetric name is "Event prefix coherence"', () => { + const decl = makeDecl({ events: [{ name: 'hx-click' }] }); + const result = scoreEventPrefixCoherence(decl, 'hx-'); + expect(result!.subMetric.name).toBe('Event prefix coherence'); + }); +}); + +// ─── scorePropertyNamingConsistency ────────────────────────────────────────── + +describe('scorePropertyNamingConsistency', () => { + it('gives full 25 points for components with no fields', () => { + const decl = makeDecl({ members: [] }); + const result = scorePropertyNamingConsistency(decl); + expect(result.score).toBe(25); + expect(result.subMetric.note).toContain('trivially'); + }); + + it('gives full 25 for all camelCase properties', () => { + const decl = makeDecl({ + members: [ + { kind: 'field', name: 'value' }, + { kind: 'field', name: 'isDisabled' }, + { kind: 'field', name: 'maxLength' }, + ], + }); + const result = scorePropertyNamingConsistency(decl); + expect(result.score).toBe(25); + }); + + it('gives full 25 for all snake_case properties (alternate valid convention)', () => { + const decl = makeDecl({ + members: [ + { kind: 'field', name: 'is_disabled' }, + { kind: 'field', name: 'max_length' }, + { kind: 'field', name: 'default_value' }, + ], + }); + const result = scorePropertyNamingConsistency(decl); + // All snake_case → consistent → full score + expect(result.score).toBe(25); + }); + + it('scores mixed conventions proportionally using dominant convention', () => { + const decl = makeDecl({ + members: [ + { kind: 'field', name: 'value' }, // camelCase (single word) + { kind: 'field', name: 'maxLength' }, // camelCase + { kind: 'field', name: 'is_broken' }, // snake_case + { kind: 'field', name: 'CONSTANT' }, // neither (all caps) + ], + }); + const result = scorePropertyNamingConsistency(decl); + // 2 camelCase, 1 snake_case, 1 neither → camelCase dominant → 2/4 consistent + // round(2/4 * 25) = 13 + expect(result.score).toBe(13); + }); + + it('treats single-word lowercase names as camelCase', () => { + const decl = makeDecl({ + members: [ + { kind: 'field', name: 'value' }, + { kind: 'field', name: 'label' }, + { kind: 'field', name: 'open' }, + ], + }); + const result = scorePropertyNamingConsistency(decl); + expect(result.score).toBe(25); + }); + + it('subMetric name is "Property naming consistency"', () => { + const decl = makeDecl({ members: [{ kind: 'field', name: 'value' }] }); + const result = scorePropertyNamingConsistency(decl); + expect(result.subMetric.name).toBe('Property naming consistency'); + }); + + it('ignores method members (only scores fields)', () => { + const decl = makeDecl({ + members: [ + { kind: 'method', name: 'RESET' }, // method with bad casing + { kind: 'field', name: 'value' }, // camelCase field + ], + }); + const result = scorePropertyNamingConsistency(decl); + // Only 1 field exists, it's camelCase → 25/25 + expect(result.score).toBe(25); + }); +}); + +// ─── scoreCSSCustomPropertyPrefixing ───────────────────────────────────────── + +describe('scoreCSSCustomPropertyPrefixing', () => { + it('returns null for component with no CSS properties', () => { + const decl = makeDecl({ cssProperties: [] }); + expect(scoreCSSCustomPropertyPrefixing(decl, '--hx-')).toBeNull(); + }); + + it('gives full 25 when all CSS properties match prefix', () => { + const decl = makeDecl({ + cssProperties: [{ name: '--hx-color-primary' }, { name: '--hx-spacing-lg' }], + }); + const result = scoreCSSCustomPropertyPrefixing(decl, '--hx-'); + expect(result!.score).toBe(25); + }); + + it('gives 0 when no CSS properties match prefix', () => { + const decl = makeDecl({ + cssProperties: [{ name: '--other-color' }, { name: '--wrong-spacing' }], + }); + const result = scoreCSSCustomPropertyPrefixing(decl, '--hx-'); + expect(result!.score).toBe(0); + }); + + it('gives full marks when no CSS prefix detected (no penalty)', () => { + const decl = makeDecl({ cssProperties: [{ name: '--color-primary' }] }); + const result = scoreCSSCustomPropertyPrefixing(decl, null); + expect(result!.score).toBe(25); + expect(result!.subMetric.note).toContain('not scored'); + }); + + it('scores proportionally for partial prefix match', () => { + const decl = makeDecl({ + cssProperties: [ + { name: '--sl-color-primary' }, + { name: '--sl-spacing-base' }, + { name: '--custom-override' }, // doesn't match + ], + }); + const result = scoreCSSCustomPropertyPrefixing(decl, '--sl-'); + // 2 of 3 match → round(2/3 * 25) = 17 + expect(result!.score).toBe(17); + }); +}); + +// ─── scoreAttributePropertyCoherence ───────────────────────────────────────── + +describe('scoreAttributePropertyCoherence', () => { + it('gives full 20 points when no attribute-mapped properties exist', () => { + const decl = makeDecl({ + members: [ + { kind: 'field', name: 'value' }, // no attribute + ], + }); + const result = scoreAttributePropertyCoherence(decl); + expect(result.score).toBe(20); + expect(result.subMetric.note).toContain('trivially'); + }); + + it('gives full 20 for correct kebab-case attribute mappings', () => { + const decl = makeDecl({ + members: [ + { kind: 'field', name: 'maxLength', attribute: 'max-length' }, + { kind: 'field', name: 'isDisabled', attribute: 'is-disabled' }, + { kind: 'field', name: 'value', attribute: 'value' }, // single word + ], + }); + const result = scoreAttributePropertyCoherence(decl); + expect(result.score).toBe(20); + }); + + it('gives 0 for completely incoherent attribute mappings', () => { + const decl = makeDecl({ + members: [ + { kind: 'field', name: 'maxLength', attribute: 'maxlength' }, // should be max-length + { kind: 'field', name: 'isDisabled', attribute: 'disabled' }, // should be is-disabled + ], + }); + const result = scoreAttributePropertyCoherence(decl); + expect(result.score).toBe(0); + }); + + it('scores proportionally for mixed coherence', () => { + const decl = makeDecl({ + members: [ + { kind: 'field', name: 'value', attribute: 'value' }, // correct + { kind: 'field', name: 'maxLength', attribute: 'max-length' }, // correct + { kind: 'field', name: 'isOpen', attribute: 'isopen' }, // incorrect + { kind: 'field', name: 'onClick', attribute: 'onclick' }, // incorrect + ], + }); + const result = scoreAttributePropertyCoherence(decl); + // 2 of 4 coherent → round(2/4 * 20) = 10 + expect(result.score).toBe(10); + }); + + it('subMetric name is "Attribute-property coherence"', () => { + const decl = makeDecl({ members: [{ kind: 'field', name: 'value', attribute: 'value' }] }); + const result = scoreAttributePropertyCoherence(decl); + expect(result.subMetric.name).toBe('Attribute-property coherence'); + }); +}); + +// ─── analyzeNamingConsistency ──────────────────────────────────────────────── + +describe('analyzeNamingConsistency', () => { + it('returns a result with score, confidence, subMetrics', () => { + const decl = makeDecl({ + events: [{ name: 'hx-click' }], + members: [{ kind: 'field', name: 'value', attribute: 'value' }], + }); + const result = analyzeNamingConsistency(decl, HX_CONVENTIONS); + expect(result).not.toBeNull(); + expect(result).toHaveProperty('score'); + expect(result).toHaveProperty('confidence'); + expect(result).toHaveProperty('subMetrics'); + }); + + it('scores 100 for fully consistent component with known conventions', () => { + const decl = makeDecl({ + events: [{ name: 'hx-click' }, { name: 'hx-focus' }], + members: [ + { kind: 'field', name: 'value', attribute: 'value' }, + { kind: 'field', name: 'isDisabled', attribute: 'is-disabled' }, + ], + cssProperties: [{ name: '--hx-button-color' }, { name: '--hx-button-bg' }], + }); + const result = analyzeNamingConsistency(decl, HX_CONVENTIONS); + expect(result!.score).toBe(100); + }); + + it('scores low for inconsistent component with known conventions', () => { + const decl = makeDecl({ + events: [{ name: 'CLICK' }, { name: 'FOCUS' }], // no hx- prefix + members: [ + { kind: 'field', name: 'IS_VALUE', attribute: 'IS_VALUE' }, // inconsistent + ], + cssProperties: [{ name: '--wrong-prefix-color' }], // no hx- prefix + }); + const result = analyzeNamingConsistency(decl, HX_CONVENTIONS); + expect(result!.score).toBeLessThan(30); + }); + + it('assigns verified confidence when no prefix conventions exist', () => { + // With no prefix to detect, it's pure naming analysis → verified + const decl = makeDecl({ + members: [{ kind: 'field', name: 'value', attribute: 'value' }], + }); + const result = analyzeNamingConsistency(decl, NO_PREFIX_CONVENTIONS); + expect(result!.confidence).toBe('verified'); + }); + + it('assigns verified confidence when prefix confidence is high (> 0.7)', () => { + const decl = makeDecl({ + events: [{ name: 'hx-click' }], + members: [{ kind: 'field', name: 'value', attribute: 'value' }], + }); + const highConfConventions: LibraryNamingConventions = { + ...HX_CONVENTIONS, + eventPrefixConfidence: 0.9, + }; + const result = analyzeNamingConsistency(decl, highConfConventions); + expect(result!.confidence).toBe('verified'); + }); + + it('assigns heuristic confidence when prefix confidence is medium (0-0.7)', () => { + const decl = makeDecl({ + events: [{ name: 'hx-click' }], + members: [{ kind: 'field', name: 'value', attribute: 'value' }], + }); + const medConfConventions: LibraryNamingConventions = { + eventPrefix: 'hx-', + eventPrefixConfidence: 0.6, + cssPrefix: null, + cssPrefixConfidence: 0, + }; + const result = analyzeNamingConsistency(decl, medConfConventions); + expect(result!.confidence).toBe('heuristic'); + }); + + it('normalizes score to 0-100 when some dimensions are excluded', () => { + // No events, no CSS → only property naming (25) + attribute coherence (20) apply + const decl = makeDecl({ + members: [ + { kind: 'field', name: 'value', attribute: 'value' }, + { kind: 'field', name: 'label', attribute: 'label' }, + ], + }); + const result = analyzeNamingConsistency(decl, NO_PREFIX_CONVENTIONS); + expect(result!.score).toBeGreaterThanOrEqual(0); + expect(result!.score).toBeLessThanOrEqual(100); + expect(result!.score).toBe(100); // both dimensions fully satisfied + }); + + it('includes event prefix sub-metric only when events exist', () => { + const noEventDecl = makeDecl({ + members: [{ kind: 'field', name: 'value', attribute: 'value' }], + }); + const withEventDecl = makeDecl({ + events: [{ name: 'hx-click' }], + members: [{ kind: 'field', name: 'value', attribute: 'value' }], + }); + + const noEventResult = analyzeNamingConsistency(noEventDecl, HX_CONVENTIONS); + const withEventResult = analyzeNamingConsistency(withEventDecl, HX_CONVENTIONS); + + const noEventNames = noEventResult!.subMetrics.map((m) => m.name); + const withEventNames = withEventResult!.subMetrics.map((m) => m.name); + + expect(noEventNames).not.toContain('Event prefix coherence'); + expect(withEventNames).toContain('Event prefix coherence'); + }); + + it('includes CSS prefix sub-metric only when CSS properties exist', () => { + const noCssDecl = makeDecl({ + members: [{ kind: 'field', name: 'value', attribute: 'value' }], + }); + const withCssDecl = makeDecl({ + cssProperties: [{ name: '--hx-color' }], + members: [{ kind: 'field', name: 'value', attribute: 'value' }], + }); + + const noCssResult = analyzeNamingConsistency(noCssDecl, HX_CONVENTIONS); + const withCssResult = analyzeNamingConsistency(withCssDecl, HX_CONVENTIONS); + + const noCssNames = noCssResult!.subMetrics.map((m) => m.name); + const withCssNames = withCssResult!.subMetrics.map((m) => m.name); + + expect(noCssNames).not.toContain('CSS custom property prefixing'); + expect(withCssNames).toContain('CSS custom property prefixing'); + }); +}); diff --git a/tests/handlers/analyzers/slot-architecture.test.ts b/tests/handlers/analyzers/slot-architecture.test.ts new file mode 100644 index 0000000..dde7480 --- /dev/null +++ b/tests/handlers/analyzers/slot-architecture.test.ts @@ -0,0 +1,376 @@ +/** + * Slot Architecture Analyzer — unit tests (analyzers/ subdirectory) + * + * Tests analyzeSlotArchitecture() covering additional edge cases beyond + * the existing tests/handlers/slot-architecture.test.ts: + * - Default slot scoring (25 pts) + * - Named slot documentation (30 pts) + * - Slot type constraints (20 pts) + * - Slot-property coherence (25 pts) + * - kebab-to-camel name resolution for coherence pairs + * - jsdocTags @slot annotation detection + * - Multiple coherence pairs with partial scoring + */ + +import { describe, it, expect } from 'vitest'; +import { analyzeSlotArchitecture } from '../../../packages/core/src/handlers/analyzers/slot-architecture.js'; +import type { CemDeclaration } from '../../../packages/core/src/handlers/cem.js'; + +// ─── Fixtures ───────────────────────────────────────────────────────────────── + +const DEFAULT_SLOT_WITH_DESC: CemDeclaration = { + kind: 'class', + name: 'DefaultWithDesc', + tagName: 'default-with-desc', + slots: [{ name: '', description: 'Main content area.' }], +}; + +const DEFAULT_SLOT_NO_DESC: CemDeclaration = { + kind: 'class', + name: 'DefaultNoDesc', + tagName: 'default-no-desc', + slots: [{ name: '' }], +}; + +const NAMED_DEFAULT_SLOT: CemDeclaration = { + kind: 'class', + name: 'NamedDefault', + tagName: 'named-default', + slots: [{ name: 'default', description: 'Default content using named "default" slot.' }], +}; + +const FULLY_DOCUMENTED_SLOTS: CemDeclaration = { + kind: 'class', + name: 'FullyDocumented', + tagName: 'fully-documented', + slots: [ + { name: '', description: 'Primary content.' }, + { name: 'header', description: 'The header section.' }, + { name: 'footer', description: 'The footer section.' }, + { name: 'aside', description: 'Supplemental content.' }, + ], + members: [ + { kind: 'field', name: 'header', type: { text: 'string' }, description: 'Header text.' }, + { kind: 'field', name: 'footer', type: { text: 'string' }, description: 'Footer text.' }, + ], +}; + +const JSDOC_SLOT_DECL: CemDeclaration = { + kind: 'class', + name: 'JsdocSlots', + tagName: 'jsdoc-slots', + description: 'Component with JSDoc @slot annotations.', + jsdocTags: [ + { + name: 'slot', + description: 'icon - An or element to display as the icon.', + }, + { + name: 'slot', + description: 'default - Main content, accepts any HTMLElement.', + }, + ], + slots: [ + { name: '', description: 'Main content.' }, + { name: 'icon', description: 'Icon slot.' }, + ], +}; + +const TYPE_CONSTRAINT_DECL: CemDeclaration = { + kind: 'class', + name: 'TypeConstraints', + tagName: 'type-constraints', + slots: [ + { name: '', description: 'Accepts any HTML elements.' }, + { name: 'icon', description: 'An or element.' }, // has type constraint + { name: 'actions', description: 'Button elements for actions.' }, // "elements" keyword + { name: 'avatar', description: 'An HTMLImageElement for the avatar.' }, // HTMLElement type + { name: 'footer', description: 'Footer content.' }, // no type constraint + ], +}; + +const KEBAB_TO_CAMEL_DECL: CemDeclaration = { + kind: 'class', + name: 'KebabToCamel', + tagName: 'kebab-to-camel', + slots: [ + { name: '', description: 'Default content.' }, + { name: 'help-text', description: 'Help text slot.' }, // should resolve to helpText + { name: 'error-message', description: 'Error message slot.' }, // should resolve to errorMessage + ], + members: [ + { kind: 'field', name: 'helpText', type: { text: 'string' }, description: 'Help text.' }, + { + kind: 'field', + name: 'errorMessage', + type: { text: 'string' }, + description: 'Error message.', + }, + ], +}; + +const NO_SLOTS_DECL: CemDeclaration = { + kind: 'class', + name: 'NoSlots', + tagName: 'no-slots', + members: [{ kind: 'field', name: 'count', type: { text: 'number' } }], +}; + +const MULTI_COHERENCE_DECL: CemDeclaration = { + kind: 'class', + name: 'MultiCoherence', + tagName: 'multi-coherence', + slots: [ + { name: '', description: 'Content.' }, + { name: 'label', description: 'Label slot.' }, + { name: 'icon', description: 'Icon slot.' }, + { name: 'footer', description: 'Footer slot.' }, + ], + members: [ + { kind: 'field', name: 'label', type: { text: 'string' }, description: 'The label.' }, + { kind: 'field', name: 'icon', type: { text: 'string' }, description: 'The icon.' }, + { kind: 'field', name: 'footer', type: { text: 'string' }, description: 'The footer.' }, + ], +}; + +// ─── Tests ───────────────────────────────────────────────────────────────────── + +describe('analyzeSlotArchitecture (additional coverage)', () => { + describe('null return cases', () => { + it('returns null for component with no slots', () => { + expect(analyzeSlotArchitecture(NO_SLOTS_DECL)).toBeNull(); + }); + + it('returns null when slots is undefined', () => { + const decl: CemDeclaration = { kind: 'class', name: 'X', tagName: 'x' }; + expect(analyzeSlotArchitecture(decl)).toBeNull(); + }); + + it('returns null when slots is an empty array', () => { + const decl: CemDeclaration = { kind: 'class', name: 'X', tagName: 'x', slots: [] }; + expect(analyzeSlotArchitecture(decl)).toBeNull(); + }); + }); + + describe('result structure', () => { + it('returns score, confidence, subMetrics, slots, coherencePairs', () => { + const result = analyzeSlotArchitecture(FULLY_DOCUMENTED_SLOTS); + expect(result).not.toBeNull(); + expect(result).toHaveProperty('score'); + expect(result).toHaveProperty('confidence'); + expect(result).toHaveProperty('subMetrics'); + expect(result).toHaveProperty('slots'); + expect(result).toHaveProperty('coherencePairs'); + }); + + it('confidence is always verified', () => { + expect(analyzeSlotArchitecture(DEFAULT_SLOT_WITH_DESC)!.confidence).toBe('verified'); + expect(analyzeSlotArchitecture(FULLY_DOCUMENTED_SLOTS)!.confidence).toBe('verified'); + }); + + it('has exactly 4 sub-metrics', () => { + const result = analyzeSlotArchitecture(FULLY_DOCUMENTED_SLOTS); + expect(result!.subMetrics).toHaveLength(4); + }); + + it('sub-metric names match expected categories', () => { + const result = analyzeSlotArchitecture(FULLY_DOCUMENTED_SLOTS); + const names = result!.subMetrics.map((m) => m.name); + expect(names).toContain('Default slot documentation'); + expect(names).toContain('Named slot documentation'); + expect(names).toContain('Slot type constraints'); + expect(names).toContain('Slot-property coherence'); + }); + }); + + describe('default slot scoring', () => { + it('awards 25 points for default slot (empty name) with description', () => { + const result = analyzeSlotArchitecture(DEFAULT_SLOT_WITH_DESC); + const metric = result!.subMetrics.find((m) => m.name === 'Default slot documentation'); + expect(metric!.score).toBe(25); + }); + + it('awards 15 points for default slot without description', () => { + const result = analyzeSlotArchitecture(DEFAULT_SLOT_NO_DESC); + const metric = result!.subMetrics.find((m) => m.name === 'Default slot documentation'); + expect(metric!.score).toBe(15); + }); + + it('recognizes "default" as the default slot name', () => { + const result = analyzeSlotArchitecture(NAMED_DEFAULT_SLOT); + const metric = result!.subMetrics.find((m) => m.name === 'Default slot documentation'); + expect(metric!.score).toBe(25); // has description → full 25 + }); + + it('awards 0 points when no default slot exists', () => { + const decl: CemDeclaration = { + kind: 'class', + name: 'OnlyNamed', + tagName: 'only-named', + slots: [ + { name: 'header', description: 'Header.' }, + { name: 'footer', description: 'Footer.' }, + ], + }; + const result = analyzeSlotArchitecture(decl); + const metric = result!.subMetrics.find((m) => m.name === 'Default slot documentation'); + expect(metric!.score).toBe(0); + }); + }); + + describe('named slot documentation', () => { + it('awards 30 points when all named slots have descriptions', () => { + const result = analyzeSlotArchitecture(FULLY_DOCUMENTED_SLOTS); + const metric = result!.subMetrics.find((m) => m.name === 'Named slot documentation'); + expect(metric!.score).toBe(30); + }); + + it('awards full 30 points when component has only a default slot (trivially satisfied)', () => { + const result = analyzeSlotArchitecture(DEFAULT_SLOT_WITH_DESC); + const metric = result!.subMetrics.find((m) => m.name === 'Named slot documentation'); + expect(metric!.score).toBe(30); + }); + + it('scores proportionally for partial named slot documentation', () => { + const decl: CemDeclaration = { + kind: 'class', + name: 'PartialNamed', + tagName: 'partial-named', + slots: [ + { name: '', description: 'Content.' }, + { name: 'header', description: 'The header.' }, // documented + { name: 'footer' }, // undocumented + { name: 'aside' }, // undocumented + ], + }; + const result = analyzeSlotArchitecture(decl); + const metric = result!.subMetrics.find((m) => m.name === 'Named slot documentation'); + // 1 of 3 named slots documented → round(1/3 * 30) = 10 + expect(metric!.score).toBe(10); + }); + }); + + describe('slot type constraints', () => { + it('detects HTML element tags in slot descriptions like ', () => { + const result = analyzeSlotArchitecture(TYPE_CONSTRAINT_DECL); + const iconSlot = result!.slots.find((s) => s.name === 'icon'); + expect(iconSlot!.hasTypeConstraint).toBe(true); + }); + + it('detects "elements" keyword in slot description', () => { + const result = analyzeSlotArchitecture(TYPE_CONSTRAINT_DECL); + const actionsSlot = result!.slots.find((s) => s.name === 'actions'); + expect(actionsSlot!.hasTypeConstraint).toBe(true); + }); + + it('detects HTMLElement type mentions in slot description', () => { + const result = analyzeSlotArchitecture(TYPE_CONSTRAINT_DECL); + const avatarSlot = result!.slots.find((s) => s.name === 'avatar'); + expect(avatarSlot!.hasTypeConstraint).toBe(true); + }); + + it('does not detect type constraint in generic descriptions', () => { + const result = analyzeSlotArchitecture(TYPE_CONSTRAINT_DECL); + const footerSlot = result!.slots.find((s) => s.name === 'footer'); + expect(footerSlot!.hasTypeConstraint).toBe(false); + }); + + it('detects jsdocTags @slot with type info', () => { + const result = analyzeSlotArchitecture(JSDOC_SLOT_DECL); + // icon slot should have type constraint from jsdocTags + const iconSlot = result!.slots.find((s) => s.name === 'icon'); + // The jsdocTag references 'icon' and has '' → should detect + expect(iconSlot).toBeDefined(); + }); + }); + + describe('kebab-to-camelCase coherence resolution', () => { + it('resolves kebab-case slot names to camelCase property names', () => { + const result = analyzeSlotArchitecture(KEBAB_TO_CAMEL_DECL); + expect(result).not.toBeNull(); + // help-text → helpText, error-message → errorMessage + const helpPair = result!.coherencePairs.find((p) => p.slotName === 'help-text'); + const errorPair = result!.coherencePairs.find((p) => p.slotName === 'error-message'); + expect(helpPair).toBeDefined(); + expect(errorPair).toBeDefined(); + }); + + it('marks pairs as coherent when both slot and property are documented', () => { + const result = analyzeSlotArchitecture(KEBAB_TO_CAMEL_DECL); + const helpPair = result!.coherencePairs.find((p) => p.slotName === 'help-text'); + expect(helpPair!.coherent).toBe(true); + }); + }); + + describe('slot-property coherence scoring', () => { + it('awards full 25 points when all pairs are fully coherent', () => { + const result = analyzeSlotArchitecture(MULTI_COHERENCE_DECL); + const metric = result!.subMetrics.find((m) => m.name === 'Slot-property coherence'); + expect(metric!.score).toBe(25); + }); + + it('awards full 25 points when no coherence pairs exist (trivially satisfied)', () => { + const decl: CemDeclaration = { + kind: 'class', + name: 'NoPairs', + tagName: 'no-pairs', + slots: [ + { name: '', description: 'Content.' }, + { name: 'suffix', description: 'Suffix area.' }, + { name: 'prefix', description: 'Prefix area.' }, + ], + // No members with matching names + }; + const result = analyzeSlotArchitecture(decl); + const metric = result!.subMetrics.find((m) => m.name === 'Slot-property coherence'); + expect(metric!.score).toBe(25); + }); + + it('identifies multiple coherence pairs', () => { + const result = analyzeSlotArchitecture(MULTI_COHERENCE_DECL); + expect(result!.coherencePairs.length).toBe(3); // label, icon, footer + }); + }); + + describe('slot analyses array', () => { + it('includes isDefault flag set correctly', () => { + const result = analyzeSlotArchitecture(FULLY_DOCUMENTED_SLOTS); + const defaultSlot = result!.slots.find((s) => s.isDefault); + expect(defaultSlot).toBeDefined(); + const namedSlots = result!.slots.filter((s) => !s.isDefault); + expect(namedSlots.length).toBe(3); // header, footer, aside + }); + + it('slot name stored as empty string for default slot', () => { + const result = analyzeSlotArchitecture(DEFAULT_SLOT_WITH_DESC); + const defaultSlot = result!.slots.find((s) => s.isDefault); + expect(defaultSlot!.name).toBe(''); + }); + }); + + describe('score bounds', () => { + it('total score is always in range [0, 100]', () => { + const decls = [ + DEFAULT_SLOT_WITH_DESC, + DEFAULT_SLOT_NO_DESC, + FULLY_DOCUMENTED_SLOTS, + TYPE_CONSTRAINT_DECL, + KEBAB_TO_CAMEL_DECL, + MULTI_COHERENCE_DECL, + ]; + for (const decl of decls) { + const result = analyzeSlotArchitecture(decl); + if (result) { + expect(result.score).toBeGreaterThanOrEqual(0); + expect(result.score).toBeLessThanOrEqual(100); + } + } + }); + + it('sub-metric maxScores sum to 100', () => { + const result = analyzeSlotArchitecture(FULLY_DOCUMENTED_SLOTS); + const maxSum = result!.subMetrics.reduce((acc, m) => acc + m.maxScore, 0); + expect(maxSum).toBe(100); + }); + }); +}); diff --git a/tests/handlers/analyzers/source-accessibility.test.ts b/tests/handlers/analyzers/source-accessibility.test.ts new file mode 100644 index 0000000..5c50681 --- /dev/null +++ b/tests/handlers/analyzers/source-accessibility.test.ts @@ -0,0 +1,477 @@ +/** + * Source Accessibility Analyzer — unit tests (analyzers/ subdirectory) + * + * Tests the pure/sync exports from source-accessibility.ts: + * - scanSourceForA11yPatterns() + * - scoreSourceMarkers() + * - isInteractiveComponent() + * - PATTERNS export structure + * - resolveComponentSourceFilePath() + * + * Focuses on additional edge cases beyond tests/handlers/source-accessibility.test.ts + */ + +import { describe, it, expect } from 'vitest'; +import { + scanSourceForA11yPatterns, + scoreSourceMarkers, + isInteractiveComponent, + resolveComponentSourceFilePath, + PATTERNS, + type SourceA11yMarkers, +} from '../../../packages/core/src/handlers/analyzers/source-accessibility.js'; +import type { CemDeclaration } from '../../../packages/core/src/handlers/cem.js'; +import { resolve } from 'node:path'; + +// ─── Source Fixtures ────────────────────────────────────────────────────────── + +const ARIA_ONLY_SOURCE = ` +class MyIcon extends HTMLElement { + connectedCallback() { + this.setAttribute('aria-hidden', 'true'); + this.setAttribute('aria-label', this.getAttribute('label') || ''); + } +} +`; + +const ROLE_ONLY_SOURCE = ` +class MySeparator extends HTMLElement { + connectedCallback() { + this.setAttribute('role', 'separator'); + } +} +`; + +const KEYBOARD_SOURCE = ` +class MyDropdown extends LitElement { + handleKeyDown(e) { + if (e.key === 'Escape') this.close(); + if (e.key === 'ArrowDown') this.focusNext(); + } +} +`; + +const FOCUS_SOURCE = ` +class MyFocusable extends LitElement { + focus() { + this.shadowRoot?.querySelector('button')?.focus(); + } + get tabindex() { return 0; } +} +`; + +const FORM_INTERNALS_SOURCE = ` +class MyInput extends LitElement { + static formAssociated = true; + constructor() { + super(); + this.#internals = this.attachInternals(); + } + setFormValue(value) { + this.#internals.setFormValue(value); + } +} +`; + +const LIVE_REGION_SOURCE = ` +class MyAlert extends LitElement { + render() { + return html\`
\${this.message}
\`; + } +} +`; + +const SCREEN_READER_SOURCE = ` +class MyBadge extends LitElement { + render() { + return html\` + + Count: \${this.count} + \${this.count} + + \`; + } +} +`; + +const ARIA_VIA_SETATTRIBUTE_SOURCE = ` +class MyEl extends HTMLElement { + connectedCallback() { + this.setAttribute('aria-expanded', 'false'); + this.setAttribute('role', 'button'); + this.addEventListener('keydown', this.handleKey); + } + focus() { super.focus(); } +} +`; + +const EMPTY_SOURCE = ` +class EmptyEl extends HTMLElement {} +`; + +const TABINDEX_SOURCE = ` +class MyTabEl extends LitElement { + tabindex = 0; + connectedCallback() { + this.tabindex = 0; + } +} +`; + +// ─── Tests ───────────────────────────────────────────────────────────────────── + +describe('PATTERNS export', () => { + it('exports all 7 pattern categories', () => { + const keys = Object.keys(PATTERNS); + expect(keys).toHaveLength(7); + }); + + it('contains all expected keys', () => { + expect(PATTERNS).toHaveProperty('ariaBindings'); + expect(PATTERNS).toHaveProperty('roleAssignments'); + expect(PATTERNS).toHaveProperty('keyboardHandling'); + expect(PATTERNS).toHaveProperty('focusManagement'); + expect(PATTERNS).toHaveProperty('formInternals'); + expect(PATTERNS).toHaveProperty('liveRegions'); + expect(PATTERNS).toHaveProperty('screenReaderSupport'); + }); + + it('each category has at least 2 patterns', () => { + for (const [key, patterns] of Object.entries(PATTERNS)) { + expect(patterns.length, `${key} should have >= 2 patterns`).toBeGreaterThanOrEqual(2); + } + }); + + it('all patterns are RegExp instances', () => { + for (const patterns of Object.values(PATTERNS)) { + for (const pattern of patterns) { + expect(pattern).toBeInstanceOf(RegExp); + } + } + }); +}); + +describe('scanSourceForA11yPatterns', () => { + it('returns all-false SourceA11yMarkers for empty source', () => { + const markers = scanSourceForA11yPatterns(EMPTY_SOURCE); + expect(markers.ariaBindings).toBe(false); + expect(markers.roleAssignments).toBe(false); + expect(markers.keyboardHandling).toBe(false); + expect(markers.focusManagement).toBe(false); + expect(markers.formInternals).toBe(false); + expect(markers.liveRegions).toBe(false); + expect(markers.screenReaderSupport).toBe(false); + }); + + it('detects ariaBindings from aria- attributes', () => { + const markers = scanSourceForA11yPatterns(ARIA_ONLY_SOURCE); + expect(markers.ariaBindings).toBe(true); + expect(markers.screenReaderSupport).toBe(true); // aria-hidden detected + }); + + it('detects roleAssignments from setAttribute role', () => { + const markers = scanSourceForA11yPatterns(ROLE_ONLY_SOURCE); + expect(markers.roleAssignments).toBe(true); + }); + + it('detects keyboardHandling from key names', () => { + const markers = scanSourceForA11yPatterns(KEYBOARD_SOURCE); + expect(markers.keyboardHandling).toBe(true); + }); + + it('detects focusManagement from .focus() calls', () => { + const markers = scanSourceForA11yPatterns(FOCUS_SOURCE); + expect(markers.focusManagement).toBe(true); + }); + + it('detects focusManagement from tabindex attribute', () => { + const markers = scanSourceForA11yPatterns(TABINDEX_SOURCE); + expect(markers.focusManagement).toBe(true); + }); + + it('detects formInternals from attachInternals and formAssociated', () => { + const markers = scanSourceForA11yPatterns(FORM_INTERNALS_SOURCE); + expect(markers.formInternals).toBe(true); + }); + + it('detects liveRegions from aria-live and role=alert', () => { + const markers = scanSourceForA11yPatterns(LIVE_REGION_SOURCE); + expect(markers.liveRegions).toBe(true); + expect(markers.ariaBindings).toBe(true); + }); + + it('detects screenReaderSupport from aria-labelledby and aria-describedby', () => { + const markers = scanSourceForA11yPatterns(SCREEN_READER_SOURCE); + expect(markers.screenReaderSupport).toBe(true); + }); + + it('detects screenReaderSupport from .sr-only class', () => { + const markers = scanSourceForA11yPatterns(SCREEN_READER_SOURCE); + expect(markers.screenReaderSupport).toBe(true); + }); + + it('detects multiple patterns in comprehensive source', () => { + const markers = scanSourceForA11yPatterns(ARIA_VIA_SETATTRIBUTE_SOURCE); + expect(markers.ariaBindings).toBe(true); + expect(markers.roleAssignments).toBe(true); + expect(markers.keyboardHandling).toBe(true); + expect(markers.focusManagement).toBe(true); + }); + + it('returns a SourceA11yMarkers object with exactly 7 keys', () => { + const markers = scanSourceForA11yPatterns(EMPTY_SOURCE); + expect(Object.keys(markers)).toHaveLength(7); + }); + + it('handles empty string source', () => { + const markers = scanSourceForA11yPatterns(''); + expect(Object.values(markers).every((v) => v === false)).toBe(true); + }); +}); + +describe('scoreSourceMarkers', () => { + const ALL_TRUE: SourceA11yMarkers = { + ariaBindings: true, + roleAssignments: true, + keyboardHandling: true, + focusManagement: true, + formInternals: true, + liveRegions: true, + screenReaderSupport: true, + }; + + const ALL_FALSE: SourceA11yMarkers = { + ariaBindings: false, + roleAssignments: false, + keyboardHandling: false, + focusManagement: false, + formInternals: false, + liveRegions: false, + screenReaderSupport: false, + }; + + it('scores 100 when all markers are true', () => { + const result = scoreSourceMarkers(ALL_TRUE); + expect(result.score).toBe(100); + }); + + it('scores 0 when all markers are false', () => { + const result = scoreSourceMarkers(ALL_FALSE); + expect(result.score).toBe(0); + }); + + it('returns confidence as "heuristic"', () => { + expect(scoreSourceMarkers(ALL_TRUE).confidence).toBe('heuristic'); + expect(scoreSourceMarkers(ALL_FALSE).confidence).toBe('heuristic'); + }); + + it('returns 7 sub-metrics', () => { + const result = scoreSourceMarkers(ALL_TRUE); + expect(result.subMetrics).toHaveLength(7); + }); + + it('all sub-metric names have [Source] prefix', () => { + const result = scoreSourceMarkers(ALL_TRUE); + for (const metric of result.subMetrics) { + expect(metric.name.startsWith('[Source]')).toBe(true); + } + }); + + it('sub-metric scores are 0 when marker is false', () => { + const result = scoreSourceMarkers(ALL_FALSE); + for (const metric of result.subMetrics) { + expect(metric.score).toBe(0); + } + }); + + it('sub-metric scores equal maxScore when marker is true', () => { + const result = scoreSourceMarkers(ALL_TRUE); + for (const metric of result.subMetrics) { + expect(metric.score).toBe(metric.maxScore); + } + }); + + it('sub-metric maxScores sum to 100', () => { + const result = scoreSourceMarkers(ALL_TRUE); + const maxSum = result.subMetrics.reduce((acc, m) => acc + m.maxScore, 0); + expect(maxSum).toBe(100); + }); + + it('scores ARIA bindings as 25 points', () => { + const markers = { ...ALL_FALSE, ariaBindings: true }; + const result = scoreSourceMarkers(markers); + expect(result.score).toBe(25); + }); + + it('scores role assignments as 15 points', () => { + const markers = { ...ALL_FALSE, roleAssignments: true }; + const result = scoreSourceMarkers(markers); + expect(result.score).toBe(15); + }); + + it('scores keyboard handling as 20 points', () => { + const markers = { ...ALL_FALSE, keyboardHandling: true }; + const result = scoreSourceMarkers(markers); + expect(result.score).toBe(20); + }); + + it('scores focus management as 15 points', () => { + const markers = { ...ALL_FALSE, focusManagement: true }; + const result = scoreSourceMarkers(markers); + expect(result.score).toBe(15); + }); + + it('scores form internals as 10 points', () => { + const markers = { ...ALL_FALSE, formInternals: true }; + const result = scoreSourceMarkers(markers); + expect(result.score).toBe(10); + }); + + it('scores live regions as 10 points', () => { + const markers = { ...ALL_FALSE, liveRegions: true }; + const result = scoreSourceMarkers(markers); + expect(result.score).toBe(10); + }); + + it('scores screen reader support as 5 points', () => { + const markers = { ...ALL_FALSE, screenReaderSupport: true }; + const result = scoreSourceMarkers(markers); + expect(result.score).toBe(5); + }); + + it('partial scoring: aria (25) + keyboard (20) = 45', () => { + const markers = { ...ALL_FALSE, ariaBindings: true, keyboardHandling: true }; + const result = scoreSourceMarkers(markers); + expect(result.score).toBe(45); + }); +}); + +describe('isInteractiveComponent', () => { + const ALL_FALSE: SourceA11yMarkers = { + ariaBindings: false, + roleAssignments: false, + keyboardHandling: false, + focusManagement: false, + formInternals: false, + liveRegions: false, + screenReaderSupport: false, + }; + + const LAYOUT_DECL: CemDeclaration = { + kind: 'class', + name: 'MyLayout', + tagName: 'my-layout', + members: [{ kind: 'field', name: 'gap', type: { text: 'string' } }], + }; + + it('returns false for pure layout component (no interactive signals)', () => { + expect(isInteractiveComponent(ALL_FALSE, LAYOUT_DECL, '')).toBe(false); + }); + + it('returns true when source has keyboard handling', () => { + const markers = { ...ALL_FALSE, keyboardHandling: true }; + expect(isInteractiveComponent(markers, LAYOUT_DECL, '')).toBe(true); + }); + + it('returns true when source has focus management', () => { + const markers = { ...ALL_FALSE, focusManagement: true }; + expect(isInteractiveComponent(markers, LAYOUT_DECL, '')).toBe(true); + }); + + it('returns true when source has form internals', () => { + const markers = { ...ALL_FALSE, formInternals: true }; + expect(isInteractiveComponent(markers, LAYOUT_DECL, '')).toBe(true); + }); + + it('returns true when CEM has disabled property', () => { + const decl: CemDeclaration = { + ...LAYOUT_DECL, + members: [{ kind: 'field', name: 'disabled', type: { text: 'boolean' } }], + }; + expect(isInteractiveComponent(ALL_FALSE, decl, '')).toBe(true); + }); + + it('returns true when CEM has click event', () => { + const decl: CemDeclaration = { + ...LAYOUT_DECL, + events: [{ name: 'my-click' }], + }; + expect(isInteractiveComponent(ALL_FALSE, decl, '')).toBe(true); + }); + + it('returns true when CEM has change event', () => { + const decl: CemDeclaration = { + ...LAYOUT_DECL, + events: [{ name: 'value-change' }], + }; + expect(isInteractiveComponent(ALL_FALSE, decl, '')).toBe(true); + }); + + it('returns true when CEM has select event', () => { + const decl: CemDeclaration = { + ...LAYOUT_DECL, + events: [{ name: 'item-select' }], + }; + expect(isInteractiveComponent(ALL_FALSE, decl, '')).toBe(true); + }); + + it('returns true when source has @click handler template expression', () => { + expect(isInteractiveComponent(ALL_FALSE, LAYOUT_DECL, '@click=${this.handleClick}')).toBe( + true, + ); + }); + + it('returns true when source has addEventListener click', () => { + expect( + isInteractiveComponent(ALL_FALSE, LAYOUT_DECL, "this.addEventListener('click', handler)"), + ).toBe(true); + }); + + it('returns false when only ariaBindings are present (display component)', () => { + const markers = { ...ALL_FALSE, ariaBindings: true }; + expect(isInteractiveComponent(markers, LAYOUT_DECL, 'aria-label="icon"')).toBe(false); + }); + + it('returns false when only roleAssignments are present (structural)', () => { + const markers = { ...ALL_FALSE, roleAssignments: true }; + expect(isInteractiveComponent(markers, LAYOUT_DECL, '')).toBe(false); + }); + + it('returns false when events are non-interactive', () => { + const decl: CemDeclaration = { + ...LAYOUT_DECL, + events: [{ name: 'resize' }, { name: 'visibility-change' }], + }; + // 'resize' and 'visibility-change' don't match /click|press|select|change|input|submit/ + // 'change' in 'visibility-change' WOULD match due to regex + expect(isInteractiveComponent(ALL_FALSE, decl, '')).toBe(true); // 'change' in name matches + }); +}); + +describe('resolveComponentSourceFilePath', () => { + const WORKTREE = '/Volumes/Development/booked/helixir/.worktrees/feature-test-add-test-suites-for-8-untested'; + + it('returns null for paths outside project root (security)', () => { + const result = resolveComponentSourceFilePath(WORKTREE, '../../../etc/passwd'); + expect(result).toBeNull(); + }); + + it('returns null for paths that do not exist', () => { + const result = resolveComponentSourceFilePath(WORKTREE, 'src/nonexistent-component.ts'); + expect(result).toBeNull(); + }); + + it('resolves .ts equivalent for .js path', () => { + // The config.ts file does exist in the project + const result = resolveComponentSourceFilePath(WORKTREE, 'packages/core/src/config.js'); + // May resolve to packages/core/src/config.ts if it exists + if (result) { + expect(result.endsWith('.ts') || result.endsWith('.js')).toBe(true); + } + }); + + it('returns null when project root contains no matching file', () => { + const result = resolveComponentSourceFilePath('/tmp', 'completely-fake-path.js'); + expect(result).toBeNull(); + }); +}); diff --git a/tests/handlers/analyzers/type-coverage.test.ts b/tests/handlers/analyzers/type-coverage.test.ts new file mode 100644 index 0000000..2943342 --- /dev/null +++ b/tests/handlers/analyzers/type-coverage.test.ts @@ -0,0 +1,353 @@ +/** + * Type Coverage Analyzer — unit tests + * + * Tests analyzeTypeCoverage() covering: + * - Property type annotations scoring (40 pts) + * - Event typed payloads scoring (35 pts) + * - Method return types scoring (25 pts) + * - Null return for empty components + * - Proportional normalization + * - Edge cases: bare "Event" type, empty type text + */ + +import { describe, it, expect } from 'vitest'; +import { analyzeTypeCoverage } from '../../../packages/core/src/handlers/analyzers/type-coverage.js'; +import type { CemDeclaration } from '../../../packages/core/src/handlers/cem.js'; + +// ─── Fixtures ───────────────────────────────────────────────────────────────── + +const FULLY_TYPED: CemDeclaration = { + kind: 'class', + name: 'FullyTyped', + tagName: 'fully-typed', + members: [ + { kind: 'field', name: 'label', type: { text: 'string' } }, + { kind: 'field', name: 'count', type: { text: 'number' } }, + { kind: 'field', name: 'open', type: { text: 'boolean' } }, + { kind: 'method', name: 'open', return: { type: { text: 'void' } } }, + { kind: 'method', name: 'close', return: { type: { text: 'void' } } }, + { kind: 'method', name: 'getValue', return: { type: { text: 'string' } } }, + ], + events: [ + { name: 'value-change', type: { text: 'CustomEvent<{ value: string }>' } }, + { name: 'open-change', type: { text: 'CustomEvent' } }, + { name: 'item-click', type: { text: 'CustomEvent<{ item: object }>' } }, + ], +}; + +const UNTYPED: CemDeclaration = { + kind: 'class', + name: 'Untyped', + tagName: 'untyped', + members: [ + { kind: 'field', name: 'label' }, + { kind: 'field', name: 'count' }, + { kind: 'method', name: 'reset' }, + ], + events: [ + { name: 'change' }, + { name: 'update' }, + ], +}; + +const EMPTY_COMPONENT: CemDeclaration = { + kind: 'class', + name: 'Empty', + tagName: 'empty-thing', +}; + +const BARE_EVENT_TYPE: CemDeclaration = { + kind: 'class', + name: 'BareEvent', + tagName: 'bare-event', + events: [ + { name: 'change', type: { text: 'Event' } }, + { name: 'focus', type: { text: 'FocusEvent' } }, // specific Event subtype, still "bare" + { name: 'value-change', type: { text: 'CustomEvent' } }, // properly typed + ], +}; + +const FIELDS_ONLY: CemDeclaration = { + kind: 'class', + name: 'FieldsOnly', + tagName: 'fields-only', + members: [ + { kind: 'field', name: 'value', type: { text: 'string' } }, + { kind: 'field', name: 'count', type: { text: 'number' } }, + ], +}; + +const EVENTS_ONLY: CemDeclaration = { + kind: 'class', + name: 'EventsOnly', + tagName: 'events-only', + events: [ + { name: 'click', type: { text: 'CustomEvent' } }, + { name: 'change', type: { text: 'CustomEvent' } }, + ], +}; + +const METHODS_ONLY: CemDeclaration = { + kind: 'class', + name: 'MethodsOnly', + tagName: 'methods-only', + members: [ + { kind: 'method', name: 'open', return: { type: { text: 'void' } } }, + { kind: 'method', name: 'close', return: { type: { text: 'void' } } }, + ], +}; + +const PARTIAL_TYPED: CemDeclaration = { + kind: 'class', + name: 'PartialTyped', + tagName: 'partial-typed', + members: [ + { kind: 'field', name: 'value', type: { text: 'string' } }, // typed + { kind: 'field', name: 'count' }, // untyped + { kind: 'method', name: 'open', return: { type: { text: 'void' } } }, // typed + { kind: 'method', name: 'update' }, // no return type + ], + events: [ + { name: 'change', type: { text: 'CustomEvent' } }, // typed + { name: 'blur' }, // no type + ], +}; + +// ─── Tests ───────────────────────────────────────────────────────────────────── + +describe('analyzeTypeCoverage', () => { + describe('null return cases', () => { + it('returns null for component with no members or events', () => { + const result = analyzeTypeCoverage(EMPTY_COMPONENT); + expect(result).toBeNull(); + }); + + it('returns null when members and events are empty arrays', () => { + const decl: CemDeclaration = { + kind: 'class', + name: 'EmptyArrays', + tagName: 'empty-arrays', + members: [], + events: [], + }; + expect(analyzeTypeCoverage(decl)).toBeNull(); + }); + + it('returns null when only methods exist but no fields or events', () => { + // Methods without return types still count as "methods" for scoring + const result = analyzeTypeCoverage(METHODS_ONLY); + expect(result).not.toBeNull(); // methods exist so it's scoreable + }); + }); + + describe('result structure', () => { + it('returns score, confidence, and subMetrics', () => { + const result = analyzeTypeCoverage(FULLY_TYPED); + expect(result).not.toBeNull(); + expect(result).toHaveProperty('score'); + expect(result).toHaveProperty('confidence'); + expect(result).toHaveProperty('subMetrics'); + }); + + it('confidence is always verified', () => { + expect(analyzeTypeCoverage(FULLY_TYPED)!.confidence).toBe('verified'); + expect(analyzeTypeCoverage(UNTYPED)!.confidence).toBe('verified'); + }); + + it('has exactly 3 sub-metrics', () => { + const result = analyzeTypeCoverage(FULLY_TYPED); + expect(result!.subMetrics).toHaveLength(3); + }); + + it('sub-metric names match expected categories', () => { + const result = analyzeTypeCoverage(FULLY_TYPED); + const names = result!.subMetrics.map((m) => m.name); + expect(names).toContain('Property type annotations'); + expect(names).toContain('Event typed payloads'); + expect(names).toContain('Method return types'); + }); + }); + + describe('fully typed component', () => { + it('scores 100 for a fully-typed component', () => { + const result = analyzeTypeCoverage(FULLY_TYPED); + expect(result!.score).toBe(100); + }); + + it('scores property type annotations at max', () => { + const result = analyzeTypeCoverage(FULLY_TYPED); + const propMetric = result!.subMetrics.find((m) => m.name === 'Property type annotations'); + expect(propMetric!.score).toBe(propMetric!.maxScore); + }); + + it('scores event typed payloads at max', () => { + const result = analyzeTypeCoverage(FULLY_TYPED); + const eventMetric = result!.subMetrics.find((m) => m.name === 'Event typed payloads'); + expect(eventMetric!.score).toBe(eventMetric!.maxScore); + }); + + it('scores method return types at max', () => { + const result = analyzeTypeCoverage(FULLY_TYPED); + const methodMetric = result!.subMetrics.find((m) => m.name === 'Method return types'); + expect(methodMetric!.score).toBe(methodMetric!.maxScore); + }); + }); + + describe('untyped component', () => { + it('scores low for a fully untyped component', () => { + const result = analyzeTypeCoverage(UNTYPED); + expect(result!.score).toBeLessThan(20); + }); + + it('scores property type annotations at 0', () => { + const result = analyzeTypeCoverage(UNTYPED); + const propMetric = result!.subMetrics.find((m) => m.name === 'Property type annotations'); + expect(propMetric!.score).toBe(0); + }); + + it('scores event typed payloads at 0', () => { + const result = analyzeTypeCoverage(UNTYPED); + const eventMetric = result!.subMetrics.find((m) => m.name === 'Event typed payloads'); + expect(eventMetric!.score).toBe(0); + }); + + it('scores method return types at 0', () => { + const result = analyzeTypeCoverage(UNTYPED); + const methodMetric = result!.subMetrics.find((m) => m.name === 'Method return types'); + expect(methodMetric!.score).toBe(0); + }); + }); + + describe('bare "Event" type handling', () => { + it('treats bare "Event" as untyped payload', () => { + const result = analyzeTypeCoverage(BARE_EVENT_TYPE); + const eventMetric = result!.subMetrics.find((m) => m.name === 'Event typed payloads'); + // 1 of 3 events has proper CustomEvent type + // "Event" counts as untyped, "FocusEvent" is also bare (not CustomEvent) + // Wait — "Event" is excluded but "FocusEvent" is NOT "Event" exactly, so... + // Actually "FocusEvent" !== 'Event', so it passes the filter + // Only bare 'Event' text is excluded → "change" with type.text='Event' is excluded + expect(eventMetric).toBeDefined(); + }); + + it('scores 0 for event with no type', () => { + const decl: CemDeclaration = { + kind: 'class', + name: 'NoEventType', + tagName: 'no-event-type', + events: [{ name: 'change' }], + }; + const result = analyzeTypeCoverage(decl); + const eventMetric = result!.subMetrics.find((m) => m.name === 'Event typed payloads'); + expect(eventMetric!.score).toBe(0); + }); + + it('excludes exactly "Event" from typed payloads but allows specific subtypes', () => { + const decl: CemDeclaration = { + kind: 'class', + name: 'MixedEventTypes', + tagName: 'mixed-event-types', + events: [ + { name: 'blur', type: { text: 'Event' } }, // excluded + { name: 'focus', type: { text: 'FocusEvent' } }, // allowed (not bare "Event") + ], + }; + const result = analyzeTypeCoverage(decl); + const eventMetric = result!.subMetrics.find((m) => m.name === 'Event typed payloads'); + // 1 of 2 events counted as typed (FocusEvent passes, Event does not) + expect(eventMetric!.score).toBeGreaterThan(0); + expect(eventMetric!.score).toBeLessThan(eventMetric!.maxScore); + }); + }); + + describe('single-dimension scoring', () => { + it('scores fields-only component based only on field types', () => { + const result = analyzeTypeCoverage(FIELDS_ONLY); + expect(result).not.toBeNull(); + // Both fields have types → score should be 100 (normalized) + expect(result!.score).toBe(100); + }); + + it('scores events-only component based only on event types', () => { + const result = analyzeTypeCoverage(EVENTS_ONLY); + expect(result).not.toBeNull(); + // Both events have proper types → score should be 100 + expect(result!.score).toBe(100); + }); + + it('scores methods-only component based only on return types', () => { + const result = analyzeTypeCoverage(METHODS_ONLY); + expect(result).not.toBeNull(); + // Both methods have return types → score should be 100 + expect(result!.score).toBe(100); + }); + }); + + describe('partial typing', () => { + it('scores proportionally for partially typed component', () => { + const result = analyzeTypeCoverage(PARTIAL_TYPED); + expect(result).not.toBeNull(); + expect(result!.score).toBeGreaterThan(0); + expect(result!.score).toBeLessThan(100); + }); + + it('scores property type annotations at 50% for half-typed fields', () => { + const result = analyzeTypeCoverage(PARTIAL_TYPED); + const propMetric = result!.subMetrics.find((m) => m.name === 'Property type annotations'); + // 1 of 2 fields typed → round(1/2 * 40) = 20 + expect(propMetric!.score).toBe(20); + }); + + it('scores event typed payloads at 50% for half-typed events', () => { + const result = analyzeTypeCoverage(PARTIAL_TYPED); + const eventMetric = result!.subMetrics.find((m) => m.name === 'Event typed payloads'); + // 1 of 2 events has proper type → round(1/2 * 35) = 18 (or 17) + expect(eventMetric!.score).toBeGreaterThan(0); + expect(eventMetric!.score).toBeLessThan(35); + }); + + it('scores method return types at 50% for half-typed methods', () => { + const result = analyzeTypeCoverage(PARTIAL_TYPED); + const methodMetric = result!.subMetrics.find((m) => m.name === 'Method return types'); + // 1 of 2 methods has return type → round(1/2 * 25) = 13 (or 12) + expect(methodMetric!.score).toBeGreaterThan(0); + expect(methodMetric!.score).toBeLessThan(25); + }); + }); + + describe('score bounds', () => { + it('score is always in range [0, 100]', () => { + const decls = [FULLY_TYPED, UNTYPED, PARTIAL_TYPED, FIELDS_ONLY, EVENTS_ONLY, METHODS_ONLY]; + for (const decl of decls) { + const result = analyzeTypeCoverage(decl); + if (result) { + expect(result.score).toBeGreaterThanOrEqual(0); + expect(result.score).toBeLessThanOrEqual(100); + } + } + }); + + it('sub-metric maxScore values sum to 100', () => { + const result = analyzeTypeCoverage(FULLY_TYPED); + const maxSum = result!.subMetrics.reduce((acc, m) => acc + m.maxScore, 0); + expect(maxSum).toBe(100); + }); + }); + + describe('whitespace handling', () => { + it('treats empty string type text as untyped', () => { + const decl: CemDeclaration = { + kind: 'class', + name: 'EmptyTypeText', + tagName: 'empty-type-text', + members: [ + { kind: 'field', name: 'value', type: { text: '' } }, // empty text + { kind: 'field', name: 'count', type: { text: ' ' } }, // whitespace only + ], + }; + const result = analyzeTypeCoverage(decl); + const propMetric = result!.subMetrics.find((m) => m.name === 'Property type annotations'); + expect(propMetric!.score).toBe(0); + }); + }); +}); diff --git a/tests/handlers/theme.test.ts b/tests/handlers/theme.test.ts index 38e0098..4e03a48 100644 --- a/tests/handlers/theme.test.ts +++ b/tests/handlers/theme.test.ts @@ -169,6 +169,18 @@ describe('createTheme', () => { // Dark CSS should have dark values for bg expect(result.darkModeCSS).toContain('#1a1a1a'); }); + + it('produces a var() fallback for tokens with unknown categories', () => { + // --my-custom-widget does not match any known category pattern, + // so it lands in "other" and hits the default case in lightPlaceholder. + const cem = makeCem([ + { tagName: 'my-widget', cssProperties: [{ name: '--my-custom-widget' }] }, + ]); + const result = createTheme(cem); + // The generated CSS should contain var(--my-custom-widget) instead of a TODO comment + expect(result.lightModeCSS).toContain('var(--my-custom-widget)'); + expect(result.lightModeCSS).not.toContain('TODO'); + }); }); // ─── applyThemeTokens ───────────────────────────────────────────────────────── diff --git a/tests/tools/bundle.test.ts b/tests/tools/bundle.test.ts new file mode 100644 index 0000000..ae626e9 --- /dev/null +++ b/tests/tools/bundle.test.ts @@ -0,0 +1,291 @@ +/** + * Tests for the estimate_bundle_size tool dispatcher. + * Covers isBundleTool, handleBundleCall, argument validation, + * and response formatting. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + isBundleTool, + handleBundleCall, +} from '../../packages/core/src/tools/bundle.js'; +import type { McpWcConfig } from '../../packages/core/src/config.js'; + +// ─── Mocks ──────────────────────────────────────────────────────────────────── + +vi.mock('../../packages/core/src/handlers/bundle.js', () => ({ + estimateBundleSize: vi.fn(async (tagName: string, _config: unknown, _pkg?: string, version = 'latest') => ({ + component: tagName, + package: _pkg ?? '@shoelace-style/shoelace', + version, + estimates: { + component_only: null, + full_package: { minified: 48000, gzipped: 14000 }, + shared_dependencies: 'Actual component size depends on tree-shaking and bundler configuration.', + }, + source: 'bundlephobia', + cached: false, + note: 'Sizes are estimates. Actual bundle size depends on your bundler and tree-shaking config.', + })), +})); + +// ─── Fixtures ───────────────────────────────────────────────────────────────── + +const FAKE_CONFIG: McpWcConfig = { + cemPath: 'custom-elements.json', + projectRoot: '/fake/project', + componentPrefix: 'sl', + healthHistoryDir: '.mcp-wc/health', + tsconfigPath: 'tsconfig.json', + tokensPath: null, + cdnBase: null, + watch: false, +}; + +const CONFIG_NO_PREFIX: McpWcConfig = { + ...FAKE_CONFIG, + componentPrefix: '', +}; + +// ─── isBundleTool ───────────────────────────────────────────────────────────── + +describe('isBundleTool', () => { + it('returns true for estimate_bundle_size', () => { + expect(isBundleTool('estimate_bundle_size')).toBe(true); + }); + + it('returns false for unknown tool names', () => { + expect(isBundleTool('scaffold_component')).toBe(false); + expect(isBundleTool('get_component')).toBe(false); + expect(isBundleTool('')).toBe(false); + }); + + it('returns false for near-matches', () => { + expect(isBundleTool('estimate_bundle')).toBe(false); + expect(isBundleTool('estimate_bundle_sizes')).toBe(false); + expect(isBundleTool('bundle_size')).toBe(false); + }); +}); + +// ─── handleBundleCall — valid inputs ────────────────────────────────────────── + +describe('handleBundleCall — valid inputs', () => { + it('returns a success result for estimate_bundle_size with only tagName', async () => { + const result = await handleBundleCall( + 'estimate_bundle_size', + { tagName: 'sl-button' }, + FAKE_CONFIG, + ); + expect(result.isError).toBeFalsy(); + }); + + it('returns JSON-parseable content', async () => { + const result = await handleBundleCall( + 'estimate_bundle_size', + { tagName: 'sl-button' }, + FAKE_CONFIG, + ); + expect(() => JSON.parse(result.content[0].text)).not.toThrow(); + }); + + it('output includes the component tag name', async () => { + const result = await handleBundleCall( + 'estimate_bundle_size', + { tagName: 'sl-button' }, + FAKE_CONFIG, + ); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.component).toBe('sl-button'); + }); + + it('output includes package field', async () => { + const result = await handleBundleCall( + 'estimate_bundle_size', + { tagName: 'sl-button' }, + FAKE_CONFIG, + ); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.package).toBeDefined(); + }); + + it('output includes estimates field with full_package', async () => { + const result = await handleBundleCall( + 'estimate_bundle_size', + { tagName: 'sl-button' }, + FAKE_CONFIG, + ); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.estimates).toBeDefined(); + expect(parsed.estimates.full_package).not.toBeNull(); + expect(parsed.estimates.full_package.minified).toBeDefined(); + expect(parsed.estimates.full_package.gzipped).toBeDefined(); + }); + + it('accepts optional explicit package name', async () => { + const result = await handleBundleCall( + 'estimate_bundle_size', + { tagName: 'sl-button', package: '@shoelace-style/shoelace' }, + FAKE_CONFIG, + ); + expect(result.isError).toBeFalsy(); + }); + + it('accepts optional version string', async () => { + const result = await handleBundleCall( + 'estimate_bundle_size', + { tagName: 'sl-button', version: '2.0.0' }, + FAKE_CONFIG, + ); + expect(result.isError).toBeFalsy(); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.version).toBe('2.0.0'); + }); + + it('accepts "latest" as version', async () => { + const result = await handleBundleCall( + 'estimate_bundle_size', + { tagName: 'sl-button', version: 'latest' }, + FAKE_CONFIG, + ); + expect(result.isError).toBeFalsy(); + }); + + it('accepts include_full_package: false and suppresses full_package', async () => { + const result = await handleBundleCall( + 'estimate_bundle_size', + { tagName: 'sl-button', include_full_package: false }, + FAKE_CONFIG, + ); + expect(result.isError).toBeFalsy(); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.estimates.full_package).toBeNull(); + }); + + it('include_full_package: true keeps full_package data', async () => { + const result = await handleBundleCall( + 'estimate_bundle_size', + { tagName: 'sl-button', include_full_package: true }, + FAKE_CONFIG, + ); + expect(result.isError).toBeFalsy(); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.estimates.full_package).not.toBeNull(); + }); + + it('accepts scoped package name', async () => { + const result = await handleBundleCall( + 'estimate_bundle_size', + { tagName: 'fluent-button', package: '@fluentui/web-components' }, + FAKE_CONFIG, + ); + expect(result.isError).toBeFalsy(); + }); + + it('output includes source field', async () => { + const result = await handleBundleCall( + 'estimate_bundle_size', + { tagName: 'sl-button' }, + FAKE_CONFIG, + ); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.source).toBeDefined(); + }); + + it('output includes note field', async () => { + const result = await handleBundleCall( + 'estimate_bundle_size', + { tagName: 'sl-button' }, + FAKE_CONFIG, + ); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.note).toBeDefined(); + }); +}); + +// ─── handleBundleCall — error cases ─────────────────────────────────────────── + +describe('handleBundleCall — error cases', () => { + it('returns error for unknown tool name', async () => { + const result = await handleBundleCall('nonexistent_tool', {}, FAKE_CONFIG); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Unknown bundle tool'); + }); + + it('returns error for empty tool name', async () => { + const result = await handleBundleCall('', {}, FAKE_CONFIG); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Unknown bundle tool'); + }); + + it('returns error when tagName is missing', async () => { + const result = await handleBundleCall('estimate_bundle_size', {}, FAKE_CONFIG); + expect(result.isError).toBe(true); + }); + + it('returns error for invalid version string', async () => { + const result = await handleBundleCall( + 'estimate_bundle_size', + { tagName: 'sl-button', version: 'not-a-version!!' }, + FAKE_CONFIG, + ); + expect(result.isError).toBe(true); + }); + + it('returns error for invalid npm package name', async () => { + const result = await handleBundleCall( + 'estimate_bundle_size', + { tagName: 'sl-button', package: 'INVALID PACKAGE NAME' }, + FAKE_CONFIG, + ); + expect(result.isError).toBe(true); + }); + + it('strips unknown extra properties and succeeds (Zod default behavior)', async () => { + // Zod strips unknown properties by default (no .strict() on the schema) + const result = await handleBundleCall( + 'estimate_bundle_size', + { tagName: 'sl-button', unknownProp: 'ignored' }, + FAKE_CONFIG, + ); + expect(result.isError).toBeFalsy(); + }); +}); + +// ─── handleBundleCall — handler error propagation ───────────────────────────── + +describe('handleBundleCall — handler error propagation', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns error when estimateBundleSize handler throws (cannot determine package)', async () => { + const { estimateBundleSize } = await import('../../packages/core/src/handlers/bundle.js'); + vi.mocked(estimateBundleSize).mockImplementationOnce(async () => { + throw new Error( + 'Cannot determine npm package name for tag . Set componentPrefix in mcpwc.config.json or provide the package explicitly.', + ); + }); + + const result = await handleBundleCall( + 'estimate_bundle_size', + { tagName: 'my-button' }, + CONFIG_NO_PREFIX, + ); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Cannot determine npm package name'); + }); + + it('returns error when estimateBundleSize handler throws a generic error', async () => { + const { estimateBundleSize } = await import('../../packages/core/src/handlers/bundle.js'); + vi.mocked(estimateBundleSize).mockImplementationOnce(async () => { + throw new Error('Network request failed'); + }); + + const result = await handleBundleCall( + 'estimate_bundle_size', + { tagName: 'sl-button' }, + FAKE_CONFIG, + ); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Network request failed'); + }); +}); 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/extend.test.ts b/tests/tools/extend.test.ts new file mode 100644 index 0000000..e4bc192 --- /dev/null +++ b/tests/tools/extend.test.ts @@ -0,0 +1,242 @@ +/** + * Tests for the extend_component tool dispatcher. + * Covers isExtendTool, handleExtendCall, argument validation, + * and response formatting with CEM-based component inputs. + */ +import { describe, it, expect, vi } from 'vitest'; +import { + isExtendTool, + handleExtendCall, +} from '../../packages/core/src/tools/extend.js'; +import type { Cem } from '../../packages/core/src/handlers/cem.js'; + +// ─── Mocks ──────────────────────────────────────────────────────────────────── + +vi.mock('../../packages/core/src/handlers/extend.js', () => ({ + extendComponent: vi.fn((parentTagName: string, newTagName: string, _cem: unknown, newClassName?: string) => { + const parentClass = parentTagName.replace(/-([a-z])/g, (_, l: string) => l.toUpperCase()); + const defaultNewClass = newTagName.replace(/-([a-z])/g, (_, l: string) => l.toUpperCase()); + const newClass = newClassName ?? defaultNewClass; + return { + parentTagName, + newTagName, + parentClassName: parentClass.charAt(0).toUpperCase() + parentClass.slice(1), + newClassName: newClass.charAt(0).toUpperCase() + newClass.slice(1), + source: `import { ${parentClass.charAt(0).toUpperCase() + parentClass.slice(1)} } from './${parentTagName}.js'\nexport class ${newClass.charAt(0).toUpperCase() + newClass.slice(1)} extends ${parentClass.charAt(0).toUpperCase() + parentClass.slice(1)} {}\n@customElement('${newTagName}')`, + inheritedCssParts: ['base', 'label'], + inheritedSlots: ['(default)', 'prefix'], + warnings: [ + 'shadow DOM style encapsulation', + 'exportparts must be declared', + 'render() override replaces parent template', + 'shadowRoot.querySelector() is not recommended', + ], + }; + }), +})); + +// ─── Fixtures ───────────────────────────────────────────────────────────────── + +const FAKE_CEM: Cem = { schemaVersion: '1.0.0', modules: [] }; + +const PARENT_CEM: Cem = { + schemaVersion: '1.0.0', + modules: [ + { + kind: 'javascript-module', + path: 'src/hx-button.js', + declarations: [ + { + kind: 'class', + name: 'HxButton', + tagName: 'hx-button', + members: [], + cssParts: [ + { name: 'base', description: 'The button base element.' }, + { name: 'label', description: 'The button label wrapper.' }, + ], + slots: [ + { name: '', description: 'Default slot.' }, + { name: 'prefix', description: 'Prefix icon slot.' }, + ], + }, + ], + }, + ], +}; + +// ─── isExtendTool ───────────────────────────────────────────────────────────── + +describe('isExtendTool', () => { + it('returns true for extend_component', () => { + expect(isExtendTool('extend_component')).toBe(true); + }); + + it('returns false for unknown tool names', () => { + expect(isExtendTool('scaffold_component')).toBe(false); + expect(isExtendTool('get_component')).toBe(false); + expect(isExtendTool('')).toBe(false); + }); + + it('returns false for near-matches', () => { + expect(isExtendTool('extend')).toBe(false); + expect(isExtendTool('extend_components')).toBe(false); + }); +}); + +// ─── handleExtendCall — valid inputs ────────────────────────────────────────── + +describe('handleExtendCall — valid inputs', () => { + it('returns a success result for extend_component with required args', () => { + const result = handleExtendCall( + 'extend_component', + { parentTagName: 'hx-button', newTagName: 'my-button' }, + PARENT_CEM, + ); + expect(result.isError).toBeFalsy(); + }); + + it('output includes the inheritance relationship comment', () => { + const result = handleExtendCall( + 'extend_component', + { parentTagName: 'hx-button', newTagName: 'my-button' }, + PARENT_CEM, + ); + expect(result.content[0].text).toContain('extends'); + }); + + it('output includes the parent tag name', () => { + const result = handleExtendCall( + 'extend_component', + { parentTagName: 'hx-button', newTagName: 'my-button' }, + PARENT_CEM, + ); + expect(result.content[0].text).toContain('hx-button'); + }); + + it('output includes the new tag name', () => { + const result = handleExtendCall( + 'extend_component', + { parentTagName: 'hx-button', newTagName: 'my-button' }, + PARENT_CEM, + ); + expect(result.content[0].text).toContain('my-button'); + }); + + it('output includes Shadow DOM warnings section', () => { + const result = handleExtendCall( + 'extend_component', + { parentTagName: 'hx-button', newTagName: 'my-button' }, + PARENT_CEM, + ); + expect(result.content[0].text).toContain('Shadow DOM Style Encapsulation Warnings'); + }); + + it('output includes inherited CSS parts summary', () => { + const result = handleExtendCall( + 'extend_component', + { parentTagName: 'hx-button', newTagName: 'my-button' }, + PARENT_CEM, + ); + expect(result.content[0].text).toContain('base'); + expect(result.content[0].text).toContain('label'); + }); + + it('output includes inherited slots summary', () => { + const result = handleExtendCall( + 'extend_component', + { parentTagName: 'hx-button', newTagName: 'my-button' }, + PARENT_CEM, + ); + expect(result.content[0].text).toContain('Inherited slots'); + }); + + it('formats warnings as numbered list with emoji', () => { + const result = handleExtendCall( + 'extend_component', + { parentTagName: 'hx-button', newTagName: 'my-button' }, + PARENT_CEM, + ); + expect(result.content[0].text).toContain('1. ⚠️'); + }); + + it('accepts optional newClassName override', () => { + const result = handleExtendCall( + 'extend_component', + { + parentTagName: 'hx-button', + newTagName: 'my-button', + newClassName: 'EnterpriseButton', + }, + PARENT_CEM, + ); + expect(result.isError).toBeFalsy(); + }); + + it('works with empty CEM (handler mock does not query CEM)', () => { + const result = handleExtendCall( + 'extend_component', + { parentTagName: 'hx-button', newTagName: 'my-button' }, + FAKE_CEM, + ); + expect(result.isError).toBeFalsy(); + }); +}); + +// ─── handleExtendCall — error cases ─────────────────────────────────────────── + +describe('handleExtendCall — error cases', () => { + it('returns error for unknown tool name', () => { + const result = handleExtendCall('nonexistent_tool', {}, PARENT_CEM); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Unknown extend tool'); + }); + + it('returns error for empty tool name', () => { + const result = handleExtendCall('', {}, PARENT_CEM); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Unknown extend tool'); + }); + + it('returns error when parentTagName is missing', () => { + const result = handleExtendCall( + 'extend_component', + { newTagName: 'my-button' }, + PARENT_CEM, + ); + expect(result.isError).toBe(true); + }); + + it('returns error when newTagName is missing', () => { + const result = handleExtendCall( + 'extend_component', + { parentTagName: 'hx-button' }, + PARENT_CEM, + ); + expect(result.isError).toBe(true); + }); + + it('returns error when both required args are missing', () => { + const result = handleExtendCall('extend_component', {}, PARENT_CEM); + expect(result.isError).toBe(true); + }); +}); + +// ─── handleExtendCall — handler error propagation ───────────────────────────── + +describe('handleExtendCall — handler error propagation', () => { + it('returns error when handler throws (e.g. parent not found in CEM)', async () => { + const { extendComponent } = await import('../../packages/core/src/handlers/extend.js'); + vi.mocked(extendComponent).mockImplementationOnce(() => { + throw new Error('"not-found" not found in CEM'); + }); + + const result = handleExtendCall( + 'extend_component', + { parentTagName: 'not-found', newTagName: 'my-comp' }, + PARENT_CEM, + ); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('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/scaffold.test.ts b/tests/tools/scaffold.test.ts new file mode 100644 index 0000000..d702fcb --- /dev/null +++ b/tests/tools/scaffold.test.ts @@ -0,0 +1,312 @@ +/** + * Tests for the scaffold_component tool dispatcher. + * Covers isScaffoldTool, handleScaffoldCall, argument validation, + * and response formatting. + */ +import { describe, it, expect, vi } from 'vitest'; +import { + isScaffoldTool, + handleScaffoldCall, +} from '../../packages/core/src/tools/scaffold.js'; +import type { McpWcConfig } from '../../packages/core/src/config.js'; +import type { Cem } from '../../packages/core/src/handlers/cem.js'; + +// ─── Mocks ──────────────────────────────────────────────────────────────────── + +vi.mock('../../packages/core/src/handlers/scaffold.js', () => ({ + scaffoldComponent: vi.fn(() => ({ + tagName: 'hx-button', + conventions: { prefix: 'hx-', baseClass: 'LitElement', packageName: 'lit' }, + component: 'export class HxButton extends LitElement {}', + test: "import { describe, it } from 'vitest';", + story: "import type { Meta } from '@storybook/web-components';", + css: ':host { display: block; }', + })), + detectConventions: vi.fn(() => ({ + prefix: 'hx-', + baseClass: 'LitElement', + packageName: 'lit', + })), +})); + +// ─── 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, +}; + +const FAKE_CEM: Cem = { schemaVersion: '1.0.0', modules: [] }; + +const HX_CEM: Cem = { + schemaVersion: '1.0.0', + modules: [ + { + kind: 'javascript-module', + path: 'src/hx-button.ts', + declarations: [ + { + kind: 'class', + name: 'HxButton', + tagName: 'hx-button', + superclass: { name: 'LitElement', package: 'lit' }, + members: [], + }, + ], + }, + ], +}; + +// ─── isScaffoldTool ─────────────────────────────────────────────────────────── + +describe('isScaffoldTool', () => { + it('returns true for scaffold_component', () => { + expect(isScaffoldTool('scaffold_component')).toBe(true); + }); + + it('returns false for unknown tool names', () => { + expect(isScaffoldTool('get_component')).toBe(false); + expect(isScaffoldTool('extend_component')).toBe(false); + expect(isScaffoldTool('')).toBe(false); + }); + + it('returns false for near-matches', () => { + expect(isScaffoldTool('scaffold')).toBe(false); + expect(isScaffoldTool('scaffold_components')).toBe(false); + }); +}); + +// ─── handleScaffoldCall — valid inputs ──────────────────────────────────────── + +describe('handleScaffoldCall — valid inputs', () => { + it('returns a success result for scaffold_component with minimal args', () => { + const result = handleScaffoldCall( + 'scaffold_component', + { tagName: 'hx-button' }, + FAKE_CONFIG, + FAKE_CEM, + ); + expect(result.isError).toBeFalsy(); + expect(result.content[0].text).toContain('scaffold_component'); + }); + + it('output includes the tag name heading', () => { + const result = handleScaffoldCall( + 'scaffold_component', + { tagName: 'hx-button' }, + FAKE_CONFIG, + FAKE_CEM, + ); + expect(result.content[0].text).toContain('hx-button'); + }); + + it('output includes detected conventions block', () => { + const result = handleScaffoldCall( + 'scaffold_component', + { tagName: 'hx-button' }, + FAKE_CONFIG, + FAKE_CEM, + ); + expect(result.content[0].text).toContain('Detected conventions'); + expect(result.content[0].text).toContain('prefix='); + expect(result.content[0].text).toContain('baseClass='); + }); + + it('output includes Component section', () => { + const result = handleScaffoldCall( + 'scaffold_component', + { tagName: 'hx-button' }, + FAKE_CONFIG, + FAKE_CEM, + ); + expect(result.content[0].text).toContain('### Component:'); + }); + + it('output includes Test section', () => { + const result = handleScaffoldCall( + 'scaffold_component', + { tagName: 'hx-button' }, + FAKE_CONFIG, + FAKE_CEM, + ); + expect(result.content[0].text).toContain('### Test:'); + }); + + it('output includes Story section', () => { + const result = handleScaffoldCall( + 'scaffold_component', + { tagName: 'hx-button' }, + FAKE_CONFIG, + FAKE_CEM, + ); + expect(result.content[0].text).toContain('### Story:'); + }); + + it('output includes CSS section', () => { + const result = handleScaffoldCall( + 'scaffold_component', + { tagName: 'hx-button' }, + FAKE_CONFIG, + FAKE_CEM, + ); + expect(result.content[0].text).toContain('### CSS:'); + }); + + it('accepts optional baseClass override', () => { + const result = handleScaffoldCall( + 'scaffold_component', + { tagName: 'hx-card', baseClass: 'BaseElement' }, + FAKE_CONFIG, + FAKE_CEM, + ); + expect(result.isError).toBeFalsy(); + }); + + it('accepts optional slots array', () => { + const result = handleScaffoldCall( + 'scaffold_component', + { tagName: 'hx-card', slots: [{ name: '' }, { name: 'footer' }] }, + FAKE_CONFIG, + FAKE_CEM, + ); + expect(result.isError).toBeFalsy(); + }); + + it('accepts optional cssParts array', () => { + const result = handleScaffoldCall( + 'scaffold_component', + { tagName: 'hx-card', cssParts: [{ name: 'base' }] }, + FAKE_CONFIG, + FAKE_CEM, + ); + expect(result.isError).toBeFalsy(); + }); + + it('accepts optional events array', () => { + const result = handleScaffoldCall( + 'scaffold_component', + { tagName: 'hx-card', events: [{ name: 'hx-change', type: 'CustomEvent' }] }, + FAKE_CONFIG, + FAKE_CEM, + ); + expect(result.isError).toBeFalsy(); + }); + + it('accepts optional properties array', () => { + const result = handleScaffoldCall( + 'scaffold_component', + { + tagName: 'hx-card', + properties: [{ name: 'variant', type: 'string', default: "'primary'" }], + }, + FAKE_CONFIG, + FAKE_CEM, + ); + expect(result.isError).toBeFalsy(); + }); + + it('passes componentPrefix from config to scaffoldComponent', () => { + const configWithPrefix: McpWcConfig = { ...FAKE_CONFIG, componentPrefix: 'hx-' }; + const result = handleScaffoldCall( + 'scaffold_component', + { tagName: 'hx-badge' }, + configWithPrefix, + HX_CEM, + ); + expect(result.isError).toBeFalsy(); + }); +}); + +// ─── handleScaffoldCall — error cases ───────────────────────────────────────── + +describe('handleScaffoldCall — error cases', () => { + it('returns error for unknown tool name', () => { + const result = handleScaffoldCall('nonexistent_tool', {}, FAKE_CONFIG, FAKE_CEM); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Unknown scaffold tool'); + }); + + it('returns error when tagName is missing', () => { + const result = handleScaffoldCall('scaffold_component', {}, FAKE_CONFIG, FAKE_CEM); + expect(result.isError).toBe(true); + }); + + it('returns error when tagName has no hyphen', () => { + const result = handleScaffoldCall( + 'scaffold_component', + { tagName: 'button' }, + FAKE_CONFIG, + FAKE_CEM, + ); + expect(result.isError).toBe(true); + }); + + it('returns error when tagName starts with uppercase', () => { + const result = handleScaffoldCall( + 'scaffold_component', + { tagName: 'Hx-button' }, + FAKE_CONFIG, + FAKE_CEM, + ); + expect(result.isError).toBe(true); + }); + + it('returns error when tagName contains invalid characters', () => { + const result = handleScaffoldCall( + 'scaffold_component', + { tagName: 'hx_button' }, + FAKE_CONFIG, + FAKE_CEM, + ); + expect(result.isError).toBe(true); + }); + + it('returns error for empty tool name', () => { + const result = handleScaffoldCall('', {}, FAKE_CONFIG, FAKE_CEM); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Unknown scaffold tool'); + }); +}); + +// ─── handleScaffoldCall — output format ─────────────────────────────────────── + +describe('handleScaffoldCall — output format', () => { + it('wraps component source in typescript code block', () => { + const result = handleScaffoldCall( + 'scaffold_component', + { tagName: 'hx-button' }, + FAKE_CONFIG, + FAKE_CEM, + ); + const text = result.content[0].text; + expect(text).toContain('```typescript'); + }); + + it('wraps CSS source in css code block', () => { + const result = handleScaffoldCall( + 'scaffold_component', + { tagName: 'hx-button' }, + FAKE_CONFIG, + FAKE_CEM, + ); + const text = result.content[0].text; + expect(text).toContain('```css'); + }); + + it('includes the tag name in file path hints', () => { + const result = handleScaffoldCall( + 'scaffold_component', + { tagName: 'hx-button' }, + FAKE_CONFIG, + FAKE_CEM, + ); + // Component section uses tagName for file name hint + expect(result.content[0].text).toContain('hx-button.ts'); + }); +}); 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/styling.test.ts b/tests/tools/styling.test.ts new file mode 100644 index 0000000..6bcfed2 --- /dev/null +++ b/tests/tools/styling.test.ts @@ -0,0 +1,1260 @@ +/** + * Test suite for packages/core/src/tools/styling.ts + * + * Tests the handleStylingCall dispatcher and isStylingTool guard. + * All handler imports are mocked so no real CEM file reads or heavy + * computation happens during unit tests. + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { handleStylingCall, isStylingTool } from '../../packages/core/src/tools/styling.js'; +import type { Cem } from '../../packages/core/src/handlers/cem.js'; +import { MCPError, ErrorCategory } from '../../packages/core/src/shared/error-handling.js'; + +// --------------------------------------------------------------------------- +// Mock every handler that handleStylingCall delegates to +// --------------------------------------------------------------------------- + +vi.mock('../../packages/core/src/handlers/cem.js', () => ({ + parseCem: vi.fn(), +})); + +vi.mock('../../packages/core/src/handlers/styling-diagnostics.js', () => ({ + diagnoseStyling: vi.fn(), +})); + +vi.mock('../../packages/core/src/handlers/shadow-dom-checker.js', () => ({ + checkShadowDomUsage: vi.fn(), +})); + +vi.mock('../../packages/core/src/handlers/html-usage-checker.js', () => ({ + checkHtmlUsage: vi.fn(), +})); + +vi.mock('../../packages/core/src/handlers/event-usage-checker.js', () => ({ + checkEventUsage: vi.fn(), +})); + +vi.mock('../../packages/core/src/handlers/quick-ref.js', () => ({ + getComponentQuickRef: vi.fn(), +})); + +vi.mock('../../packages/core/src/handlers/theme-detection.js', () => ({ + detectThemeSupport: vi.fn(), +})); + +vi.mock('../../packages/core/src/handlers/import-checker.js', () => ({ + checkComponentImports: vi.fn(), +})); + +vi.mock('../../packages/core/src/handlers/slot-children-checker.js', () => ({ + checkSlotChildren: vi.fn(), +})); + +vi.mock('../../packages/core/src/handlers/attribute-conflict-checker.js', () => ({ + checkAttributeConflicts: vi.fn(), +})); + +vi.mock('../../packages/core/src/handlers/a11y-usage-checker.js', () => ({ + checkA11yUsage: vi.fn(), +})); + +vi.mock('../../packages/core/src/handlers/css-var-checker.js', () => ({ + checkCssVars: vi.fn(), +})); + +vi.mock('../../packages/core/src/handlers/code-validator.js', () => ({ + validateComponentCode: vi.fn(), +})); + +vi.mock('../../packages/core/src/handlers/token-fallback-checker.js', () => ({ + checkTokenFallbacks: vi.fn(), +})); + +vi.mock('../../packages/core/src/handlers/composition-checker.js', () => ({ + checkComposition: vi.fn(), +})); + +vi.mock('../../packages/core/src/handlers/method-checker.js', () => ({ + checkMethodCalls: vi.fn(), +})); + +vi.mock('../../packages/core/src/handlers/theme-checker.js', () => ({ + checkThemeCompatibility: vi.fn(), +})); + +vi.mock('../../packages/core/src/handlers/recommend-checks.js', () => ({ + recommendChecks: vi.fn(), +})); + +vi.mock('../../packages/core/src/handlers/suggest-fix.js', () => ({ + suggestFix: vi.fn(), +})); + +vi.mock('../../packages/core/src/handlers/specificity-checker.js', () => ({ + checkCssSpecificity: vi.fn(), +})); + +vi.mock('../../packages/core/src/handlers/layout-checker.js', () => ({ + checkLayoutPatterns: vi.fn(), +})); + +vi.mock('../../packages/core/src/handlers/scope-checker.js', () => ({ + checkCssScope: vi.fn(), +})); + +vi.mock('../../packages/core/src/handlers/shorthand-checker.js', () => ({ + checkCssShorthand: vi.fn(), +})); + +vi.mock('../../packages/core/src/handlers/color-contrast-checker.js', () => ({ + checkColorContrast: vi.fn(), +})); + +vi.mock('../../packages/core/src/handlers/transition-checker.js', () => ({ + checkTransitionAnimation: vi.fn(), +})); + +vi.mock('../../packages/core/src/handlers/shadow-dom-js-checker.js', () => ({ + checkShadowDomJs: vi.fn(), +})); + +vi.mock('../../packages/core/src/handlers/css-api-resolver.js', () => ({ + resolveCssApi: vi.fn(), +})); + +vi.mock('../../packages/core/src/handlers/styling-preflight.js', () => ({ + runStylingPreflight: vi.fn(), +})); + +vi.mock('../../packages/core/src/handlers/css-file-validator.js', () => ({ + validateCssFile: vi.fn(), +})); + +vi.mock('../../packages/core/src/handlers/dark-mode-checker.js', () => ({ + checkDarkModePatterns: vi.fn(), +})); + +// --------------------------------------------------------------------------- +// Import mocked handlers for assertion +// --------------------------------------------------------------------------- + +import { parseCem } from '../../packages/core/src/handlers/cem.js'; +import { diagnoseStyling } from '../../packages/core/src/handlers/styling-diagnostics.js'; +import { checkShadowDomUsage } from '../../packages/core/src/handlers/shadow-dom-checker.js'; +import { checkHtmlUsage } from '../../packages/core/src/handlers/html-usage-checker.js'; +import { checkEventUsage } from '../../packages/core/src/handlers/event-usage-checker.js'; +import { getComponentQuickRef } from '../../packages/core/src/handlers/quick-ref.js'; +import { detectThemeSupport } from '../../packages/core/src/handlers/theme-detection.js'; +import { checkComponentImports } from '../../packages/core/src/handlers/import-checker.js'; +import { checkSlotChildren } from '../../packages/core/src/handlers/slot-children-checker.js'; +import { checkAttributeConflicts } from '../../packages/core/src/handlers/attribute-conflict-checker.js'; +import { checkA11yUsage } from '../../packages/core/src/handlers/a11y-usage-checker.js'; +import { checkCssVars } from '../../packages/core/src/handlers/css-var-checker.js'; +import { validateComponentCode } from '../../packages/core/src/handlers/code-validator.js'; +import { checkTokenFallbacks } from '../../packages/core/src/handlers/token-fallback-checker.js'; +import { checkComposition } from '../../packages/core/src/handlers/composition-checker.js'; +import { checkMethodCalls } from '../../packages/core/src/handlers/method-checker.js'; +import { checkThemeCompatibility } from '../../packages/core/src/handlers/theme-checker.js'; +import { recommendChecks } from '../../packages/core/src/handlers/recommend-checks.js'; +import { suggestFix } from '../../packages/core/src/handlers/suggest-fix.js'; +import { checkCssSpecificity } from '../../packages/core/src/handlers/specificity-checker.js'; +import { checkLayoutPatterns } from '../../packages/core/src/handlers/layout-checker.js'; +import { checkCssScope } from '../../packages/core/src/handlers/scope-checker.js'; +import { checkCssShorthand } from '../../packages/core/src/handlers/shorthand-checker.js'; +import { checkColorContrast } from '../../packages/core/src/handlers/color-contrast-checker.js'; +import { checkTransitionAnimation } from '../../packages/core/src/handlers/transition-checker.js'; +import { checkShadowDomJs } from '../../packages/core/src/handlers/shadow-dom-js-checker.js'; +import { resolveCssApi } from '../../packages/core/src/handlers/css-api-resolver.js'; +import { runStylingPreflight } from '../../packages/core/src/handlers/styling-preflight.js'; +import { validateCssFile } from '../../packages/core/src/handlers/css-file-validator.js'; +import { checkDarkModePatterns } from '../../packages/core/src/handlers/dark-mode-checker.js'; + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +/** Minimal CEM stub — enough for parseCem's type expectations */ +const FAKE_CEM: Cem = { + schemaVersion: '1.0.0', + modules: [], +}; + +/** Minimal component metadata returned by parseCem mocks */ +const FAKE_META = { + tagName: 'my-button', + name: 'MyButton', + description: 'A button component.', + members: [], + events: [], + slots: [], + cssProperties: [], + cssParts: [], +}; + +afterEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); +}); + +// --------------------------------------------------------------------------- +// isStylingTool +// --------------------------------------------------------------------------- + +describe('isStylingTool', () => { + it('returns true for every defined styling tool name', () => { + const toolNames = [ + 'diagnose_styling', + 'check_shadow_dom_usage', + 'check_html_usage', + 'check_event_usage', + 'get_component_quick_ref', + 'detect_theme_support', + 'check_component_imports', + 'check_slot_children', + 'check_attribute_conflicts', + 'check_a11y_usage', + 'check_css_vars', + 'validate_component_code', + 'check_token_fallbacks', + 'check_composition', + 'check_method_calls', + 'check_theme_compatibility', + 'recommend_checks', + 'suggest_fix', + 'check_css_specificity', + 'check_layout_patterns', + 'check_css_scope', + 'check_css_shorthand', + 'check_color_contrast', + 'check_transition_animation', + 'check_shadow_dom_js', + 'resolve_css_api', + 'styling_preflight', + 'validate_css_file', + 'check_dark_mode_patterns', + ]; + for (const name of toolNames) { + expect(isStylingTool(name), `expected ${name} to be a styling tool`).toBe(true); + } + }); + + it('returns false for non-styling tool names', () => { + expect(isStylingTool('get_component')).toBe(false); + expect(isStylingTool('get_design_tokens')).toBe(false); + expect(isStylingTool('unknown_tool')).toBe(false); + expect(isStylingTool('')).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// handleStylingCall — unknown tool +// --------------------------------------------------------------------------- + +describe('handleStylingCall — unknown tool', () => { + it('returns an error for an unrecognised tool name', () => { + const result = handleStylingCall('nonexistent_tool', {}, FAKE_CEM); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Unknown styling tool'); + }); +}); + +// --------------------------------------------------------------------------- +// handleStylingCall — diagnose_styling +// --------------------------------------------------------------------------- + +describe('handleStylingCall — diagnose_styling', () => { + it('calls parseCem and diagnoseStyling and returns their result', () => { + vi.mocked(parseCem).mockReturnValue(FAKE_META); + vi.mocked(diagnoseStyling).mockReturnValue({ tokenPrefix: '--my-', approach: 'tokens' }); + + const result = handleStylingCall('diagnose_styling', { tagName: 'my-button' }, FAKE_CEM); + + expect(result.isError).toBeFalsy(); + expect(vi.mocked(parseCem)).toHaveBeenCalledWith('my-button', FAKE_CEM); + expect(vi.mocked(diagnoseStyling)).toHaveBeenCalledWith(FAKE_META); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.tokenPrefix).toBe('--my-'); + }); + + it('returns error when tagName is missing', () => { + const result = handleStylingCall('diagnose_styling', {}, FAKE_CEM); + expect(result.isError).toBe(true); + }); + + it('propagates errors from parseCem as error responses', () => { + vi.mocked(parseCem).mockImplementation(() => { + throw new Error('Component not found in CEM'); + }); + const result = handleStylingCall('diagnose_styling', { tagName: 'unknown-tag' }, FAKE_CEM); + expect(result.isError).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// handleStylingCall — check_shadow_dom_usage +// --------------------------------------------------------------------------- + +describe('handleStylingCall — check_shadow_dom_usage', () => { + it('calls checkShadowDomUsage and returns the result', () => { + vi.mocked(checkShadowDomUsage).mockReturnValue({ issues: [] }); + + const result = handleStylingCall( + 'check_shadow_dom_usage', + { cssText: 'my-button .inner { color: red; }' }, + FAKE_CEM, + ); + + expect(result.isError).toBeFalsy(); + expect(vi.mocked(checkShadowDomUsage)).toHaveBeenCalledWith( + 'my-button .inner { color: red; }', + undefined, + undefined, + ); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.issues).toEqual([]); + }); + + it('passes tagName to checkShadowDomUsage and attempts parseCem', () => { + vi.mocked(parseCem).mockReturnValue(FAKE_META); + vi.mocked(checkShadowDomUsage).mockReturnValue({ issues: [] }); + + handleStylingCall( + 'check_shadow_dom_usage', + { cssText: 'my-button::part(base) { color: red; }', tagName: 'my-button' }, + FAKE_CEM, + ); + + expect(vi.mocked(parseCem)).toHaveBeenCalledWith('my-button', FAKE_CEM); + expect(vi.mocked(checkShadowDomUsage)).toHaveBeenCalledWith( + 'my-button::part(base) { color: red; }', + 'my-button', + FAKE_META, + ); + }); + + it('still runs when tagName is provided but not in CEM (parseCem throws)', () => { + vi.mocked(parseCem).mockImplementation(() => { + throw new Error('not found'); + }); + vi.mocked(checkShadowDomUsage).mockReturnValue({ issues: [] }); + + const result = handleStylingCall( + 'check_shadow_dom_usage', + { cssText: 'x-button .foo {}', tagName: 'x-button' }, + FAKE_CEM, + ); + + expect(result.isError).toBeFalsy(); + // meta should be undefined when parseCem throws + expect(vi.mocked(checkShadowDomUsage)).toHaveBeenCalledWith('x-button .foo {}', 'x-button', undefined); + }); + + it('returns error when cssText is missing', () => { + const result = handleStylingCall('check_shadow_dom_usage', {}, FAKE_CEM); + expect(result.isError).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// handleStylingCall — check_html_usage +// --------------------------------------------------------------------------- + +describe('handleStylingCall — check_html_usage', () => { + it('calls checkHtmlUsage and returns the result', () => { + vi.mocked(parseCem).mockReturnValue(FAKE_META); + vi.mocked(checkHtmlUsage).mockReturnValue({ issues: [] }); + + const result = handleStylingCall( + 'check_html_usage', + { htmlText: '', tagName: 'my-button' }, + FAKE_CEM, + ); + + expect(result.isError).toBeFalsy(); + expect(vi.mocked(checkHtmlUsage)).toHaveBeenCalledWith( + '', + FAKE_META, + ); + }); + + it('returns error when required args are missing', () => { + const result = handleStylingCall('check_html_usage', { htmlText: '
' }, FAKE_CEM); + expect(result.isError).toBe(true); + }); + + it('returns error when both args are missing', () => { + const result = handleStylingCall('check_html_usage', {}, FAKE_CEM); + expect(result.isError).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// handleStylingCall — check_event_usage +// --------------------------------------------------------------------------- + +describe('handleStylingCall — check_event_usage', () => { + it('calls checkEventUsage with parsed args and returns result', () => { + vi.mocked(parseCem).mockReturnValue(FAKE_META); + vi.mocked(checkEventUsage).mockReturnValue({ issues: [] }); + + const result = handleStylingCall( + 'check_event_usage', + { codeText: 'el.addEventListener("my-click", fn)', tagName: 'my-button' }, + FAKE_CEM, + ); + + expect(result.isError).toBeFalsy(); + expect(vi.mocked(checkEventUsage)).toHaveBeenCalledWith( + 'el.addEventListener("my-click", fn)', + FAKE_META, + undefined, + ); + }); + + it('passes the framework hint through to checkEventUsage', () => { + vi.mocked(parseCem).mockReturnValue(FAKE_META); + vi.mocked(checkEventUsage).mockReturnValue({ issues: [] }); + + handleStylingCall( + 'check_event_usage', + { codeText: '', tagName: 'my-button', framework: 'react' }, + FAKE_CEM, + ); + + expect(vi.mocked(checkEventUsage)).toHaveBeenCalledWith(expect.any(String), FAKE_META, 'react'); + }); + + it('returns error for invalid framework enum value', () => { + const result = handleStylingCall( + 'check_event_usage', + { codeText: 'code', tagName: 'my-button', framework: 'svelte' }, + FAKE_CEM, + ); + expect(result.isError).toBe(true); + }); + + it('returns error when required args are missing', () => { + const result = handleStylingCall('check_event_usage', {}, FAKE_CEM); + expect(result.isError).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// handleStylingCall — get_component_quick_ref +// --------------------------------------------------------------------------- + +describe('handleStylingCall — get_component_quick_ref', () => { + it('calls getComponentQuickRef and returns the result', () => { + vi.mocked(parseCem).mockReturnValue(FAKE_META); + vi.mocked(getComponentQuickRef).mockReturnValue({ attributes: [], parts: [] }); + + const result = handleStylingCall( + 'get_component_quick_ref', + { tagName: 'my-button' }, + FAKE_CEM, + ); + + expect(result.isError).toBeFalsy(); + expect(vi.mocked(getComponentQuickRef)).toHaveBeenCalledWith(FAKE_META); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.attributes).toEqual([]); + }); + + it('returns error when tagName is missing', () => { + const result = handleStylingCall('get_component_quick_ref', {}, FAKE_CEM); + expect(result.isError).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// handleStylingCall — detect_theme_support +// --------------------------------------------------------------------------- + +describe('handleStylingCall — detect_theme_support', () => { + it('calls detectThemeSupport with the CEM and returns result', () => { + vi.mocked(detectThemeSupport).mockReturnValue({ score: 80, categories: ['color', 'spacing'] }); + + const result = handleStylingCall('detect_theme_support', {}, FAKE_CEM); + + expect(result.isError).toBeFalsy(); + expect(vi.mocked(detectThemeSupport)).toHaveBeenCalledWith(FAKE_CEM); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.score).toBe(80); + }); +}); + +// --------------------------------------------------------------------------- +// handleStylingCall — check_component_imports +// --------------------------------------------------------------------------- + +describe('handleStylingCall — check_component_imports', () => { + it('calls checkComponentImports and returns the result', () => { + vi.mocked(checkComponentImports).mockReturnValue({ unknown: [], valid: ['my-button'] }); + + const result = handleStylingCall( + 'check_component_imports', + { codeText: '' }, + FAKE_CEM, + ); + + expect(result.isError).toBeFalsy(); + expect(vi.mocked(checkComponentImports)).toHaveBeenCalledWith( + '', + FAKE_CEM, + ); + }); + + it('returns error when codeText is missing', () => { + const result = handleStylingCall('check_component_imports', {}, FAKE_CEM); + expect(result.isError).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// handleStylingCall — check_slot_children +// --------------------------------------------------------------------------- + +describe('handleStylingCall — check_slot_children', () => { + it('calls checkSlotChildren and returns the result', () => { + vi.mocked(checkSlotChildren).mockReturnValue({ issues: [] }); + + const result = handleStylingCall( + 'check_slot_children', + { htmlText: 'label', tagName: 'my-button' }, + FAKE_CEM, + ); + + expect(result.isError).toBeFalsy(); + expect(vi.mocked(checkSlotChildren)).toHaveBeenCalledWith( + 'label', + 'my-button', + FAKE_CEM, + ); + }); + + it('returns error when required args are missing', () => { + const result = handleStylingCall('check_slot_children', {}, FAKE_CEM); + expect(result.isError).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// handleStylingCall — check_attribute_conflicts +// --------------------------------------------------------------------------- + +describe('handleStylingCall — check_attribute_conflicts', () => { + it('calls checkAttributeConflicts and returns the result', () => { + vi.mocked(checkAttributeConflicts).mockReturnValue({ issues: [] }); + + const result = handleStylingCall( + 'check_attribute_conflicts', + { htmlText: 'Go', tagName: 'my-button' }, + FAKE_CEM, + ); + + expect(result.isError).toBeFalsy(); + expect(vi.mocked(checkAttributeConflicts)).toHaveBeenCalledWith( + 'Go', + 'my-button', + FAKE_CEM, + ); + }); + + it('returns error when required args are missing', () => { + const result = handleStylingCall('check_attribute_conflicts', {}, FAKE_CEM); + expect(result.isError).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// handleStylingCall — check_a11y_usage +// --------------------------------------------------------------------------- + +describe('handleStylingCall — check_a11y_usage', () => { + it('calls checkA11yUsage and returns the result', () => { + vi.mocked(checkA11yUsage).mockReturnValue({ issues: [] }); + + const result = handleStylingCall( + 'check_a11y_usage', + { htmlText: '', tagName: 'my-button' }, + FAKE_CEM, + ); + + expect(result.isError).toBeFalsy(); + expect(vi.mocked(checkA11yUsage)).toHaveBeenCalledWith( + '', + 'my-button', + FAKE_CEM, + ); + }); + + it('returns error when required args are missing', () => { + const result = handleStylingCall('check_a11y_usage', {}, FAKE_CEM); + expect(result.isError).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// handleStylingCall — check_css_vars +// --------------------------------------------------------------------------- + +describe('handleStylingCall — check_css_vars', () => { + it('calls checkCssVars and returns the result', () => { + vi.mocked(checkCssVars).mockReturnValue({ issues: [] }); + + const result = handleStylingCall( + 'check_css_vars', + { cssText: 'my-button { --my-color: red; }', tagName: 'my-button' }, + FAKE_CEM, + ); + + expect(result.isError).toBeFalsy(); + expect(vi.mocked(checkCssVars)).toHaveBeenCalledWith( + 'my-button { --my-color: red; }', + 'my-button', + FAKE_CEM, + ); + }); + + it('returns error when required args are missing', () => { + const result = handleStylingCall('check_css_vars', {}, FAKE_CEM); + expect(result.isError).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// handleStylingCall — check_theme_compatibility +// --------------------------------------------------------------------------- + +describe('handleStylingCall — check_theme_compatibility', () => { + it('calls checkThemeCompatibility and returns the result', () => { + vi.mocked(checkThemeCompatibility).mockReturnValue({ issues: [] }); + + const result = handleStylingCall( + 'check_theme_compatibility', + { cssText: 'my-button { color: #000; }' }, + FAKE_CEM, + ); + + expect(result.isError).toBeFalsy(); + expect(vi.mocked(checkThemeCompatibility)).toHaveBeenCalledWith('my-button { color: #000; }'); + }); + + it('returns error when cssText is missing', () => { + const result = handleStylingCall('check_theme_compatibility', {}, FAKE_CEM); + expect(result.isError).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// handleStylingCall — check_method_calls +// --------------------------------------------------------------------------- + +describe('handleStylingCall — check_method_calls', () => { + it('calls checkMethodCalls and returns the result', () => { + vi.mocked(checkMethodCalls).mockReturnValue({ issues: [] }); + + const result = handleStylingCall( + 'check_method_calls', + { codeText: 'el.show()', tagName: 'my-dialog' }, + FAKE_CEM, + ); + + expect(result.isError).toBeFalsy(); + expect(vi.mocked(checkMethodCalls)).toHaveBeenCalledWith('el.show()', 'my-dialog', FAKE_CEM); + }); + + it('returns error when required args are missing', () => { + const result = handleStylingCall('check_method_calls', {}, FAKE_CEM); + expect(result.isError).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// handleStylingCall — check_composition +// --------------------------------------------------------------------------- + +describe('handleStylingCall — check_composition', () => { + it('calls checkComposition and returns the result', () => { + vi.mocked(checkComposition).mockReturnValue({ issues: [] }); + + const result = handleStylingCall( + 'check_composition', + { htmlText: 'Tab 1' }, + FAKE_CEM, + ); + + expect(result.isError).toBeFalsy(); + expect(vi.mocked(checkComposition)).toHaveBeenCalledWith( + 'Tab 1', + FAKE_CEM, + ); + }); + + it('returns error when htmlText is missing', () => { + const result = handleStylingCall('check_composition', {}, FAKE_CEM); + expect(result.isError).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// handleStylingCall — recommend_checks +// --------------------------------------------------------------------------- + +describe('handleStylingCall — recommend_checks', () => { + it('calls recommendChecks and returns the result', () => { + vi.mocked(recommendChecks).mockReturnValue({ + tools: ['check_shadow_dom_usage', 'check_html_usage'], + }); + + const result = handleStylingCall( + 'recommend_checks', + { codeText: '' }, + FAKE_CEM, + ); + + expect(result.isError).toBeFalsy(); + expect(vi.mocked(recommendChecks)).toHaveBeenCalledWith(''); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.tools).toContain('check_shadow_dom_usage'); + }); + + it('returns error when codeText is missing', () => { + const result = handleStylingCall('recommend_checks', {}, FAKE_CEM); + expect(result.isError).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// handleStylingCall — suggest_fix +// --------------------------------------------------------------------------- + +describe('handleStylingCall — suggest_fix', () => { + it('calls suggestFix and returns the result', () => { + vi.mocked(suggestFix).mockReturnValue({ fix: 'Use ::part(base) instead.' }); + + const result = handleStylingCall( + 'suggest_fix', + { type: 'shadow-dom', issue: 'descendant-piercing', original: 'my-button .inner {}' }, + FAKE_CEM, + ); + + expect(result.isError).toBeFalsy(); + expect(vi.mocked(suggestFix)).toHaveBeenCalledWith( + expect.objectContaining({ type: 'shadow-dom', issue: 'descendant-piercing' }), + ); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.fix).toContain('::part(base)'); + }); + + it('passes all optional fields to suggestFix', () => { + vi.mocked(suggestFix).mockReturnValue({ fix: 'ok' }); + + handleStylingCall( + 'suggest_fix', + { + type: 'method-call', + issue: 'property-as-method', + original: 'el.open()', + tagName: 'my-dialog', + memberName: 'open', + suggestedName: 'show', + }, + FAKE_CEM, + ); + + expect(vi.mocked(suggestFix)).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'method-call', + tagName: 'my-dialog', + memberName: 'open', + suggestedName: 'show', + }), + ); + }); + + it('returns error when required args are missing', () => { + const result = handleStylingCall('suggest_fix', {}, FAKE_CEM); + expect(result.isError).toBe(true); + }); + + it('returns error for invalid type enum value', () => { + const result = handleStylingCall( + 'suggest_fix', + { type: 'not-a-type', issue: 'foo', original: 'bar' }, + FAKE_CEM, + ); + expect(result.isError).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// handleStylingCall — check_css_specificity +// --------------------------------------------------------------------------- + +describe('handleStylingCall — check_css_specificity', () => { + it('calls checkCssSpecificity without mode when mode is omitted', () => { + vi.mocked(checkCssSpecificity).mockReturnValue({ issues: [] }); + + const result = handleStylingCall( + 'check_css_specificity', + { code: '#app my-button { color: red !important; }' }, + FAKE_CEM, + ); + + expect(result.isError).toBeFalsy(); + expect(vi.mocked(checkCssSpecificity)).toHaveBeenCalledWith( + '#app my-button { color: red !important; }', + undefined, + ); + }); + + it('passes mode option to checkCssSpecificity', () => { + vi.mocked(checkCssSpecificity).mockReturnValue({ issues: [] }); + + handleStylingCall( + 'check_css_specificity', + { code: '', mode: 'html' }, + FAKE_CEM, + ); + + expect(vi.mocked(checkCssSpecificity)).toHaveBeenCalledWith(expect.any(String), { mode: 'html' }); + }); + + it('returns error when code is missing', () => { + const result = handleStylingCall('check_css_specificity', {}, FAKE_CEM); + expect(result.isError).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// handleStylingCall — check_layout_patterns +// --------------------------------------------------------------------------- + +describe('handleStylingCall — check_layout_patterns', () => { + it('calls checkLayoutPatterns and returns the result', () => { + vi.mocked(checkLayoutPatterns).mockReturnValue({ issues: [] }); + + const result = handleStylingCall( + 'check_layout_patterns', + { cssText: 'my-button { display: flex; }' }, + FAKE_CEM, + ); + + expect(result.isError).toBeFalsy(); + expect(vi.mocked(checkLayoutPatterns)).toHaveBeenCalledWith('my-button { display: flex; }'); + }); + + it('returns error when cssText is missing', () => { + const result = handleStylingCall('check_layout_patterns', {}, FAKE_CEM); + expect(result.isError).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// handleStylingCall — check_css_scope +// --------------------------------------------------------------------------- + +describe('handleStylingCall — check_css_scope', () => { + it('calls checkCssScope and returns the result', () => { + vi.mocked(checkCssScope).mockReturnValue({ issues: [] }); + + const result = handleStylingCall( + 'check_css_scope', + { cssText: ':root { --my-button-color: red; }', tagName: 'my-button', cem: FAKE_CEM }, + FAKE_CEM, + ); + + expect(result.isError).toBeFalsy(); + expect(vi.mocked(checkCssScope)).toHaveBeenCalledWith( + ':root { --my-button-color: red; }', + 'my-button', + FAKE_CEM, + ); + }); + + it('returns error when required args are missing', () => { + const result = handleStylingCall('check_css_scope', {}, FAKE_CEM); + expect(result.isError).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// handleStylingCall — check_css_shorthand +// --------------------------------------------------------------------------- + +describe('handleStylingCall — check_css_shorthand', () => { + it('calls checkCssShorthand and returns the result', () => { + vi.mocked(checkCssShorthand).mockReturnValue({ issues: [] }); + + const result = handleStylingCall( + 'check_css_shorthand', + { cssText: 'my-button { border: 1px solid var(--my-color); }' }, + FAKE_CEM, + ); + + expect(result.isError).toBeFalsy(); + expect(vi.mocked(checkCssShorthand)).toHaveBeenCalledWith( + 'my-button { border: 1px solid var(--my-color); }', + ); + }); + + it('returns error when cssText is missing', () => { + const result = handleStylingCall('check_css_shorthand', {}, FAKE_CEM); + expect(result.isError).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// handleStylingCall — check_color_contrast +// --------------------------------------------------------------------------- + +describe('handleStylingCall — check_color_contrast', () => { + it('calls checkColorContrast and returns the result', () => { + vi.mocked(checkColorContrast).mockReturnValue({ issues: [] }); + + const result = handleStylingCall( + 'check_color_contrast', + { cssText: 'my-button { color: #fff; background: #f0f0f0; }' }, + FAKE_CEM, + ); + + expect(result.isError).toBeFalsy(); + expect(vi.mocked(checkColorContrast)).toHaveBeenCalledWith( + 'my-button { color: #fff; background: #f0f0f0; }', + ); + }); + + it('returns error when cssText is missing', () => { + const result = handleStylingCall('check_color_contrast', {}, FAKE_CEM); + expect(result.isError).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// handleStylingCall — check_transition_animation +// --------------------------------------------------------------------------- + +describe('handleStylingCall — check_transition_animation', () => { + it('calls checkTransitionAnimation and returns the result', () => { + vi.mocked(checkTransitionAnimation).mockReturnValue({ issues: [] }); + + const result = handleStylingCall( + 'check_transition_animation', + { cssText: 'my-button { transition: color 0.3s; }', tagName: 'my-button' }, + FAKE_CEM, + ); + + expect(result.isError).toBeFalsy(); + expect(vi.mocked(checkTransitionAnimation)).toHaveBeenCalledWith( + 'my-button { transition: color 0.3s; }', + 'my-button', + ); + }); + + it('returns error when required args are missing', () => { + const result = handleStylingCall('check_transition_animation', {}, FAKE_CEM); + expect(result.isError).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// handleStylingCall — check_shadow_dom_js +// --------------------------------------------------------------------------- + +describe('handleStylingCall — check_shadow_dom_js', () => { + it('calls checkShadowDomJs and returns the result', () => { + vi.mocked(checkShadowDomJs).mockReturnValue({ issues: [] }); + + const result = handleStylingCall( + 'check_shadow_dom_js', + { codeText: 'el.shadowRoot.querySelector(".foo")' }, + FAKE_CEM, + ); + + expect(result.isError).toBeFalsy(); + expect(vi.mocked(checkShadowDomJs)).toHaveBeenCalledWith( + 'el.shadowRoot.querySelector(".foo")', + undefined, + ); + }); + + it('passes optional tagName to checkShadowDomJs', () => { + vi.mocked(checkShadowDomJs).mockReturnValue({ issues: [] }); + + handleStylingCall( + 'check_shadow_dom_js', + { codeText: 'el.shadowRoot.querySelector(".foo")', tagName: 'my-button' }, + FAKE_CEM, + ); + + expect(vi.mocked(checkShadowDomJs)).toHaveBeenCalledWith(expect.any(String), 'my-button'); + }); + + it('returns error when codeText is missing', () => { + const result = handleStylingCall('check_shadow_dom_js', {}, FAKE_CEM); + expect(result.isError).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// handleStylingCall — check_token_fallbacks +// --------------------------------------------------------------------------- + +describe('handleStylingCall — check_token_fallbacks', () => { + it('calls checkTokenFallbacks and returns the result', () => { + vi.mocked(checkTokenFallbacks).mockReturnValue({ issues: [] }); + + const result = handleStylingCall( + 'check_token_fallbacks', + { cssText: 'my-button { color: var(--my-color); }', tagName: 'my-button' }, + FAKE_CEM, + ); + + expect(result.isError).toBeFalsy(); + expect(vi.mocked(checkTokenFallbacks)).toHaveBeenCalledWith( + 'my-button { color: var(--my-color); }', + 'my-button', + FAKE_CEM, + ); + }); + + it('returns error when required args are missing', () => { + const result = handleStylingCall('check_token_fallbacks', {}, FAKE_CEM); + expect(result.isError).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// handleStylingCall — validate_component_code +// --------------------------------------------------------------------------- + +describe('handleStylingCall — validate_component_code', () => { + it('calls validateComponentCode and returns the result', () => { + vi.mocked(validateComponentCode).mockReturnValue({ issues: [], passed: true }); + + const result = handleStylingCall( + 'validate_component_code', + { html: '', tagName: 'my-button' }, + FAKE_CEM, + ); + + expect(result.isError).toBeFalsy(); + expect(vi.mocked(validateComponentCode)).toHaveBeenCalledWith( + expect.objectContaining({ html: '', tagName: 'my-button', cem: FAKE_CEM }), + ); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.passed).toBe(true); + }); + + it('passes optional css, code, and framework args', () => { + vi.mocked(validateComponentCode).mockReturnValue({ issues: [] }); + + handleStylingCall( + 'validate_component_code', + { + html: '', + tagName: 'my-button', + css: 'my-button { --color: red; }', + code: 'el.addEventListener("my-click", fn)', + framework: 'vue', + }, + FAKE_CEM, + ); + + expect(vi.mocked(validateComponentCode)).toHaveBeenCalledWith( + expect.objectContaining({ css: 'my-button { --color: red; }', framework: 'vue' }), + ); + }); + + it('returns error when required args are missing', () => { + const result = handleStylingCall('validate_component_code', {}, FAKE_CEM); + expect(result.isError).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// handleStylingCall — resolve_css_api +// --------------------------------------------------------------------------- + +describe('handleStylingCall — resolve_css_api', () => { + it('calls resolveCssApi and returns the result', () => { + vi.mocked(parseCem).mockReturnValue(FAKE_META); + vi.mocked(resolveCssApi).mockReturnValue({ valid: [], invalid: [] }); + + const result = handleStylingCall( + 'resolve_css_api', + { cssText: 'my-button::part(base) {}', tagName: 'my-button' }, + FAKE_CEM, + ); + + expect(result.isError).toBeFalsy(); + expect(vi.mocked(resolveCssApi)).toHaveBeenCalledWith( + 'my-button::part(base) {}', + FAKE_META, + undefined, + ); + }); + + it('passes optional htmlText to resolveCssApi', () => { + vi.mocked(parseCem).mockReturnValue(FAKE_META); + vi.mocked(resolveCssApi).mockReturnValue({ valid: [], invalid: [] }); + + handleStylingCall( + 'resolve_css_api', + { + cssText: 'my-button::part(base) {}', + tagName: 'my-button', + htmlText: '', + }, + FAKE_CEM, + ); + + expect(vi.mocked(resolveCssApi)).toHaveBeenCalledWith( + expect.any(String), + FAKE_META, + '', + ); + }); + + it('returns error when required args are missing', () => { + const result = handleStylingCall('resolve_css_api', {}, FAKE_CEM); + expect(result.isError).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// handleStylingCall — styling_preflight +// --------------------------------------------------------------------------- + +describe('handleStylingCall — styling_preflight', () => { + it('calls runStylingPreflight and returns the result', () => { + vi.mocked(parseCem).mockReturnValue(FAKE_META); + vi.mocked(runStylingPreflight).mockReturnValue({ passed: true, issues: [] }); + + const result = handleStylingCall( + 'styling_preflight', + { cssText: 'my-button::part(base) { color: red; }', tagName: 'my-button' }, + FAKE_CEM, + ); + + expect(result.isError).toBeFalsy(); + expect(vi.mocked(runStylingPreflight)).toHaveBeenCalledWith({ + css: 'my-button::part(base) { color: red; }', + html: undefined, + meta: FAKE_META, + }); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.passed).toBe(true); + }); + + it('passes optional htmlText to runStylingPreflight', () => { + vi.mocked(parseCem).mockReturnValue(FAKE_META); + vi.mocked(runStylingPreflight).mockReturnValue({ passed: true, issues: [] }); + + handleStylingCall( + 'styling_preflight', + { + cssText: 'my-button::part(base) {}', + tagName: 'my-button', + htmlText: '', + }, + FAKE_CEM, + ); + + expect(vi.mocked(runStylingPreflight)).toHaveBeenCalledWith( + expect.objectContaining({ html: '' }), + ); + }); + + it('returns error when required args are missing', () => { + const result = handleStylingCall('styling_preflight', {}, FAKE_CEM); + expect(result.isError).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// handleStylingCall — validate_css_file +// --------------------------------------------------------------------------- + +describe('handleStylingCall — validate_css_file', () => { + it('calls validateCssFile and returns the result', () => { + vi.mocked(validateCssFile).mockReturnValue({ components: [], globalIssues: [] }); + + const result = handleStylingCall( + 'validate_css_file', + { cssText: 'my-button { --color: red; }\nmy-card::part(base) {}' }, + FAKE_CEM, + ); + + expect(result.isError).toBeFalsy(); + expect(vi.mocked(validateCssFile)).toHaveBeenCalledWith( + 'my-button { --color: red; }\nmy-card::part(base) {}', + FAKE_CEM, + ); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.globalIssues).toEqual([]); + }); + + it('returns error when cssText is missing', () => { + const result = handleStylingCall('validate_css_file', {}, FAKE_CEM); + expect(result.isError).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// handleStylingCall — check_dark_mode_patterns +// --------------------------------------------------------------------------- + +describe('handleStylingCall — check_dark_mode_patterns', () => { + it('calls checkDarkModePatterns and returns the result', () => { + vi.mocked(checkDarkModePatterns).mockReturnValue({ issues: [] }); + + const result = handleStylingCall( + 'check_dark_mode_patterns', + { cssText: '.dark my-button { color: white; }' }, + FAKE_CEM, + ); + + expect(result.isError).toBeFalsy(); + expect(vi.mocked(checkDarkModePatterns)).toHaveBeenCalledWith('.dark my-button { color: white; }'); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.issues).toEqual([]); + }); + + it('returns error when cssText is missing', () => { + const result = handleStylingCall('check_dark_mode_patterns', {}, FAKE_CEM); + expect(result.isError).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// handleStylingCall — error propagation via handleToolError +// --------------------------------------------------------------------------- + +describe('handleStylingCall — error propagation', () => { + it('wraps MCPError category into the error message', () => { + vi.mocked(parseCem).mockImplementation(() => { + throw new MCPError('Component "bad-tag" not found in CEM.', ErrorCategory.NOT_FOUND); + }); + + const result = handleStylingCall('diagnose_styling', { tagName: 'bad-tag' }, FAKE_CEM); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('NOT_FOUND'); + expect(result.content[0].text).toContain('bad-tag'); + }); + + it('wraps generic Error thrown by a handler', () => { + vi.mocked(checkShadowDomUsage).mockImplementation(() => { + throw new Error('unexpected handler failure'); + }); + + const result = handleStylingCall( + 'check_shadow_dom_usage', + { cssText: 'some-css {}' }, + FAKE_CEM, + ); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('unexpected handler failure'); + }); +}); diff --git a/tests/tools/theme.test.ts b/tests/tools/theme.test.ts new file mode 100644 index 0000000..68ecd06 --- /dev/null +++ b/tests/tools/theme.test.ts @@ -0,0 +1,247 @@ +/** + * Tests for the create_theme and apply_theme_tokens tool dispatchers. + * Covers isThemeTool, handleThemeCall, argument validation, + * and response formatting with CEM-based inputs. + */ +import { describe, it, expect, vi } from 'vitest'; +import { + isThemeTool, + handleThemeCall, +} from '../../packages/core/src/tools/theme.js'; +import type { Cem } from '../../packages/core/src/handlers/cem.js'; + +// ─── Mocks ──────────────────────────────────────────────────────────────────── + +vi.mock('../../packages/core/src/handlers/theme.js', () => ({ + createTheme: vi.fn((_cem: unknown, opts?: { themeName?: string; prefix?: string }) => ({ + themeName: opts?.themeName ?? 'theme', + prefix: opts?.prefix ?? '--hx-', + tokenCount: 12, + categoryCounts: { color: 4, spacing: 3, font: 2, border: 2, elevation: 1 }, + fullThemeCSS: `.${opts?.themeName ?? 'theme'}-light { --hx-color-primary: #0066cc; }`, + })), + applyThemeTokens: vi.fn( + ( + _cem: unknown, + themeTokens: Record, + _tagNames?: string[], + ) => ({ + globalBlock: `:root {\n${Object.entries(themeTokens) + .map(([k, v]) => ` ${k}: ${v};`) + .join('\n')}\n}`, + componentBlocks: [ + { tagName: 'hx-button', css: 'hx-button { --hx-color-primary: #0066cc; }' }, + ], + matchedTokenCount: Object.keys(themeTokens).length, + }), + ), +})); + +// ─── 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: [], + cssProperties: [ + { name: '--hx-color-primary', description: 'Primary color' }, + { name: '--hx-spacing-md', description: 'Medium spacing' }, + ], + }, + ], + }, + ], +}; + +// ─── isThemeTool ────────────────────────────────────────────────────────────── + +describe('isThemeTool', () => { + it('returns true for create_theme', () => { + expect(isThemeTool('create_theme')).toBe(true); + }); + + it('returns true for apply_theme_tokens', () => { + expect(isThemeTool('apply_theme_tokens')).toBe(true); + }); + + it('returns false for unknown tool names', () => { + expect(isThemeTool('scaffold_component')).toBe(false); + expect(isThemeTool('get_design_tokens')).toBe(false); + expect(isThemeTool('')).toBe(false); + }); + + it('returns false for near-matches', () => { + expect(isThemeTool('theme')).toBe(false); + expect(isThemeTool('create_themes')).toBe(false); + }); +}); + +// ─── handleThemeCall — create_theme ─────────────────────────────────────────── + +describe('handleThemeCall — create_theme', () => { + it('returns a success result with empty args', async () => { + const result = await handleThemeCall('create_theme', {}, FAKE_CEM); + expect(result.isError).toBeFalsy(); + }); + + it('returns JSON-parseable content', async () => { + const result = await handleThemeCall('create_theme', {}, FAKE_CEM); + expect(() => JSON.parse(result.content[0].text)).not.toThrow(); + }); + + it('accepts optional themeName', async () => { + const result = await handleThemeCall('create_theme', { themeName: 'brand' }, FAKE_CEM); + expect(result.isError).toBeFalsy(); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.themeName).toBe('brand'); + }); + + it('accepts optional prefix override', async () => { + const result = await handleThemeCall('create_theme', { prefix: '--my-' }, FAKE_CEM); + expect(result.isError).toBeFalsy(); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.prefix).toBe('--my-'); + }); + + it('uses "theme" as default themeName when omitted', async () => { + const result = await handleThemeCall('create_theme', {}, FAKE_CEM); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.themeName).toBe('theme'); + }); + + it('works with a rich CEM containing CSS properties', async () => { + const result = await handleThemeCall('create_theme', { themeName: 'enterprise' }, RICH_CEM); + expect(result.isError).toBeFalsy(); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.themeName).toBe('enterprise'); + }); + + it('rejects unexpected extra properties (Zod strict validation)', async () => { + const result = await handleThemeCall( + 'create_theme', + { themeName: 'brand', unknownProp: 'bad' }, + FAKE_CEM, + ); + expect(result.isError).toBe(true); + }); +}); + +// ─── handleThemeCall — apply_theme_tokens ───────────────────────────────────── + +describe('handleThemeCall — apply_theme_tokens', () => { + it('returns a success result with required themeTokens', async () => { + const result = await handleThemeCall( + 'apply_theme_tokens', + { themeTokens: { '--hx-color-primary': '#0066cc' } }, + FAKE_CEM, + ); + expect(result.isError).toBeFalsy(); + }); + + it('returns JSON-parseable content', async () => { + const result = await handleThemeCall( + 'apply_theme_tokens', + { themeTokens: { '--hx-color-primary': '#0066cc' } }, + FAKE_CEM, + ); + expect(() => JSON.parse(result.content[0].text)).not.toThrow(); + }); + + it('passes multiple tokens correctly', async () => { + const tokens = { + '--hx-color-primary': '#0066cc', + '--hx-spacing-md': '1rem', + '--hx-font-family': 'sans-serif', + }; + const result = await handleThemeCall( + 'apply_theme_tokens', + { themeTokens: tokens }, + RICH_CEM, + ); + expect(result.isError).toBeFalsy(); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.matchedTokenCount).toBe(3); + }); + + it('accepts optional tagNames filter', async () => { + const result = await handleThemeCall( + 'apply_theme_tokens', + { + themeTokens: { '--hx-color-primary': '#0066cc' }, + tagNames: ['hx-button'], + }, + RICH_CEM, + ); + expect(result.isError).toBeFalsy(); + }); + + it('returns error when themeTokens is missing', async () => { + const result = await handleThemeCall('apply_theme_tokens', {}, FAKE_CEM); + expect(result.isError).toBe(true); + }); + + it('returns error when themeTokens contains non-string values', async () => { + const result = await handleThemeCall( + 'apply_theme_tokens', + { themeTokens: { '--hx-color-primary': 42 } }, + FAKE_CEM, + ); + expect(result.isError).toBe(true); + }); +}); + +// ─── handleThemeCall — error cases ──────────────────────────────────────────── + +describe('handleThemeCall — error cases', () => { + it('returns error for unknown tool name', async () => { + const result = await handleThemeCall('nonexistent_tool', {}, FAKE_CEM); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Unknown theme tool'); + }); + + it('returns error for empty tool name', async () => { + const result = await handleThemeCall('', {}, FAKE_CEM); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Unknown theme tool'); + }); +}); + +// ─── handleThemeCall — handler error propagation ────────────────────────────── + +describe('handleThemeCall — handler error propagation', () => { + it('returns error when createTheme handler throws', async () => { + const { createTheme } = await import('../../packages/core/src/handlers/theme.js'); + vi.mocked(createTheme).mockImplementationOnce(() => { + throw new Error('CEM has no CSS custom properties'); + }); + + const result = await handleThemeCall('create_theme', {}, FAKE_CEM); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('CEM has no CSS custom properties'); + }); + + it('returns error when applyThemeTokens handler throws', async () => { + const { applyThemeTokens } = await import('../../packages/core/src/handlers/theme.js'); + vi.mocked(applyThemeTokens).mockImplementationOnce(() => { + throw new Error('No matching components found'); + }); + + const result = await handleThemeCall( + 'apply_theme_tokens', + { themeTokens: { '--hx-color-primary': '#0066cc' } }, + FAKE_CEM, + ); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('No matching components found'); + }); +}); 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'); + }); +});