@devicecloud.dev/dcd 4.0.0 → 4.0.2

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.
@@ -18,6 +18,7 @@ export default class Cloud extends Command {
18
18
  'app-binary-id': import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
19
19
  'app-file': import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
20
20
  'artifacts-path': import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
21
+ 'junit-path': import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
21
22
  async: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
22
23
  config: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
23
24
  debug: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
@@ -53,11 +54,4 @@ export default class Cloud extends Command {
53
54
  };
54
55
  private versionCheck;
55
56
  run(): Promise<any>;
56
- /**
57
- * Generate the JSON output file path based on upload ID or custom filename
58
- * @param uploadId - Upload ID to use if custom filename is not provided
59
- * @param jsonFileName - Optional custom filename (can include relative path)
60
- * @returns Path to the JSON output file
61
- */
62
- private getJsonOutputPath;
63
57
  }
@@ -64,7 +64,7 @@ class Cloud extends core_1.Command {
64
64
  let jsonFile = false;
65
65
  try {
66
66
  const { args, flags, raw } = await this.parse(Cloud);
67
- let { 'additional-app-binary-ids': nonFlatAdditionalAppBinaryIds, 'additional-app-files': nonFlatAdditionalAppFiles, 'android-api-level': androidApiLevel, 'android-device': androidDevice, apiKey: apiKeyFlag, apiUrl, 'app-binary-id': appBinaryId, 'app-file': appFile, 'artifacts-path': artifactsPath, async, config: configFile, debug, 'device-locale': deviceLocale, 'download-artifacts': downloadArtifacts, 'dry-run': dryRun, env, 'exclude-flows': excludeFlows, 'exclude-tags': excludeTags, flows, 'google-play': googlePlay, 'ignore-sha-check': ignoreShaCheck, 'include-tags': includeTags, 'ios-device': iOSDevice, 'ios-version': iOSVersion, json, 'json-file-name': jsonFileName, 'maestro-version': maestroVersion, metadata, mitmHost, mitmPath, 'moropo-v1-api-key': moropoApiKey, name, orientation, quiet, report, retry, 'runner-type': runnerType, 'x86-arch': x86Arch, ...rest } = flags;
67
+ let { 'additional-app-binary-ids': nonFlatAdditionalAppBinaryIds, 'additional-app-files': nonFlatAdditionalAppFiles, 'android-api-level': androidApiLevel, 'android-device': androidDevice, apiKey: apiKeyFlag, apiUrl, 'app-binary-id': appBinaryId, 'app-file': appFile, 'artifacts-path': artifactsPath, 'junit-path': junitPath, async, config: configFile, debug, 'device-locale': deviceLocale, 'download-artifacts': downloadArtifacts, 'dry-run': dryRun, env, 'exclude-flows': excludeFlows, 'exclude-tags': excludeTags, flows, 'google-play': googlePlay, 'ignore-sha-check': ignoreShaCheck, 'include-tags': includeTags, 'ios-device': iOSDevice, 'ios-version': iOSVersion, json, 'json-file-name': jsonFileName, 'maestro-version': maestroVersion, metadata, mitmHost, mitmPath, 'moropo-v1-api-key': moropoApiKey, name, orientation, quiet, report, retry, 'runner-type': runnerType, 'x86-arch': x86Arch, ...rest } = flags;
68
68
  // Resolve "latest" maestro version to actual version
69
69
  const resolvedMaestroVersion = (0, constants_1.resolveMaestroVersion)(maestroVersion);
70
70
  // Store debug flag for use in catch block
@@ -228,6 +228,9 @@ class Cloud extends core_1.Command {
228
228
  if (runnerType === 'm1') {
229
229
  this.log('Note: runnerType m1 is experimental and currently supports Android (Pixel 7, API Level 34) only.');
230
230
  }
231
+ if (runnerType === 'gpu1') {
232
+ this.log('Note: runnerType gpu1 is Android-only and requires contacting support to enable. Without support enablement, your runner type will revert to default.');
233
+ }
231
234
  const additionalAppBinaryIds = nonFlatAdditionalAppBinaryIds?.flat();
232
235
  const additionalAppFiles = nonFlatAdditionalAppFiles?.flat();
233
236
  const { firstFile, secondFile } = args;
@@ -320,7 +323,7 @@ class Cloud extends core_1.Command {
320
323
  }
321
324
  throw error;
322
325
  }
323
- const { allExcludeTags, allIncludeTags, flowMetadata, flowsToRun: testFileNames, referencedFiles, sequence, workspaceConfig, } = executionPlan;
326
+ const { allExcludeTags, allIncludeTags, flowMetadata, flowOverrides, flowsToRun: testFileNames, referencedFiles, sequence, workspaceConfig, } = executionPlan;
324
327
  if (debug) {
325
328
  this.log(`DEBUG: All include tags: ${allIncludeTags?.join(', ') || 'none'}`);
326
329
  this.log(`DEBUG: All exclude tags: ${allExcludeTags?.join(', ') || 'none'}`);
@@ -370,6 +373,23 @@ class Cloud extends core_1.Command {
370
373
  flagLogs.push(`${k}: ${v}`);
371
374
  }
372
375
  }
376
+ // Format overrides information
377
+ const overridesEntries = Object.entries(flowOverrides);
378
+ const hasOverrides = overridesEntries.some(([, overrides]) => Object.keys(overrides).length > 0);
379
+ let overridesLog = '';
380
+ if (hasOverrides) {
381
+ overridesLog = '\n With overrides';
382
+ for (const [flowPath, overrides] of overridesEntries) {
383
+ if (Object.keys(overrides).length > 0) {
384
+ const relativePath = flowPath.replace(process.cwd(), '.');
385
+ overridesLog += `\n → ${relativePath}:`;
386
+ for (const [key, value] of Object.entries(overrides)) {
387
+ overridesLog += `\n ${key}: ${value}`;
388
+ }
389
+ }
390
+ }
391
+ overridesLog += '\n';
392
+ }
373
393
  this.log(`
374
394
 
375
395
  Submitting new job
@@ -381,7 +401,7 @@ class Cloud extends core_1.Command {
381
401
 
382
402
  With options
383
403
  → ${flagLogs.join(`
384
- → `)}
404
+ → `)}${overridesLog}
385
405
 
386
406
  `);
387
407
  if (dryRun) {
@@ -478,6 +498,10 @@ class Cloud extends core_1.Command {
478
498
  key.replaceAll(commonRoot, '.').split(path.sep).join('/'),
479
499
  value,
480
500
  ]))));
501
+ testFormData.set('testFileOverrides', JSON.stringify(Object.fromEntries(Object.entries(flowOverrides).map(([key, value]) => [
502
+ key.replaceAll(commonRoot, '.').split(path.sep).join('/'),
503
+ value,
504
+ ]))));
481
505
  testFormData.set('sequentialFlows', JSON.stringify(sequentialFlows.map((t) => t.replaceAll(commonRoot, '.').split(path.sep).join('/'))));
482
506
  testFormData.set('env', JSON.stringify(envObject));
483
507
  testFormData.set('googlePlay', googlePlay ? 'true' : 'false');
@@ -568,7 +592,7 @@ class Cloud extends core_1.Command {
568
592
  uploadId: results[0].test_upload_id,
569
593
  };
570
594
  if (flags['json-file']) {
571
- const jsonFilePath = this.getJsonOutputPath(results[0].test_upload_id, jsonFileName);
595
+ const jsonFilePath = jsonFileName || `${results[0].test_upload_id}_dcd.json`;
572
596
  (0, methods_1.writeJSONFile)(jsonFilePath, jsonOutput, this);
573
597
  }
574
598
  if (json) {
@@ -660,6 +684,34 @@ class Cloud extends core_1.Command {
660
684
  this.warn('Failed to download artifacts');
661
685
  }
662
686
  }
687
+ // Handle report download separately if --report junit is specified
688
+ if (report === 'junit') {
689
+ try {
690
+ if (debug) {
691
+ this.log('DEBUG: Downloading JUNIT report');
692
+ }
693
+ const reportPath = path.resolve(process.cwd(), junitPath || 'report.xml');
694
+ await api_gateway_1.ApiGateway.downloadReport(apiUrl, apiKey, results[0].test_upload_id, reportPath);
695
+ this.log('\n');
696
+ this.log(`JUNIT test report has been downloaded to ${reportPath}`);
697
+ }
698
+ catch (error) {
699
+ if (debug) {
700
+ this.log(`DEBUG: Error downloading JUNIT report: ${error}`);
701
+ }
702
+ const errorMessage = error instanceof Error ? error.message : String(error);
703
+ this.warn(`Failed to download JUNIT report: ${errorMessage}`);
704
+ if (errorMessage.includes('404')) {
705
+ this.warn('No JUNIT reports found for this upload. Make sure your tests were run with --report junit flag.');
706
+ }
707
+ else if (errorMessage.includes('EACCES') || errorMessage.includes('EPERM')) {
708
+ this.warn('Permission denied. Check write permissions for the current directory.');
709
+ }
710
+ else if (errorMessage.includes('ENOENT')) {
711
+ this.warn('Directory does not exist. Make sure you have write access to the current directory.');
712
+ }
713
+ }
714
+ }
663
715
  const resultsWithoutEarlierTries = updatedResults.filter((result) => {
664
716
  const originalTryId = result.retry_of || result.id;
665
717
  const tries = updatedResults.filter((r) => r.retry_of === originalTryId || r.id === originalTryId);
@@ -683,7 +735,7 @@ class Cloud extends core_1.Command {
683
735
  uploadId: results[0].test_upload_id,
684
736
  };
685
737
  if (flags['json-file']) {
686
- const jsonFilePath = this.getJsonOutputPath(results[0].test_upload_id, jsonFileName);
738
+ const jsonFilePath = jsonFileName || `${results[0].test_upload_id}_dcd.json`;
687
739
  (0, methods_1.writeJSONFile)(jsonFilePath, jsonOutput, this);
688
740
  }
689
741
  if (json) {
@@ -709,7 +761,7 @@ class Cloud extends core_1.Command {
709
761
  uploadId: results[0].test_upload_id,
710
762
  };
711
763
  if (flags['json-file']) {
712
- const jsonFilePath = this.getJsonOutputPath(results[0].test_upload_id, jsonFileName);
764
+ const jsonFilePath = jsonFileName || `${results[0].test_upload_id}_dcd.json`;
713
765
  (0, methods_1.writeJSONFile)(jsonFilePath, jsonOutput, this);
714
766
  }
715
767
  if (json) {
@@ -759,17 +811,5 @@ class Cloud extends core_1.Command {
759
811
  }
760
812
  }
761
813
  }
762
- /**
763
- * Generate the JSON output file path based on upload ID or custom filename
764
- * @param uploadId - Upload ID to use if custom filename is not provided
765
- * @param jsonFileName - Optional custom filename (can include relative path)
766
- * @returns Path to the JSON output file
767
- */
768
- getJsonOutputPath(uploadId, jsonFileName) {
769
- if (jsonFileName) {
770
- return jsonFileName;
771
- }
772
- return `${uploadId}_dcd.json`;
773
- }
774
814
  }
775
815
  exports.default = Cloud;
@@ -12,6 +12,7 @@ export declare const flags: {
12
12
  'app-binary-id': import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
13
13
  'app-file': import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
14
14
  'artifacts-path': import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
15
+ 'junit-path': import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
15
16
  async: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
16
17
  config: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
17
18
  debug: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
package/dist/constants.js CHANGED
@@ -73,6 +73,10 @@ exports.flags = {
73
73
  dependsOn: ['download-artifacts'],
74
74
  description: 'Custom file path for downloaded artifacts (default: ./artifacts.zip)',
75
75
  }),
76
+ 'junit-path': core_1.Flags.string({
77
+ dependsOn: ['report'],
78
+ description: 'Custom file path for downloaded JUnit report (default: ./report.xml)',
79
+ }),
76
80
  async: core_1.Flags.boolean({
77
81
  description: 'Immediately return (exit code 0) from the command without waiting for the results of the run (useful for saving CI minutes)',
78
82
  }),
@@ -87,7 +91,7 @@ exports.flags = {
87
91
  description: 'Locale that will be set to a device, ISO-639-1 code and uppercase ISO-3166-1 code e.g. "de_DE" for Germany',
88
92
  }),
89
93
  'download-artifacts': core_1.Flags.string({
90
- description: 'Download a zip containing the logs, screenshots and videos for each result in this run. You will debited a $0.01 egress fee for each result. Use --download-artifacts=FAILED for failures only or --download-artifacts=ALL for every result.',
94
+ description: 'Download a zip containing the logs, screenshots and videos for each result in this run. You will be debited a $0.01 egress fee for each result. Options: ALL (everything), FAILED (failures only).',
91
95
  options: ['ALL', 'FAILED'],
92
96
  }),
93
97
  'dry-run': core_1.Flags.boolean({
@@ -198,8 +202,8 @@ exports.flags = {
198
202
  }),
199
203
  'runner-type': core_1.Flags.string({
200
204
  default: 'default',
201
- description: '[experimental] The type of runner to use - note: anything other than default will incur premium pricing tiers, see https://docs.devicecloud.dev/reference/runner-type for more information',
202
- options: ['default', 'm4', 'm1'],
205
+ description: '[experimental] The type of runner to use - note: anything other than default will incur premium pricing tiers, see https://docs.devicecloud.dev/reference/runner-type for more information. gpu1 is Android-only and requires contacting support to enable, otherwise reverts to default.',
206
+ options: ['default', 'm4', 'm1', 'gpu1'],
203
207
  }),
204
208
  'show-crosshairs': core_1.Flags.boolean({
205
209
  default: false,
@@ -4,17 +4,18 @@ export declare const ApiGateway: {
4
4
  appBinaryId: string;
5
5
  exists: boolean;
6
6
  }>;
7
+ downloadReport(baseUrl: string, apiKey: string, uploadId: string, reportPath?: string): Promise<void>;
7
8
  downloadArtifactsZip(baseUrl: string, apiKey: string, uploadId: string, results: "ALL" | "FAILED", artifactsPath?: string): Promise<void>;
8
9
  finaliseUpload(baseUrl: string, apiKey: string, id: string, metadata: TAppMetadata, path: string, sha: string): Promise<Record<string, never>>;
9
10
  getBinaryUploadUrl(baseUrl: string, apiKey: string, platform: "android" | "ios"): Promise<{
10
- id: string;
11
11
  message: string;
12
12
  path: string;
13
13
  token: string;
14
+ id: string;
14
15
  }>;
15
16
  getResultsForUpload(baseUrl: string, apiKey: string, uploadId: string): Promise<{
16
- results?: import("../types/schema.types").components["schemas"]["TResultResponse"][];
17
17
  statusCode?: number;
18
+ results?: import("../types/schema.types").components["schemas"]["TResultResponse"][];
18
19
  }>;
19
20
  getUploadStatus(baseUrl: string, apiKey: string, options: {
20
21
  name?: string;
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ApiGateway = void 0;
4
+ const fs = require("node:fs/promises");
5
+ const path = require("node:path");
4
6
  exports.ApiGateway = {
5
7
  async checkForExistingUpload(baseUrl, apiKey, sha) {
6
8
  const res = await fetch(`${baseUrl}/uploads/checkForExistingUpload`, {
@@ -13,6 +15,25 @@ exports.ApiGateway = {
13
15
  });
14
16
  return res.json();
15
17
  },
18
+ async downloadReport(baseUrl, apiKey, uploadId, reportPath) {
19
+ const finalReportPath = reportPath || path.resolve(process.cwd(), `report-${uploadId}.xml`);
20
+ const url = `${baseUrl}/results/${uploadId}/report`;
21
+ const res = await fetch(url, {
22
+ headers: {
23
+ 'x-app-api-key': apiKey,
24
+ },
25
+ method: 'GET',
26
+ });
27
+ if (!res.ok) {
28
+ const errorText = await res.text();
29
+ if (res.status === 404) {
30
+ throw new Error(`Upload ID '${uploadId}' not found or no results available for this upload`);
31
+ }
32
+ throw new Error(`Failed to download report: ${res.status} ${errorText}`);
33
+ }
34
+ const buffer = await res.arrayBuffer();
35
+ await fs.writeFile(finalReportPath, Buffer.from(buffer));
36
+ },
16
37
  async downloadArtifactsZip(baseUrl, apiKey, uploadId, results, artifactsPath = './artifacts.zip') {
17
38
  const res = await fetch(`${baseUrl}/results/${uploadId}/download`, {
18
39
  body: JSON.stringify({ results }),
package/dist/methods.js CHANGED
@@ -251,9 +251,18 @@ const writeJSONFile = (filePath, data, logger) => {
251
251
  logger.log(`JSON output written to: ${path.resolve(filePath)}`);
252
252
  }
253
253
  catch (error) {
254
+ const errorMessage = error instanceof Error ? error.message : String(error);
255
+ const isPermissionError = errorMessage.includes('EACCES') || errorMessage.includes('EPERM');
256
+ const isNoSuchFileError = errorMessage.includes('ENOENT');
254
257
  logger.warn(`Failed to write JSON output to file: ${filePath}`);
255
- // Use console.debug instead of logger.debug since debug is protected in Command
256
- logger.warn(`Error details: ${error instanceof Error ? error.message : String(error)}`);
258
+ if (isPermissionError) {
259
+ logger.warn('Permission denied - check file/directory write permissions');
260
+ logger.warn('Try running with appropriate permissions or choose a different output location');
261
+ }
262
+ else if (isNoSuchFileError) {
263
+ logger.warn('Directory does not exist - create the directory first or choose an existing path');
264
+ }
265
+ logger.warn(`Error details: ${errorMessage}`);
257
266
  }
258
267
  };
259
268
  exports.writeJSONFile = writeJSONFile;
package/dist/plan.d.ts CHANGED
@@ -16,6 +16,7 @@ interface IExecutionPlan {
16
16
  allExcludeTags?: null | string[];
17
17
  allIncludeTags?: null | string[];
18
18
  flowMetadata: Record<string, Record<string, unknown>>;
19
+ flowOverrides: Record<string, Record<string, unknown>>;
19
20
  flowsToRun: string[];
20
21
  referencedFiles: string[];
21
22
  sequence?: IFlowSequence | null;
package/dist/plan.js CHANGED
@@ -42,16 +42,28 @@ function filterFlowFiles(unfilteredFlowFiles, excludeFlows) {
42
42
  return unfilteredFlowFiles;
43
43
  }
44
44
  function getWorkspaceConfig(input, unfilteredFlowFiles) {
45
- const possibleConfigPaths = new Set([
46
- path.join(input, 'config.yaml'),
47
- path.join(input, 'config.yml'),
48
- ].map((p) => path.normalize(p)));
45
+ const possibleConfigPaths = new Set([path.join(input, 'config.yaml'), path.join(input, 'config.yml')].map((p) => path.normalize(p)));
49
46
  const configFilePath = unfilteredFlowFiles.find((file) => possibleConfigPaths.has(path.normalize(file)));
50
47
  const config = configFilePath
51
48
  ? (0, planMethods_1.readYamlFileAsJson)(configFilePath)
52
49
  : {};
53
50
  return config;
54
51
  }
52
+ function extractDeviceCloudOverrides(config) {
53
+ if (!config || !config.env || typeof config.env !== 'object') {
54
+ return {};
55
+ }
56
+ const overrides = {};
57
+ const envVars = config.env;
58
+ for (const [key, value] of Object.entries(envVars)) {
59
+ if (key.startsWith('DEVICECLOUD_OVERRIDE_')) {
60
+ // Remove the DEVICECLOUD_OVERRIDE_ prefix and use the rest as the override key
61
+ const overrideKey = key.replace('DEVICECLOUD_OVERRIDE_', '');
62
+ overrides[overrideKey] = value;
63
+ }
64
+ }
65
+ return overrides;
66
+ }
55
67
  async function plan(input, includeTags, excludeTags, excludeFlows, configFile) {
56
68
  const normalizedInput = path.normalize(input);
57
69
  const flowMetadata = {};
@@ -64,12 +76,15 @@ async function plan(input, includeTags, excludeTags, excludeFlows, configFile) {
64
76
  throw new Error('If using config.yaml, pass the workspace folder path, not the config file or a custom path via --config');
65
77
  }
66
78
  const { config } = (0, planMethods_1.readTestYamlFileAsJson)(normalizedInput);
79
+ const flowOverrides = {};
67
80
  if (config) {
68
81
  flowMetadata[normalizedInput] = config;
82
+ flowOverrides[normalizedInput] = extractDeviceCloudOverrides(config);
69
83
  }
70
84
  const checkedDependancies = await checkDependencies(normalizedInput);
71
85
  return {
72
86
  flowMetadata,
87
+ flowOverrides,
73
88
  flowsToRun: [normalizedInput],
74
89
  referencedFiles: [...new Set(checkedDependancies)],
75
90
  totalFlowFiles: 1,
@@ -151,11 +166,13 @@ async function plan(input, includeTags, excludeTags, excludeFlows, configFile) {
151
166
  ...excludeTags,
152
167
  ...(workspaceConfig.excludeTags || []),
153
168
  ];
169
+ const flowOverrides = {};
154
170
  const allFlows = unfilteredFlowFiles.filter((filePath) => {
155
171
  const config = configPerFlowFile[filePath];
156
172
  const tags = config?.tags || [];
157
173
  if (config) {
158
174
  flowMetadata[filePath] = config;
175
+ flowOverrides[filePath] = extractDeviceCloudOverrides(config);
159
176
  }
160
177
  return ((allIncludeTags.length === 0 ||
161
178
  tags.some((tag) => allIncludeTags.includes(tag))) &&
@@ -185,6 +202,7 @@ async function plan(input, includeTags, excludeTags, excludeFlows, configFile) {
185
202
  allExcludeTags,
186
203
  allIncludeTags,
187
204
  flowMetadata,
205
+ flowOverrides,
188
206
  flowsToRun: normalFlows,
189
207
  referencedFiles: [...new Set(allFiles)],
190
208
  sequence: {