UNPKG

@devicecloud.dev/dcd

Version:

Better cloud maestro testing

509 lines (505 loc) 24.8 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); /* eslint-disable complexity */ const core_1 = require("@oclif/core"); const errors_1 = require("@oclif/core/lib/errors"); const path = require("node:path"); const constants_1 = require("../constants"); const api_gateway_1 = require("../gateways/api-gateway"); const methods_1 = require("../methods"); const device_validation_service_1 = require("../services/device-validation.service"); const execution_plan_service_1 = require("../services/execution-plan.service"); const moropo_service_1 = require("../services/moropo.service"); const report_download_service_1 = require("../services/report-download.service"); const results_polling_service_1 = require("../services/results-polling.service"); const test_submission_service_1 = require("../services/test-submission.service"); const version_service_1 = require("../services/version.service"); const compatibility_1 = require("../utils/compatibility"); // 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; deviceValidationService = new device_validation_service_1.DeviceValidationService(); moropoService = new moropo_service_1.MoropoService(); reportDownloadService = new report_download_service_1.ReportDownloadService(); resultsPollingService = new results_polling_service_1.ResultsPollingService(); testSubmissionService = new test_submission_service_1.TestSubmissionService(); versionCheck = async () => { const latestVersion = await this.versionService.checkLatestCliVersion(); if (latestVersion && this.versionService.isOutdated(this.config.version, latestVersion)) { 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 ------------------- `); } }; versionService = new version_service_1.VersionService(); 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 { 'android-api-level': androidApiLevel, 'android-device': androidDevice, apiKey: apiKeyFlag, apiUrl, 'app-binary-id': appBinaryId, 'app-file': appFile, 'artifacts-path': artifactsPath, 'junit-path': junitPath, 'allure-path': allurePath, 'html-path': htmlPath, 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, } = flags; // 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) { flows = await this.moropoService.downloadAndExtract({ apiKey: moropoApiKey, branchName: 'main', debug, json, logger: this.log.bind(this), quiet, }); } 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}`); } // Resolve and validate Maestro version using API data const resolvedMaestroVersion = this.versionService.resolveMaestroVersion(maestroVersion, compatibilityData, { debug, logger: this.log.bind(this), }); 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.'); } if (runnerType === 'gpu1') { this.log('Note: runnerType gpu1 is Android-only and requires contacting support to enable. Without support enablement, your runner type will revert to default.'); } const { firstFile, secondFile } = args; let finalBinaryId = appBinaryId; 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'}`); } 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'); } // Validate iOS device configuration this.deviceValidationService.validateiOSDevice(iOSVersion, iOSDevice, compatibilityData, { debug, logger: this.log.bind(this) }); // Validate Android device configuration this.deviceValidationService.validateAndroidDevice(androidApiLevel, androidDevice, googlePlay, compatibilityData, { debug, logger: this.log.bind(this) }); 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, execution_plan_service_1.plan)({ input: flowFile, includeTags: includeTags.flat(), excludeTags: excludeTags.flat(), excludeFlows: excludeFlows.flat(), configFile, debug, }); 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, flowOverrides, flowsToRun: testFileNames, referencedFiles, sequence, } = 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); } } const flagLogs = []; const sensitiveFlags = new Set(['api-key', 'apiKey', 'moropo-v1-api-key']); for (const [k, v] of Object.entries(flags)) { if (v && v.toString().length > 0 && !sensitiveFlags.has(k)) { 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} 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}`); } } // finalBinaryId should always be defined after validation - fail fast if not if (!finalBinaryId) { throw new Error('Internal error: finalBinaryId should be defined after validation'); } const testFormData = await this.testSubmissionService.buildTestFormData({ androidApiLevel, androidDevice, appBinaryId: finalBinaryId, cliVersion: this.config.version, commonRoot, continueOnFailure, debug, deviceLocale, env, executionPlan, flowFile, googlePlay, iOSDevice, iOSVersion, logger: this.log.bind(this), maestroVersion: resolvedMaestroVersion, metadata, mitmHost, mitmPath, name, orientation, raw, report, retry, runnerType, showCrosshairs: flags['show-crosshairs'], skipChromeOnboarding: flags['skip-chrome-onboarding'], }); 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 = jsonFileName || `${results[0].test_upload_id}_dcd.json`; (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 results until completion const pollingResult = await this.resultsPollingService .pollUntilComplete(results, { apiKey, apiUrl, consoleUrl: url, debug, json, logger: this.log.bind(this), quiet, uploadId: results[0].test_upload_id, }) .catch(async (error) => { if (error instanceof results_polling_service_1.RunFailedError) { // Handle failed test run const jsonOutput = error.result; if (flags['json-file']) { const jsonFilePath = jsonFileName || `${results[0].test_upload_id}_dcd.json`; (0, methods_1.writeJSONFile)(jsonFilePath, jsonOutput, this); } if (json) { output = jsonOutput; } // Download artifacts and reports even when tests fail if (downloadArtifacts) { await this.reportDownloadService.downloadArtifacts({ apiKey, apiUrl, artifactsPath, debug, downloadType: downloadArtifacts, logger: this.log.bind(this), uploadId: results[0].test_upload_id, warnLogger: this.warn.bind(this), }); } if (report && ['allure', 'html', 'junit'].includes(report)) { await this.reportDownloadService.downloadReports({ allurePath, apiKey, apiUrl, debug, htmlPath, junitPath, logger: this.log.bind(this), reportType: report, uploadId: results[0].test_upload_id, warnLogger: this.warn.bind(this), }); } throw new Error('RUN_FAILED'); } throw error; }); // Handle successful completion - download artifacts and reports if (downloadArtifacts) { await this.reportDownloadService.downloadArtifacts({ apiKey, apiUrl, artifactsPath, debug, downloadType: downloadArtifacts, logger: this.log.bind(this), uploadId: results[0].test_upload_id, warnLogger: this.warn.bind(this), }); } // Handle report downloads based on --report flag if (report && ['allure', 'html', 'junit'].includes(report)) { await this.reportDownloadService.downloadReports({ allurePath, apiKey, apiUrl, debug, htmlPath, junitPath, logger: this.log.bind(this), reportType: report, uploadId: results[0].test_upload_id, warnLogger: this.warn.bind(this), }); } const jsonOutput = pollingResult; if (flags['json-file']) { const jsonFilePath = jsonFileName || `${results[0].test_upload_id}_dcd.json`; (0, methods_1.writeJSONFile)(jsonFilePath, jsonOutput, this); } if (json) { output = jsonOutput; } } 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; } } } } exports.default = Cloud;