@devicecloud.dev/dcd
Version:
Better cloud maestro testing
334 lines (333 loc) • 14.4 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.ResultsPollingService = exports.RunFailedError = void 0;
const core_1 = require("@oclif/core");
const cli_ux_1 = require("@oclif/core/lib/cli-ux");
const path = require("node:path");
const api_gateway_1 = require("../gateways/api-gateway");
const methods_1 = require("../methods");
const connectivity_1 = require("../utils/connectivity");
const styling_1 = require("../utils/styling");
/**
* Custom error for run failures that includes the polling result
*/
class RunFailedError extends Error {
result;
constructor(result) {
super('RUN_FAILED');
this.result = result;
this.name = 'RunFailedError';
}
}
exports.RunFailedError = RunFailedError;
/**
* Service for polling test results from the API
*/
class ResultsPollingService {
MAX_SEQUENTIAL_FAILURES = 10;
POLL_INTERVAL_MS = 10_000;
/**
* Poll for test results until all tests complete
* @param results Initial test results from submission
* @param options Polling configuration
* @param testMetadata Optional metadata map for each test (flowName, tags)
* @returns Promise that resolves with final test results or rejects if tests fail
*/
async pollUntilComplete(results, options, testMetadata) {
const { apiUrl, apiKey, uploadId, consoleUrl, quiet = false, json = false, debug = false, logger } = options;
this.initializePollingDisplay(json, logger);
let sequentialPollFailures = 0;
let previousSummary = '';
if (debug && logger) {
logger(`[DEBUG] Starting polling loop for results`);
}
// Poll in a loop until all tests complete
// eslint-disable-next-line no-constant-condition
while (true) {
try {
const updatedResults = await this.fetchAndLogResults(apiUrl, apiKey, uploadId, debug, logger);
const { summary } = this.calculateStatusSummary(updatedResults);
previousSummary = this.updateDisplayStatus(updatedResults, quiet, json, summary, previousSummary);
const allComplete = updatedResults.every((result) => !['PENDING', 'QUEUED', 'RUNNING'].includes(result.status));
if (allComplete) {
return await this.handleCompletedTests(updatedResults, {
consoleUrl,
debug,
json,
logger,
testMetadata,
uploadId,
});
}
// Reset failure counter on successful poll
sequentialPollFailures = 0;
// Wait before next poll
await this.sleep(this.POLL_INTERVAL_MS);
}
catch (error) {
// Re-throw RunFailedError immediately (test failures, not polling errors)
if (error instanceof RunFailedError) {
throw error;
}
sequentialPollFailures++;
// Handle polling errors (network issues, etc.)
await this.handlePollingError(error, sequentialPollFailures, debug, logger);
// Wait before retrying after an error
await this.sleep(this.POLL_INTERVAL_MS);
}
}
}
buildPollingResult(results, uploadId, consoleUrl, testMetadata) {
const resultsWithoutEarlierTries = this.filterLatestResults(results);
return {
consoleUrl,
status: resultsWithoutEarlierTries.some((result) => result.status === 'FAILED')
? 'FAILED'
: 'PASSED',
tests: resultsWithoutEarlierTries.map((r) => ({
durationSeconds: r.duration_seconds,
failReason: r.status === 'FAILED' ? r.fail_reason || 'No reason provided' : undefined,
fileName: r.test_file_name,
flowName: testMetadata?.[r.test_file_name]?.flowName || path.parse(r.test_file_name).name,
name: r.test_file_name,
status: r.status,
tags: testMetadata?.[r.test_file_name]?.tags || [],
})),
uploadId,
};
}
calculateStatusSummary(results) {
const statusCounts = {};
for (const result of results) {
statusCounts[result.status] = (statusCounts[result.status] || 0) + 1;
}
const passed = statusCounts.PASSED || 0;
const failed = statusCounts.FAILED || 0;
const pending = statusCounts.PENDING || 0;
const queued = statusCounts.QUEUED || 0;
const running = statusCounts.RUNNING || 0;
const total = results.length;
const completed = passed + failed;
const summary = (0, styling_1.formatTestSummary)({
completed,
failed,
passed,
pending,
queued,
running,
total,
});
return { completed, failed, passed, pending, queued, running, summary, total };
}
displayFinalResults(results, consoleUrl, json, logger) {
if (json) {
return;
}
core_1.ux.action.stop(styling_1.colors.success('completed'));
if (logger) {
logger('\n');
}
const hasFailedTests = results.some((result) => result.status === 'FAILED');
(0, cli_ux_1.table)(results, {
duration: {
get(row) {
return row.duration_seconds
? styling_1.colors.dim((0, methods_1.formatDurationSeconds)(Number(row.duration_seconds)))
: styling_1.colors.dim('-');
},
},
status: {
get(row) {
const statusUpper = row.status.toUpperCase();
switch (statusUpper) {
case 'PASSED': {
return styling_1.colors.success(row.status);
}
case 'FAILED': {
return styling_1.colors.error(row.status);
}
case 'RUNNING': {
return styling_1.colors.info(row.status);
}
case 'PENDING': {
return styling_1.colors.warning(row.status);
}
case 'QUEUED': {
return styling_1.colors.dim(row.status);
}
default: {
return styling_1.colors.dim(row.status);
}
}
},
},
test: {
get(row) {
const testName = row.test_file_name;
const retry = row.retry_of ? styling_1.colors.dim(' (retry)') : '';
return `${testName}${retry}`;
},
},
...(hasFailedTests && {
// eslint-disable-next-line camelcase
fail_reason: {
get(row) {
return row.status === 'FAILED' && row.fail_reason
? styling_1.colors.error(row.fail_reason)
: '';
},
},
}),
}, { printLine: logger });
if (logger) {
logger('\n');
logger(styling_1.colors.bold('Run completed') + styling_1.colors.dim(', you can access the results at:'));
logger(styling_1.colors.url(consoleUrl));
logger('\n');
}
}
/**
* Fetch results from API and log debug information
* @param apiUrl API base URL
* @param apiKey API authentication key
* @param uploadId Upload ID to fetch results for
* @param debug Whether debug logging is enabled
* @param logger Optional logger function
* @returns Promise resolving to test results
*/
async fetchAndLogResults(apiUrl, apiKey, uploadId, debug, logger) {
if (debug && logger) {
logger(`[DEBUG] Polling for results: ${uploadId}`);
}
const { results: updatedResults } = await api_gateway_1.ApiGateway.getResultsForUpload(apiUrl, apiKey, uploadId);
if (!updatedResults) {
throw new Error('no results');
}
if (debug && logger) {
logger(`[DEBUG] Poll received ${updatedResults.length} results`);
for (const result of updatedResults) {
logger(`[DEBUG] Result status: ${result.test_file_name} - ${result.status}`);
}
}
return updatedResults;
}
filterLatestResults(results) {
return results.filter((result) => {
const originalTryId = result.retry_of || result.id;
const tries = results.filter((r) => r.retry_of === originalTryId || r.id === originalTryId);
return result.id === Math.max(...tries.map((t) => t.id));
});
}
/**
* Handle completed tests and return final result
* @param updatedResults Test results from API
* @param options Completion handling options
* @returns Promise resolving to final polling result
*/
async handleCompletedTests(updatedResults, options) {
const { uploadId, consoleUrl, json, debug, logger, testMetadata } = options;
if (debug && logger) {
logger(`[DEBUG] All tests completed, stopping poll`);
}
this.displayFinalResults(updatedResults, consoleUrl, json, logger);
const output = this.buildPollingResult(updatedResults, uploadId, consoleUrl, testMetadata);
if (output.status === 'FAILED') {
if (debug && logger) {
logger(`[DEBUG] Some tests failed, returning failed status`);
}
throw new RunFailedError(output);
}
if (debug && logger) {
logger(`[DEBUG] All tests passed, returning success status`);
}
return output;
}
async handlePollingError(error, sequentialPollFailures, debug, logger) {
if (debug && logger) {
logger(`[DEBUG] Error polling for results: ${error}`);
logger(`[DEBUG] Sequential poll failures: ${sequentialPollFailures}`);
}
if (sequentialPollFailures > this.MAX_SEQUENTIAL_FAILURES) {
if (debug && logger) {
logger('[DEBUG] Checking internet connectivity...');
}
const connectivityCheck = await (0, connectivity_1.checkInternetConnectivity)();
if (debug && logger) {
logger(`[DEBUG] ${connectivityCheck.message}`);
for (const result of connectivityCheck.endpointResults) {
if (result.success) {
logger(`[DEBUG] ✓ ${result.endpoint} - ${result.statusCode} (${result.latencyMs}ms)`);
}
else {
logger(`[DEBUG] ✗ ${result.endpoint} - ${result.error} (${result.latencyMs}ms)`);
}
}
}
if (!connectivityCheck.connected) {
const endpointDetails = connectivityCheck.endpointResults
.map((r) => ` - ${r.endpoint}: ${r.error} (${r.latencyMs}ms)`)
.join('\n');
throw new Error(`Unable to fetch results after ${this.MAX_SEQUENTIAL_FAILURES} attempts.\n\nInternet connectivity check failed - all test endpoints unreachable:\n${endpointDetails}\n\nPlease verify your network connection and DNS resolution.`);
}
throw new Error(`unable to fetch results after ${this.MAX_SEQUENTIAL_FAILURES} attempts`);
}
if (logger) {
logger('unable to fetch results, trying again...');
}
}
/**
* Initialize the polling display UI
* @param json Whether to output in JSON format
* @param logger Optional logger function for output
* @returns void
*/
initializePollingDisplay(json, logger) {
if (!json) {
core_1.ux.action.start(styling_1.colors.bold('Waiting for results'), styling_1.colors.dim('Initializing'), {
stdout: true,
});
if (logger) {
logger(styling_1.colors.dim('\nYou can safely close this terminal and the tests will continue\n'));
}
}
}
/**
* Sleep for the specified number of milliseconds
* @param ms Number of milliseconds to sleep
* @returns Promise that resolves after the delay
*/
async sleep(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
updateDisplayStatus(results, quiet, json, summary, previousSummary) {
if (json) {
return previousSummary;
}
if (quiet) {
if (summary !== previousSummary) {
core_1.ux.action.status = summary;
return summary;
}
}
else {
core_1.ux.action.status = styling_1.colors.dim('\nStatus Test\n─────────── ───────────────');
for (const { retry_of: isRetry, status, test_file_name: test, } of results) {
const statusFormatted = status.toUpperCase() === 'PASSED'
? styling_1.colors.success(status.padEnd(10, ' '))
: status.toUpperCase() === 'FAILED'
? styling_1.colors.error(status.padEnd(10, ' '))
: status.toUpperCase() === 'RUNNING'
? styling_1.colors.info(status.padEnd(10, ' '))
: status.toUpperCase() === 'QUEUED'
? styling_1.colors.dim(status.padEnd(10, ' '))
: styling_1.colors.warning(status.padEnd(10, ' '));
const retryText = isRetry ? styling_1.colors.dim(' (retry)') : '';
core_1.ux.action.status += `\n${statusFormatted} ${test}${retryText}`;
}
}
return previousSummary;
}
}
exports.ResultsPollingService = ResultsPollingService;