diff --git a/.changeset/busy-pianos-stop.md b/.changeset/busy-pianos-stop.md new file mode 100644 index 00000000..23067dff --- /dev/null +++ b/.changeset/busy-pianos-stop.md @@ -0,0 +1,5 @@ +--- +"@calycode/cli": patch +--- + +chore: cleanup registry schema, unify casing, adjust schema for future diff --git a/.changeset/fluffy-carpets-check.md b/.changeset/fluffy-carpets-check.md new file mode 100644 index 00000000..84917c74 --- /dev/null +++ b/.changeset/fluffy-carpets-check.md @@ -0,0 +1,6 @@ +--- +"@calycode/core": minor +"@calycode/cli": minor +--- + +fix: fixes for test runner, improved test-config-schema, including warning count in the test summary output. diff --git a/packages/cli/src/commands/registry/implementation/registry.ts b/packages/cli/src/commands/registry/implementation/registry.ts index 55812b59..f7666ef6 100644 --- a/packages/cli/src/commands/registry/implementation/registry.ts +++ b/packages/cli/src/commands/registry/implementation/registry.ts @@ -24,6 +24,13 @@ function isAlreadyExistsError(errorObj: any): boolean { ); } +/** + * Adds one or more registry components to a Xano instance, attempting to install each component file and collecting success, skip, and failure outcomes. + * + * @param componentNames - Registry component names to install; if empty, the user will be prompted to select components. + * @param context - CLI context used to resolve instance, workspace, and branch configuration; defaults to an empty object. + * @returns An object with `installed` (entries with `component`, `file` path, and `response`), `failed` (entries with `component`, `file`, `error`, and optional `response`), and `skipped` (entries for items skipped because the resource already exists). + */ async function addToXano({ componentNames, context = {}, @@ -61,21 +68,21 @@ async function addToXano({ if (installResult.success) { results.installed.push({ component: componentName, - file: file.target || file.path, + file: file.path, response: installResult.body, }); } else if (installResult.body && isAlreadyExistsError(installResult.body)) { // Skipped due to already existing results.skipped.push({ component: componentName, - file: file.target || file.path, + file: file.path, error: installResult.body.message, }); } else { // Other failures results.failed.push({ component: componentName, - file: file.target || file.path, + file: file.path, error: installResult.error || 'Installation failed', response: installResult.body, }); @@ -93,12 +100,11 @@ async function addToXano({ } /** - * Installs a component file to Xano. + * Install a single component file into the configured Xano instance. * - * @param {Object} file - The component file metadata. - * @param {Object} resolvedContext - The resolved context configs. - * @param {any} core - Core utilities. - * @returns {Promise<{ success: boolean, error?: string, body?: any }>} + * @param file - Component file metadata (e.g., `type`, `path`, `target`, and for query files `apiGroupName`) that identifies what to install and where. + * @param resolvedContext - Resolved configuration objects: `instanceConfig`, `workspaceConfig`, and `branchConfig`. + * @returns An object with `success: true` and the parsed response `body` on success; on failure `success: false` and `error` contains a human-readable message, `body` may include the raw response when available. */ async function installComponentToXano(file, resolvedContext, core) { const { instanceConfig, workspaceConfig, branchConfig } = resolvedContext; @@ -107,7 +113,7 @@ async function installComponentToXano(file, resolvedContext, core) { // For types that require dynamic IDs, resolve them first if (file.type === 'registry:query') { const targetApiGroup = await getApiGroupByName( - file['api-group-name'], + file.apiGroupName, { instanceConfig, workspaceConfig, branchConfig }, core ); @@ -177,4 +183,4 @@ async function installComponentToXano(file, resolvedContext, core) { } } -export { addToXano, scaffoldRegistry }; +export { addToXano, scaffoldRegistry }; \ No newline at end of file diff --git a/packages/cli/src/commands/test/implementation/test.ts b/packages/cli/src/commands/test/implementation/test.ts index 66fac86b..4fe7a54d 100644 --- a/packages/cli/src/commands/test/implementation/test.ts +++ b/packages/cli/src/commands/test/implementation/test.ts @@ -10,46 +10,101 @@ import { } from '../../../utils/index'; /** - * Prints a formatted summary table of test outcomes to the log. + * Print a formatted table of test outcomes and an optional detailed warnings section to the log. * - * Logs a header, one row per result showing status, HTTP method, path, and duration, and a final summary line with totals and aggregate duration. + * The table includes columns for status, HTTP method, path, warnings count, and duration (ms), + * followed by an aggregate summary line with total, passed, failed, and total duration. * * @param results - Array of test result objects. Each object should include: - * - `success` (boolean): whether the test passed, - * - `method` (string): HTTP method used, - * - `path` (string): endpoint path, - * - `duration` (number, optional): duration of the test in milliseconds + * - `success`: whether the test passed + * - `method`: HTTP method used for the test + * - `path`: endpoint path exercised by the test + * - `duration` (optional): duration of the test in milliseconds + * - `warnings` (optional): array of warning objects; each warning should include `key` and `message` */ function printTestSummary(results) { - const total = results.length; - const succeeded = results.filter((r) => r.success).length; - const failed = total - succeeded; - const totalDuration = results.reduce((sum, r) => sum + (r.duration || 0), 0); + // Collect all rows for sizing + const rows = results.map((r) => { + const status = r.success ? '✅' : '❌'; + const method = r.method || ''; + const path = r.path || ''; + const warningsCount = r.warnings && Array.isArray(r.warnings) ? r.warnings.length : 0; + const duration = (r.duration || 0).toString(); + return { status, method, path, warnings: warningsCount.toString(), duration }; + }); - // Table header - log.message( - `${'='.repeat(60)} - Test Results Summary - ${'-'.repeat(60)} - ${'Status'.padEnd(4)} | ${'Method'.padEnd(6)} | ${'Path'.padEnd(24)} | ${'Duration (ms)'} - ${'-'.repeat(60)}` + // Calculate max width for each column (including header) + const headers = { + status: 'Status', + method: 'Method', + path: 'Path', + warnings: 'Warnings', + duration: 'Duration (ms)', + }; + + const colWidths = { + status: Math.max(headers.status.length, ...rows.map((r) => r.status.length)), + method: Math.max(headers.method.length, ...rows.map((r) => r.method.length)), + path: Math.max(headers.path.length, ...rows.map((r) => r.path.length)), + warnings: Math.max(headers.warnings.length, ...rows.map((r) => r.warnings.length)), + duration: Math.max(headers.duration.length, ...rows.map((r) => r.duration.length)), + }; + + // Helper to pad cell + const pad = (str, len) => str.padEnd(len); + + const sepLine = '-'.repeat( + colWidths.status + + colWidths.method + + colWidths.path + + colWidths.warnings + + colWidths.duration + + 13 ); - // Table rows - for (const r of results) { - const status = r.success ? '✅' : '❌'; + // Header + log.message(`${'='.repeat(sepLine.length)} + Test Results Summary + ${sepLine} + ${pad(headers.status, colWidths.status)} | ${pad(headers.method, colWidths.method)} | ${pad( + headers.path, + colWidths.path + )} | ${pad(headers.warnings, colWidths.warnings)} | ${pad(headers.duration, colWidths.duration)} + ${sepLine}`); + + // Rows + for (const r of rows) { log.message( - `${status.padEnd(4)} | ${r.method.padEnd(6)} | ${r.path.padEnd(24)} | ${( - r.duration || 0 - ).toString()}` + `${pad(r.status, colWidths.status)} | ${pad(r.method, colWidths.method)} | ${pad( + r.path, + colWidths.path + )} | ${pad(r.warnings, colWidths.warnings)} | ${pad(r.duration, colWidths.duration)}` ); } + // Summary + const total = results.length; + const succeeded = results.filter((r) => r.success).length; + const failed = total - succeeded; + const totalDuration = results.reduce((sum, r) => sum + (r.duration || 0), 0); + log.message( - `${'-'.repeat(60)} + `${sepLine} Total: ${total} | Passed: ${succeeded} | Failed: ${failed} | Total Duration: ${totalDuration} ms - ${'-'.repeat(60)}` + ${sepLine}` ); + + // Print out the warnings list: + const testsWithWarnings = results.filter((r) => r.warnings && r.warnings.length > 0); + if (testsWithWarnings.length > 0) { + log.message('\nWarnings details:'); + for (const r of testsWithWarnings) { + log.message(`- ${r.method} ${r.path}:`); + for (const warn of r.warnings) { + log.message(` [${warn.key}] ${warn.message}`); + } + } + } } /** @@ -75,9 +130,13 @@ async function loadTestConfig(testConfigPath) { } /** - * Runs API tests for selected API groups using a provided test configuration and writes per-group results to disk. + * Run API tests for selected API groups, write per-group JSON results to disk, and print a formatted summary. + * + * Resolves the target instance/workspace/branch, selects API groups (optionally prompting), loads the test + * configuration, executes tests via the provided runtime `core`, writes each group's results to a timestamped + * JSON file under the configured output path, and prints a summary table and optional output directory path. * - * @param instance - Name or alias of the target instance + * @param instance - Target instance name or alias used to resolve configuration * @param workspace - Workspace name within the instance * @param branch - Branch label within the workspace * @param group - Specific API group name to run; when omitted and `isAll` is false the user may be prompted diff --git a/packages/cli/src/utils/feature-focused/registry/scaffold.ts b/packages/cli/src/utils/feature-focused/registry/scaffold.ts index c8aecc6d..b547888e 100644 --- a/packages/cli/src/utils/feature-focused/registry/scaffold.ts +++ b/packages/cli/src/utils/feature-focused/registry/scaffold.ts @@ -1,12 +1,21 @@ import { dirname, join } from 'node:path'; import { mkdir, writeFile } from 'node:fs/promises'; +/** + * Ensures the parent directory for the provided file path exists, creating it recursively if needed. + * + * @param filePath - Path to the target file whose containing directory should exist + */ async function ensureDirForFile(filePath: string) { const dir = dirname(filePath); await mkdir(dir, { recursive: true }); } -// [ ] CLI +/** + * Creates a sample registry scaffold (components, function implementation, function definition, and index) under the specified registry root. + * + * @param registryRoot - Root directory where registry files will be written. Defaults to `'registry'`. + */ async function scaffoldRegistry( { registryRoot }: { registryRoot?: string } = { registryRoot: 'registry', @@ -49,7 +58,6 @@ async function scaffoldRegistry( { path: `${componentsRoot}/functions/${functionFileName}`, type: 'registry:function', - target: `/caly/${functionName}`, }, ], }; @@ -71,4 +79,4 @@ async function scaffoldRegistry( await writeFile(indexPath, JSON.stringify(sampleIndex, null, 2), 'utf8'); } -export { scaffoldRegistry }; +export { scaffoldRegistry }; \ No newline at end of file diff --git a/packages/core/src/features/testing/index.ts b/packages/core/src/features/testing/index.ts index e505bfff..5b03ec7e 100644 --- a/packages/core/src/features/testing/index.ts +++ b/packages/core/src/features/testing/index.ts @@ -42,9 +42,24 @@ function getByPath(obj, path) { } /** - * testConfig is actually an array of objects defining in what order and which - * endpoints to run, also optionally define custom asserts (either inline func, or predefined asserts) - * ApiGroupConfig allows for extra keys. In this case it should include an 'oas' key + * Execute the configured API tests across the provided API groups and return per-group results. + * + * For each group, ensures an OpenAPI spec is available (fetching and patching from the remote API if absent), + * runs the endpoints defined by `testConfig` in order, evaluates assertions (built-in or custom), + * optionally extracts runtime values from JSON responses into a shared runtime store, and records timing, + * successes, errors, and warnings for each endpoint. + * + * @param context - Execution context containing instance, workspace, and branch identifiers + * @param groups - Array of API group configurations; each group may include an `oas` property (OpenAPI) and a `canonical` identifier used to build request base URLs + * @param testConfig - Ordered array of endpoint test definitions. Each entry should include: + * - `path` and `method` for the request + * - `headers`, `queryParams`, and `requestBody` for request composition + * - optional `store` mappings [{ key, path }] to extract values from JSON responses into runtime variables + * - optional `customAsserts` to override or provide per-endpoint assertions + * + * @returns An array of objects, one per input group, each containing the original `group` and a `results` array. + * Each result includes `path`, `method`, `success` (true if no assertion errors), `errors` (or null), + * `warnings` (or null), and `duration` (milliseconds) */ async function testRunner({ context, @@ -134,7 +149,6 @@ async function testRunner({ const customAssertKeys = customAsserts ? Object.keys(customAsserts) : []; if (customAssertKeys.length > 0) { - // Use only asserts provided in customAsserts (with their specified levels/fns) for (const key of customAssertKeys) { const assertOpt = customAsserts[key]; if (assertOpt && typeof assertOpt.fn === 'function' && assertOpt.level !== 'off') { @@ -145,7 +159,6 @@ async function testRunner({ } } } else { - // Use all available asserts (with their default levels/fns) for (const [key, assertOpt] of Object.entries(availableAsserts)) { if (assertOpt.level !== 'off') { assertsToRun.push({ @@ -246,4 +259,4 @@ async function testRunner({ return finalOutput; } -export { testRunner }; +export { testRunner }; \ No newline at end of file diff --git a/schemas/registry/registry-item.json b/schemas/registry/registry-item.json index b08aa383..a0753db4 100644 --- a/schemas/registry/registry-item.json +++ b/schemas/registry/registry-item.json @@ -32,28 +32,9 @@ ], "description": "The type of the item. This is used to determine the type and target path of the item when resolved for a Xano instance." }, - "description": { - "type": "string", - "description": "The description of the item. This is used to provide a **brief** overview of the item." - }, - "title": { - "type": "string", - "description": "The human-readable title for your registry item. Keep it short and descriptive. This can be used for searching or selecting in the CLI." - }, - "author": { - "type": "string", - "description": "The author of the item. Recommended format: username \u003Curl\u003E" - }, - "registryDependencies": { - "type": "array", - "description": "An array of registry items that this item depends on. Use the name of the item to reference it. Referencing items in external registries is NOT supported.", - "items": { - "type": "string" - } - }, "files": { "type": "array", - "description": "The main payload of the registry item. This is an array of files that are part of the registry item. Each file is an object with a path, content, type, and target.", + "description": "The main payload of the registry item. This is an array of files that are part of the registry item. Each file is an object with a path, content, type.", "items": { "type": "object", "properties": { @@ -63,7 +44,7 @@ }, "content": { "type": "string", - "description": "The content of the file. Optional." + "description": "The content of the file. Optional, in case of Xano it can be the text representation of a .xs file." }, "type": { "type": "string", @@ -86,25 +67,25 @@ "registry:realtime/trigger", "registry:test", "registry:snippet", - "registry:file", "registry:item" ], "description": "The type of the file. This is used to determine the type of the file when resolved for a Xano instance and is also used to determine the required Metadata API path for resolution." }, - "target": { - "type": "string", - "description": "The target path of the file. This is the path to the file in the project." - }, - "api-group-name": { + "apiGroupName": { "type": "string", "description": "The name of the api group into which a specific registry:query should be added. Only required for registry:query." + }, + "meta": { + "type": "object", + "description": "Additional metadata for the registry item's component. This is an object with any key value pairs. Put here anything that Xano can meaningfully accept or that helps you identify the current item. e.g. updated_at or similar.", + "additionalProperties": true } }, "if": { "properties": { "type": { "enum": [ - "registry:file" + "registry:query" ] } } @@ -113,54 +94,60 @@ "required": [ "path", "type", - "target" + "apiGroupName" ] }, "else": { - "if": { - "properties": { - "type": { - "enum": [ - "registry:query" - ] - } - } - }, - "then": { - "required": [ - "path", - "type", - "api-group-name" - ] - }, - "else": { - "required": [ - "path", - "type" - ] - } + "required": [ + "path", + "type" + ] } - }, - "meta": { - "type": "object", - "description": "Additional metadata for the registry item. This is an object with any key value pairs. Put here anything that Xano can meaningfully accept or that helps you identify the current item. e.g. updated_at or similar.", - "additionalProperties": true - }, - "docs": { + } + }, + "description": { + "type": "string", + "description": "The description of the item. This is used to provide a **brief** overview of the item." + }, + "docs": { + "type": "string", + "description": "The documentation for the registry item. This is a markdown string. This can be a longer explanation of how to use the item, or can have embedded images / videos etc, in the registry preview, will be rendered with Docsify." + }, + "postInstallHint": { + "type": "string", + "description": "A short hint that will be presented in the console, after installing this component. Useful to take notes of variables or required customizations to make a registry item work smoothly." + }, + "title": { + "type": "string", + "description": "The human-readable title for your registry item. Keep it short and descriptive. This can be used for searching or selecting in the CLI." + }, + "author": { + "type": "string", + "description": "The author of the item. Recommended format: username \u003Curl\u003E" + }, + "registryDependencies": { + "type": "array", + "description": "An array of registry items that this item depends on. Use the name of the item to reference it. Referencing items in external registries is NOT supported.", + "items": { + "type": "string" + } + }, + "categories": { + "type": "array", + "items": { "type": "string", - "description": "The documentation for the registry item. This is a markdown string." - }, - "categories": { - "type": "array", - "items": { - "type": "string", - "description": "The categories of the registry item. This is an array of strings. Helps in search and categorisation." - } + "description": "The categories of the registry item. This is an array of strings. Helps in search and categorisation." } + }, + "meta": { + "type": "object", + "description": "Additional metadata for the registry item. This is an object with any key value pairs. Put here extra content, that you consider useful in making this registry item be easy to understand.", + "additionalProperties": true } }, "required": [ "name", - "type" + "type", + "files" ] } \ No newline at end of file diff --git a/schemas/registry/registry.json b/schemas/registry/registry.json index 71939208..c46fb589 100644 --- a/schemas/registry/registry.json +++ b/schemas/registry/registry.json @@ -7,10 +7,16 @@ "type": "string" }, "homepage": { + "type": "string", + "format": "uri" + }, + "description": { "type": "string" }, "items": { "type": "array", + "minItems": 1, + "uniqueItems": true, "items": { "$ref": "https://calycode.com/schemas/registry/registry-item.json" } @@ -20,7 +26,5 @@ "name", "homepage", "items" - ], - "uniqueItems": true, - "minItems": 1 + ] } \ No newline at end of file diff --git a/schemas/testing/config.json b/schemas/testing/config.json index 54d17014..dff13e1d 100644 --- a/schemas/testing/config.json +++ b/schemas/testing/config.json @@ -4,7 +4,7 @@ "type": "array", "items": { "type": "object", - "required": ["path", "method", "headers", "queryParams", "requestBody", "customAsserts"], + "required": ["path", "method", "requestBody", "customAsserts"], "properties": { "path": { "type": "string" @@ -17,8 +17,15 @@ "additionalProperties": { "type": "string" } }, "queryParams": { - "type": "object", - "additionalProperties": { "type": "string" } + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "value": {} + }, + "required": ["name", "value"] + } }, "requestBody": {}, "store": { @@ -39,10 +46,14 @@ "required": ["level"], "properties": { "fn": {}, - "level": { "type": "string" } + "level": { + "type": "string", + "enum": ["error", "warn", "off"] + } } } } - } + }, + "additionalProperties": false } }