@devicecloud.dev/dcd
Version:
Better cloud maestro testing
573 lines (572 loc) • 29.6 kB
JavaScript
"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 .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;