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