@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.
@@ -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)(flowFile, includeTags.flat(), excludeTags.flat(), excludeFlows.flat(), configFile, debug);
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: 'wx' });
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(input: string, includeTags: string[], excludeTags: string[], excludeFlows?: string[], configFile?: string, debug?: boolean): Promise<IExecutionPlan>;
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(input, includeTags, excludeTags, excludeFlows, configFile, debug = false) {
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
- if (debug && logger) {
22
- logger('DEBUG: Moropo v1 API key detected, downloading tests from Moropo API');
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
- if (debug && logger) {
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
- // Write zip file to moropo directory
45
+ // Download zip file
55
46
  const zipPath = path.join(moropoDir, 'moropo-tests.zip');
56
- const fileStream = fs.createWriteStream(zipPath);
57
- const reader = response.body?.getReader();
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
- // eslint-disable-next-line new-cap
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
- if (debug && logger) {
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
- const configPath = path.join(moropoDir, 'config.yaml');
96
- fs.writeFileSync(configPath, 'flows:\n- ./**/*.yaml\n- ./*.yaml\n');
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
- if (debug && logger) {
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
- // Calculate summary statistics
65
- const statusCounts = {};
66
- for (const result of updatedResults) {
67
- statusCounts[result.status] = (statusCounts[result.status] || 0) + 1;
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
- if (!json) {
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 resultsWithoutEarlierTries = updatedResults.filter((result) => {
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
- if (debug && logger) {
169
- logger(`DEBUG: Error polling for results: ${error}`);
170
- logger(`DEBUG: Sequential poll failures: ${sequentialPollFailures}`);
91
+ try {
92
+ await this.handlePollingError(error, sequentialPollFailures, debug, logger);
171
93
  }
172
- if (sequentialPollFailures > this.MAX_SEQUENTIAL_FAILURES) {
173
- // dropped poll requests shouldn't err user CI
94
+ catch (error) {
174
95
  clearInterval(intervalId);
175
- // Check if the failure is due to internet connectivity issues
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
- // eslint-disable-next-line unicorn/no-array-reduce
24
- const envObject = env.reduce((acc, cur) => {
25
- const [key, ...value] = cur.split('=');
26
- // handle case where value includes an equals sign
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 (debug && logger) {
44
- logger(`DEBUG: Compressing files from path: ${flowFile}`);
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
- if (debug && logger) {
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(testFileNames.map((t) => t.replaceAll(commonRoot, '.').split(path.sep).join('/'))));
64
- testFormData.set('flowMetadata', JSON.stringify(Object.fromEntries(Object.entries(flowMetadata).map(([key, value]) => [
65
- key.replaceAll(commonRoot, '.').split(path.sep).join('/'),
66
- value,
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
- if (debug && logger) {
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
- if (androidApiLevel)
100
- testFormData.set('androidApiLevel', androidApiLevel.toString());
101
- if (androidDevice)
102
- testFormData.set('androidDevice', androidDevice.toString());
103
- if (iOSVersion)
104
- testFormData.set('iOSVersion', iOSVersion.toString());
105
- if (iOSDevice)
106
- testFormData.set('iOSDevice', iOSDevice.toString());
107
- if (name)
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 debug - Enable debug logging
23
- * @param logger - Optional logger function
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 debug - Enable debug logging
48
- * @param logger - Optional logger function
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
  */
@@ -565,5 +565,5 @@
565
565
  ]
566
566
  }
567
567
  },
568
- "version": "4.1.3"
568
+ "version": "4.1.4"
569
569
  }
package/package.json CHANGED
@@ -65,7 +65,7 @@
65
65
  "type": "git",
66
66
  "url": "https://devicecloud.dev"
67
67
  },
68
- "version": "4.1.3",
68
+ "version": "4.1.4",
69
69
  "bugs": {
70
70
  "url": "https://discord.gg/gm3mJwcNw8"
71
71
  },