@devicecloud.dev/dcd 4.2.5 → 4.3.0

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.
@@ -57,6 +57,7 @@ export default class Cloud extends Command {
57
57
  'show-crosshairs': import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
58
58
  'maestro-chrome-onboarding': import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
59
59
  'android-no-snapshot': import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
60
+ 'enable-animations': import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
60
61
  'app-binary-id': import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
61
62
  'app-file': import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
62
63
  'ignore-sha-check': import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
@@ -404,6 +404,7 @@ class Cloud extends core_1.Command {
404
404
  runnerType,
405
405
  showCrosshairs: flags['show-crosshairs'],
406
406
  maestroChromeOnboarding: flags['maestro-chrome-onboarding'],
407
+ enableAnimations: flags['enable-animations'],
407
408
  });
408
409
  if (debug) {
409
410
  this.log(`[DEBUG] Submitting flow upload request to ${apiUrl}/uploads/flow`);
@@ -490,7 +491,7 @@ class Cloud extends core_1.Command {
490
491
  warnLogger: this.warn.bind(this),
491
492
  });
492
493
  }
493
- if (report && ['allure', 'html', 'junit'].includes(report)) {
494
+ if (report && ['allure', 'html', 'html-detailed', 'junit'].includes(report)) {
494
495
  await this.reportDownloadService.downloadReports({
495
496
  allurePath,
496
497
  apiKey,
@@ -522,7 +523,7 @@ class Cloud extends core_1.Command {
522
523
  });
523
524
  }
524
525
  // Handle report downloads based on --report flag
525
- if (report && ['allure', 'html', 'junit'].includes(report)) {
526
+ if (report && ['allure', 'html', 'html-detailed', 'junit'].includes(report)) {
526
527
  await this.reportDownloadService.downloadReports({
527
528
  allurePath,
528
529
  apiKey,
@@ -12,4 +12,5 @@ export declare const deviceFlags: {
12
12
  'show-crosshairs': import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
13
13
  'maestro-chrome-onboarding': import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
14
14
  'android-no-snapshot': import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
15
+ 'enable-animations': import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
15
16
  };
@@ -47,4 +47,8 @@ exports.deviceFlags = {
47
47
  default: false,
48
48
  description: '[Android only] Force cold boot instead of using snapshot boot. This is automatically enabled for API 35+ but can be used to force cold boot on older API levels.',
49
49
  }),
50
+ 'enable-animations': core_1.Flags.boolean({
51
+ default: false,
52
+ description: '[Android only] Keep device animations enabled during test execution. By default, animations are disabled to reduce CPU load and improve test reliability.',
53
+ }),
50
54
  };
@@ -37,7 +37,7 @@ exports.executionFlags = {
37
37
  }),
38
38
  'maestro-version': core_1.Flags.string({
39
39
  aliases: ['maestroVersion'],
40
- description: 'Maestro version to run your flow against. Use "latest" for the most recent version. Supported versions are fetched from the API.',
40
+ description: 'Maestro version to run your flow against. Use "latest" for the most recent version. See https://docs.devicecloud.dev/reference/maestro-versions for supported versions.',
41
41
  }),
42
42
  retry: core_1.Flags.integer({
43
43
  description: 'Automatically retry the run up to the number of times specified (same as pressing retry in the UI) - this is free of charge',
@@ -56,6 +56,6 @@ exports.outputFlags = {
56
56
  report: core_1.Flags.string({
57
57
  aliases: ['format'],
58
58
  description: 'Generate and download test reports in the specified format. Use "allure" for a complete HTML report.',
59
- options: ['allure', 'junit', 'html'],
59
+ options: ['allure', 'junit', 'html', 'html-detailed'],
60
60
  }),
61
61
  };
@@ -44,6 +44,7 @@ export declare const flags: {
44
44
  'show-crosshairs': import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
45
45
  'maestro-chrome-onboarding': import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
46
46
  'android-no-snapshot': import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
47
+ 'enable-animations': import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
47
48
  'app-binary-id': import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
48
49
  'app-file': import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
49
50
  'ignore-sha-check': import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
@@ -23,6 +23,7 @@ export declare const ApiGateway: {
23
23
  apiKey: string;
24
24
  backblazeSuccess: boolean;
25
25
  baseUrl: string;
26
+ bytes: number;
26
27
  id: string;
27
28
  metadata: TAppMetadata;
28
29
  path: string;
@@ -35,6 +36,7 @@ export declare const ApiGateway: {
35
36
  finalPath: string;
36
37
  id: string;
37
38
  b2?: import("../types/generated/schema.types").components["schemas"]["B2UploadStrategy"];
39
+ token?: string;
38
40
  }>;
39
41
  finishLargeFile(baseUrl: string, apiKey: string, fileId: string, partSha1Array: string[]): Promise<any>;
40
42
  getResultsForUpload(baseUrl: string, apiKey: string, uploadId: string): Promise<{
@@ -156,11 +156,12 @@ exports.ApiGateway = {
156
156
  }
157
157
  },
158
158
  async finaliseUpload(config) {
159
- const { baseUrl, apiKey, id, metadata, path, sha, supabaseSuccess, backblazeSuccess } = config;
159
+ const { baseUrl, apiKey, id, metadata, path, sha, supabaseSuccess, backblazeSuccess, bytes } = config;
160
160
  try {
161
161
  const res = await fetch(`${baseUrl}/uploads/finaliseUpload`, {
162
162
  body: JSON.stringify({
163
163
  backblazeSuccess,
164
+ bytes,
164
165
  id,
165
166
  metadata,
166
167
  path, // This is tempPath for TUS uploads
package/dist/methods.js CHANGED
@@ -523,6 +523,7 @@ async function performUpload(config) {
523
523
  apiKey,
524
524
  backblazeSuccess: backblazeResult.success,
525
525
  baseUrl: apiUrl,
526
+ bytes: file.size,
526
527
  id,
527
528
  metadata,
528
529
  path: tempPath,
@@ -647,6 +648,94 @@ async function readFileObjectChunk(file, start, end) {
647
648
  const arrayBuffer = await slice.arrayBuffer();
648
649
  return Buffer.from(arrayBuffer);
649
650
  }
651
+ /**
652
+ * Reads a file chunk from either a File object or disk
653
+ * @param fileObject - Optional File object to read from
654
+ * @param filePath - Path to file on disk
655
+ * @param start - Start byte position
656
+ * @param end - End byte position
657
+ * @returns Promise resolving to Buffer containing the chunk
658
+ */
659
+ async function readChunk(fileObject, filePath, start, end) {
660
+ return fileObject
661
+ ? readFileObjectChunk(fileObject, start, end)
662
+ : readFileChunk(filePath, start, end);
663
+ }
664
+ /**
665
+ * Calculates SHA1 hash for a buffer
666
+ * @param buffer - Buffer to hash
667
+ * @returns SHA1 hash as hex string
668
+ */
669
+ function calculateSha1(buffer) {
670
+ const sha1 = (0, node_crypto_1.createHash)('sha1');
671
+ sha1.update(buffer);
672
+ return sha1.digest('hex');
673
+ }
674
+ /**
675
+ * Uploads a single part to Backblaze
676
+ * @param config - Configuration for the part upload
677
+ * @returns Promise that resolves when upload completes
678
+ */
679
+ async function uploadPartToBackblaze(config) {
680
+ const { uploadUrl, authorizationToken, partBuffer, partLength, sha1Hex, partNumber, totalParts, debug } = config;
681
+ if (debug) {
682
+ console.log(`[DEBUG] Uploading part ${partNumber}/${totalParts} (${(partLength / 1024 / 1024).toFixed(2)} MB, SHA1: ${sha1Hex})`);
683
+ }
684
+ try {
685
+ const response = await fetch(uploadUrl, {
686
+ body: new Uint8Array(partBuffer),
687
+ headers: {
688
+ Authorization: authorizationToken,
689
+ 'Content-Length': partLength.toString(),
690
+ 'X-Bz-Content-Sha1': sha1Hex,
691
+ 'X-Bz-Part-Number': partNumber.toString(),
692
+ },
693
+ method: 'POST',
694
+ });
695
+ if (!response.ok) {
696
+ const errorText = await response.text();
697
+ if (debug) {
698
+ console.error(`[DEBUG] Part ${partNumber} upload failed with status ${response.status}: ${errorText}`);
699
+ }
700
+ throw new Error(`Part ${partNumber} upload failed with status ${response.status}`);
701
+ }
702
+ }
703
+ catch (error) {
704
+ if (error instanceof TypeError && error.message === 'fetch failed') {
705
+ if (debug) {
706
+ console.error(`[DEBUG] Network error uploading part ${partNumber} - could be DNS, connection, or SSL issue`);
707
+ }
708
+ throw new Error(`Part ${partNumber} upload failed due to network error`);
709
+ }
710
+ throw error;
711
+ }
712
+ if (debug) {
713
+ console.log(`[DEBUG] Part ${partNumber}/${totalParts} uploaded successfully`);
714
+ }
715
+ }
716
+ /**
717
+ * Logs detailed error information for Backblaze upload failures
718
+ * @param error - The error that occurred
719
+ * @param debug - Whether debug logging is enabled
720
+ * @returns void
721
+ */
722
+ function logBackblazeUploadError(error, debug) {
723
+ if (debug) {
724
+ console.error('[DEBUG] === BACKBLAZE LARGE FILE UPLOAD EXCEPTION ===');
725
+ console.error('[DEBUG] Large file upload exception:', error);
726
+ console.error(`[DEBUG] Error type: ${error instanceof Error ? error.name : typeof error}`);
727
+ console.error(`[DEBUG] Error message: ${error instanceof Error ? error.message : String(error)}`);
728
+ if (error instanceof Error && error.stack) {
729
+ console.error(`[DEBUG] Stack trace:\n${error.stack}`);
730
+ }
731
+ }
732
+ if (error instanceof Error && error.message.includes('network error')) {
733
+ console.warn('Warning: Backblaze large file upload failed due to network error');
734
+ }
735
+ else {
736
+ console.warn(`Warning: Backblaze large file upload failed: ${error instanceof Error ? error.message : String(error)}`);
737
+ }
738
+ }
650
739
  /**
651
740
  * Upload large file to Backblaze using multi-part upload with streaming (for files >= 5MB)
652
741
  * Uses file streaming to avoid loading entire file into memory, preventing OOM errors on large files
@@ -657,7 +746,6 @@ async function uploadLargeFileToBackblaze(config) {
657
746
  const { apiUrl, apiKey, fileId, uploadPartUrls, filePath, fileSize, debug, fileObject } = config;
658
747
  try {
659
748
  const partSha1Array = [];
660
- // Calculate part size (divide file evenly across all parts)
661
749
  const partSize = Math.ceil(fileSize / uploadPartUrls.length);
662
750
  if (debug) {
663
751
  console.log(`[DEBUG] Uploading large file in ${uploadPartUrls.length} parts (streaming mode)`);
@@ -673,87 +761,38 @@ async function uploadLargeFileToBackblaze(config) {
673
761
  if (debug) {
674
762
  console.log(`[DEBUG] Reading part ${partNumber}/${uploadPartUrls.length} bytes ${start}-${end}`);
675
763
  }
676
- // Read part from File object (for .app bundles) or from disk
677
- const partBuffer = fileObject
678
- ? await readFileObjectChunk(fileObject, start, end)
679
- : await readFileChunk(filePath, start, end);
680
- // Calculate SHA1 for this part
681
- const sha1 = (0, node_crypto_1.createHash)('sha1');
682
- sha1.update(partBuffer);
683
- const sha1Hex = sha1.digest('hex');
764
+ const partBuffer = await readChunk(fileObject, filePath, start, end);
765
+ const sha1Hex = calculateSha1(partBuffer);
684
766
  partSha1Array.push(sha1Hex);
685
- if (debug) {
686
- console.log(`[DEBUG] Uploading part ${partNumber}/${uploadPartUrls.length} (${(partLength / 1024 / 1024).toFixed(2)} MB, SHA1: ${sha1Hex})`);
687
- }
688
- try {
689
- const response = await fetch(uploadPartUrls[i].uploadUrl, {
690
- body: new Uint8Array(partBuffer),
691
- headers: {
692
- Authorization: uploadPartUrls[i].authorizationToken,
693
- 'Content-Length': partLength.toString(),
694
- 'X-Bz-Content-Sha1': sha1Hex,
695
- 'X-Bz-Part-Number': partNumber.toString(),
696
- },
697
- method: 'POST',
698
- });
699
- if (!response.ok) {
700
- const errorText = await response.text();
701
- if (debug) {
702
- console.error(`[DEBUG] Part ${partNumber} upload failed with status ${response.status}: ${errorText}`);
703
- }
704
- throw new Error(`Part ${partNumber} upload failed with status ${response.status}`);
705
- }
706
- }
707
- catch (error) {
708
- if (error instanceof TypeError && error.message === 'fetch failed') {
709
- if (debug) {
710
- console.error(`[DEBUG] Network error uploading part ${partNumber} - could be DNS, connection, or SSL issue`);
711
- }
712
- throw new Error(`Part ${partNumber} upload failed due to network error`);
713
- }
714
- throw error;
715
- }
716
- if (debug) {
717
- console.log(`[DEBUG] Part ${partNumber}/${uploadPartUrls.length} uploaded successfully`);
718
- }
767
+ await uploadPartToBackblaze({
768
+ authorizationToken: uploadPartUrls[i].authorizationToken,
769
+ debug,
770
+ partBuffer,
771
+ partLength,
772
+ partNumber,
773
+ sha1Hex,
774
+ totalParts: uploadPartUrls.length,
775
+ uploadUrl: uploadPartUrls[i].uploadUrl,
776
+ });
719
777
  }
720
778
  // Validate all parts were uploaded
721
779
  if (partSha1Array.length !== uploadPartUrls.length) {
722
780
  const errorMsg = `Part count mismatch: uploaded ${partSha1Array.length} parts but expected ${uploadPartUrls.length}`;
723
- if (debug) {
781
+ if (debug)
724
782
  console.error(`[DEBUG] ${errorMsg}`);
725
- }
726
783
  throw new Error(errorMsg);
727
784
  }
728
- // Finish the large file upload
729
785
  if (debug) {
730
786
  console.log('[DEBUG] Finishing large file upload...');
731
787
  console.log(`[DEBUG] Finalizing ${partSha1Array.length} parts with fileId: ${fileId}`);
732
788
  }
733
789
  await api_gateway_1.ApiGateway.finishLargeFile(apiUrl, apiKey, fileId, partSha1Array);
734
- if (debug) {
790
+ if (debug)
735
791
  console.log('[DEBUG] Large file upload completed successfully');
736
- }
737
792
  return true;
738
793
  }
739
794
  catch (error) {
740
- if (debug) {
741
- console.error('[DEBUG] === BACKBLAZE LARGE FILE UPLOAD EXCEPTION ===');
742
- console.error('[DEBUG] Large file upload exception:', error);
743
- console.error(`[DEBUG] Error type: ${error instanceof Error ? error.name : typeof error}`);
744
- console.error(`[DEBUG] Error message: ${error instanceof Error ? error.message : String(error)}`);
745
- if (error instanceof Error && error.stack) {
746
- console.error(`[DEBUG] Stack trace:\n${error.stack}`);
747
- }
748
- }
749
- // Provide more specific error messages
750
- if (error instanceof Error && error.message.includes('network error')) {
751
- console.warn('Warning: Backblaze large file upload failed due to network error');
752
- }
753
- else {
754
- // Don't throw - we don't want Backblaze failures to block the primary upload
755
- console.warn(`Warning: Backblaze large file upload failed: ${error instanceof Error ? error.message : String(error)}`);
756
- }
795
+ logBackblazeUploadError(error, debug);
757
796
  return false;
758
797
  }
759
798
  }
@@ -89,6 +89,104 @@ function extractDeviceCloudOverrides(config) {
89
89
  }
90
90
  return overrides;
91
91
  }
92
+ /**
93
+ * Generate execution plan for a single flow file
94
+ * @param normalizedInput - Normalized path to the flow file
95
+ * @returns Execution plan for the single file with dependencies
96
+ */
97
+ async function planSingleFile(normalizedInput) {
98
+ if (normalizedInput.endsWith('config.yaml') ||
99
+ normalizedInput.endsWith('config.yml')) {
100
+ throw new Error('If using config.yaml, pass the workspace folder path, not the config file or a custom path via --config');
101
+ }
102
+ const { config } = (0, execution_plan_utils_1.readTestYamlFileAsJson)(normalizedInput);
103
+ const flowMetadata = {};
104
+ const flowOverrides = {};
105
+ if (config) {
106
+ flowMetadata[normalizedInput] = config;
107
+ flowOverrides[normalizedInput] = extractDeviceCloudOverrides(config);
108
+ }
109
+ const checkedDependancies = await checkDependencies(normalizedInput);
110
+ return {
111
+ flowMetadata,
112
+ flowOverrides,
113
+ flowsToRun: [normalizedInput],
114
+ referencedFiles: [...new Set(checkedDependancies)],
115
+ totalFlowFiles: 1,
116
+ };
117
+ }
118
+ /**
119
+ * Apply workspace.flows glob patterns to filter flow files
120
+ * @param workspaceConfig - Workspace configuration containing flow globs
121
+ * @param normalizedInput - Normalized path to the workspace directory
122
+ * @param unfilteredFlowFiles - List of all discovered flow files
123
+ * @param configFile - Optional custom config file path
124
+ * @returns Filtered list of flow file paths matching the globs
125
+ */
126
+ async function applyFlowGlobs(workspaceConfig, normalizedInput, unfilteredFlowFiles, configFile) {
127
+ if (workspaceConfig.flows) {
128
+ const globs = workspaceConfig.flows.map((g) => g);
129
+ const matchedFiles = await (0, glob_1.glob)(globs, {
130
+ cwd: normalizedInput,
131
+ nodir: true,
132
+ });
133
+ return matchedFiles
134
+ .filter((file) => {
135
+ if (file === 'config.yaml' || file === 'config.yml')
136
+ return false;
137
+ if (configFile && file === path.basename(configFile))
138
+ return false;
139
+ if (!file.endsWith('.yaml') && !file.endsWith('.yml'))
140
+ return false;
141
+ const pathParts = file.split(path.sep);
142
+ for (const part of pathParts) {
143
+ if (part.endsWith('.app'))
144
+ return false;
145
+ }
146
+ return true;
147
+ })
148
+ .map((file) => path.resolve(normalizedInput, file));
149
+ }
150
+ return unfilteredFlowFiles.filter((file) => !file.endsWith('config.yaml') &&
151
+ !file.endsWith('config.yml') &&
152
+ (!configFile || !file.endsWith(configFile)));
153
+ }
154
+ /**
155
+ * Resolve sequential execution order from workspace config
156
+ * @param workspaceConfig - Workspace configuration with executionOrder
157
+ * @param pathsByName - Map of flow names to their file paths
158
+ * @param debug - Whether to output debug logging
159
+ * @returns Array of flow paths in sequential execution order
160
+ */
161
+ function resolveSequentialFlows(workspaceConfig, pathsByName, debug) {
162
+ if (!workspaceConfig.executionOrder?.flowsOrder) {
163
+ return [];
164
+ }
165
+ if (debug) {
166
+ console.log('[DEBUG] executionOrder.flowsOrder:', workspaceConfig.executionOrder.flowsOrder);
167
+ console.log('[DEBUG] Available flow names:', Object.keys(pathsByName));
168
+ }
169
+ const flowsToRunInSequence = workspaceConfig.executionOrder.flowsOrder
170
+ .flatMap((flowOrder) => {
171
+ const normalizedFlowOrder = flowOrder.replace(/\.ya?ml$/i, '');
172
+ if (debug && flowOrder !== normalizedFlowOrder) {
173
+ console.log(`[DEBUG] Stripping trailing extension: "${flowOrder}" -> "${normalizedFlowOrder}"`);
174
+ }
175
+ return (0, execution_plan_utils_1.getFlowsToRunInSequence)(pathsByName, [normalizedFlowOrder], debug);
176
+ });
177
+ if (debug) {
178
+ console.log(`[DEBUG] Sequential flows resolved: ${flowsToRunInSequence.length} flow(s)`);
179
+ }
180
+ if (workspaceConfig.executionOrder.flowsOrder.length > 0 &&
181
+ flowsToRunInSequence.length === 0) {
182
+ console.warn(`Warning: executionOrder specified ${workspaceConfig.executionOrder.flowsOrder.length} flow(s) but none were found.\n` +
183
+ `This may be intentional if flows were excluded by tags.\n\n` +
184
+ `Expected flows: ${workspaceConfig.executionOrder.flowsOrder.join(', ')}\n` +
185
+ `Available flow names: ${Object.keys(pathsByName).join(', ')}\n\n` +
186
+ `Hint: Flow names come from either the 'name' field in the flow config or the filename without extension.`);
187
+ }
188
+ return flowsToRunInSequence;
189
+ }
92
190
  /**
93
191
  * Generate execution plan for test flows
94
192
  *
@@ -113,24 +211,7 @@ async function plan(options) {
113
211
  throw new Error(`Flow path does not exist: ${path.resolve(normalizedInput)}`);
114
212
  }
115
213
  if (fs.lstatSync(normalizedInput).isFile()) {
116
- if (normalizedInput.endsWith('config.yaml') ||
117
- normalizedInput.endsWith('config.yml')) {
118
- throw new Error('If using config.yaml, pass the workspace folder path, not the config file or a custom path via --config');
119
- }
120
- const { config } = (0, execution_plan_utils_1.readTestYamlFileAsJson)(normalizedInput);
121
- const flowOverrides = {};
122
- if (config) {
123
- flowMetadata[normalizedInput] = config;
124
- flowOverrides[normalizedInput] = extractDeviceCloudOverrides(config);
125
- }
126
- const checkedDependancies = await checkDependencies(normalizedInput);
127
- return {
128
- flowMetadata,
129
- flowOverrides,
130
- flowsToRun: [normalizedInput],
131
- referencedFiles: [...new Set(checkedDependancies)],
132
- totalFlowFiles: 1,
133
- };
214
+ return planSingleFile(normalizedInput);
134
215
  }
135
216
  let unfilteredFlowFiles = await (0, execution_plan_utils_1.readDirectory)(normalizedInput, execution_plan_utils_1.isFlowFile);
136
217
  if (unfilteredFlowFiles.length === 0) {
@@ -148,66 +229,21 @@ async function plan(options) {
148
229
  else {
149
230
  workspaceConfig = getWorkspaceConfig(normalizedInput, unfilteredFlowFiles);
150
231
  }
151
- if (workspaceConfig.flows) {
152
- const globs = workspaceConfig.flows.map((glob) => glob);
153
- const matchedFiles = await (0, glob_1.glob)(globs, {
154
- cwd: normalizedInput,
155
- nodir: true,
156
- });
157
- // overwrite the list of files with the globbed ones
158
- unfilteredFlowFiles = matchedFiles
159
- .filter((file) => {
160
- // Exclude config files
161
- if (file === 'config.yaml' || file === 'config.yml') {
162
- return false;
163
- }
164
- // Exclude config file if specified
165
- if (configFile && file === path.basename(configFile)) {
166
- return false;
167
- }
168
- // Check file extension
169
- if (!file.endsWith('.yaml') && !file.endsWith('.yml')) {
170
- return false;
171
- }
172
- // Exclude files inside .app bundles
173
- // Check if any directory in the path ends with .app
174
- const pathParts = file.split(path.sep);
175
- for (const part of pathParts) {
176
- if (part.endsWith('.app')) {
177
- return false;
178
- }
179
- }
180
- return true;
181
- })
182
- .map((file) => path.resolve(normalizedInput, file));
183
- }
184
- else {
185
- // workspace config has no flows, so we need to remove the config file from the test list
186
- unfilteredFlowFiles = unfilteredFlowFiles.filter((file) => !file.endsWith('config.yaml') &&
187
- !file.endsWith('config.yml') &&
188
- (!configFile || !file.endsWith(configFile)));
189
- }
232
+ unfilteredFlowFiles = await applyFlowGlobs(workspaceConfig, normalizedInput, unfilteredFlowFiles, configFile);
190
233
  if (unfilteredFlowFiles.length === 0) {
191
234
  const error = workspaceConfig.flows
192
235
  ? new Error(`Flow inclusion pattern(s) did not match any Flow files:\n${workspaceConfig.flows.join('\n')}`)
193
236
  : new Error(`Workspace does not contain any Flows: ${path.resolve(normalizedInput)}`);
194
237
  throw error;
195
238
  }
196
- const configPerFlowFile =
197
239
  // eslint-disable-next-line unicorn/no-array-reduce
198
- unfilteredFlowFiles.reduce((acc, filePath) => {
240
+ const configPerFlowFile = unfilteredFlowFiles.reduce((acc, filePath) => {
199
241
  const { config } = (0, execution_plan_utils_1.readTestYamlFileAsJson)(filePath);
200
242
  acc[filePath] = config;
201
243
  return acc;
202
244
  }, {});
203
- const allIncludeTags = [
204
- ...includeTags,
205
- ...(workspaceConfig.includeTags || []),
206
- ];
207
- const allExcludeTags = [
208
- ...excludeTags,
209
- ...(workspaceConfig.excludeTags || []),
210
- ];
245
+ const allIncludeTags = [...includeTags, ...(workspaceConfig.includeTags || [])];
246
+ const allExcludeTags = [...excludeTags, ...(workspaceConfig.excludeTags || [])];
211
247
  const flowOverrides = {};
212
248
  const allFlows = unfilteredFlowFiles.filter((filePath) => {
213
249
  const config = configPerFlowFile[filePath];
@@ -225,7 +261,6 @@ async function plan(options) {
225
261
  if (allFlows.length === 0) {
226
262
  throw new Error(`Include / Exclude tags did not match any Flows:\n\nInclude Tags:\n${allIncludeTags.join('\n')}\n\nExclude Tags:\n${allExcludeTags.join('\n')}`);
227
263
  }
228
- // Check dependencies only for the filtered flows
229
264
  const allFiles = await Promise.all(allFlows.map((filePath) => checkDependencies(filePath))).then((results) => [...new Set(results.flat())]);
230
265
  // eslint-disable-next-line unicorn/no-array-reduce
231
266
  const pathsByName = allFlows.reduce((acc, filePath) => {
@@ -237,35 +272,7 @@ async function plan(options) {
237
272
  }
238
273
  return acc;
239
274
  }, {});
240
- if (debug && workspaceConfig.executionOrder?.flowsOrder) {
241
- console.log('[DEBUG] executionOrder.flowsOrder:', workspaceConfig.executionOrder.flowsOrder);
242
- console.log('[DEBUG] Available flow names:', Object.keys(pathsByName));
243
- }
244
- const flowsToRunInSequence = workspaceConfig.executionOrder?.flowsOrder
245
- ?.map((flowOrder) => {
246
- // Strip .yaml/.yml extension only from the END of the string
247
- // This supports flowsOrder entries like "my_test.yml" matching "my_test"
248
- // while preserving extensions in the middle like "(file.yml) Name"
249
- const normalizedFlowOrder = flowOrder.replace(/\.ya?ml$/i, '');
250
- if (debug && flowOrder !== normalizedFlowOrder) {
251
- console.log(`[DEBUG] Stripping trailing extension: "${flowOrder}" -> "${normalizedFlowOrder}"`);
252
- }
253
- return (0, execution_plan_utils_1.getFlowsToRunInSequence)(pathsByName, [normalizedFlowOrder], debug);
254
- })
255
- .flat() || [];
256
- if (debug) {
257
- console.log(`[DEBUG] Sequential flows resolved: ${flowsToRunInSequence.length} flow(s)`);
258
- }
259
- // Validate that all specified flows were found
260
- if (workspaceConfig.executionOrder?.flowsOrder &&
261
- workspaceConfig.executionOrder.flowsOrder.length > 0 &&
262
- flowsToRunInSequence.length === 0) {
263
- console.warn(`Warning: executionOrder specified ${workspaceConfig.executionOrder.flowsOrder.length} flow(s) but none were found.\n` +
264
- `This may be intentional if flows were excluded by tags.\n\n` +
265
- `Expected flows: ${workspaceConfig.executionOrder.flowsOrder.join(', ')}\n` +
266
- `Available flow names: ${Object.keys(pathsByName).join(', ')}\n\n` +
267
- `Hint: Flow names come from either the 'name' field in the flow config or the filename without extension.`);
268
- }
275
+ const flowsToRunInSequence = resolveSequentialFlows(workspaceConfig, pathsByName, debug);
269
276
  const normalFlows = allFlows
270
277
  .filter((flow) => !flowsToRunInSequence.includes(flow))
271
278
  .sort((a, b) => a.localeCompare(b));
@@ -14,7 +14,7 @@ export interface ReportDownloadOptions extends DownloadOptions {
14
14
  allurePath?: string;
15
15
  htmlPath?: string;
16
16
  junitPath?: string;
17
- reportType: 'allure' | 'html' | 'junit';
17
+ reportType: 'allure' | 'html' | 'html-detailed' | 'junit';
18
18
  }
19
19
  /**
20
20
  * Service for downloading test artifacts and reports
@@ -57,7 +57,8 @@ class ReportDownloadService {
57
57
  });
58
58
  break;
59
59
  }
60
- case 'html': {
60
+ case 'html':
61
+ case 'html-detailed': {
61
62
  const htmlReportPath = path.resolve(process.cwd(), htmlPath || 'report.html');
62
63
  await this.downloadReport('html', htmlReportPath, {
63
64
  ...downloadOptions,
@@ -9,6 +9,7 @@ export interface TestSubmissionConfig {
9
9
  continueOnFailure?: boolean;
10
10
  debug?: boolean;
11
11
  deviceLocale?: string;
12
+ enableAnimations?: boolean;
12
13
  env?: string[];
13
14
  executionPlan: IExecutionPlan;
14
15
  flowFile: string;
@@ -17,7 +17,7 @@ class TestSubmissionService {
17
17
  * @returns FormData ready to be submitted to the API
18
18
  */
19
19
  async buildTestFormData(config) {
20
- const { appBinaryId, flowFile, executionPlan, commonRoot, cliVersion, env = [], metadata = [], googlePlay = false, androidApiLevel, androidDevice, androidNoSnapshot, iOSVersion, iOSDevice, name, runnerType, maestroVersion, deviceLocale, orientation, mitmHost, mitmPath, retry, continueOnFailure = true, report, showCrosshairs, maestroChromeOnboarding, raw, debug = false, logger, } = config;
20
+ const { appBinaryId, flowFile, executionPlan, commonRoot, cliVersion, env = [], metadata = [], googlePlay = false, androidApiLevel, androidDevice, androidNoSnapshot, iOSVersion, iOSDevice, name, runnerType, maestroVersion, deviceLocale, orientation, mitmHost, mitmPath, retry, continueOnFailure = true, report, showCrosshairs, maestroChromeOnboarding, raw, enableAnimations, debug = false, logger, } = config;
21
21
  const { allExcludeTags, allIncludeTags, flowMetadata, flowOverrides, flowsToRun: testFileNames, referencedFiles, sequence, workspaceConfig, } = executionPlan;
22
22
  const { flows: sequentialFlows = [] } = sequence ?? {};
23
23
  const testFormData = new FormData();
@@ -65,7 +65,8 @@ class TestSubmissionService {
65
65
  testFormData.set('testFileOverrides', JSON.stringify(this.normalizePathMap(flowOverrides, commonRoot)));
66
66
  testFormData.set('sequentialFlows', JSON.stringify(this.normalizePaths(sequentialFlows, commonRoot)));
67
67
  testFormData.set('env', JSON.stringify(envObject));
68
- testFormData.set('googlePlay', googlePlay ? 'true' : 'false');
68
+ // Note: googlePlay is now included in configPayload below instead of as a separate field
69
+ // to work around a FormData parsing issue in the API
69
70
  const configPayload = {
70
71
  allExcludeTags,
71
72
  allIncludeTags,
@@ -73,6 +74,7 @@ class TestSubmissionService {
73
74
  autoRetriesRemaining: retry,
74
75
  continueOnFailure,
75
76
  deviceLocale,
77
+ googlePlay,
76
78
  maestroVersion,
77
79
  mitmHost,
78
80
  mitmPath,
@@ -81,6 +83,7 @@ class TestSubmissionService {
81
83
  report,
82
84
  showCrosshairs,
83
85
  maestroChromeOnboarding,
86
+ enableAnimations,
84
87
  version: cliVersion,
85
88
  };
86
89
  testFormData.set('config', JSON.stringify(configPayload));