UNPKG

@devicecloud.dev/dcd

Version:

Better cloud maestro testing

797 lines (793 loc) 42.1 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.mimeTypeLookupByExtension = void 0; /* eslint-disable complexity */ const core_1 = require("@oclif/core"); const cli_ux_1 = require("@oclif/core/lib/cli-ux"); const errors_1 = require("@oclif/core/lib/errors"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const streamZip = require("node-stream-zip"); const constants_1 = require("../constants"); const api_gateway_1 = require("../gateways/api-gateway"); const methods_1 = require("../methods"); const plan_1 = require("../plan"); const compatibility_1 = require("../utils/compatibility"); exports.mimeTypeLookupByExtension = { apk: 'application/vnd.android.package-archive', yaml: 'application/x-yaml', zip: 'application/zip', }; // Suppress punycode deprecation warning (caused by whatwg, supabase dependancy) process.removeAllListeners('warning'); process.on('warning', (warning) => { if (warning.name === 'DeprecationWarning' && warning.message.includes('punycode')) { // Ignore punycode deprecation warnings } }); class Cloud extends core_1.Command { static args = { firstFile: core_1.Args.string({ description: 'The binary file of the app to run your flow against, e.g. test.apk for android or test.app/.zip for ios', hidden: true, name: 'App file', }), secondFile: core_1.Args.string({ description: 'The flow file to run against the app, e.g. test.yaml', hidden: true, name: 'Flow file', }), }; static description = `Test a Flow or set of Flows on devicecloud.dev (https://devicecloud.dev)\nProvide your application file and a folder with Maestro flows to run them in parallel on multiple devices in devicecloud.dev\nThe command will block until all analyses have completed`; static enableJsonFlag = true; static examples = ['<%= config.bin %> <%= command.id %>']; static flags = constants_1.flags; versionCheck = async () => { const versionResponse = await fetch('https://registry.npmjs.org/@devicecloud.dev/dcd/latest'); const versionResponseJson = await versionResponse.json(); const latestVersion = versionResponseJson.version; if (latestVersion !== this.config.version) { this.log(` ------------------- A new version of the devicecloud.dev CLI is available: ${latestVersion} Run 'npm install -g @devicecloud.dev/dcd@latest' to update to the latest version ------------------- `); } }; async run() { let output = null; // Store debug flag outside try/catch to access it in catch block let debugFlag = false; let jsonFile = false; try { const { args, flags, raw } = await this.parse(Cloud); let { 'additional-app-binary-ids': nonFlatAdditionalAppBinaryIds, 'additional-app-files': nonFlatAdditionalAppFiles, 'android-api-level': androidApiLevel, 'android-device': androidDevice, apiKey: apiKeyFlag, apiUrl, 'app-binary-id': appBinaryId, 'app-file': appFile, 'artifacts-path': artifactsPath, async, config: configFile, debug, 'device-locale': deviceLocale, 'download-artifacts': downloadArtifacts, 'dry-run': dryRun, env, 'exclude-flows': excludeFlows, 'exclude-tags': excludeTags, flows, 'google-play': googlePlay, 'ignore-sha-check': ignoreShaCheck, 'include-tags': includeTags, 'ios-device': iOSDevice, 'ios-version': iOSVersion, json, 'json-file-name': jsonFileName, 'maestro-version': maestroVersion, metadata, mitmHost, mitmPath, 'moropo-v1-api-key': moropoApiKey, name, orientation, quiet, report, retry, 'runner-type': runnerType, 'x86-arch': x86Arch, ...rest } = flags; // Resolve "latest" maestro version to actual version const resolvedMaestroVersion = (0, constants_1.resolveMaestroVersion)(maestroVersion); // Store debug flag for use in catch block debugFlag = debug === true; jsonFile = flags['json-file'] === true; if (debug) { this.log('DEBUG: Starting command execution with debug logging enabled'); this.log(`DEBUG: CLI Version: ${this.config.version}`); this.log(`DEBUG: Node version: ${process.versions.node}`); this.log(`DEBUG: OS: ${process.platform} ${process.arch}`); } if (flags['json-file']) { quiet = true; this.log('--json-file is true: JSON output will be written to file, forcing --quiet flag for better CI output'); } if (json) { const originalStdoutWrite = process.stdout.write; process.stdout.write = function (chunk, encodingOrCallback, cb) { if (typeof chunk === 'string' && chunk.includes('Not sure what to do with typed value of type 0x4')) { return true; } return originalStdoutWrite.call(process.stdout, chunk, encodingOrCallback, cb); }; } const [major] = process.versions.node.split('.').map(Number); if (major < 20) { this.warn(`WARNING: You are using node version ${major}. DeviceCloud requires node version 20 or later`); if (major < 18) { throw new Error('Invalid node version'); } } await this.versionCheck(); // Download and expand Moropo zip if API key is present if (moropoApiKey) { if (debug) { this.log('DEBUG: Moropo v1 API key detected, downloading tests from Moropo API'); } if (debug) { this.log(`DEBUG: Using branch name: main`); } try { if (!quiet && !json) { core_1.ux.action.start('Downloading Moropo tests', 'Initializing', { stdout: true, }); } const response = await fetch('https://api.moropo.com/tests', { headers: { accept: 'application/zip', 'x-app-api-key': moropoApiKey, 'x-branch-name': 'main', }, }); if (!response.ok) { throw new Error(`Failed to download Moropo tests: ${response.statusText}`); } const contentLength = response.headers.get('content-length'); const totalSize = contentLength ? Number.parseInt(contentLength, 10) : 0; let downloadedSize = 0; const moropoDir = path.join(os.tmpdir(), `moropo-tests-${Date.now()}`); if (debug) { this.log(`DEBUG: Extracting Moropo tests to: ${moropoDir}`); } // Create moropo directory if it doesn't exist if (!fs.existsSync(moropoDir)) { fs.mkdirSync(moropoDir, { recursive: true }); } // Write zip file to moropo directory const zipPath = path.join(moropoDir, 'moropo-tests.zip'); const fileStream = fs.createWriteStream(zipPath); const reader = response.body?.getReader(); if (!reader) { throw new Error('Failed to get response reader'); } let readerResult = await reader.read(); while (!readerResult.done) { const { value } = readerResult; downloadedSize += value.length; if (!quiet && !json && totalSize) { const progress = Math.round((downloadedSize / totalSize) * 100); core_1.ux.action.status = `Downloading: ${progress}%`; } fileStream.write(value); readerResult = await reader.read(); } fileStream.end(); await new Promise((resolve) => { fileStream.on('finish', () => { resolve(); }); }); if (!quiet && !json) { core_1.ux.action.status = 'Extracting tests...'; } // Extract zip file const StreamZip = streamZip; // eslint-disable-next-line new-cap const zip = new StreamZip.async({ file: zipPath }); await zip.extract(null, moropoDir); await zip.close(); // Delete zip file after extraction fs.unlinkSync(zipPath); if (!quiet && !json) { core_1.ux.action.stop('completed'); } if (debug) { this.log('DEBUG: Successfully extracted Moropo tests'); } // Create config.yaml file const configPath = path.join(moropoDir, 'config.yaml'); fs.writeFileSync(configPath, 'flows:\n- ./**/*.yaml\n- ./*.yaml\n'); if (debug) { this.log('DEBUG: Created config.yaml file'); } // Update flows to point to the extracted directory flows = moropoDir; } catch (error) { if (!quiet && !json) { core_1.ux.action.stop('failed'); } if (debug) { this.log(`DEBUG: Error downloading/extracting Moropo tests: ${error}`); } throw new Error(`Failed to download/extract Moropo tests: ${error}`); } } const apiKey = apiKeyFlag || process.env.DEVICE_CLOUD_API_KEY; if (!apiKey) throw new Error('You must provide an API key via --api-key flag or DEVICE_CLOUD_API_KEY environment variable'); // Fetch compatibility data from API let compatibilityData; try { compatibilityData = await (0, compatibility_1.fetchCompatibilityData)(apiUrl, apiKey); if (debug) { this.log('DEBUG: Successfully fetched compatibility data from API'); } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); if (debug) { this.log(`DEBUG: Failed to fetch compatibility data from API: ${errorMessage}`); } throw new Error(`Failed to fetch device compatibility data: ${errorMessage}. Please check your API key and connection.`); } if (debug) { this.log(`DEBUG: API URL: ${apiUrl}`); this.log(`DEBUG: API Key provided: ${apiKey ? 'Yes' : 'No'}`); } if (retry && retry > 2) { this.log("Retries are now free of charge but limited to 2. If you're test is still failing after 2 retries, please ask for help on Discord."); flags.retry = 2; retry = 2; } if (runnerType === 'm4') { this.log('Note: runnerType m4 is experimental and currently supports iOS only, Android will revert to default.'); } if (runnerType === 'm1') { this.log('Note: runnerType m1 is experimental and currently supports Android (Pixel 7, API Level 34) only.'); } const additionalAppBinaryIds = nonFlatAdditionalAppBinaryIds?.flat(); const additionalAppFiles = nonFlatAdditionalAppFiles?.flat(); const { firstFile, secondFile } = args; let finalBinaryId = appBinaryId; let finalAdditionalBinaryIds = additionalAppBinaryIds; const finalAppFile = appFile ?? firstFile; let flowFile = flows ?? secondFile; if (debug) { this.log(`DEBUG: First file argument: ${firstFile || 'not provided'}`); this.log(`DEBUG: Second file argument: ${secondFile || 'not provided'}`); this.log(`DEBUG: App binary ID: ${appBinaryId || 'not provided'}`); this.log(`DEBUG: App file: ${finalAppFile || 'not provided'}`); this.log(`DEBUG: Flow file: ${flowFile || 'not provided'}`); this.log(`DEBUG: Additional app binary IDs: ${additionalAppBinaryIds?.join(', ') || 'none'}`); this.log(`DEBUG: Additional app files: ${additionalAppFiles?.join(', ') || 'none'}`); } if (appBinaryId) { if (secondFile) { throw new Error('You cannot provide both an appBinaryId and a binary file'); } flowFile = flows ?? firstFile; } if (!flowFile) { throw new Error('You must provide a flow file'); } if (iOSVersion || iOSDevice) { const iOSDeviceID = iOSDevice || 'iphone-14'; const supportediOSVersions = compatibilityData?.ios?.[iOSDeviceID] || []; const version = iOSVersion || '17'; if (supportediOSVersions.length === 0) { throw new Error(`Device ${iOSDeviceID} is not supported. Please check the docs for supported devices: https://docs.devicecloud.dev/getting-started/devices-configuration`); } if (Array.isArray(supportediOSVersions) && !supportediOSVersions.includes(version)) { throw new Error(`${iOSDeviceID} only supports these iOS versions: ${supportediOSVersions.join(', ')}`); } if (debug) { this.log(`DEBUG: iOS device: ${iOSDeviceID}`); this.log(`DEBUG: iOS version: ${version}`); this.log(`DEBUG: Supported iOS versions: ${supportediOSVersions.join(', ')}`); } } if (androidApiLevel || androidDevice) { const androidDeviceID = androidDevice || 'pixel-7'; const lookup = googlePlay ? compatibilityData.androidPlay : compatibilityData.android; const supportedAndroidVersions = lookup?.[androidDeviceID] || []; const version = androidApiLevel || '34'; if (supportedAndroidVersions.length === 0) { throw new Error(`We don't support that device configuration - please check the docs for supported devices: https://docs.devicecloud.dev/getting-started/devices-configuration`); } if (Array.isArray(supportedAndroidVersions) && !supportedAndroidVersions.includes(version)) { throw new Error(`${androidDeviceID} ${googlePlay ? '(Play Store) ' : ''}only supports these Android API levels: ${supportedAndroidVersions.join(', ')}`); } if (debug) { this.log(`DEBUG: Android device: ${androidDeviceID}`); this.log(`DEBUG: Android API level: ${version}`); this.log(`DEBUG: Google Play enabled: ${googlePlay}`); this.log(`DEBUG: Supported Android versions: ${supportedAndroidVersions.join(', ')}`); } } flowFile = path.resolve(flowFile); if (!flowFile?.endsWith('.yaml') && !flowFile?.endsWith('.yml') && !flowFile?.endsWith('/')) { flowFile += '/'; } if (debug) { this.log(`DEBUG: Resolved flow file path: ${flowFile}`); } let executionPlan; try { if (debug) { this.log('DEBUG: Generating execution plan...'); } executionPlan = await (0, plan_1.plan)(flowFile, includeTags.flat(), excludeTags.flat(), excludeFlows.flat(), configFile); if (debug) { this.log(`DEBUG: Execution plan generated`); this.log(`DEBUG: Total flow files: ${executionPlan.totalFlowFiles}`); this.log(`DEBUG: Flows to run: ${executionPlan.flowsToRun.length}`); this.log(`DEBUG: Referenced files: ${executionPlan.referencedFiles.length}`); this.log(`DEBUG: Sequential flows: ${executionPlan.sequence?.flows.length || 0}`); } } catch (error) { if (debug) { this.log(`DEBUG: Error generating execution plan: ${error}`); } throw error; } const { allExcludeTags, allIncludeTags, flowMetadata, flowOverrides, flowsToRun: testFileNames, referencedFiles, sequence, workspaceConfig, } = executionPlan; if (debug) { this.log(`DEBUG: All include tags: ${allIncludeTags?.join(', ') || 'none'}`); this.log(`DEBUG: All exclude tags: ${allExcludeTags?.join(', ') || 'none'}`); this.log(`DEBUG: Test file names: ${testFileNames.join(', ')}`); } const pathsShortestToLongest = [ ...testFileNames, ...referencedFiles, ].sort((a, b) => a.split(path.sep).length - b.split(path.sep).length); let commonRoot = path.parse(process.cwd()).root; const folders = pathsShortestToLongest[0].split(path.sep); for (const [index] of folders.entries()) { const folderPath = folders.slice(0, index).join(path.sep); const isRoot = pathsShortestToLongest.every((file) => file.startsWith(folderPath)); if (isRoot) commonRoot = folderPath; } if (debug) { this.log(`DEBUG: Common root directory: ${commonRoot}`); } const { continueOnFailure = true, flows: sequentialFlows = [] } = sequence ?? {}; if (debug && sequentialFlows.length > 0) { this.log(`DEBUG: Sequential flows: ${sequentialFlows.join(', ')}`); this.log(`DEBUG: Continue on failure: ${continueOnFailure}`); } if (!appBinaryId) { if (!(flowFile && finalAppFile)) { throw new Error('You must provide a flow file and an app binary id'); } if (!['apk', '.app', '.zip'].some((ext) => finalAppFile.endsWith(ext))) { throw new Error('App file must be a .apk for android or .app/.zip file for iOS'); } if (finalAppFile.endsWith('.zip')) { if (debug) { this.log(`DEBUG: Verifying iOS app zip file: ${finalAppFile}`); } await (0, methods_1.verifyAppZip)(finalAppFile); } } if (debug && additionalAppFiles?.length) { this.log(`DEBUG: Verifying additional app files: ${additionalAppFiles.join(', ')}`); } await (0, methods_1.verifyAdditionalAppFiles)(additionalAppFiles); const flagLogs = []; for (const [k, v] of Object.entries(flags)) { if (v && v.toString().length > 0) { flagLogs.push(`${k}: ${v}`); } } // Format overrides information const overridesEntries = Object.entries(flowOverrides); const hasOverrides = overridesEntries.some(([, overrides]) => Object.keys(overrides).length > 0); let overridesLog = ''; if (hasOverrides) { overridesLog = '\n With overrides'; for (const [flowPath, overrides] of overridesEntries) { if (Object.keys(overrides).length > 0) { const relativePath = flowPath.replace(process.cwd(), '.'); overridesLog += `\n → ${relativePath}:`; for (const [key, value] of Object.entries(overrides)) { overridesLog += `\n ${key}: ${value}`; } } } overridesLog += '\n'; } this.log(` Submitting new job → Flow(s): ${flowFile} → App: ${appBinaryId || finalAppFile} ${additionalAppBinaryIds.length > 0 || additionalAppFiles.length > 0 ? `→ Additional app(s): ${additionalAppBinaryIds} ${additionalAppFiles}` : ''} With options → ${flagLogs.join(` → `)}${overridesLog} `); if (dryRun) { this.log('\nDry run mode - no tests were actually triggered\n'); this.log('The following tests would have been run:'); this.log('─────────────────────────────────────────'); for (const test of testFileNames) { this.log(`• ${test}`); } if (sequentialFlows.length > 0) { this.log('\nSequential flows:'); this.log('────────────────'); for (const flow of sequentialFlows) { this.log(`• ${flow}`); } } this.log('\n'); return; } if (!finalBinaryId) { if (!finalAppFile) throw new Error('You must provide either an app binary id or an app file'); if (debug) { this.log(`DEBUG: Uploading binary file: ${finalAppFile}`); } const binaryId = await (0, methods_1.uploadBinary)(finalAppFile, apiUrl, apiKey, ignoreShaCheck, !json); finalBinaryId = binaryId; if (debug) { this.log(`DEBUG: Binary uploaded with ID: ${binaryId}`); } } let uploadedBinaryIds = []; if (additionalAppFiles?.length) { if (debug) { this.log(`DEBUG: Uploading additional binary files: ${additionalAppFiles.join(', ')}`); } uploadedBinaryIds = await (0, methods_1.uploadBinaries)(additionalAppFiles, apiUrl, apiKey, ignoreShaCheck, !json); finalAdditionalBinaryIds = [ ...finalAdditionalBinaryIds, ...uploadedBinaryIds, ]; if (debug) { this.log(`DEBUG: Additional binaries uploaded with IDs: ${uploadedBinaryIds.join(', ')}`); this.log(`DEBUG: Final additional binary IDs: ${finalAdditionalBinaryIds.join(', ')}`); } } const testFormData = new FormData(); // eslint-disable-next-line unicorn/no-array-reduce const envObject = (env ?? []).reduce((acc, cur) => { const [key, ...value] = cur.split('='); // handle case where value includes an equals sign acc[key] = value.join('='); return acc; }, {}); // eslint-disable-next-line unicorn/no-array-reduce const metadataObject = (metadata ?? []).reduce((acc, cur) => { const [key, ...value] = cur.split('='); // handle case where value includes an equals sign acc[key] = value.join('='); return acc; }, {}); if (debug && Object.keys(envObject).length > 0) { this.log(`DEBUG: Environment variables: ${JSON.stringify(envObject)}`); } if (debug && Object.keys(metadataObject).length > 0) { this.log(`DEBUG: User metadata: ${JSON.stringify(metadataObject)}`); } if (debug) { this.log(`DEBUG: Compressing files from path: ${flowFile}`); } const buffer = await (0, methods_1.compressFilesFromRelativePath)(flowFile?.endsWith('.yaml') || flowFile?.endsWith('.yml') ? path.dirname(flowFile) : flowFile, [ ...new Set([ ...referencedFiles, ...testFileNames, ...sequentialFlows, ]), ], commonRoot); if (debug) { this.log(`DEBUG: Compressed file size: ${buffer.length} bytes`); } const blob = new Blob([buffer], { type: exports.mimeTypeLookupByExtension.zip, }); testFormData.set('file', blob, 'flowFile.zip'); // finalBinaryId should always be defined after validation - fail fast if not if (!finalBinaryId) { throw new Error('Internal error: finalBinaryId should be defined after validation'); } testFormData.set('appBinaryId', finalBinaryId); testFormData.set('testFileNames', JSON.stringify(testFileNames.map((t) => t.replaceAll(commonRoot, '.').split(path.sep).join('/')))); testFormData.set('flowMetadata', JSON.stringify(Object.fromEntries(Object.entries(flowMetadata).map(([key, value]) => [ key.replaceAll(commonRoot, '.').split(path.sep).join('/'), value, ])))); testFormData.set('testFileOverrides', JSON.stringify(Object.fromEntries(Object.entries(flowOverrides).map(([key, value]) => [ key.replaceAll(commonRoot, '.').split(path.sep).join('/'), value, ])))); testFormData.set('sequentialFlows', JSON.stringify(sequentialFlows.map((t) => t.replaceAll(commonRoot, '.').split(path.sep).join('/')))); testFormData.set('env', JSON.stringify(envObject)); testFormData.set('googlePlay', googlePlay ? 'true' : 'false'); const config = { allExcludeTags, allIncludeTags, autoRetriesRemaining: retry, continueOnFailure, deviceLocale, maestroVersion: resolvedMaestroVersion, mitmHost, mitmPath, orientation, raw: JSON.stringify(raw), report, showCrosshairs: flags['show-crosshairs'], skipChromeOnboarding: flags['skip-chrome-onboarding'], uploadedBinaryIds, version: this.config.version, x86Arch, }; if (finalAdditionalBinaryIds?.length > 0) { config.additionalAppBinaryIds = finalAdditionalBinaryIds; } if (uploadedBinaryIds?.length > 0) { config.uploadedBinaryIds = uploadedBinaryIds; } testFormData.set('config', JSON.stringify(config)); if (Object.keys(metadataObject).length > 0) { const metadataPayload = { userMetadata: metadataObject }; testFormData.set('metadata', JSON.stringify(metadataPayload)); if (debug) { this.log(`DEBUG: Sending metadata to API: ${JSON.stringify(metadataPayload)}`); } } if (androidApiLevel) testFormData.set('androidApiLevel', androidApiLevel.toString()); if (androidDevice) testFormData.set('androidDevice', androidDevice.toString()); if (iOSVersion) testFormData.set('iOSVersion', iOSVersion.toString()); if (iOSDevice) testFormData.set('iOSDevice', iOSDevice.toString()); if (name) testFormData.set('name', name.toString()); if (runnerType) testFormData.set('runnerType', runnerType.toString()); if (workspaceConfig) testFormData.set('workspaceConfig', JSON.stringify(workspaceConfig)); for (const [key, value] of Object.entries(rest)) { if (value) { testFormData.set(key, value); } } if (debug) { this.log(`DEBUG: Submitting flow upload request to ${apiUrl}/uploads/flow`); } const { message, results } = await api_gateway_1.ApiGateway.uploadFlow(apiUrl, apiKey, testFormData); if (debug) { this.log(`DEBUG: Flow upload response received`); this.log(`DEBUG: Message: ${message}`); this.log(`DEBUG: Results count: ${results?.length || 0}`); } if (!results?.length) (0, errors_1.error)('No tests created: ' + message); this.log(message); this.log(`\nCreated ${results.length} tests: ${results .map((r) => r.test_file_name) .sort((a, b) => a.localeCompare(b)) .join(', ')}\n`); this.log('Run triggered, you can access the results at:'); const url = `https://console.devicecloud.dev/results?upload=${results[0].test_upload_id}&result=${results[0].id}`; this.log(url); this.log(`\n`); this.log(`Your upload ID is: ${results[0].test_upload_id}`); this.log(`Poll upload status using: dcd status --api-key ... --upload-id ${results[0].test_upload_id}`); if (async) { if (debug) { this.log(`DEBUG: Async flag is set, not waiting for results`); } const jsonOutput = { consoleUrl: url, status: 'PENDING', tests: results.map((r) => ({ name: r.test_file_name, status: r.status, })), uploadId: results[0].test_upload_id, }; if (flags['json-file']) { const jsonFilePath = this.getJsonOutputPath(results[0].test_upload_id, jsonFileName); (0, methods_1.writeJSONFile)(jsonFilePath, jsonOutput, this); } if (json) { return jsonOutput; } this.log('Not waiting for results as async flag is set to true'); return; } // poll for the run status every 5 seconds if (!json) { core_1.ux.action.start('Waiting for results', 'Initializing', { stdout: true, }); this.log('\nYou can safely close this terminal and the tests will continue\n'); } let sequentialPollFaillures = 0; if (debug) { this.log(`DEBUG: Starting polling loop for results`); } await new Promise((resolve, reject) => { const intervalId = setInterval(async () => { try { if (debug) { this.log(`DEBUG: Polling for results: ${results[0].test_upload_id}`); } const { results: updatedResults } = await api_gateway_1.ApiGateway.getResultsForUpload(apiUrl, apiKey, results[0].test_upload_id); if (!updatedResults) { throw new Error('no results'); } if (debug) { this.log(`DEBUG: Poll received ${updatedResults.length} results`); for (const result of updatedResults) { this.log(`DEBUG: Result status: ${result.test_file_name} - ${result.status}`); } } if (!quiet && !json) { core_1.ux.action.status = '\nStatus Test\n─────────── ───────────────'; for (const { retry_of: isRetry, status, test_file_name: test, } of updatedResults) { core_1.ux.action.status += `\n${status.padEnd(10, ' ')} ${test} ${isRetry ? '(retry)' : ''}`; } } if (updatedResults.every((result) => !['PENDING', 'RUNNING'].includes(result.status))) { if (debug) { this.log(`DEBUG: All tests completed, stopping poll`); } if (!json) { core_1.ux.action.stop('completed'); this.log('\n'); const hasFailedTests = updatedResults.some((result) => result.status === 'FAILED'); (0, cli_ux_1.table)(updatedResults, { status: { get: (row) => row.status }, test: { get: (row) => `${row.test_file_name} ${row.retry_of ? '(retry)' : ''}`, }, duration: { get: (row) => row.duration_seconds ? (0, methods_1.formatDurationSeconds)(Number(row.duration_seconds)) : '-', }, ...(hasFailedTests && { // eslint-disable-next-line camelcase fail_reason: { get: (row) => row.status === 'FAILED' && row.fail_reason ? row.fail_reason : '', }, }), }, { printLine: this.log.bind(this) }); this.log('\n'); this.log('Run completed, you can access the results at:'); this.log(url); this.log('\n'); } clearInterval(intervalId); if (downloadArtifacts) { try { if (debug) { this.log(`DEBUG: Downloading artifacts: ${downloadArtifacts}`); } await api_gateway_1.ApiGateway.downloadArtifactsZip(apiUrl, apiKey, results[0].test_upload_id, downloadArtifacts, artifactsPath); this.log('\n'); this.log(`Test artifacts have been downloaded to ${artifactsPath || './artifacts.zip'}`); } catch (error) { if (debug) { this.log(`DEBUG: Error downloading artifacts: ${error}`); } this.warn('Failed to download artifacts'); } } const resultsWithoutEarlierTries = updatedResults.filter((result) => { const originalTryId = result.retry_of || result.id; const tries = updatedResults.filter((r) => r.retry_of === originalTryId || r.id === originalTryId); return result.id === Math.max(...tries.map((t) => t.id)); }); if (resultsWithoutEarlierTries.some((result) => result.status === 'FAILED')) { if (debug) { this.log(`DEBUG: Some tests failed, returning failed status`); } const jsonOutput = { consoleUrl: url, status: 'FAILED', tests: resultsWithoutEarlierTries.map((r) => ({ name: r.test_file_name, status: r.status, durationSeconds: r.duration_seconds, failReason: r.status === 'FAILED' ? r.fail_reason || 'No reason provided' : undefined, })), uploadId: results[0].test_upload_id, }; if (flags['json-file']) { const jsonFilePath = this.getJsonOutputPath(results[0].test_upload_id, jsonFileName); (0, methods_1.writeJSONFile)(jsonFilePath, jsonOutput, this); } if (json) { output = jsonOutput; } reject(new Error('RUN_FAILED')); } else { if (debug) { this.log(`DEBUG: All tests passed, returning success status`); } const jsonOutput = { consoleUrl: url, status: 'PASSED', tests: resultsWithoutEarlierTries.map((r) => ({ name: r.test_file_name, status: r.status, durationSeconds: r.duration_seconds, failReason: r.status === 'FAILED' ? r.fail_reason || 'No reason provided' : undefined, })), uploadId: results[0].test_upload_id, }; if (flags['json-file']) { const jsonFilePath = this.getJsonOutputPath(results[0].test_upload_id, jsonFileName); (0, methods_1.writeJSONFile)(jsonFilePath, jsonOutput, this); } if (json) { output = jsonOutput; } } sequentialPollFaillures = 0; resolve(); } } catch (error) { sequentialPollFaillures++; if (debug) { this.log(`DEBUG: Error polling for results: ${error}`); this.log(`DEBUG: Sequential poll failures: ${sequentialPollFaillures}`); } if (sequentialPollFaillures > 10) { // dropped poll requests shouldn't err user CI clearInterval(intervalId); throw new Error('unable to fetch results after 10 attempts'); } this.log('unable to fetch results, trying again...'); } }, 5000); }); } catch (error) { if (debugFlag && error instanceof Error) { this.log(`DEBUG: Error in command execution: ${error.message}`); this.log(`DEBUG: Error stack: ${error.stack}`); } if (error instanceof Error && error.message === 'RUN_FAILED') { if (jsonFile) { // mimic oclif's json functionality this.exit(0); } this.exit(2); } else { this.error(error, { exit: 1 }); } } finally { if (output) { // eslint-disable-next-line no-unsafe-finally return output; } } } /** * Generate the JSON output file path based on upload ID or custom filename * @param uploadId - Upload ID to use if custom filename is not provided * @param jsonFileName - Optional custom filename (can include relative path) * @returns Path to the JSON output file */ getJsonOutputPath(uploadId, jsonFileName) { if (jsonFileName) { return jsonFileName; } return `${uploadId}_dcd.json`; } } exports.default = Cloud;