diff --git a/README.md b/README.md index de39877..f449170 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-87%2B-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) 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/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/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/core/src/tools/library.ts b/packages/core/src/tools/library.ts index 10eb77b..7e4274a 100644 --- a/packages/core/src/tools/library.ts +++ b/packages/core/src/tools/library.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { readFileSync } from 'fs'; +import { readFile } from 'fs/promises'; import { resolve, sep } from 'path'; import type { McpWcConfig } from '../config.js'; @@ -124,7 +124,7 @@ export async function handleLibraryCall( } let raw: string; try { - raw = readFileSync(absPath, 'utf-8'); + raw = await readFile(absPath, 'utf-8'); } catch { return createErrorResponse(`CEM file not found at ${absPath}`); } 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..5d419f4 --- /dev/null +++ b/packages/vscode/package.json @@ -0,0 +1,67 @@ +{ + "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" + } + ], + "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/extension.ts b/packages/vscode/src/extension.ts new file mode 100644 index 0000000..47192ac --- /dev/null +++ b/packages/vscode/src/extension.ts @@ -0,0 +1,39 @@ +import * as vscode from 'vscode'; +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); + + 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/mcp/index.ts b/src/mcp/index.ts index 37aea86..c3c49bd 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -92,6 +92,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'; @@ -199,6 +209,8 @@ export async function main(): Promise { ...TYPEGENERATE_TOOL_DEFINITIONS, ...STYLING_TOOL_DEFINITIONS, ...THEME_TOOL_DEFINITIONS, + ...SCAFFOLD_TOOL_DEFINITIONS, + ...EXTEND_TOOL_DEFINITIONS, ...tsTools, ]; @@ -290,6 +302,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/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/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'); + }); +});