@devicecloud.dev/dcd
Version:
Better cloud maestro testing
509 lines (505 loc) • 24.8 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");
// 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;