Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/busy-pianos-stop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@calycode/cli": patch
---

chore: cleanup registry schema, unify casing, adjust schema for future
6 changes: 6 additions & 0 deletions .changeset/fluffy-carpets-check.md
Original file line number Diff line number Diff line change
@@ -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.
26 changes: 16 additions & 10 deletions packages/cli/src/commands/registry/implementation/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {},
Expand Down Expand Up @@ -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,
});
Expand All @@ -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;
Expand All @@ -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
);
Expand Down Expand Up @@ -177,4 +183,4 @@ async function installComponentToXano(file, resolvedContext, core) {
}
}

export { addToXano, scaffoldRegistry };
export { addToXano, scaffoldRegistry };
113 changes: 86 additions & 27 deletions packages/cli/src/commands/test/implementation/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
}
}
}
}

/**
Expand All @@ -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
Expand Down
14 changes: 11 additions & 3 deletions packages/cli/src/utils/feature-focused/registry/scaffold.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -49,7 +58,6 @@ async function scaffoldRegistry(
{
path: `${componentsRoot}/functions/${functionFileName}`,
type: 'registry:function',
target: `/caly/${functionName}`,
},
],
};
Expand All @@ -71,4 +79,4 @@ async function scaffoldRegistry(
await writeFile(indexPath, JSON.stringify(sampleIndex, null, 2), 'utf8');
}

export { scaffoldRegistry };
export { scaffoldRegistry };
25 changes: 19 additions & 6 deletions packages/core/src/features/testing/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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') {
Expand All @@ -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({
Expand Down Expand Up @@ -246,4 +259,4 @@ async function testRunner({
return finalOutput;
}

export { testRunner };
export { testRunner };
Loading