@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.
- package/dist/commands/cloud.d.ts +1 -0
- package/dist/commands/cloud.js +3 -2
- package/dist/config/flags/device.flags.d.ts +1 -0
- package/dist/config/flags/device.flags.js +4 -0
- package/dist/config/flags/execution.flags.js +1 -1
- package/dist/config/flags/output.flags.js +1 -1
- package/dist/constants.d.ts +1 -0
- package/dist/gateways/api-gateway.d.ts +2 -0
- package/dist/gateways/api-gateway.js +2 -1
- package/dist/methods.js +104 -65
- package/dist/services/execution-plan.service.js +104 -97
- package/dist/services/report-download.service.d.ts +1 -1
- package/dist/services/report-download.service.js +2 -1
- package/dist/services/test-submission.service.d.ts +1 -0
- package/dist/services/test-submission.service.js +5 -2
- package/dist/types/generated/schema.types.d.ts +495 -171
- package/dist/types/schema.types.d.ts +333 -69
- package/oclif.manifest.json +10 -3
- package/package.json +1 -1
package/dist/commands/cloud.d.ts
CHANGED
|
@@ -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>;
|
package/dist/commands/cloud.js
CHANGED
|
@@ -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.
|
|
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
|
};
|
package/dist/constants.d.ts
CHANGED
|
@@ -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
|
-
|
|
677
|
-
const
|
|
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
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
@@ -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
|
-
|
|
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));
|