497 lines
14 KiB
JavaScript
497 lines
14 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Test suite for HeroUI Skill scripts (JavaScript/Node.js).
|
|
*
|
|
* Usage:
|
|
* # Test against production (default)
|
|
* node test_skills_scripts.mjs
|
|
*
|
|
* # Test against staging
|
|
* node test_skills_scripts.mjs --env staging
|
|
*
|
|
* # Test against local dev
|
|
* node test_skills_scripts.mjs --env dev
|
|
*
|
|
* # Test specific script
|
|
* node test_skills_scripts.mjs --script list_components
|
|
*
|
|
* # Verbose output
|
|
* node test_skills_scripts.mjs -v
|
|
*
|
|
* Environments:
|
|
* - dev: http://localhost:8787
|
|
* - staging: https://staging-mcp-api.heroui.com
|
|
* - production: https://mcp-api.heroui.com
|
|
*
|
|
* Note: Tests are updated for v1 API endpoints which return {results: [...]} format
|
|
* for batch requests (multiple components).
|
|
*/
|
|
|
|
import {execFile} from "child_process";
|
|
import {dirname, join} from "path";
|
|
import {fileURLToPath} from "url";
|
|
import {promisify} from "util";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
// Environment configurations
|
|
const ENVIRONMENTS = {
|
|
dev: "http://localhost:8787",
|
|
production: "https://mcp-api.heroui.com",
|
|
staging: "https://staging-mcp-api.heroui.com",
|
|
};
|
|
|
|
// Get script directory
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = dirname(__filename);
|
|
const SCRIPTS_DIR = join(__dirname, "..", "..", "skills/heroui-react/scripts");
|
|
const NODE = "node";
|
|
|
|
/**
|
|
* Result of a single test.
|
|
*/
|
|
class TestResult {
|
|
constructor(name, passed, duration, output, error = null) {
|
|
this.name = name;
|
|
this.passed = passed;
|
|
this.duration = duration;
|
|
this.output = output;
|
|
this.error = error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Run a script and return (stdout, stderr, return_code, duration).
|
|
*/
|
|
async function runScript(scriptName, args = null, envVars = null, timeout = 30000) {
|
|
const scriptPath = join(SCRIPTS_DIR, scriptName);
|
|
const cmd = [NODE, scriptPath, ...(args || [])];
|
|
|
|
const env = {...process.env};
|
|
|
|
if (envVars) {
|
|
Object.assign(env, envVars);
|
|
}
|
|
|
|
const start = Date.now();
|
|
|
|
try {
|
|
// Use AbortController for timeout (Node 15+)
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
|
|
try {
|
|
const {stderr, stdout} = await execFileAsync(cmd[0], cmd.slice(1), {
|
|
encoding: "utf8",
|
|
env,
|
|
signal: controller.signal,
|
|
});
|
|
|
|
clearTimeout(timeoutId);
|
|
const duration = (Date.now() - start) / 1000;
|
|
|
|
return [stdout, stderr, 0, duration];
|
|
} catch (error) {
|
|
clearTimeout(timeoutId);
|
|
|
|
if (error.signal === "SIGABRT" || error.code === "ABORT_ERR") {
|
|
const duration = (Date.now() - start) / 1000;
|
|
|
|
return ["", `Timeout after ${timeout / 1000}s`, -1, duration];
|
|
}
|
|
|
|
// execFile throws error with stdout/stderr in error object
|
|
const duration = (Date.now() - start) / 1000;
|
|
|
|
return [error.stdout || "", error.stderr || "", error.code || -1, duration];
|
|
}
|
|
} catch (error) {
|
|
const duration = (Date.now() - start) / 1000;
|
|
|
|
return ["", String(error), -1, duration];
|
|
}
|
|
}
|
|
|
|
async function testListComponents(apiBase, verbose = false) {
|
|
const name = "list_components";
|
|
const envVars = {HEROUI_API_BASE: apiBase};
|
|
|
|
const [stdout, _stderr, _code, duration] = await runScript("list_components.mjs", null, envVars);
|
|
|
|
// Validate output
|
|
try {
|
|
const data = JSON.parse(stdout);
|
|
const hasComponents = "components" in data && data.components.length > 0;
|
|
const hasVersion = "latestVersion" in data;
|
|
|
|
if (!hasComponents) {
|
|
return new TestResult(name, false, duration, stdout, "No components in response");
|
|
}
|
|
|
|
if (!hasVersion) {
|
|
return new TestResult(name, false, duration, stdout, "No latestVersion in response");
|
|
}
|
|
|
|
if (verbose) {
|
|
console.log(` Found ${data.components.length} components, version: ${data.latestVersion}`);
|
|
}
|
|
|
|
return new TestResult(name, true, duration, `Found ${data.components.length} components`);
|
|
} catch (e) {
|
|
return new TestResult(name, false, duration, stdout, `Invalid JSON: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
async function testGetComponentDocs(apiBase, verbose = false) {
|
|
const name = "get_component_docs";
|
|
const envVars = {HEROUI_API_BASE: apiBase};
|
|
|
|
// Test with single component
|
|
const [stdout, stderr, _code, duration] = await runScript(
|
|
"get_component_docs.mjs",
|
|
["Button"],
|
|
envVars,
|
|
);
|
|
|
|
// Should contain MDX content
|
|
const hasContent = stdout.length > 100;
|
|
|
|
if (!hasContent) {
|
|
return new TestResult(name, false, duration, stdout.slice(0, 200), "No content returned");
|
|
}
|
|
|
|
if (verbose) {
|
|
console.log(` Received ${stdout.length} chars of documentation`);
|
|
|
|
if (stderr.toLowerCase().includes("fallback")) {
|
|
console.log(` Note: Used fallback method`);
|
|
}
|
|
}
|
|
|
|
return new TestResult(name, true, duration, `Received ${stdout.length} chars`);
|
|
}
|
|
|
|
async function testGetComponentDocsMultiple(apiBase, verbose = false) {
|
|
const name = "get_component_docs_multiple";
|
|
const envVars = {HEROUI_API_BASE: apiBase};
|
|
|
|
const [stdout, _stderr, _code, duration] = await runScript(
|
|
"get_component_docs.mjs",
|
|
["Button", "Card"],
|
|
envVars,
|
|
);
|
|
|
|
// v1 API returns {results: [...]} format
|
|
try {
|
|
const data = JSON.parse(stdout);
|
|
|
|
// Check for v1 API format: {results: [...]}
|
|
if (data.results && Array.isArray(data.results) && data.results.length === 2) {
|
|
if (verbose) {
|
|
console.log(` Received docs for ${data.results.length} components (v1 format)`);
|
|
}
|
|
|
|
return new TestResult(name, true, duration, `Received ${data.results.length} component docs`);
|
|
}
|
|
|
|
// Fallback: check if it's a plain array (fallback format)
|
|
if (Array.isArray(data) && data.length === 2) {
|
|
if (verbose) {
|
|
console.log(` Received docs for ${data.length} components (fallback format)`);
|
|
}
|
|
|
|
return new TestResult(name, true, duration, `Received ${data.length} component docs`);
|
|
}
|
|
|
|
return new TestResult(
|
|
name,
|
|
false,
|
|
duration,
|
|
stdout.slice(0, 200),
|
|
"Expected {results: [...]} or array of 2 results",
|
|
);
|
|
} catch (e) {
|
|
// If not JSON, might be concatenated MDX (also acceptable)
|
|
if (stdout.length > 200) {
|
|
return new TestResult(name, true, duration, `Received ${stdout.length} chars (MDX format)`);
|
|
}
|
|
|
|
return new TestResult(name, false, duration, stdout.slice(0, 200), "Invalid response format");
|
|
}
|
|
}
|
|
|
|
async function testGetSource(apiBase, verbose = false) {
|
|
const name = "get_source";
|
|
const envVars = {HEROUI_API_BASE: apiBase};
|
|
|
|
const [stdout, _stderr, _code, duration] = await runScript("get_source.mjs", ["Button"], envVars);
|
|
|
|
// Should contain TypeScript/React code
|
|
const hasContent = stdout.length > 100;
|
|
|
|
if (!hasContent) {
|
|
return new TestResult(name, false, duration, stdout.slice(0, 200), "No content returned");
|
|
}
|
|
|
|
if (verbose) {
|
|
console.log(` Received ${stdout.length} chars of source code`);
|
|
|
|
if (stdout.includes("GitHub:")) {
|
|
console.log(` Includes GitHub URL`);
|
|
}
|
|
}
|
|
|
|
return new TestResult(name, true, duration, `Received ${stdout.length} chars`);
|
|
}
|
|
|
|
async function testGetStyles(apiBase, verbose = false) {
|
|
const name = "get_styles";
|
|
const envVars = {HEROUI_API_BASE: apiBase};
|
|
|
|
const [stdout, _stderr, _code, duration] = await runScript("get_styles.mjs", ["Button"], envVars);
|
|
|
|
// Should contain CSS
|
|
const hasBem = stdout.includes("--"); // BEM modifiers like .button--primary
|
|
const hasContent = stdout.length > 100;
|
|
|
|
if (!hasContent) {
|
|
return new TestResult(name, false, duration, stdout.slice(0, 200), "No content returned");
|
|
}
|
|
|
|
if (verbose) {
|
|
console.log(` Received ${stdout.length} chars of CSS`);
|
|
|
|
if (hasBem) {
|
|
console.log(` Contains BEM classes`);
|
|
}
|
|
}
|
|
|
|
return new TestResult(name, true, duration, `Received ${stdout.length} chars`);
|
|
}
|
|
|
|
async function testGetTheme(apiBase, verbose = false) {
|
|
const name = "get_theme";
|
|
const envVars = {HEROUI_API_BASE: apiBase};
|
|
|
|
const [stdout, _stderr, _code, duration] = await runScript("get_theme.mjs", null, envVars);
|
|
|
|
// Should contain CSS variables
|
|
const hasOklch = stdout.toLowerCase().includes("oklch");
|
|
const hasContent = stdout.length > 100;
|
|
|
|
if (!hasContent) {
|
|
return new TestResult(name, false, duration, stdout.slice(0, 200), "No content returned");
|
|
}
|
|
|
|
if (verbose) {
|
|
console.log(` Received ${stdout.length} chars of theme variables`);
|
|
|
|
if (hasOklch) {
|
|
console.log(` Uses oklch color format`);
|
|
}
|
|
}
|
|
|
|
return new TestResult(name, true, duration, `Received ${stdout.length} chars`);
|
|
}
|
|
|
|
async function testGetDocs(apiBase, verbose = false) {
|
|
const name = "get_docs";
|
|
const envVars = {HEROUI_API_BASE: apiBase};
|
|
|
|
const [stdout, _stderr, _code, duration] = await runScript(
|
|
"get_docs.mjs",
|
|
["/docs/react/getting-started/theming"],
|
|
envVars,
|
|
);
|
|
|
|
// Should contain MDX content about theming
|
|
const hasContent = stdout.length > 100;
|
|
|
|
if (!hasContent) {
|
|
return new TestResult(name, false, duration, stdout.slice(0, 200), "No content returned");
|
|
}
|
|
|
|
if (verbose) {
|
|
console.log(` Received ${stdout.length} chars of documentation`);
|
|
}
|
|
|
|
return new TestResult(name, true, duration, `Received ${stdout.length} chars`);
|
|
}
|
|
|
|
async function testGetDocsUsage(apiBase, verbose = false) {
|
|
const name = "get_docs_usage";
|
|
const envVars = {HEROUI_API_BASE: apiBase};
|
|
|
|
const [_stdout, stderr, _code, duration] = await runScript("get_docs.mjs", null, envVars);
|
|
|
|
// Should show usage in stderr
|
|
const hasUsage =
|
|
stderr.toLowerCase().includes("usage") || stderr.toLowerCase().includes("example");
|
|
|
|
if (!hasUsage) {
|
|
return new TestResult(name, false, duration, stderr.slice(0, 200), "No usage info shown");
|
|
}
|
|
|
|
if (verbose) {
|
|
console.log(` Shows usage instructions`);
|
|
}
|
|
|
|
return new TestResult(name, true, duration, "Shows usage instructions");
|
|
}
|
|
|
|
async function testErrorHandling(apiBase, verbose = false) {
|
|
const name = "error_handling";
|
|
const envVars = {HEROUI_API_BASE: apiBase};
|
|
|
|
const [stdout, stderr, _code, duration] = await runScript(
|
|
"get_component_docs.mjs",
|
|
["NonExistentComponent12345"],
|
|
envVars,
|
|
);
|
|
|
|
// Should handle gracefully (either error message or empty result)
|
|
const combined = (stdout + stderr).toLowerCase();
|
|
|
|
if (verbose) {
|
|
if (combined.includes("error")) {
|
|
console.log(` Returns error message for invalid component`);
|
|
} else if (stderr.toLowerCase().includes("fallback")) {
|
|
console.log(` Uses fallback for invalid component`);
|
|
}
|
|
}
|
|
|
|
return new TestResult(name, true, duration, "Handles invalid input gracefully");
|
|
}
|
|
|
|
// All tests
|
|
const ALL_TESTS = {
|
|
error_handling: testErrorHandling,
|
|
get_component_docs: testGetComponentDocs,
|
|
get_component_docs_multiple: testGetComponentDocsMultiple,
|
|
get_docs: testGetDocs,
|
|
get_docs_usage: testGetDocsUsage,
|
|
get_source: testGetSource,
|
|
get_styles: testGetStyles,
|
|
get_theme: testGetTheme,
|
|
list_components: testListComponents,
|
|
};
|
|
|
|
async function runTests(env, scripts = null, verbose = false) {
|
|
const apiBase = ENVIRONMENTS[env] || ENVIRONMENTS.production;
|
|
const results = [];
|
|
|
|
const testsToRun = Object.fromEntries(
|
|
Object.entries(ALL_TESTS).filter(([k]) => !scripts || scripts.includes(k)),
|
|
);
|
|
|
|
console.log(`\n${"=".repeat(60)}`);
|
|
console.log(`HeroUI Skill Scripts Test Suite (JavaScript)`);
|
|
console.log(`${"=".repeat(60)}`);
|
|
console.log(`Environment: ${env} (${apiBase})`);
|
|
console.log(`Tests to run: ${Object.keys(testsToRun).length}`);
|
|
console.log(`${"=".repeat(60)}\n`);
|
|
|
|
for (const [name, testFunc] of Object.entries(testsToRun)) {
|
|
process.stdout.write(`Running: ${name}... `);
|
|
const result = await testFunc(apiBase, verbose);
|
|
|
|
results.push(result);
|
|
|
|
const status = result.passed ? "✓ PASS" : "✗ FAIL";
|
|
|
|
console.log(`${status} (${result.duration.toFixed(2)}s)`);
|
|
|
|
if (result.error && (verbose || !result.passed)) {
|
|
console.log(` Error: ${result.error}`);
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
function printSummary(results) {
|
|
const passed = results.filter((r) => r.passed).length;
|
|
const failed = results.filter((r) => !r.passed).length;
|
|
const totalTime = results.reduce((sum, r) => sum + r.duration, 0);
|
|
|
|
console.log(`\n${"=".repeat(60)}`);
|
|
console.log(`Test Summary`);
|
|
console.log(`${"=".repeat(60)}`);
|
|
console.log(`Total: ${results.length}`);
|
|
console.log(`Passed: ${passed}`);
|
|
console.log(`Failed: ${failed}`);
|
|
console.log(`Time: ${totalTime.toFixed(2)}s`);
|
|
console.log(`${"=".repeat(60)}`);
|
|
|
|
if (failed > 0) {
|
|
console.log(`\nFailed tests:`);
|
|
|
|
for (const r of results) {
|
|
if (!r.passed) {
|
|
console.log(` - ${r.name}: ${r.error}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
return failed === 0;
|
|
}
|
|
|
|
function parseArgs() {
|
|
const args = process.argv.slice(2);
|
|
const options = {
|
|
env: "production",
|
|
list: false,
|
|
script: [],
|
|
verbose: false,
|
|
};
|
|
|
|
for (let i = 0; i < args.length; i++) {
|
|
const arg = args[i];
|
|
|
|
if (arg === "--env" || arg === "-e") {
|
|
options.env = args[++i];
|
|
} else if (arg === "--script" || arg === "-s") {
|
|
options.script.push(args[++i]);
|
|
} else if (arg === "--verbose" || arg === "-v") {
|
|
options.verbose = true;
|
|
} else if (arg === "--list" || arg === "-l") {
|
|
options.list = true;
|
|
}
|
|
}
|
|
|
|
return options;
|
|
}
|
|
|
|
async function main() {
|
|
const options = parseArgs();
|
|
|
|
if (options.list) {
|
|
console.log("Available tests:");
|
|
|
|
for (const name of Object.keys(ALL_TESTS)) {
|
|
console.log(` - ${name}`);
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
const results = await runTests(
|
|
options.env,
|
|
options.script.length > 0 ? options.script : null,
|
|
options.verbose,
|
|
);
|
|
const success = printSummary(results);
|
|
|
|
return success ? 0 : 1;
|
|
}
|
|
|
|
// Run main and exit with code
|
|
main()
|
|
.then((code) => {
|
|
process.exit(code);
|
|
})
|
|
.catch((error) => {
|
|
console.error("Error:", error);
|
|
process.exit(1);
|
|
});
|