UNPKG

@devicecloud.dev/dcd

Version:

Better cloud maestro testing

573 lines (572 loc) 29.6 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"); const styling_1 = require("../utils/styling"); // 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 } }); /** * Primary CLI command for executing tests on DeviceCloud. * Orchestrates the complete test workflow: * - Binary upload with SHA deduplication * - Flow file analysis and dependency resolution * - Device compatibility validation * - Test submission with parallel execution * - Real-time result polling with 10-second intervals * - Artifact download (reports, videos, logs) * * Replaces `maestro cloud` with DeviceCloud-specific functionality. */ 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; /** Service for device/OS compatibility validation */ deviceValidationService = new device_validation_service_1.DeviceValidationService(); /** Service for Moropo test framework integration */ moropoService = new moropo_service_1.MoropoService(); /** Service for downloading test reports and artifacts */ reportDownloadService = new report_download_service_1.ReportDownloadService(); /** Service for polling test results with 10-second intervals */ resultsPollingService = new results_polling_service_1.ResultsPollingService(); /** Service for submitting tests to the API */ testSubmissionService = new test_submission_service_1.TestSubmissionService(); /** * Check for CLI updates and notify user if outdated * Compares current version with latest npm release * @returns Promise that resolves when version check is complete */ versionCheck = async () => { const latestVersion = await this.versionService.checkLatestCliVersion(); if (latestVersion && this.versionService.isOutdated(this.config.version, latestVersion)) { this.log(`\n${styling_1.dividers.light}\n` + `${styling_1.colors.warning('⚠')} ${styling_1.colors.bold('Update Available')}\n` + styling_1.colors.dim(` A new version of the DeviceCloud CLI is available: `) + styling_1.colors.highlight(latestVersion) + `\n` + styling_1.colors.dim(` Run: `) + styling_1.colors.info(`npm install -g @devicecloud.dev/dcd@latest`) + `\n` + `${styling_1.dividers.light}\n`); } }; /** Service for CLI version checking */ versionService = new version_service_1.VersionService(); /** * Main command execution entry point * Orchestrates the complete test submission and monitoring workflow * @returns Promise that resolves when command execution is complete * @throws RunFailedError if tests fail * @throws Error for infrastructure or configuration errors */ 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, 'android-no-snapshot': androidNoSnapshot, } = flags; // Store debug flag for use in catch block debugFlag = debug === true; jsonFile = flags['json-file'] === true; this.log(`CLI Version: ${this.config.version}`); if (debug) { this.log('[DEBUG] Starting command execution with debug logging enabled'); 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(styling_1.colors.warning('⚠') + ' ' + styling_1.colors.dim("Retries are now free of charge but limited to 2. If your test is still failing after 2 retries, please ask for help on Discord.")); flags.retry = 2; retry = 2; } if (runnerType === 'm4') { this.log(styling_1.colors.info('ℹ') + ' ' + styling_1.colors.dim('Note: runnerType m4 is experimental and currently supports iOS only, Android will revert to default.')); } if (runnerType === 'm1') { this.log(styling_1.colors.info('ℹ') + ' ' + styling_1.colors.dim('Note: runnerType m1 is experimental and currently supports Android (Pixel 7, API Level 34) only.')); } if (runnerType === 'gpu1') { this.log(styling_1.colors.info('ℹ') + ' ' + styling_1.colors.dim('Note: runnerType gpu1 is Android-only (all devices, API Level 34 or 35), available to all users.')); } 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) }); // Warn if maestro-chrome-onboarding flag is used without Android devices if (flags['maestro-chrome-onboarding'] && !androidApiLevel && !androidDevice) { this.warn('The --maestro-chrome-onboarding flag only applies to Android tests and will be ignored for iOS tests.'); } 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, flowMetadata, 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}`); } // Build testMetadataMap from flowMetadata (keyed by normalized test file name) // This map provides flowName and tags for each test for JSON output const testMetadataMap = {}; for (const [absolutePath, metadata] of Object.entries(flowMetadata)) { // Normalize the path to match the format used in results (e.g., "./flows/test.yaml") const normalizedPath = absolutePath.replaceAll(commonRoot, '.').split(path.sep).join('/'); const metadataRecord = metadata; const flowName = metadataRecord?.name || path.parse(absolutePath).name; const rawTags = metadataRecord?.tags; const tags = Array.isArray(rawTags) ? rawTags.map(String) : (rawTags ? [String(rawTags)] : []); testMetadataMap[normalizedPath] = { flowName, tags }; } if (debug) { this.log(`[DEBUG] Built testMetadataMap for ${Object.keys(testMetadataMap).length} flows`); } 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\n ' + styling_1.colors.bold('With overrides'); for (const [flowPath, overrides] of overridesEntries) { if (Object.keys(overrides).length > 0) { const relativePath = flowPath.replace(process.cwd(), '.'); overridesLog += `\n ${styling_1.colors.dim('→')} ${relativePath}:`; for (const [key, value] of Object.entries(overrides)) { overridesLog += `\n ${styling_1.colors.dim(key + ':')} ${styling_1.colors.highlight(value)}`; } } } } this.log(`\n${(0, styling_1.sectionHeader)('Submitting new job')}`); this.log(` ${styling_1.colors.dim('→ Flow(s):')} ${styling_1.colors.highlight(flowFile)}`); this.log(` ${styling_1.colors.dim('→ App:')} ${styling_1.colors.highlight(appBinaryId || finalAppFile)}`); if (flagLogs.length > 0) { this.log(`\n ${styling_1.colors.bold('With options')}`); for (const flagLog of flagLogs) { const [key, ...valueParts] = flagLog.split(': '); const value = valueParts.join(': '); this.log(` ${styling_1.colors.dim('→ ' + key + ':')} ${styling_1.colors.highlight(value)}`); } } if (hasOverrides) { this.log(overridesLog); } this.log(''); if (dryRun) { this.log(`\n${styling_1.colors.warning('⚠')} ${styling_1.colors.bold('Dry run mode')} ${styling_1.colors.dim('- no tests were actually triggered')}\n`); this.log(styling_1.colors.bold('The following tests would have been run:')); this.log(styling_1.dividers.light); for (const test of testFileNames) { this.log((0, styling_1.listItem)(test)); } if (sequentialFlows.length > 0) { this.log(`\n${styling_1.colors.bold('Sequential flows:')}`); this.log(styling_1.dividers.short); for (const flow of sequentialFlows) { this.log((0, styling_1.listItem)(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)({ apiKey, apiUrl, debug, filePath: finalAppFile, ignoreShaCheck, log: !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, androidNoSnapshot, 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'], maestroChromeOnboarding: flags['maestro-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(styling_1.colors.success('✓') + ' ' + styling_1.colors.dim(message)); const testNames = results .map((r) => r.test_file_name) .sort((a, b) => a.localeCompare(b)) .join(styling_1.colors.dim(', ')); this.log(`\n${styling_1.colors.bold(`Created ${results.length} test${results.length === 1 ? '' : 's'}:`)} ${testNames}\n`); const url = (0, styling_1.getConsoleUrl)(apiUrl, results[0].test_upload_id, results[0].id); this.log(styling_1.colors.bold('Run triggered') + styling_1.colors.dim(', you can access the results at:')); this.log((0, styling_1.formatUrl)(url)); this.log(``); this.log(styling_1.colors.dim('Your upload ID is: ') + (0, styling_1.formatId)(results[0].test_upload_id)); this.log(styling_1.colors.dim('Poll upload status using: ') + styling_1.colors.info(`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) => ({ fileName: r.test_file_name, flowName: testMetadataMap[r.test_file_name]?.flowName || path.parse(r.test_file_name).name, name: r.test_file_name, status: r.status, tags: testMetadataMap[r.test_file_name]?.tags || [], })), 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(`\n${styling_1.colors.info('ℹ')} ${styling_1.colors.dim('Not waiting for results as async flag is set to true')}\n`); 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, }, testMetadataMap) .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;