UNPKG

@devicecloud.dev/dcd

Version:

Better cloud maestro testing

334 lines (333 loc) 14.4 kB
"use strict"; 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;