@devicecloud.dev/dcd 4.1.3 → 4.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/cloud.js +37 -3
- package/dist/gateways/api-gateway.js +1 -1
- package/dist/services/device-validation.service.d.ts +2 -0
- package/dist/services/device-validation.service.js +2 -0
- package/dist/services/execution-plan.service.d.ts +9 -1
- package/dist/services/execution-plan.service.js +2 -1
- package/dist/services/moropo.service.d.ts +5 -0
- package/dist/services/moropo.service.js +60 -55
- package/dist/services/report-download.service.d.ts +3 -0
- package/dist/services/report-download.service.js +3 -0
- package/dist/services/results-polling.service.d.ts +6 -0
- package/dist/services/results-polling.service.js +131 -117
- package/dist/services/test-submission.service.d.ts +6 -0
- package/dist/services/test-submission.service.js +56 -50
- package/dist/services/version.service.d.ts +3 -2
- package/dist/services/version.service.js +3 -2
- package/oclif.manifest.json +1 -1
- package/package.json +1 -1
package/dist/commands/cloud.js
CHANGED
|
@@ -186,7 +186,14 @@ class Cloud extends core_1.Command {
|
|
|
186
186
|
if (debug) {
|
|
187
187
|
this.log('DEBUG: Generating execution plan...');
|
|
188
188
|
}
|
|
189
|
-
executionPlan = await (0, execution_plan_service_1.plan)(
|
|
189
|
+
executionPlan = await (0, execution_plan_service_1.plan)({
|
|
190
|
+
input: flowFile,
|
|
191
|
+
includeTags: includeTags.flat(),
|
|
192
|
+
excludeTags: excludeTags.flat(),
|
|
193
|
+
excludeFlows: excludeFlows.flat(),
|
|
194
|
+
configFile,
|
|
195
|
+
debug,
|
|
196
|
+
});
|
|
190
197
|
if (debug) {
|
|
191
198
|
this.log(`DEBUG: Execution plan generated`);
|
|
192
199
|
this.log(`DEBUG: Total flow files: ${executionPlan.totalFlowFiles}`);
|
|
@@ -395,7 +402,7 @@ class Cloud extends core_1.Command {
|
|
|
395
402
|
quiet,
|
|
396
403
|
uploadId: results[0].test_upload_id,
|
|
397
404
|
})
|
|
398
|
-
.catch((error) => {
|
|
405
|
+
.catch(async (error) => {
|
|
399
406
|
if (error instanceof results_polling_service_1.RunFailedError) {
|
|
400
407
|
// Handle failed test run
|
|
401
408
|
const jsonOutput = error.result;
|
|
@@ -406,11 +413,38 @@ class Cloud extends core_1.Command {
|
|
|
406
413
|
if (json) {
|
|
407
414
|
output = jsonOutput;
|
|
408
415
|
}
|
|
416
|
+
// Download artifacts and reports even when tests fail
|
|
417
|
+
if (downloadArtifacts) {
|
|
418
|
+
await this.reportDownloadService.downloadArtifacts({
|
|
419
|
+
apiKey,
|
|
420
|
+
apiUrl,
|
|
421
|
+
artifactsPath,
|
|
422
|
+
debug,
|
|
423
|
+
downloadType: downloadArtifacts,
|
|
424
|
+
logger: this.log.bind(this),
|
|
425
|
+
uploadId: results[0].test_upload_id,
|
|
426
|
+
warnLogger: this.warn.bind(this),
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
if (report && ['allure', 'html', 'junit'].includes(report)) {
|
|
430
|
+
await this.reportDownloadService.downloadReports({
|
|
431
|
+
allurePath,
|
|
432
|
+
apiKey,
|
|
433
|
+
apiUrl,
|
|
434
|
+
debug,
|
|
435
|
+
htmlPath,
|
|
436
|
+
junitPath,
|
|
437
|
+
logger: this.log.bind(this),
|
|
438
|
+
reportType: report,
|
|
439
|
+
uploadId: results[0].test_upload_id,
|
|
440
|
+
warnLogger: this.warn.bind(this),
|
|
441
|
+
});
|
|
442
|
+
}
|
|
409
443
|
throw new Error('RUN_FAILED');
|
|
410
444
|
}
|
|
411
445
|
throw error;
|
|
412
446
|
});
|
|
413
|
-
// Handle successful completion
|
|
447
|
+
// Handle successful completion - download artifacts and reports
|
|
414
448
|
if (downloadArtifacts) {
|
|
415
449
|
await this.reportDownloadService.downloadArtifacts({
|
|
416
450
|
apiKey,
|
|
@@ -95,7 +95,7 @@ exports.ApiGateway = {
|
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
97
|
}
|
|
98
|
-
const fileStream = createWriteStream(artifactsPath, { flags: '
|
|
98
|
+
const fileStream = createWriteStream(artifactsPath, { flags: 'w' });
|
|
99
99
|
await finished(Readable.fromWeb(res.body).pipe(fileStream));
|
|
100
100
|
},
|
|
101
101
|
// eslint-disable-next-line max-params
|
|
@@ -14,6 +14,7 @@ export declare class DeviceValidationService {
|
|
|
14
14
|
* @param googlePlay Whether Google Play services are enabled
|
|
15
15
|
* @param compatibilityData Compatibility data from API
|
|
16
16
|
* @param options Validation options
|
|
17
|
+
* @returns void
|
|
17
18
|
* @throws Error if device/API level combination is not supported
|
|
18
19
|
*/
|
|
19
20
|
validateAndroidDevice(androidApiLevel: string | undefined, androidDevice: string | undefined, googlePlay: boolean, compatibilityData: CompatibilityData, options?: DeviceValidationOptions): void;
|
|
@@ -23,6 +24,7 @@ export declare class DeviceValidationService {
|
|
|
23
24
|
* @param iOSDevice iOS device model to validate
|
|
24
25
|
* @param compatibilityData Compatibility data from API
|
|
25
26
|
* @param options Validation options
|
|
27
|
+
* @returns void
|
|
26
28
|
* @throws Error if device/version combination is not supported
|
|
27
29
|
*/
|
|
28
30
|
validateiOSDevice(iOSVersion: string | undefined, iOSDevice: string | undefined, compatibilityData: CompatibilityData, options?: DeviceValidationOptions): void;
|
|
@@ -12,6 +12,7 @@ class DeviceValidationService {
|
|
|
12
12
|
* @param googlePlay Whether Google Play services are enabled
|
|
13
13
|
* @param compatibilityData Compatibility data from API
|
|
14
14
|
* @param options Validation options
|
|
15
|
+
* @returns void
|
|
15
16
|
* @throws Error if device/API level combination is not supported
|
|
16
17
|
*/
|
|
17
18
|
validateAndroidDevice(androidApiLevel, androidDevice, googlePlay, compatibilityData, options = {}) {
|
|
@@ -45,6 +46,7 @@ class DeviceValidationService {
|
|
|
45
46
|
* @param iOSDevice iOS device model to validate
|
|
46
47
|
* @param compatibilityData Compatibility data from API
|
|
47
48
|
* @param options Validation options
|
|
49
|
+
* @returns void
|
|
48
50
|
* @throws Error if device/version combination is not supported
|
|
49
51
|
*/
|
|
50
52
|
validateiOSDevice(iOSVersion, iOSDevice, compatibilityData, options = {}) {
|
|
@@ -20,6 +20,14 @@ interface IExecutionOrder {
|
|
|
20
20
|
continueOnFailure: boolean;
|
|
21
21
|
flowsOrder: string[];
|
|
22
22
|
}
|
|
23
|
+
export interface PlanOptions {
|
|
24
|
+
configFile?: string;
|
|
25
|
+
debug?: boolean;
|
|
26
|
+
excludeFlows?: string[];
|
|
27
|
+
excludeTags?: string[];
|
|
28
|
+
includeTags?: string[];
|
|
29
|
+
input: string;
|
|
30
|
+
}
|
|
23
31
|
export interface IExecutionPlan {
|
|
24
32
|
allExcludeTags?: null | string[];
|
|
25
33
|
allIncludeTags?: null | string[];
|
|
@@ -35,5 +43,5 @@ interface IFlowSequence {
|
|
|
35
43
|
continueOnFailure?: boolean;
|
|
36
44
|
flows: string[];
|
|
37
45
|
}
|
|
38
|
-
export declare function plan(
|
|
46
|
+
export declare function plan(options: PlanOptions): Promise<IExecutionPlan>;
|
|
39
47
|
export {};
|
|
@@ -64,7 +64,8 @@ function extractDeviceCloudOverrides(config) {
|
|
|
64
64
|
}
|
|
65
65
|
return overrides;
|
|
66
66
|
}
|
|
67
|
-
async function plan(
|
|
67
|
+
async function plan(options) {
|
|
68
|
+
const { input, includeTags = [], excludeTags = [], excludeFlows, configFile, debug = false, } = options;
|
|
68
69
|
const normalizedInput = path.normalize(input);
|
|
69
70
|
const flowMetadata = {};
|
|
70
71
|
if (!fs.existsSync(normalizedInput)) {
|
|
@@ -17,4 +17,9 @@ export declare class MoropoService {
|
|
|
17
17
|
* @returns Path to the extracted Moropo tests directory
|
|
18
18
|
*/
|
|
19
19
|
downloadAndExtract(options: MoropoDownloadOptions): Promise<string>;
|
|
20
|
+
private createConfigFile;
|
|
21
|
+
private downloadZipFile;
|
|
22
|
+
private extractZipFile;
|
|
23
|
+
private logDebug;
|
|
24
|
+
private showProgress;
|
|
20
25
|
}
|
|
@@ -18,10 +18,8 @@ class MoropoService {
|
|
|
18
18
|
*/
|
|
19
19
|
async downloadAndExtract(options) {
|
|
20
20
|
const { apiKey, branchName = 'main', debug = false, quiet = false, json = false, logger } = options;
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
logger(`DEBUG: Using branch name: ${branchName}`);
|
|
24
|
-
}
|
|
21
|
+
this.logDebug(debug, logger, 'DEBUG: Moropo v1 API key detected, downloading tests from Moropo API');
|
|
22
|
+
this.logDebug(debug, logger, `DEBUG: Using branch name: ${branchName}`);
|
|
25
23
|
try {
|
|
26
24
|
if (!quiet && !json) {
|
|
27
25
|
core_1.ux.action.start('Downloading Moropo tests', 'Initializing', {
|
|
@@ -38,76 +36,83 @@ class MoropoService {
|
|
|
38
36
|
if (!response.ok) {
|
|
39
37
|
throw new Error(`Failed to download Moropo tests: ${response.statusText}`);
|
|
40
38
|
}
|
|
41
|
-
const contentLength = response.headers.get('content-length');
|
|
42
|
-
const totalSize = contentLength
|
|
43
|
-
? Number.parseInt(contentLength, 10)
|
|
44
|
-
: 0;
|
|
45
|
-
let downloadedSize = 0;
|
|
46
39
|
const moropoDir = path.join(os.tmpdir(), `moropo-tests-${Date.now()}`);
|
|
47
|
-
|
|
48
|
-
logger(`DEBUG: Extracting Moropo tests to: ${moropoDir}`);
|
|
49
|
-
}
|
|
40
|
+
this.logDebug(debug, logger, `DEBUG: Extracting Moropo tests to: ${moropoDir}`);
|
|
50
41
|
// Create moropo directory if it doesn't exist
|
|
51
42
|
if (!fs.existsSync(moropoDir)) {
|
|
52
43
|
fs.mkdirSync(moropoDir, { recursive: true });
|
|
53
44
|
}
|
|
54
|
-
//
|
|
45
|
+
// Download zip file
|
|
55
46
|
const zipPath = path.join(moropoDir, 'moropo-tests.zip');
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if (!reader) {
|
|
59
|
-
throw new Error('Failed to get response reader');
|
|
60
|
-
}
|
|
61
|
-
let readerResult = await reader.read();
|
|
62
|
-
while (!readerResult.done) {
|
|
63
|
-
const { value } = readerResult;
|
|
64
|
-
downloadedSize += value.length;
|
|
65
|
-
if (!quiet && !json && totalSize) {
|
|
66
|
-
const progress = Math.round((downloadedSize / totalSize) * 100);
|
|
67
|
-
core_1.ux.action.status = `Downloading: ${progress}%`;
|
|
68
|
-
}
|
|
69
|
-
fileStream.write(value);
|
|
70
|
-
readerResult = await reader.read();
|
|
71
|
-
}
|
|
72
|
-
fileStream.end();
|
|
73
|
-
await new Promise((resolve) => {
|
|
74
|
-
fileStream.on('finish', () => {
|
|
75
|
-
resolve();
|
|
76
|
-
});
|
|
77
|
-
});
|
|
78
|
-
if (!quiet && !json) {
|
|
79
|
-
core_1.ux.action.status = 'Extracting tests...';
|
|
80
|
-
}
|
|
47
|
+
await this.downloadZipFile(response, zipPath, { quiet, json });
|
|
48
|
+
this.showProgress(quiet, json, 'Extracting tests...');
|
|
81
49
|
// Extract zip file
|
|
82
|
-
|
|
83
|
-
const zip = new StreamZip.async({ file: zipPath });
|
|
84
|
-
await zip.extract(null, moropoDir);
|
|
85
|
-
await zip.close();
|
|
86
|
-
// Delete zip file after extraction
|
|
87
|
-
fs.unlinkSync(zipPath);
|
|
50
|
+
await this.extractZipFile(zipPath, moropoDir);
|
|
88
51
|
if (!quiet && !json) {
|
|
89
52
|
core_1.ux.action.stop('completed');
|
|
90
53
|
}
|
|
91
|
-
|
|
92
|
-
logger('DEBUG: Successfully extracted Moropo tests');
|
|
93
|
-
}
|
|
54
|
+
this.logDebug(debug, logger, 'DEBUG: Successfully extracted Moropo tests');
|
|
94
55
|
// Create config.yaml file
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
if (debug && logger) {
|
|
98
|
-
logger('DEBUG: Created config.yaml file');
|
|
99
|
-
}
|
|
56
|
+
this.createConfigFile(moropoDir);
|
|
57
|
+
this.logDebug(debug, logger, 'DEBUG: Created config.yaml file');
|
|
100
58
|
return moropoDir;
|
|
101
59
|
}
|
|
102
60
|
catch (error) {
|
|
103
61
|
if (!quiet && !json) {
|
|
104
62
|
core_1.ux.action.stop('failed');
|
|
105
63
|
}
|
|
106
|
-
|
|
107
|
-
logger(`DEBUG: Error downloading/extracting Moropo tests: ${error}`);
|
|
108
|
-
}
|
|
64
|
+
this.logDebug(debug, logger, `DEBUG: Error downloading/extracting Moropo tests: ${error}`);
|
|
109
65
|
throw new Error(`Failed to download/extract Moropo tests: ${error}`);
|
|
110
66
|
}
|
|
111
67
|
}
|
|
68
|
+
createConfigFile(moropoDir) {
|
|
69
|
+
const configPath = path.join(moropoDir, 'config.yaml');
|
|
70
|
+
fs.writeFileSync(configPath, 'flows:\n- ./**/*.yaml\n- ./*.yaml\n');
|
|
71
|
+
}
|
|
72
|
+
async downloadZipFile(response, zipPath, options) {
|
|
73
|
+
const { quiet, json } = options;
|
|
74
|
+
const contentLength = response.headers.get('content-length');
|
|
75
|
+
const totalSize = contentLength ? Number.parseInt(contentLength, 10) : 0;
|
|
76
|
+
let downloadedSize = 0;
|
|
77
|
+
const fileStream = fs.createWriteStream(zipPath);
|
|
78
|
+
const reader = response.body?.getReader();
|
|
79
|
+
if (!reader) {
|
|
80
|
+
throw new Error('Failed to get response reader');
|
|
81
|
+
}
|
|
82
|
+
let readerResult = await reader.read();
|
|
83
|
+
while (!readerResult.done) {
|
|
84
|
+
const { value } = readerResult;
|
|
85
|
+
downloadedSize += value.length;
|
|
86
|
+
if (!quiet && !json && totalSize) {
|
|
87
|
+
const progress = Math.round((downloadedSize / totalSize) * 100);
|
|
88
|
+
core_1.ux.action.status = `Downloading: ${progress}%`;
|
|
89
|
+
}
|
|
90
|
+
fileStream.write(value);
|
|
91
|
+
readerResult = await reader.read();
|
|
92
|
+
}
|
|
93
|
+
fileStream.end();
|
|
94
|
+
await new Promise((resolve) => {
|
|
95
|
+
fileStream.on('finish', () => {
|
|
96
|
+
resolve();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
async extractZipFile(zipPath, extractPath) {
|
|
101
|
+
// eslint-disable-next-line new-cap
|
|
102
|
+
const zip = new StreamZip.async({ file: zipPath });
|
|
103
|
+
await zip.extract(null, extractPath);
|
|
104
|
+
await zip.close();
|
|
105
|
+
fs.unlinkSync(zipPath);
|
|
106
|
+
}
|
|
107
|
+
logDebug(debug, logger, message) {
|
|
108
|
+
if (debug && logger) {
|
|
109
|
+
logger(message);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
showProgress(quiet, json, message) {
|
|
113
|
+
if (!quiet && !json) {
|
|
114
|
+
core_1.ux.action.status = message;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
112
117
|
}
|
|
113
118
|
exports.MoropoService = MoropoService;
|
|
@@ -23,11 +23,13 @@ export declare class ReportDownloadService {
|
|
|
23
23
|
/**
|
|
24
24
|
* Download test artifacts as a zip file
|
|
25
25
|
* @param options Download configuration
|
|
26
|
+
* @returns Promise that resolves when download is complete
|
|
26
27
|
*/
|
|
27
28
|
downloadArtifacts(options: ArtifactsDownloadOptions): Promise<void>;
|
|
28
29
|
/**
|
|
29
30
|
* Handle downloading reports based on the report type specified
|
|
30
31
|
* @param options Report download configuration
|
|
32
|
+
* @returns Promise that resolves when download is complete
|
|
31
33
|
*/
|
|
32
34
|
downloadReports(options: ReportDownloadOptions): Promise<void>;
|
|
33
35
|
/**
|
|
@@ -35,6 +37,7 @@ export declare class ReportDownloadService {
|
|
|
35
37
|
* @param type Report type to download
|
|
36
38
|
* @param filePath Path where report should be saved
|
|
37
39
|
* @param options Download configuration
|
|
40
|
+
* @returns Promise that resolves when download is complete
|
|
38
41
|
*/
|
|
39
42
|
private downloadReport;
|
|
40
43
|
}
|
|
@@ -10,6 +10,7 @@ class ReportDownloadService {
|
|
|
10
10
|
/**
|
|
11
11
|
* Download test artifacts as a zip file
|
|
12
12
|
* @param options Download configuration
|
|
13
|
+
* @returns Promise that resolves when download is complete
|
|
13
14
|
*/
|
|
14
15
|
async downloadArtifacts(options) {
|
|
15
16
|
const { apiUrl, apiKey, uploadId, downloadType, artifactsPath = './artifacts.zip', debug = false, logger, warnLogger, } = options;
|
|
@@ -35,6 +36,7 @@ class ReportDownloadService {
|
|
|
35
36
|
/**
|
|
36
37
|
* Handle downloading reports based on the report type specified
|
|
37
38
|
* @param options Report download configuration
|
|
39
|
+
* @returns Promise that resolves when download is complete
|
|
38
40
|
*/
|
|
39
41
|
async downloadReports(options) {
|
|
40
42
|
const { reportType, junitPath, allurePath, htmlPath, warnLogger, ...downloadOptions } = options;
|
|
@@ -75,6 +77,7 @@ class ReportDownloadService {
|
|
|
75
77
|
* @param type Report type to download
|
|
76
78
|
* @param filePath Path where report should be saved
|
|
77
79
|
* @param options Download configuration
|
|
80
|
+
* @returns Promise that resolves when download is complete
|
|
78
81
|
*/
|
|
79
82
|
async downloadReport(type, filePath, options) {
|
|
80
83
|
const { apiUrl, apiKey, uploadId, debug = false, logger, warnLogger } = options;
|
|
@@ -41,5 +41,11 @@ export declare class ResultsPollingService {
|
|
|
41
41
|
* @returns Promise that resolves with final test results or rejects if tests fail
|
|
42
42
|
*/
|
|
43
43
|
pollUntilComplete(results: TestResult[], options: PollingOptions): Promise<PollingResult>;
|
|
44
|
+
private buildPollingResult;
|
|
45
|
+
private calculateStatusSummary;
|
|
46
|
+
private displayFinalResults;
|
|
47
|
+
private filterLatestResults;
|
|
48
|
+
private handlePollingError;
|
|
49
|
+
private updateDisplayStatus;
|
|
44
50
|
}
|
|
45
51
|
export {};
|
|
@@ -61,93 +61,16 @@ class ResultsPollingService {
|
|
|
61
61
|
logger(`DEBUG: Result status: ${result.test_file_name} - ${result.status}`);
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
const passed = statusCounts.PASSED || 0;
|
|
70
|
-
const failed = statusCounts.FAILED || 0;
|
|
71
|
-
const pending = statusCounts.PENDING || 0;
|
|
72
|
-
const running = statusCounts.RUNNING || 0;
|
|
73
|
-
const total = updatedResults.length;
|
|
74
|
-
const completed = passed + failed;
|
|
75
|
-
// Display quantitative summary in quiet mode only
|
|
76
|
-
const summary = `${completed}/${total} | ✓ ${passed} | ✗ ${failed} | ▶ ${running} | ⏸ ${pending}`;
|
|
77
|
-
if (!json) {
|
|
78
|
-
if (quiet) {
|
|
79
|
-
// Only update status when the summary changes in quiet mode
|
|
80
|
-
if (summary !== previousSummary) {
|
|
81
|
-
core_1.ux.action.status = summary;
|
|
82
|
-
previousSummary = summary;
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
else {
|
|
86
|
-
// Show detailed table in non-quiet mode (no summary)
|
|
87
|
-
core_1.ux.action.status =
|
|
88
|
-
'\nStatus Test\n─────────── ───────────────';
|
|
89
|
-
for (const { retry_of: isRetry, status, test_file_name: test, } of updatedResults) {
|
|
90
|
-
core_1.ux.action.status += `\n${status.padEnd(10, ' ')} ${test} ${isRetry ? '(retry)' : ''}`;
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
if (updatedResults.every((result) => !['PENDING', 'RUNNING'].includes(result.status))) {
|
|
64
|
+
const { summary } = this.calculateStatusSummary(updatedResults);
|
|
65
|
+
previousSummary = this.updateDisplayStatus(updatedResults, quiet, json, summary, previousSummary);
|
|
66
|
+
const allComplete = updatedResults.every((result) => !['PENDING', 'RUNNING'].includes(result.status));
|
|
67
|
+
if (allComplete) {
|
|
95
68
|
if (debug && logger) {
|
|
96
69
|
logger(`DEBUG: All tests completed, stopping poll`);
|
|
97
70
|
}
|
|
98
|
-
|
|
99
|
-
core_1.ux.action.stop('completed');
|
|
100
|
-
if (logger) {
|
|
101
|
-
logger('\n');
|
|
102
|
-
}
|
|
103
|
-
const hasFailedTests = updatedResults.some((result) => result.status === 'FAILED');
|
|
104
|
-
(0, cli_ux_1.table)(updatedResults, {
|
|
105
|
-
status: { get: (row) => row.status },
|
|
106
|
-
test: {
|
|
107
|
-
get: (row) => `${row.test_file_name} ${row.retry_of ? '(retry)' : ''}`,
|
|
108
|
-
},
|
|
109
|
-
duration: {
|
|
110
|
-
get: (row) => row.duration_seconds
|
|
111
|
-
? (0, methods_1.formatDurationSeconds)(Number(row.duration_seconds))
|
|
112
|
-
: '-',
|
|
113
|
-
},
|
|
114
|
-
...(hasFailedTests && {
|
|
115
|
-
// eslint-disable-next-line camelcase
|
|
116
|
-
fail_reason: {
|
|
117
|
-
get: (row) => row.status === 'FAILED' && row.fail_reason
|
|
118
|
-
? row.fail_reason
|
|
119
|
-
: '',
|
|
120
|
-
},
|
|
121
|
-
}),
|
|
122
|
-
}, { printLine: logger });
|
|
123
|
-
if (logger) {
|
|
124
|
-
logger('\n');
|
|
125
|
-
logger('Run completed, you can access the results at:');
|
|
126
|
-
logger(consoleUrl);
|
|
127
|
-
logger('\n');
|
|
128
|
-
}
|
|
129
|
-
}
|
|
71
|
+
this.displayFinalResults(updatedResults, consoleUrl, json, logger);
|
|
130
72
|
clearInterval(intervalId);
|
|
131
|
-
const
|
|
132
|
-
const originalTryId = result.retry_of || result.id;
|
|
133
|
-
const tries = updatedResults.filter((r) => r.retry_of === originalTryId || r.id === originalTryId);
|
|
134
|
-
return result.id === Math.max(...tries.map((t) => t.id));
|
|
135
|
-
});
|
|
136
|
-
const output = {
|
|
137
|
-
consoleUrl,
|
|
138
|
-
status: resultsWithoutEarlierTries.some((result) => result.status === 'FAILED')
|
|
139
|
-
? 'FAILED'
|
|
140
|
-
: 'PASSED',
|
|
141
|
-
tests: resultsWithoutEarlierTries.map((r) => ({
|
|
142
|
-
durationSeconds: r.duration_seconds,
|
|
143
|
-
failReason: r.status === 'FAILED'
|
|
144
|
-
? r.fail_reason || 'No reason provided'
|
|
145
|
-
: undefined,
|
|
146
|
-
name: r.test_file_name,
|
|
147
|
-
status: r.status,
|
|
148
|
-
})),
|
|
149
|
-
uploadId,
|
|
150
|
-
};
|
|
73
|
+
const output = this.buildPollingResult(updatedResults, uploadId, consoleUrl);
|
|
151
74
|
if (output.status === 'FAILED') {
|
|
152
75
|
if (debug && logger) {
|
|
153
76
|
logger(`DEBUG: Some tests failed, returning failed status`);
|
|
@@ -165,46 +88,137 @@ class ResultsPollingService {
|
|
|
165
88
|
}
|
|
166
89
|
catch (error) {
|
|
167
90
|
sequentialPollFailures++;
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
logger(`DEBUG: Sequential poll failures: ${sequentialPollFailures}`);
|
|
91
|
+
try {
|
|
92
|
+
await this.handlePollingError(error, sequentialPollFailures, debug, logger);
|
|
171
93
|
}
|
|
172
|
-
|
|
173
|
-
// dropped poll requests shouldn't err user CI
|
|
94
|
+
catch (error) {
|
|
174
95
|
clearInterval(intervalId);
|
|
175
|
-
|
|
176
|
-
if (debug && logger) {
|
|
177
|
-
logger('DEBUG: Checking internet connectivity...');
|
|
178
|
-
}
|
|
179
|
-
const connectivityCheck = await (0, connectivity_1.checkInternetConnectivity)();
|
|
180
|
-
if (debug && logger) {
|
|
181
|
-
logger(`DEBUG: ${connectivityCheck.message}`);
|
|
182
|
-
for (const result of connectivityCheck.endpointResults) {
|
|
183
|
-
if (result.success) {
|
|
184
|
-
logger(`DEBUG: ✓ ${result.endpoint} - ${result.statusCode} (${result.latencyMs}ms)`);
|
|
185
|
-
}
|
|
186
|
-
else {
|
|
187
|
-
logger(`DEBUG: ✗ ${result.endpoint} - ${result.error} (${result.latencyMs}ms)`);
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
if (!connectivityCheck.connected) {
|
|
192
|
-
// Build detailed error message with endpoint diagnostics
|
|
193
|
-
const endpointDetails = connectivityCheck.endpointResults
|
|
194
|
-
.map((r) => ` - ${r.endpoint}: ${r.error} (${r.latencyMs}ms)`)
|
|
195
|
-
.join('\n');
|
|
196
|
-
reject(new Error(`Unable to fetch results after ${this.MAX_SEQUENTIAL_FAILURES} attempts.\n\nInternet connectivity check failed - all test endpoints unreachable:\n${endpointDetails}\n\nPlease verify your network connection and DNS resolution.`));
|
|
197
|
-
return;
|
|
198
|
-
}
|
|
199
|
-
reject(new Error(`unable to fetch results after ${this.MAX_SEQUENTIAL_FAILURES} attempts`));
|
|
200
|
-
return;
|
|
201
|
-
}
|
|
202
|
-
if (logger) {
|
|
203
|
-
logger('unable to fetch results, trying again...');
|
|
96
|
+
reject(error);
|
|
204
97
|
}
|
|
205
98
|
}
|
|
206
99
|
}, this.POLL_INTERVAL_MS);
|
|
207
100
|
});
|
|
208
101
|
}
|
|
102
|
+
buildPollingResult(results, uploadId, consoleUrl) {
|
|
103
|
+
const resultsWithoutEarlierTries = this.filterLatestResults(results);
|
|
104
|
+
return {
|
|
105
|
+
consoleUrl,
|
|
106
|
+
status: resultsWithoutEarlierTries.some((result) => result.status === 'FAILED')
|
|
107
|
+
? 'FAILED'
|
|
108
|
+
: 'PASSED',
|
|
109
|
+
tests: resultsWithoutEarlierTries.map((r) => ({
|
|
110
|
+
durationSeconds: r.duration_seconds,
|
|
111
|
+
failReason: r.status === 'FAILED' ? r.fail_reason || 'No reason provided' : undefined,
|
|
112
|
+
name: r.test_file_name,
|
|
113
|
+
status: r.status,
|
|
114
|
+
})),
|
|
115
|
+
uploadId,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
calculateStatusSummary(results) {
|
|
119
|
+
const statusCounts = {};
|
|
120
|
+
for (const result of results) {
|
|
121
|
+
statusCounts[result.status] = (statusCounts[result.status] || 0) + 1;
|
|
122
|
+
}
|
|
123
|
+
const passed = statusCounts.PASSED || 0;
|
|
124
|
+
const failed = statusCounts.FAILED || 0;
|
|
125
|
+
const pending = statusCounts.PENDING || 0;
|
|
126
|
+
const running = statusCounts.RUNNING || 0;
|
|
127
|
+
const total = results.length;
|
|
128
|
+
const completed = passed + failed;
|
|
129
|
+
const summary = `${completed}/${total} | ✓ ${passed} | ✗ ${failed} | ▶ ${running} | ⏸ ${pending}`;
|
|
130
|
+
return { completed, failed, passed, pending, running, summary, total };
|
|
131
|
+
}
|
|
132
|
+
displayFinalResults(results, consoleUrl, json, logger) {
|
|
133
|
+
if (json) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
core_1.ux.action.stop('completed');
|
|
137
|
+
if (logger) {
|
|
138
|
+
logger('\n');
|
|
139
|
+
}
|
|
140
|
+
const hasFailedTests = results.some((result) => result.status === 'FAILED');
|
|
141
|
+
(0, cli_ux_1.table)(results, {
|
|
142
|
+
duration: {
|
|
143
|
+
get: (row) => row.duration_seconds
|
|
144
|
+
? (0, methods_1.formatDurationSeconds)(Number(row.duration_seconds))
|
|
145
|
+
: '-',
|
|
146
|
+
},
|
|
147
|
+
status: { get: (row) => row.status },
|
|
148
|
+
test: {
|
|
149
|
+
get: (row) => `${row.test_file_name} ${row.retry_of ? '(retry)' : ''}`,
|
|
150
|
+
},
|
|
151
|
+
...(hasFailedTests && {
|
|
152
|
+
// eslint-disable-next-line camelcase
|
|
153
|
+
fail_reason: {
|
|
154
|
+
get: (row) => row.status === 'FAILED' && row.fail_reason ? row.fail_reason : '',
|
|
155
|
+
},
|
|
156
|
+
}),
|
|
157
|
+
}, { printLine: logger });
|
|
158
|
+
if (logger) {
|
|
159
|
+
logger('\n');
|
|
160
|
+
logger('Run completed, you can access the results at:');
|
|
161
|
+
logger(consoleUrl);
|
|
162
|
+
logger('\n');
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
filterLatestResults(results) {
|
|
166
|
+
return results.filter((result) => {
|
|
167
|
+
const originalTryId = result.retry_of || result.id;
|
|
168
|
+
const tries = results.filter((r) => r.retry_of === originalTryId || r.id === originalTryId);
|
|
169
|
+
return result.id === Math.max(...tries.map((t) => t.id));
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
async handlePollingError(error, sequentialPollFailures, debug, logger) {
|
|
173
|
+
if (debug && logger) {
|
|
174
|
+
logger(`DEBUG: Error polling for results: ${error}`);
|
|
175
|
+
logger(`DEBUG: Sequential poll failures: ${sequentialPollFailures}`);
|
|
176
|
+
}
|
|
177
|
+
if (sequentialPollFailures > this.MAX_SEQUENTIAL_FAILURES) {
|
|
178
|
+
if (debug && logger) {
|
|
179
|
+
logger('DEBUG: Checking internet connectivity...');
|
|
180
|
+
}
|
|
181
|
+
const connectivityCheck = await (0, connectivity_1.checkInternetConnectivity)();
|
|
182
|
+
if (debug && logger) {
|
|
183
|
+
logger(`DEBUG: ${connectivityCheck.message}`);
|
|
184
|
+
for (const result of connectivityCheck.endpointResults) {
|
|
185
|
+
if (result.success) {
|
|
186
|
+
logger(`DEBUG: ✓ ${result.endpoint} - ${result.statusCode} (${result.latencyMs}ms)`);
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
logger(`DEBUG: ✗ ${result.endpoint} - ${result.error} (${result.latencyMs}ms)`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (!connectivityCheck.connected) {
|
|
194
|
+
const endpointDetails = connectivityCheck.endpointResults
|
|
195
|
+
.map((r) => ` - ${r.endpoint}: ${r.error} (${r.latencyMs}ms)`)
|
|
196
|
+
.join('\n');
|
|
197
|
+
throw new Error(`Unable to fetch results after ${this.MAX_SEQUENTIAL_FAILURES} attempts.\n\nInternet connectivity check failed - all test endpoints unreachable:\n${endpointDetails}\n\nPlease verify your network connection and DNS resolution.`);
|
|
198
|
+
}
|
|
199
|
+
throw new Error(`unable to fetch results after ${this.MAX_SEQUENTIAL_FAILURES} attempts`);
|
|
200
|
+
}
|
|
201
|
+
if (logger) {
|
|
202
|
+
logger('unable to fetch results, trying again...');
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
updateDisplayStatus(results, quiet, json, summary, previousSummary) {
|
|
206
|
+
if (json) {
|
|
207
|
+
return previousSummary;
|
|
208
|
+
}
|
|
209
|
+
if (quiet) {
|
|
210
|
+
if (summary !== previousSummary) {
|
|
211
|
+
core_1.ux.action.status = summary;
|
|
212
|
+
return summary;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
core_1.ux.action.status = '\nStatus Test\n─────────── ───────────────';
|
|
217
|
+
for (const { retry_of: isRetry, status, test_file_name: test, } of results) {
|
|
218
|
+
core_1.ux.action.status += `\n${status.padEnd(10, ' ')} ${test} ${isRetry ? '(retry)' : ''}`;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return previousSummary;
|
|
222
|
+
}
|
|
209
223
|
}
|
|
210
224
|
exports.ResultsPollingService = ResultsPollingService;
|
|
@@ -38,4 +38,10 @@ export declare class TestSubmissionService {
|
|
|
38
38
|
* @returns FormData ready to be submitted to the API
|
|
39
39
|
*/
|
|
40
40
|
buildTestFormData(config: TestSubmissionConfig): Promise<FormData>;
|
|
41
|
+
private logDebug;
|
|
42
|
+
private normalizeFilePath;
|
|
43
|
+
private normalizePathMap;
|
|
44
|
+
private normalizePaths;
|
|
45
|
+
private parseKeyValuePairs;
|
|
46
|
+
private setOptionalFields;
|
|
41
47
|
}
|
|
@@ -20,29 +20,15 @@ class TestSubmissionService {
|
|
|
20
20
|
const { allExcludeTags, allIncludeTags, flowMetadata, flowOverrides, flowsToRun: testFileNames, referencedFiles, sequence, workspaceConfig, } = executionPlan;
|
|
21
21
|
const { flows: sequentialFlows = [] } = sequence ?? {};
|
|
22
22
|
const testFormData = new FormData();
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
acc[key] = value.join('=');
|
|
28
|
-
return acc;
|
|
29
|
-
}, {});
|
|
30
|
-
// eslint-disable-next-line unicorn/no-array-reduce
|
|
31
|
-
const metadataObject = metadata.reduce((acc, cur) => {
|
|
32
|
-
const [key, ...value] = cur.split('=');
|
|
33
|
-
// handle case where value includes an equals sign
|
|
34
|
-
acc[key] = value.join('=');
|
|
35
|
-
return acc;
|
|
36
|
-
}, {});
|
|
37
|
-
if (debug && logger && Object.keys(envObject).length > 0) {
|
|
38
|
-
logger(`DEBUG: Environment variables: ${JSON.stringify(envObject)}`);
|
|
39
|
-
}
|
|
40
|
-
if (debug && logger && Object.keys(metadataObject).length > 0) {
|
|
41
|
-
logger(`DEBUG: User metadata: ${JSON.stringify(metadataObject)}`);
|
|
23
|
+
const envObject = this.parseKeyValuePairs(env);
|
|
24
|
+
const metadataObject = this.parseKeyValuePairs(metadata);
|
|
25
|
+
if (Object.keys(envObject).length > 0) {
|
|
26
|
+
this.logDebug(debug, logger, `DEBUG: Environment variables: ${JSON.stringify(envObject)}`);
|
|
42
27
|
}
|
|
43
|
-
if (
|
|
44
|
-
logger
|
|
28
|
+
if (Object.keys(metadataObject).length > 0) {
|
|
29
|
+
this.logDebug(debug, logger, `DEBUG: User metadata: ${JSON.stringify(metadataObject)}`);
|
|
45
30
|
}
|
|
31
|
+
this.logDebug(debug, logger, `DEBUG: Compressing files from path: ${flowFile}`);
|
|
46
32
|
const buffer = await (0, methods_1.compressFilesFromRelativePath)(flowFile?.endsWith('.yaml') || flowFile?.endsWith('.yml')
|
|
47
33
|
? path.dirname(flowFile)
|
|
48
34
|
: flowFile, [
|
|
@@ -52,24 +38,16 @@ class TestSubmissionService {
|
|
|
52
38
|
...sequentialFlows,
|
|
53
39
|
]),
|
|
54
40
|
], commonRoot);
|
|
55
|
-
|
|
56
|
-
logger(`DEBUG: Compressed file size: ${buffer.length} bytes`);
|
|
57
|
-
}
|
|
41
|
+
this.logDebug(debug, logger, `DEBUG: Compressed file size: ${buffer.length} bytes`);
|
|
58
42
|
const blob = new Blob([buffer], {
|
|
59
43
|
type: mimeTypeLookupByExtension.zip,
|
|
60
44
|
});
|
|
61
45
|
testFormData.set('file', blob, 'flowFile.zip');
|
|
62
46
|
testFormData.set('appBinaryId', appBinaryId);
|
|
63
|
-
testFormData.set('testFileNames', JSON.stringify(
|
|
64
|
-
testFormData.set('flowMetadata', JSON.stringify(
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
]))));
|
|
68
|
-
testFormData.set('testFileOverrides', JSON.stringify(Object.fromEntries(Object.entries(flowOverrides).map(([key, value]) => [
|
|
69
|
-
key.replaceAll(commonRoot, '.').split(path.sep).join('/'),
|
|
70
|
-
value,
|
|
71
|
-
]))));
|
|
72
|
-
testFormData.set('sequentialFlows', JSON.stringify(sequentialFlows.map((t) => t.replaceAll(commonRoot, '.').split(path.sep).join('/'))));
|
|
47
|
+
testFormData.set('testFileNames', JSON.stringify(this.normalizePaths(testFileNames, commonRoot)));
|
|
48
|
+
testFormData.set('flowMetadata', JSON.stringify(this.normalizePathMap(flowMetadata, commonRoot)));
|
|
49
|
+
testFormData.set('testFileOverrides', JSON.stringify(this.normalizePathMap(flowOverrides, commonRoot)));
|
|
50
|
+
testFormData.set('sequentialFlows', JSON.stringify(this.normalizePaths(sequentialFlows, commonRoot)));
|
|
73
51
|
testFormData.set('env', JSON.stringify(envObject));
|
|
74
52
|
testFormData.set('googlePlay', googlePlay ? 'true' : 'false');
|
|
75
53
|
const configPayload = {
|
|
@@ -92,25 +70,53 @@ class TestSubmissionService {
|
|
|
92
70
|
if (Object.keys(metadataObject).length > 0) {
|
|
93
71
|
const metadataPayload = { userMetadata: metadataObject };
|
|
94
72
|
testFormData.set('metadata', JSON.stringify(metadataPayload));
|
|
95
|
-
|
|
96
|
-
logger(`DEBUG: Sending metadata to API: ${JSON.stringify(metadataPayload)}`);
|
|
97
|
-
}
|
|
73
|
+
this.logDebug(debug, logger, `DEBUG: Sending metadata to API: ${JSON.stringify(metadataPayload)}`);
|
|
98
74
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
if (
|
|
108
|
-
testFormData.set('name', name.toString());
|
|
109
|
-
if (runnerType)
|
|
110
|
-
testFormData.set('runnerType', runnerType.toString());
|
|
111
|
-
if (workspaceConfig)
|
|
75
|
+
this.setOptionalFields(testFormData, {
|
|
76
|
+
androidApiLevel,
|
|
77
|
+
androidDevice,
|
|
78
|
+
iOSDevice,
|
|
79
|
+
iOSVersion,
|
|
80
|
+
name,
|
|
81
|
+
runnerType,
|
|
82
|
+
});
|
|
83
|
+
if (workspaceConfig) {
|
|
112
84
|
testFormData.set('workspaceConfig', JSON.stringify(workspaceConfig));
|
|
85
|
+
}
|
|
113
86
|
return testFormData;
|
|
114
87
|
}
|
|
88
|
+
logDebug(debug, logger, message) {
|
|
89
|
+
if (debug && logger) {
|
|
90
|
+
logger(message);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
normalizeFilePath(filePath, commonRoot) {
|
|
94
|
+
return filePath.replaceAll(commonRoot, '.').split(path.sep).join('/');
|
|
95
|
+
}
|
|
96
|
+
normalizePathMap(map, commonRoot) {
|
|
97
|
+
return Object.fromEntries(Object.entries(map).map(([key, value]) => [
|
|
98
|
+
this.normalizeFilePath(key, commonRoot),
|
|
99
|
+
value,
|
|
100
|
+
]));
|
|
101
|
+
}
|
|
102
|
+
normalizePaths(paths, commonRoot) {
|
|
103
|
+
return paths.map((p) => this.normalizeFilePath(p, commonRoot));
|
|
104
|
+
}
|
|
105
|
+
parseKeyValuePairs(pairs) {
|
|
106
|
+
// eslint-disable-next-line unicorn/no-array-reduce
|
|
107
|
+
return pairs.reduce((acc, cur) => {
|
|
108
|
+
const [key, ...value] = cur.split('=');
|
|
109
|
+
// handle case where value includes an equals sign
|
|
110
|
+
acc[key] = value.join('=');
|
|
111
|
+
return acc;
|
|
112
|
+
}, {});
|
|
113
|
+
}
|
|
114
|
+
setOptionalFields(formData, fields) {
|
|
115
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
116
|
+
if (value) {
|
|
117
|
+
formData.set(key, value.toString());
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
115
121
|
}
|
|
116
122
|
exports.TestSubmissionService = TestSubmissionService;
|
|
@@ -19,8 +19,9 @@ export declare class VersionService {
|
|
|
19
19
|
* Resolve and validate Maestro version against API compatibility data
|
|
20
20
|
* @param requestedVersion - Version requested by user (or undefined for default)
|
|
21
21
|
* @param compatibilityData - API compatibility data
|
|
22
|
-
* @param
|
|
23
|
-
* @param
|
|
22
|
+
* @param options - Configuration options
|
|
23
|
+
* @param options.debug - Enable debug logging
|
|
24
|
+
* @param options.logger - Optional logger function
|
|
24
25
|
* @returns Validated Maestro version string
|
|
25
26
|
* @throws Error if version is not supported
|
|
26
27
|
*/
|
|
@@ -44,8 +44,9 @@ class VersionService {
|
|
|
44
44
|
* Resolve and validate Maestro version against API compatibility data
|
|
45
45
|
* @param requestedVersion - Version requested by user (or undefined for default)
|
|
46
46
|
* @param compatibilityData - API compatibility data
|
|
47
|
-
* @param
|
|
48
|
-
* @param
|
|
47
|
+
* @param options - Configuration options
|
|
48
|
+
* @param options.debug - Enable debug logging
|
|
49
|
+
* @param options.logger - Optional logger function
|
|
49
50
|
* @returns Validated Maestro version string
|
|
50
51
|
* @throws Error if version is not supported
|
|
51
52
|
*/
|
package/oclif.manifest.json
CHANGED