Files
2026-07-13 12:12:04 +08:00

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);
});