@devicecloud.dev/dcd 4.4.9 → 5.0.0-beta.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.
Files changed (97) hide show
  1. package/README.md +40 -2
  2. package/dist/commands/artifacts.d.ts +47 -18
  3. package/dist/commands/artifacts.js +68 -60
  4. package/dist/commands/cloud.d.ts +228 -88
  5. package/dist/commands/cloud.js +389 -288
  6. package/dist/commands/list.d.ts +39 -38
  7. package/dist/commands/list.js +122 -127
  8. package/dist/commands/live.d.ts +2 -0
  9. package/dist/commands/live.js +513 -0
  10. package/dist/commands/login.d.ts +17 -0
  11. package/dist/commands/login.js +250 -0
  12. package/dist/commands/logout.d.ts +2 -0
  13. package/dist/commands/logout.js +32 -0
  14. package/dist/commands/status.d.ts +23 -42
  15. package/dist/commands/status.js +162 -173
  16. package/dist/commands/switch-org.d.ts +12 -0
  17. package/dist/commands/switch-org.js +78 -0
  18. package/dist/commands/upgrade.d.ts +2 -0
  19. package/dist/commands/upgrade.js +122 -0
  20. package/dist/commands/upload.d.ts +33 -18
  21. package/dist/commands/upload.js +62 -67
  22. package/dist/commands/whoami.d.ts +2 -0
  23. package/dist/commands/whoami.js +34 -0
  24. package/dist/config/environments.d.ts +31 -0
  25. package/dist/config/environments.js +58 -0
  26. package/dist/config/flags/api.flags.d.ts +10 -2
  27. package/dist/config/flags/api.flags.js +12 -10
  28. package/dist/config/flags/binary.flags.d.ts +17 -4
  29. package/dist/config/flags/binary.flags.js +13 -14
  30. package/dist/config/flags/device.flags.d.ts +49 -11
  31. package/dist/config/flags/device.flags.js +41 -33
  32. package/dist/config/flags/environment.flags.d.ts +27 -6
  33. package/dist/config/flags/environment.flags.js +23 -25
  34. package/dist/config/flags/execution.flags.d.ts +35 -8
  35. package/dist/config/flags/execution.flags.js +30 -37
  36. package/dist/config/flags/github.flags.d.ts +23 -5
  37. package/dist/config/flags/github.flags.js +18 -11
  38. package/dist/config/flags/output.flags.d.ts +57 -13
  39. package/dist/config/flags/output.flags.js +47 -43
  40. package/dist/constants.d.ts +218 -51
  41. package/dist/constants.js +2 -2
  42. package/dist/gateways/api-gateway.d.ts +43 -12
  43. package/dist/gateways/api-gateway.js +240 -100
  44. package/dist/gateways/cli-auth-gateway.d.ts +13 -0
  45. package/dist/gateways/cli-auth-gateway.js +57 -0
  46. package/dist/gateways/supabase-gateway.d.ts +11 -11
  47. package/dist/gateways/supabase-gateway.js +15 -39
  48. package/dist/index.d.ts +2 -1
  49. package/dist/index.js +93 -2
  50. package/dist/methods.d.ts +3 -5
  51. package/dist/methods.js +170 -178
  52. package/dist/services/device-validation.service.d.ts +8 -0
  53. package/dist/services/device-validation.service.js +55 -35
  54. package/dist/services/execution-plan.service.js +27 -15
  55. package/dist/services/execution-plan.utils.d.ts +3 -0
  56. package/dist/services/execution-plan.utils.js +10 -32
  57. package/dist/services/metadata-extractor.service.d.ts +0 -2
  58. package/dist/services/metadata-extractor.service.js +57 -57
  59. package/dist/services/moropo.service.js +25 -24
  60. package/dist/services/report-download.service.d.ts +12 -1
  61. package/dist/services/report-download.service.js +31 -20
  62. package/dist/services/results-polling.service.d.ts +6 -7
  63. package/dist/services/results-polling.service.js +80 -33
  64. package/dist/services/telemetry.service.d.ts +40 -0
  65. package/dist/services/telemetry.service.js +230 -0
  66. package/dist/services/test-submission.service.js +2 -1
  67. package/dist/services/version.service.d.ts +3 -2
  68. package/dist/services/version.service.js +27 -11
  69. package/dist/types/domain/auth.types.d.ts +12 -0
  70. package/dist/types/{schema.types.js → domain/auth.types.js} +0 -1
  71. package/dist/types/domain/live.types.d.ts +76 -0
  72. package/dist/types/domain/live.types.js +4 -0
  73. package/dist/utils/auth.d.ts +13 -0
  74. package/dist/utils/auth.js +142 -0
  75. package/dist/utils/cli.d.ts +35 -0
  76. package/dist/utils/cli.js +127 -0
  77. package/dist/utils/compatibility.d.ts +2 -1
  78. package/dist/utils/compatibility.js +2 -2
  79. package/dist/utils/config-store.d.ts +35 -0
  80. package/dist/utils/config-store.js +125 -0
  81. package/dist/utils/connectivity.js +7 -3
  82. package/dist/utils/expo.js +14 -3
  83. package/dist/utils/orgs.d.ts +11 -0
  84. package/dist/utils/orgs.js +40 -0
  85. package/dist/utils/paths.d.ts +11 -0
  86. package/dist/utils/paths.js +24 -0
  87. package/dist/utils/progress.d.ts +13 -0
  88. package/dist/utils/progress.js +50 -0
  89. package/dist/utils/styling.d.ts +13 -5
  90. package/dist/utils/styling.js +37 -7
  91. package/package.json +26 -38
  92. package/bin/dev.cmd +0 -3
  93. package/bin/dev.js +0 -6
  94. package/bin/run.cmd +0 -3
  95. package/bin/run.js +0 -7
  96. package/dist/types/schema.types.d.ts +0 -2702
  97. package/oclif.manifest.json +0 -884
@@ -1,8 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.cloudCommand = void 0;
3
4
  /* eslint-disable complexity */
4
- const core_1 = require("@oclif/core");
5
- const errors_1 = require("@oclif/core/lib/errors");
5
+ const citty_1 = require("citty");
6
6
  const path = require("node:path");
7
7
  const constants_1 = require("../constants");
8
8
  const api_gateway_1 = require("../gateways/api-gateway");
@@ -12,19 +12,33 @@ const execution_plan_service_1 = require("../services/execution-plan.service");
12
12
  const moropo_service_1 = require("../services/moropo.service");
13
13
  const report_download_service_1 = require("../services/report-download.service");
14
14
  const results_polling_service_1 = require("../services/results-polling.service");
15
+ const telemetry_service_1 = require("../services/telemetry.service");
15
16
  const test_submission_service_1 = require("../services/test-submission.service");
16
17
  const version_service_1 = require("../services/version.service");
18
+ const device_types_1 = require("../types/domain/device.types");
19
+ const auth_1 = require("../utils/auth");
20
+ const cli_1 = require("../utils/cli");
17
21
  const compatibility_1 = require("../utils/compatibility");
22
+ const config_store_1 = require("../utils/config-store");
18
23
  const expo_1 = require("../utils/expo");
24
+ const paths_1 = require("../utils/paths");
19
25
  const styling_1 = require("../utils/styling");
20
- // Suppress punycode deprecation warning (caused by whatwg, supabase dependancy)
26
+ // Suppress punycode deprecation warning (caused by whatwg, supabase dependency).
27
+ // Every other warning must still reach the user — removeAllListeners drops
28
+ // Node's default printer, so re-emit manually.
21
29
  process.removeAllListeners('warning');
22
30
  process.on('warning', (warning) => {
23
31
  if (warning.name === 'DeprecationWarning' &&
24
32
  warning.message.includes('punycode')) {
25
- // Ignore punycode deprecation warnings
33
+ return;
26
34
  }
35
+ // eslint-disable-next-line no-console
36
+ console.warn(warning.stack ?? `${warning.name}: ${warning.message}`);
27
37
  });
38
+ const DOWNLOAD_OPTIONS = ['ALL', 'FAILED'];
39
+ const REPORT_OPTIONS = ['allure', 'html', 'html-detailed', 'junit'];
40
+ const ORIENTATION_OPTIONS = ['0', '90'];
41
+ const RUNNER_TYPE_OPTIONS = ['default', 'm4', 'm1', 'gpu1', 'cpu1'];
28
42
  /**
29
43
  * Primary CLI command for executing tests on DeviceCloud.
30
44
  * Orchestrates the complete test workflow:
@@ -37,80 +51,137 @@ process.on('warning', (warning) => {
37
51
  *
38
52
  * Replaces `maestro cloud` with DeviceCloud-specific functionality.
39
53
  */
40
- class Cloud extends core_1.Command {
41
- static args = {
42
- firstFile: core_1.Args.string({
54
+ exports.cloudCommand = (0, citty_1.defineCommand)({
55
+ meta: {
56
+ name: 'cloud',
57
+ description: 'Test a Flow or set of Flows on devicecloud.dev (https://devicecloud.dev). Provide your application file and a folder with Maestro flows to run them in parallel on multiple devices. The command will block until all analyses have completed.',
58
+ },
59
+ args: {
60
+ ...constants_1.flags,
61
+ firstFile: {
62
+ type: 'positional',
63
+ required: false,
43
64
  description: 'The binary file of the app to run your flow against, e.g. test.apk for android or test.app/.zip for ios',
44
- hidden: true,
45
- name: 'App file',
46
- }),
47
- secondFile: core_1.Args.string({
65
+ },
66
+ secondFile: {
67
+ type: 'positional',
68
+ required: false,
48
69
  description: 'The flow file to run against the app, e.g. test.yaml',
49
- hidden: true,
50
- name: 'Flow file',
51
- }),
52
- };
53
- static description = `Test a Flow or set of Flows on devicecloud.dev (https://devicecloud.dev)\nProvide your application file and a folder with Maestro flows to run them in parallel on multiple devices in devicecloud.dev\nThe command will block until all analyses have completed`;
54
- static enableJsonFlag = true;
55
- static examples = ['<%= config.bin %> <%= command.id %>'];
56
- static flags = constants_1.flags;
57
- /** Service for device/OS compatibility validation */
58
- deviceValidationService = new device_validation_service_1.DeviceValidationService();
59
- /** Service for Moropo test framework integration */
60
- moropoService = new moropo_service_1.MoropoService();
61
- /** Service for downloading test reports and artifacts */
62
- reportDownloadService = new report_download_service_1.ReportDownloadService();
63
- /** Service for polling test results with 10-second intervals */
64
- resultsPollingService = new results_polling_service_1.ResultsPollingService();
65
- /** Service for submitting tests to the API */
66
- testSubmissionService = new test_submission_service_1.TestSubmissionService();
67
- /**
68
- * Check for CLI updates and notify user if outdated
69
- * Compares current version with latest npm release
70
- * @returns Promise that resolves when version check is complete
71
- */
72
- versionCheck = async () => {
73
- const latestVersion = await this.versionService.checkLatestCliVersion();
74
- if (latestVersion &&
75
- this.versionService.isOutdated(this.config.version, latestVersion)) {
76
- this.log(`\n${styling_1.dividers.light}\n` +
77
- `${styling_1.colors.warning('⚠')} ${styling_1.colors.bold('Update Available')}\n` +
78
- styling_1.colors.dim(` A new version of the DeviceCloud CLI is available: `) + styling_1.colors.highlight(latestVersion) + `\n` +
79
- styling_1.colors.dim(` Run: `) + styling_1.colors.info(`npm install -g @devicecloud.dev/dcd@latest`) + `\n` +
80
- `${styling_1.dividers.light}\n`);
81
- }
82
- };
83
- /** Service for CLI version checking */
84
- versionService = new version_service_1.VersionService();
85
- /**
86
- * Main command execution entry point
87
- * Orchestrates the complete test submission and monitoring workflow
88
- * @returns Promise that resolves when command execution is complete
89
- * @throws RunFailedError if tests fail
90
- * @throws Error for infrastructure or configuration errors
91
- */
92
- async run() {
70
+ },
71
+ },
72
+ // eslint-disable-next-line complexity
73
+ async run({ args }) {
74
+ const cliVersion = (0, cli_1.getCliVersion)();
75
+ const deviceValidationService = new device_validation_service_1.DeviceValidationService();
76
+ const moropoService = new moropo_service_1.MoropoService();
77
+ const reportDownloadService = new report_download_service_1.ReportDownloadService();
78
+ const resultsPollingService = new results_polling_service_1.ResultsPollingService();
79
+ const testSubmissionService = new test_submission_service_1.TestSubmissionService();
80
+ const versionService = new version_service_1.VersionService();
81
+ const versionCheck = async () => {
82
+ const latestVersion = await versionService.checkLatestCliVersion();
83
+ if (latestVersion && versionService.isOutdated(cliVersion, latestVersion)) {
84
+ const body = `${styling_1.symbols.warning} ${styling_1.colors.bold('Update Available')}\n` +
85
+ styling_1.colors.dim('A new version of the DeviceCloud CLI is available: ') +
86
+ styling_1.colors.highlight(latestVersion) +
87
+ '\n' +
88
+ styling_1.colors.dim('Run: ') +
89
+ styling_1.colors.info((0, cli_1.getUpgradeCommand)());
90
+ out(`\n${(0, styling_1.box)(body)}\n`);
91
+ }
92
+ };
93
93
  let output = null;
94
- // Store debug flag outside try/catch to access it in catch block
95
94
  let debugFlag = false;
96
95
  let jsonFile = false;
97
- // Temp files created during Expo URL download / .tar.gz extraction — cleaned up in finally
96
+ let caughtError = null;
98
97
  const tempFiles = [];
98
+ // json is captured early so the progress-suppressor works if we fail before destructuring.
99
+ const jsonFlag = Boolean(args.json);
100
+ // Chatty progress logs are suppressed when --json is active so stdout stays parseable.
101
+ const out = (m) => {
102
+ if (!jsonFlag)
103
+ cli_1.logger.log(m);
104
+ };
105
+ const warnOut = (m) => {
106
+ if (!jsonFlag)
107
+ cli_1.logger.warn(m);
108
+ };
99
109
  try {
100
- const { args, flags, raw } = await this.parse(Cloud);
101
- let { 'android-api-level': androidApiLevel, 'android-device': androidDevice, apiKey: apiKeyFlag, apiUrl, 'app-binary-id': appBinaryId, 'app-file': appFile, 'app-url': appUrl, 'artifacts-path': artifactsPath, 'junit-path': junitPath, 'allure-path': allurePath, 'html-path': htmlPath, 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, 'android-no-snapshot': androidNoSnapshot, branch: ghBranch, 'commit-sha': ghCommitSha, 'repo-name': ghRepoName, 'pr-number': ghPrNumber, 'pr-url': ghPrUrl, } = flags;
102
- // Store debug flag for use in catch block
103
- debugFlag = debug === true;
104
- jsonFile = flags['json-file'] === true;
105
- this.log(`CLI Version: ${this.config.version}`);
110
+ const apiKeyFlag = args['api-key'];
111
+ const apiUrl = (0, config_store_1.resolveApiUrl)(args['api-url']);
112
+ const appBinaryId = args['app-binary-id'];
113
+ const appFile = args['app-file'];
114
+ const appUrl = args['app-url'];
115
+ const artifactsPath = args['artifacts-path'];
116
+ const junitPath = args['junit-path'];
117
+ const allurePath = args['allure-path'];
118
+ const htmlPath = args['html-path'];
119
+ const async = Boolean(args.async);
120
+ const configFile = args.config;
121
+ const debug = Boolean(args.debug);
122
+ const deviceLocale = args['device-locale'];
123
+ const downloadArtifacts = (0, cli_1.validateEnum)(args['download-artifacts'], DOWNLOAD_OPTIONS, 'download-artifacts');
124
+ const dryRun = Boolean(args['dry-run']);
125
+ const env = (0, cli_1.coerceArray)(args.env, false);
126
+ const excludeFlows = (0, cli_1.coerceArray)(args['exclude-flows']);
127
+ const excludeTags = (0, cli_1.coerceArray)(args['exclude-tags']);
128
+ let flows = args.flows;
129
+ const googlePlay = Boolean(args['google-play']);
130
+ const ignoreShaCheck = Boolean(args['ignore-sha-check']);
131
+ const includeTags = (0, cli_1.coerceArray)(args['include-tags']);
132
+ const iOSDevice = (0, cli_1.validateEnum)(args['ios-device'], Object.values(device_types_1.EiOSDevices), 'ios-device');
133
+ const iOSVersion = (0, cli_1.validateEnum)(args['ios-version'], Object.values(device_types_1.EiOSVersions), 'ios-version');
134
+ const androidApiLevel = (0, cli_1.validateEnum)(args['android-api-level'], Object.values(device_types_1.EAndroidApiLevels), 'android-api-level');
135
+ const androidDevice = (0, cli_1.validateEnum)(args['android-device'], Object.values(device_types_1.EAndroidDevices), 'android-device');
136
+ const androidNoSnapshot = Boolean(args['android-no-snapshot']);
137
+ const json = Boolean(args.json);
138
+ const jsonFileFlag = Boolean(args['json-file']);
139
+ const jsonFileName = args['json-file-name'];
140
+ const maestroVersion = args['maestro-version'];
141
+ const metadata = (0, cli_1.coerceArray)(args.metadata, false);
142
+ const mitmHost = args.mitmHost;
143
+ const mitmPath = args.mitmPath;
144
+ const moropoApiKey = args['moropo-v1-api-key'];
145
+ const name = args.name;
146
+ const orientation = (0, cli_1.validateEnum)(args.orientation, ORIENTATION_OPTIONS, 'orientation');
147
+ let quiet = Boolean(args.quiet);
148
+ const report = (0, cli_1.validateEnum)(args.report, REPORT_OPTIONS, 'report');
149
+ let retry = (0, cli_1.parseIntFlag)(args.retry, 'retry');
150
+ const runnerType = (0, cli_1.validateEnum)(args['runner-type'], RUNNER_TYPE_OPTIONS, 'runner-type') ?? 'default';
151
+ const showCrosshairs = Boolean(args['show-crosshairs']);
152
+ const maestroChromeOnboarding = Boolean(args['maestro-chrome-onboarding']);
153
+ const disableAnimations = Boolean(args['disable-animations']);
154
+ const ghBranch = args.branch;
155
+ const ghCommitSha = args['commit-sha'];
156
+ const ghRepoName = args['repo-name'];
157
+ const ghPrNumber = args['pr-number'];
158
+ const ghPrUrl = args['pr-url'];
159
+ debugFlag = debug;
160
+ jsonFile = jsonFileFlag;
161
+ out(styling_1.colors.dim(`dcd v${cliVersion}`));
106
162
  if (debug) {
107
- this.log('[DEBUG] Starting command execution with debug logging enabled');
108
- this.log(`[DEBUG] Node version: ${process.versions.node}`);
109
- this.log(`[DEBUG] OS: ${process.platform} ${process.arch}`);
163
+ out('[DEBUG] Starting command execution with debug logging enabled');
164
+ out(`[DEBUG] Node version: ${process.versions.node}`);
165
+ out(`[DEBUG] OS: ${process.platform} ${process.arch}`);
110
166
  }
111
- if (flags['json-file']) {
167
+ if (jsonFileFlag) {
112
168
  quiet = true;
113
- this.log('--json-file is true: JSON output will be written to file, forcing --quiet flag for better CI output');
169
+ out('--json-file is true: JSON output will be written to file, forcing --quiet flag for better CI output');
170
+ }
171
+ if (mitmPath && !mitmHost) {
172
+ throw new cli_1.CliError('--mitmPath requires --mitmHost to be set');
173
+ }
174
+ if (jsonFileName && !jsonFileFlag) {
175
+ throw new cli_1.CliError('--json-file-name requires --json-file');
176
+ }
177
+ if (artifactsPath && !downloadArtifacts) {
178
+ throw new cli_1.CliError('--artifacts-path requires --download-artifacts');
179
+ }
180
+ if ((junitPath || allurePath || htmlPath) && !report) {
181
+ throw new cli_1.CliError('Report path flags (--junit-path/--allure-path/--html-path) require --report');
182
+ }
183
+ if (appFile && appUrl) {
184
+ throw new cli_1.CliError('--app-file and --app-url are mutually exclusive');
114
185
  }
115
186
  if (json) {
116
187
  const originalStdoutWrite = process.stdout.write;
@@ -124,119 +195,117 @@ class Cloud extends core_1.Command {
124
195
  }
125
196
  const [major] = process.versions.node.split('.').map(Number);
126
197
  if (major < 20) {
127
- this.warn(`WARNING: You are using node version ${major}. DeviceCloud requires node version 20 or later`);
198
+ warnOut(`WARNING: You are using node version ${major}. DeviceCloud requires node version 20 or later`);
128
199
  if (major < 18) {
129
- throw new Error('Invalid node version');
200
+ throw new cli_1.CliError('Invalid node version');
130
201
  }
131
202
  }
132
- await this.versionCheck();
133
- // Download and expand Moropo zip if API key is present
203
+ await versionCheck();
134
204
  if (moropoApiKey) {
135
- flows = await this.moropoService.downloadAndExtract({
205
+ flows = await moropoService.downloadAndExtract({
136
206
  apiKey: moropoApiKey,
137
207
  branchName: 'main',
138
208
  debug,
139
209
  json,
140
- logger: this.log.bind(this),
210
+ logger: (m) => out(m),
141
211
  quiet,
142
212
  });
213
+ tempFiles.push(flows);
143
214
  }
144
- const apiKey = apiKeyFlag || process.env.DEVICE_CLOUD_API_KEY;
145
- if (!apiKey)
146
- throw new Error('You must provide an API key via --api-key flag or DEVICE_CLOUD_API_KEY environment variable');
147
- // Fetch compatibility data from API
215
+ const auth = await (0, auth_1.resolveAuth)({ apiKeyFlag });
148
216
  let compatibilityData;
149
217
  try {
150
- compatibilityData = await (0, compatibility_1.fetchCompatibilityData)(apiUrl, apiKey);
218
+ compatibilityData = await (0, compatibility_1.fetchCompatibilityData)(apiUrl, auth);
151
219
  if (debug) {
152
- this.log('[DEBUG] Successfully fetched compatibility data from API');
220
+ out('[DEBUG] Successfully fetched compatibility data from API');
153
221
  }
154
222
  }
155
223
  catch (error) {
156
224
  const errorMessage = error instanceof Error ? error.message : String(error);
157
225
  if (debug) {
158
- this.log(`[DEBUG] Failed to fetch compatibility data from API: ${errorMessage}`);
226
+ out(`[DEBUG] Failed to fetch compatibility data from API: ${errorMessage}`);
159
227
  }
160
- throw new Error(`Failed to fetch device compatibility data: ${errorMessage}. Please check your API key and connection.`);
228
+ throw new cli_1.CliError(`Failed to fetch device compatibility data: ${errorMessage}. Please check your API key and connection.`);
161
229
  }
162
230
  if (debug) {
163
- this.log(`[DEBUG] API URL: ${apiUrl}`);
231
+ out(`[DEBUG] API URL: ${apiUrl}`);
164
232
  }
165
- // Resolve and validate Maestro version using API data
166
- const resolvedMaestroVersion = this.versionService.resolveMaestroVersion(maestroVersion, compatibilityData, {
233
+ const resolvedMaestroVersion = versionService.resolveMaestroVersion(maestroVersion, compatibilityData, {
167
234
  debug,
168
- logger: this.log.bind(this),
235
+ logger: (m) => out(m),
169
236
  });
170
237
  const REMOVED_MAESTRO_VERSIONS = ['1.39.1', '1.39.2', '1.39.7', '2.0.3', '2.4.0'];
171
238
  if (REMOVED_MAESTRO_VERSIONS.includes(resolvedMaestroVersion)) {
172
- this.error(`Maestro version ${resolvedMaestroVersion} is no longer supported. ` +
239
+ throw new cli_1.CliError(`Maestro version ${resolvedMaestroVersion} is no longer supported. ` +
173
240
  `Please upgrade to a newer version. See: https://docs.devicecloud.dev/configuration/maestro-versions`);
174
241
  }
175
- const DEPRECATED_MAESTRO_VERSIONS = ['1.39.5', '1.41.0'];
176
- if (DEPRECATED_MAESTRO_VERSIONS.includes(resolvedMaestroVersion)) {
177
- this.log(styling_1.colors.warning('⚠') + ' ' + styling_1.colors.dim(`Maestro version ${resolvedMaestroVersion} will be deprecated on 26th June 2026 and will ` +
178
- `no longer be available. We recommend upgrading to 2.6.0. ` +
179
- `See: https://docs.devicecloud.dev/configuration/maestro-versions`));
180
- }
181
- if (retry && retry > 2) {
182
- this.log(styling_1.colors.warning('⚠') + ' ' + styling_1.colors.dim("Retries are now free of charge but limited to 2. If your test is still failing after 2 retries, please ask for help on Discord."));
183
- flags.retry = 2;
242
+ if (retry !== undefined && retry > 2) {
243
+ out(`${styling_1.symbols.warning} ` +
244
+ styling_1.colors.dim('Retries are now free of charge but limited to 2. If your test is still failing after 2 retries, please ask for help on Discord.'));
184
245
  retry = 2;
185
246
  }
186
247
  if (runnerType === 'm4') {
187
- this.log(styling_1.colors.info('ℹ') + ' ' + styling_1.colors.dim('Note: runnerType m4 is experimental and currently supports iOS only, Android will revert to default.'));
248
+ out(`${styling_1.symbols.info} ` +
249
+ styling_1.colors.dim('Note: runnerType m4 is experimental and currently supports iOS only, Android will revert to default.'));
188
250
  }
189
251
  if (runnerType === 'm1') {
190
- this.log(styling_1.colors.info('ℹ') + ' ' + styling_1.colors.dim('Note: runnerType m1 is experimental and currently supports Android (Pixel 7, API Level 34) only.'));
252
+ out(`${styling_1.symbols.info} ` +
253
+ styling_1.colors.dim('Note: runnerType m1 is experimental and currently supports Android (Pixel 7, API Level 34) only.'));
191
254
  }
192
255
  if (runnerType === 'gpu1') {
193
- this.log(styling_1.colors.info('ℹ') + ' ' + styling_1.colors.dim('Note: runnerType gpu1 is Android-only (all devices, API Level 34 or 35), available to all users.'));
256
+ out(`${styling_1.symbols.info} ` +
257
+ styling_1.colors.dim('Note: runnerType gpu1 is Android-only (all devices, API Level 34 or 35), available to all users.'));
194
258
  }
195
- const { firstFile, secondFile } = args;
259
+ const firstFile = args.firstFile;
260
+ const secondFile = args.secondFile;
196
261
  let finalBinaryId = appBinaryId;
197
262
  let finalAppFile = appFile ?? (appUrl ?? firstFile);
198
263
  let flowFile = flows ?? secondFile;
199
- // Resolve --app-url or a local .tar.gz to a .app path before validation
200
264
  if (finalAppFile && !appBinaryId) {
201
265
  if ((0, expo_1.isUrl)(finalAppFile)) {
202
- this.log(` ${styling_1.colors.dim('→ Downloading Expo build from URL...')}`);
203
- const tarPath = await (0, expo_1.downloadExpoUrl)(finalAppFile, debug ?? false);
266
+ out(` ${styling_1.colors.dim('→ Downloading Expo build from URL...')}`);
267
+ const tarPath = await (0, expo_1.downloadExpoUrl)(finalAppFile, debug);
204
268
  tempFiles.push(tarPath);
205
269
  finalAppFile = tarPath;
206
270
  }
207
271
  if (finalAppFile.endsWith('.tar.gz')) {
208
- this.log(` ${styling_1.colors.dim('→ Extracting Expo archive...')}`);
209
- const extractDir = await (0, expo_1.extractTarGz)(finalAppFile, debug ?? false);
272
+ out(` ${styling_1.colors.dim('→ Extracting Expo archive...')}`);
273
+ const extractDir = await (0, expo_1.extractTarGz)(finalAppFile, debug);
210
274
  tempFiles.push(extractDir);
211
275
  finalAppFile = await (0, expo_1.findAppBundle)(extractDir);
212
276
  if (debug) {
213
- this.log(`[DEBUG] Found .app bundle at: ${finalAppFile}`);
277
+ out(`[DEBUG] Found .app bundle at: ${finalAppFile}`);
214
278
  }
215
279
  }
216
280
  }
217
281
  if (debug) {
218
- this.log(`[DEBUG] First file argument: ${firstFile || 'not provided'}`);
219
- this.log(`[DEBUG] Second file argument: ${secondFile || 'not provided'}`);
220
- this.log(`[DEBUG] App binary ID: ${appBinaryId || 'not provided'}`);
221
- this.log(`[DEBUG] App file: ${finalAppFile || 'not provided'}`);
222
- this.log(`[DEBUG] Flow file: ${flowFile || 'not provided'}`);
282
+ out(`[DEBUG] First file argument: ${firstFile || 'not provided'}`);
283
+ out(`[DEBUG] Second file argument: ${secondFile || 'not provided'}`);
284
+ out(`[DEBUG] App binary ID: ${appBinaryId || 'not provided'}`);
285
+ out(`[DEBUG] App file: ${finalAppFile || 'not provided'}`);
286
+ out(`[DEBUG] Flow file: ${flowFile || 'not provided'}`);
223
287
  }
224
288
  if (appBinaryId) {
225
289
  if (secondFile) {
226
- throw new Error('You cannot provide both an appBinaryId and a binary file');
290
+ throw new cli_1.CliError('You cannot provide both an appBinaryId and a binary file');
227
291
  }
228
292
  flowFile = flows ?? firstFile;
229
293
  }
294
+ else if ((appFile || appUrl) && !flowFile) {
295
+ // The app came from a flag, so the first positional (if any) is the
296
+ // flow file — previously it was silently dropped.
297
+ flowFile = firstFile;
298
+ }
230
299
  if (!flowFile) {
231
- throw new Error('You must provide a flow file');
300
+ throw new cli_1.CliError('You must provide a flow file');
232
301
  }
233
- // Validate iOS device configuration
234
- this.deviceValidationService.validateiOSDevice(iOSVersion, iOSDevice, compatibilityData, { debug, logger: this.log.bind(this) });
235
- // Validate Android device configuration
236
- this.deviceValidationService.validateAndroidDevice(androidApiLevel, androidDevice, googlePlay, compatibilityData, { debug, logger: this.log.bind(this) });
237
- // Warn if maestro-chrome-onboarding flag is used without Android devices
238
- if (flags['maestro-chrome-onboarding'] && !androidApiLevel && !androidDevice) {
239
- this.warn('The --maestro-chrome-onboarding flag only applies to Android tests and will be ignored for iOS tests.');
302
+ deviceValidationService.validateiOSDevice(iOSVersion, iOSDevice, compatibilityData, {
303
+ debug,
304
+ logger: (m) => out(m),
305
+ });
306
+ deviceValidationService.validateAndroidDevice(androidApiLevel, androidDevice, googlePlay, compatibilityData, { debug, logger: (m) => out(m) });
307
+ if (maestroChromeOnboarding && !androidApiLevel && !androidDevice) {
308
+ warnOut('The --maestro-chrome-onboarding flag only applies to Android tests and will be ignored for iOS tests.');
240
309
  }
241
310
  flowFile = path.resolve(flowFile);
242
311
  if (!flowFile?.endsWith('.yaml') &&
@@ -245,98 +314,120 @@ class Cloud extends core_1.Command {
245
314
  flowFile += '/';
246
315
  }
247
316
  if (debug) {
248
- this.log(`[DEBUG] Resolved flow file path: ${flowFile}`);
317
+ out(`[DEBUG] Resolved flow file path: ${flowFile}`);
249
318
  }
250
319
  let executionPlan;
251
320
  try {
252
321
  if (debug) {
253
- this.log('[DEBUG] Generating execution plan...');
322
+ out('[DEBUG] Generating execution plan...');
254
323
  }
255
324
  executionPlan = await (0, execution_plan_service_1.plan)({
256
325
  input: flowFile,
257
- includeTags: includeTags.flat(),
258
- excludeTags: excludeTags.flat(),
259
- excludeFlows: excludeFlows.flat(),
326
+ includeTags,
327
+ excludeTags,
328
+ excludeFlows,
260
329
  configFile,
261
330
  debug,
262
331
  });
263
332
  if (debug) {
264
- this.log(`[DEBUG] Execution plan generated`);
265
- this.log(`[DEBUG] Total flow files: ${executionPlan.totalFlowFiles}`);
266
- this.log(`[DEBUG] Flows to run: ${executionPlan.flowsToRun.length}`);
267
- this.log(`[DEBUG] Referenced files: ${executionPlan.referencedFiles.length}`);
268
- this.log(`[DEBUG] Sequential flows: ${executionPlan.sequence?.flows.length || 0}`);
333
+ out(`[DEBUG] Execution plan generated`);
334
+ out(`[DEBUG] Total flow files: ${executionPlan.totalFlowFiles}`);
335
+ out(`[DEBUG] Flows to run: ${executionPlan.flowsToRun.length}`);
336
+ out(`[DEBUG] Referenced files: ${executionPlan.referencedFiles.length}`);
337
+ out(`[DEBUG] Sequential flows: ${executionPlan.sequence?.flows.length || 0}`);
269
338
  }
270
339
  }
271
340
  catch (error) {
272
341
  if (debug) {
273
- this.log(`[DEBUG] Error generating execution plan: ${error}`);
342
+ out(`[DEBUG] Error generating execution plan: ${error}`);
274
343
  }
275
344
  throw error;
276
345
  }
277
346
  const { allExcludeTags, allIncludeTags, flowMetadata, flowOverrides, flowsToRun: testFileNames, referencedFiles, sequence, } = executionPlan;
278
347
  if (debug) {
279
- this.log(`[DEBUG] All include tags: ${allIncludeTags?.join(', ') || 'none'}`);
280
- this.log(`[DEBUG] All exclude tags: ${allExcludeTags?.join(', ') || 'none'}`);
281
- this.log(`[DEBUG] Test file names: ${testFileNames.join(', ')}`);
348
+ out(`[DEBUG] All include tags: ${allIncludeTags?.join(', ') || 'none'}`);
349
+ out(`[DEBUG] All exclude tags: ${allExcludeTags?.join(', ') || 'none'}`);
350
+ out(`[DEBUG] Test file names: ${testFileNames.join(', ')}`);
282
351
  }
283
352
  const pathsShortestToLongest = [
284
353
  ...testFileNames,
285
354
  ...referencedFiles,
286
355
  ].sort((a, b) => a.split(path.sep).length - b.split(path.sep).length);
287
- let commonRoot = path.parse(process.cwd()).root;
288
- const folders = pathsShortestToLongest[0].split(path.sep);
289
- for (const [index] of folders.entries()) {
290
- const folderPath = folders.slice(0, index).join(path.sep);
291
- const isRoot = pathsShortestToLongest.every((file) => file.startsWith(folderPath));
292
- if (isRoot)
293
- commonRoot = folderPath;
356
+ // Longest whole-segment directory prefix shared by every path. Segment
357
+ // comparison (not startsWith) so sibling dirs like `flows`/`flows-extra`
358
+ // can't merge, and the file segment itself is never consumed. '' when
359
+ // the paths share no root at all.
360
+ const splitPaths = pathsShortestToLongest.map((p) => p.split(path.sep));
361
+ const shortestSegments = splitPaths[0];
362
+ let matchedSegments = 0;
363
+ for (let i = 0; i < shortestSegments.length - 1; i++) {
364
+ if (splitPaths.every((segments) => segments[i] === shortestSegments[i])) {
365
+ matchedSegments = i + 1;
366
+ }
367
+ else {
368
+ break;
369
+ }
294
370
  }
371
+ const commonRoot = shortestSegments.slice(0, matchedSegments).join(path.sep);
295
372
  if (debug) {
296
- this.log(`[DEBUG] Common root directory: ${commonRoot}`);
373
+ out(`[DEBUG] Common root directory: ${commonRoot}`);
297
374
  }
298
- // Build testMetadataMap from flowMetadata (keyed by normalized test file name)
299
- // This map provides flowName and tags for each test for JSON output
300
375
  const testMetadataMap = {};
301
- for (const [absolutePath, metadata] of Object.entries(flowMetadata)) {
302
- // Normalize the path to match the format used in results (e.g., "./flows/test.yaml")
303
- const normalizedPath = absolutePath.replaceAll(commonRoot, '.').split(path.sep).join('/');
304
- const metadataRecord = metadata;
376
+ for (const [absolutePath, meta] of Object.entries(flowMetadata)) {
377
+ const normalizedPath = (0, paths_1.toPortableRelativePath)(absolutePath, commonRoot);
378
+ const metadataRecord = meta;
305
379
  const flowName = metadataRecord?.name || path.parse(absolutePath).name;
306
380
  const rawTags = metadataRecord?.tags;
307
- const tags = Array.isArray(rawTags) ? rawTags.map(String) : (rawTags ? [String(rawTags)] : []);
381
+ const tags = Array.isArray(rawTags)
382
+ ? rawTags.map(String)
383
+ : rawTags
384
+ ? [String(rawTags)]
385
+ : [];
308
386
  testMetadataMap[normalizedPath] = { flowName, tags };
309
387
  }
310
388
  if (debug) {
311
- this.log(`[DEBUG] Built testMetadataMap for ${Object.keys(testMetadataMap).length} flows`);
389
+ out(`[DEBUG] Built testMetadataMap for ${Object.keys(testMetadataMap).length} flows`);
312
390
  }
313
391
  const { continueOnFailure = true, flows: sequentialFlows = [] } = sequence ?? {};
314
392
  if (debug && sequentialFlows.length > 0) {
315
- this.log(`[DEBUG] Sequential flows: ${sequentialFlows.join(', ')}`);
316
- this.log(`[DEBUG] Continue on failure: ${continueOnFailure}`);
393
+ out(`[DEBUG] Sequential flows: ${sequentialFlows.join(', ')}`);
394
+ out(`[DEBUG] Continue on failure: ${continueOnFailure}`);
317
395
  }
318
396
  if (!appBinaryId) {
319
397
  if (!(flowFile && finalAppFile)) {
320
- throw new Error('You must provide a flow file and an app binary id');
398
+ throw new cli_1.CliError('You must provide a flow file and an app binary id');
321
399
  }
322
- if (!['apk', '.app', '.zip', '.tar.gz'].some((ext) => finalAppFile.endsWith(ext))) {
323
- throw new Error('App file must be a .apk for Android, .app/.zip for iOS, or .tar.gz (Expo iOS build)');
400
+ if (!['.apk', '.app', '.zip', '.tar.gz'].some((ext) => finalAppFile.endsWith(ext))) {
401
+ throw new cli_1.CliError('App file must be a .apk for Android, .app/.zip for iOS, or .tar.gz (Expo iOS build)');
324
402
  }
325
403
  if (finalAppFile.endsWith('.zip')) {
326
404
  if (debug) {
327
- this.log(`[DEBUG] Verifying iOS app zip file: ${finalAppFile}`);
405
+ out(`[DEBUG] Verifying iOS app zip file: ${finalAppFile}`);
328
406
  }
329
407
  await (0, methods_1.verifyAppZip)(finalAppFile);
330
408
  }
331
409
  }
332
410
  const flagLogs = [];
333
- const sensitiveFlags = new Set(['api-key', 'apiKey', 'moropo-v1-api-key']);
334
- for (const [k, v] of Object.entries(flags)) {
335
- if (v && v.toString().length > 0 && !sensitiveFlags.has(k)) {
336
- flagLogs.push(`${k}: ${v}`);
411
+ // app-url carries a signed (bearer-style) download URL — treat as secret.
412
+ const sensitiveFlags = new Set([
413
+ 'api-key',
414
+ 'apiKey',
415
+ 'moropo-v1-api-key',
416
+ 'app-url',
417
+ 'appUrl',
418
+ ]);
419
+ // Only log canonical flag keys (skip citty-populated alias duplicates like apiURL/apiUrl).
420
+ const canonicalFlagKeys = new Set(Object.keys(constants_1.flags));
421
+ for (const [k, v] of Object.entries(args)) {
422
+ if (!canonicalFlagKeys.has(k))
423
+ continue;
424
+ if (v === undefined || v === null || v === false)
425
+ continue;
426
+ const asString = String(v);
427
+ if (asString.length > 0 && !sensitiveFlags.has(k)) {
428
+ flagLogs.push(`${k}: ${asString}`);
337
429
  }
338
430
  }
339
- // Format overrides information
340
431
  const overridesEntries = Object.entries(flowOverrides);
341
432
  const hasOverrides = overridesEntries.some(([, overrides]) => Object.keys(overrides).length > 0);
342
433
  let overridesLog = '';
@@ -347,51 +438,52 @@ class Cloud extends core_1.Command {
347
438
  const relativePath = flowPath.replace(process.cwd(), '.');
348
439
  overridesLog += `\n ${styling_1.colors.dim('→')} ${relativePath}:`;
349
440
  for (const [key, value] of Object.entries(overrides)) {
350
- overridesLog += `\n ${styling_1.colors.dim(key + ':')} ${styling_1.colors.highlight(value)}`;
441
+ overridesLog += `\n ${styling_1.colors.dim(key + ':')} ${styling_1.colors.highlight(String(value))}`;
351
442
  }
352
443
  }
353
444
  }
354
445
  }
355
- this.log(`\n${(0, styling_1.sectionHeader)('Submitting new job')}`);
356
- this.log(` ${styling_1.colors.dim('→ Flow(s):')} ${styling_1.colors.highlight(flowFile)}`);
357
- this.log(` ${styling_1.colors.dim('→ App:')} ${styling_1.colors.highlight(appBinaryId || finalAppFile)}`);
446
+ out((0, styling_1.sectionHeader)('Submitting new job'));
447
+ out(` ${styling_1.colors.dim('→ Flow(s):')} ${styling_1.colors.highlight(flowFile)}`);
448
+ out(` ${styling_1.colors.dim('→ App:')} ${styling_1.colors.highlight(appBinaryId || finalAppFile || '')}`);
358
449
  if (flagLogs.length > 0) {
359
- this.log(`\n ${styling_1.colors.bold('With options')}`);
450
+ out(`\n ${styling_1.colors.bold('With options')}`);
360
451
  for (const flagLog of flagLogs) {
361
452
  const [key, ...valueParts] = flagLog.split(': ');
362
453
  const value = valueParts.join(': ');
363
- this.log(` ${styling_1.colors.dim('→ ' + key + ':')} ${styling_1.colors.highlight(value)}`);
454
+ out(` ${styling_1.colors.dim('→ ' + key + ':')} ${styling_1.colors.highlight(value)}`);
364
455
  }
365
456
  }
366
457
  if (hasOverrides) {
367
- this.log(overridesLog);
458
+ out(overridesLog);
368
459
  }
369
- this.log('');
460
+ out('');
370
461
  if (dryRun) {
371
- this.log(`\n${styling_1.colors.warning('⚠')} ${styling_1.colors.bold('Dry run mode')} ${styling_1.colors.dim('- no tests were actually triggered')}\n`);
372
- this.log(styling_1.colors.bold('The following tests would have been run:'));
373
- this.log(styling_1.dividers.light);
462
+ out(`\n${styling_1.symbols.warning} ${styling_1.colors.bold('Dry run mode')} ${styling_1.colors.dim('- no tests were actually triggered')}\n`);
463
+ out(styling_1.colors.bold('The following tests would have been run:'));
464
+ out(styling_1.dividers.light);
374
465
  for (const test of testFileNames) {
375
- this.log((0, styling_1.listItem)(test));
466
+ out((0, styling_1.listItem)(test));
376
467
  }
377
468
  if (sequentialFlows.length > 0) {
378
- this.log(`\n${styling_1.colors.bold('Sequential flows:')}`);
379
- this.log(styling_1.dividers.short);
469
+ out(`\n${styling_1.colors.bold('Sequential flows:')}`);
470
+ out(styling_1.dividers.short);
380
471
  for (const flow of sequentialFlows) {
381
- this.log((0, styling_1.listItem)(flow));
472
+ out((0, styling_1.listItem)(flow));
382
473
  }
383
474
  }
384
- this.log('\n');
475
+ out('\n');
385
476
  return;
386
477
  }
387
478
  if (!finalBinaryId) {
388
- if (!finalAppFile)
389
- throw new Error('You must provide either an app binary id or an app file');
479
+ if (!finalAppFile) {
480
+ throw new cli_1.CliError('You must provide either an app binary id or an app file');
481
+ }
390
482
  if (debug) {
391
- this.log(`[DEBUG] Uploading binary file: ${finalAppFile}`);
483
+ out(`[DEBUG] Uploading binary file: ${finalAppFile}`);
392
484
  }
393
485
  const binaryId = await (0, methods_1.uploadBinary)({
394
- apiKey,
486
+ auth,
395
487
  apiUrl,
396
488
  debug,
397
489
  filePath: finalAppFile,
@@ -400,12 +492,11 @@ class Cloud extends core_1.Command {
400
492
  });
401
493
  finalBinaryId = binaryId;
402
494
  if (debug) {
403
- this.log(`[DEBUG] Binary uploaded with ID: ${binaryId}`);
495
+ out(`[DEBUG] Binary uploaded with ID: ${binaryId}`);
404
496
  }
405
497
  }
406
- // finalBinaryId should always be defined after validation - fail fast if not
407
498
  if (!finalBinaryId) {
408
- throw new Error('Internal error: finalBinaryId should be defined after validation');
499
+ throw new cli_1.CliError('Internal error: finalBinaryId should be defined after validation');
409
500
  }
410
501
  const ghMetadataOverrides = [];
411
502
  if (ghBranch)
@@ -418,14 +509,13 @@ class Cloud extends core_1.Command {
418
509
  ghMetadataOverrides.push(`gh_pr_number=${ghPrNumber}`);
419
510
  if (ghPrUrl)
420
511
  ghMetadataOverrides.push(`gh_pr_url=${ghPrUrl}`);
421
- // Explicit --metadata values take precedence (last-write-wins in parseKeyValuePairs)
422
- const mergedMetadata = [...ghMetadataOverrides, ...(metadata ?? [])];
423
- const testFormData = await this.testSubmissionService.buildTestFormData({
512
+ const mergedMetadata = [...ghMetadataOverrides, ...metadata];
513
+ const testFormData = await testSubmissionService.buildTestFormData({
424
514
  androidApiLevel,
425
515
  androidDevice,
426
516
  androidNoSnapshot,
427
517
  appBinaryId: finalBinaryId,
428
- cliVersion: this.config.version,
518
+ cliVersion,
429
519
  commonRoot,
430
520
  continueOnFailure,
431
521
  debug,
@@ -436,156 +526,174 @@ class Cloud extends core_1.Command {
436
526
  googlePlay,
437
527
  iOSDevice,
438
528
  iOSVersion,
439
- logger: this.log.bind(this),
529
+ logger: (m) => out(m),
440
530
  maestroVersion: resolvedMaestroVersion,
441
531
  metadata: mergedMetadata,
442
532
  mitmHost,
443
533
  mitmPath,
444
534
  name,
445
535
  orientation,
446
- raw,
536
+ raw: [],
447
537
  report,
448
538
  retry,
449
539
  runnerType,
450
- showCrosshairs: flags['show-crosshairs'],
451
- maestroChromeOnboarding: flags['maestro-chrome-onboarding'],
452
- disableAnimations: flags['disable-animations'],
540
+ showCrosshairs,
541
+ maestroChromeOnboarding,
542
+ disableAnimations,
453
543
  });
454
544
  if (debug) {
455
- this.log(`[DEBUG] Submitting flow upload request to ${apiUrl}/uploads/flow`);
545
+ out(`[DEBUG] Submitting flow upload request to ${apiUrl}/uploads/flow`);
456
546
  }
457
- const { message, results } = await api_gateway_1.ApiGateway.uploadFlow(apiUrl, apiKey, testFormData);
547
+ const { message, results } = await api_gateway_1.ApiGateway.uploadFlow(apiUrl, auth, testFormData);
458
548
  if (debug) {
459
- this.log(`[DEBUG] Flow upload response received`);
460
- this.log(`[DEBUG] Message: ${message}`);
461
- this.log(`[DEBUG] Results count: ${results?.length || 0}`);
549
+ out(`[DEBUG] Flow upload response received`);
550
+ out(`[DEBUG] Message: ${message}`);
551
+ out(`[DEBUG] Results count: ${results?.length || 0}`);
462
552
  }
463
- if (!results?.length)
464
- (0, errors_1.error)('No tests created: ' + message);
465
- this.log(styling_1.colors.success('✓') + ' ' + styling_1.colors.dim(message));
553
+ if (!results?.length) {
554
+ throw new cli_1.CliError('No tests created: ' + message);
555
+ }
556
+ out(`${styling_1.symbols.success} ${styling_1.colors.bold('Submitted')} ${styling_1.colors.dim(message)}`);
466
557
  const testNames = results
467
558
  .map((r) => r.test_file_name)
468
559
  .sort((a, b) => a.localeCompare(b))
469
560
  .join(styling_1.colors.dim(', '));
470
- this.log(`\n${styling_1.colors.bold(`Created ${results.length} test${results.length === 1 ? '' : 's'}:`)} ${testNames}\n`);
561
+ out(`\n${styling_1.colors.bold(`Created ${results.length} test${results.length === 1 ? '' : 's'}:`)} ${testNames}\n`);
471
562
  const url = (0, styling_1.getConsoleUrl)(apiUrl, results[0].test_upload_id, results[0].id);
472
- this.log(styling_1.colors.bold('Run triggered') + styling_1.colors.dim(', you can access the results at:'));
473
- this.log((0, styling_1.formatUrl)(url));
474
- this.log(``);
475
- this.log(styling_1.colors.dim('Your upload ID is: ') + (0, styling_1.formatId)(results[0].test_upload_id));
476
- this.log(styling_1.colors.dim('Poll upload status using: ') + styling_1.colors.info(`dcd status --api-key ... --upload-id ${results[0].test_upload_id}`));
563
+ out(styling_1.colors.bold('Run triggered') + styling_1.colors.dim(', you can access the results at:'));
564
+ out((0, styling_1.formatUrl)(url));
565
+ out('');
566
+ out(styling_1.colors.dim('Your upload ID is: ') + (0, styling_1.formatId)(results[0].test_upload_id));
567
+ out(styling_1.colors.dim('Poll upload status using: ') +
568
+ styling_1.colors.info(`dcd status --api-key ... --upload-id ${results[0].test_upload_id}`));
477
569
  if (async) {
478
570
  if (debug) {
479
- this.log(`[DEBUG] Async flag is set, not waiting for results`);
571
+ out(`[DEBUG] Async flag is set, not waiting for results`);
480
572
  }
481
573
  const jsonOutput = {
482
574
  consoleUrl: url,
483
575
  status: 'PENDING',
484
576
  tests: results.map((r) => ({
485
577
  fileName: r.test_file_name,
486
- flowName: testMetadataMap[r.test_file_name]?.flowName || path.parse(r.test_file_name).name,
578
+ flowName: testMetadataMap[r.test_file_name]?.flowName ||
579
+ path.parse(r.test_file_name).name,
487
580
  name: r.test_file_name,
488
581
  status: r.status,
489
582
  tags: testMetadataMap[r.test_file_name]?.tags || [],
490
583
  })),
491
584
  uploadId: results[0].test_upload_id,
492
585
  };
493
- if (flags['json-file']) {
586
+ if (jsonFileFlag) {
494
587
  const jsonFilePath = jsonFileName || `${results[0].test_upload_id}_dcd.json`;
495
- (0, methods_1.writeJSONFile)(jsonFilePath, jsonOutput, this);
588
+ (0, methods_1.writeJSONFile)(jsonFilePath, jsonOutput, {
589
+ log: (m) => out(m),
590
+ warn: (m) => warnOut(m),
591
+ });
496
592
  }
497
593
  if (json) {
498
- return jsonOutput;
594
+ // eslint-disable-next-line no-console
595
+ console.log(JSON.stringify(jsonOutput, null, 2));
596
+ return;
499
597
  }
500
- this.log(`\n${styling_1.colors.info('ℹ')} ${styling_1.colors.dim('Not waiting for results as async flag is set to true')}\n`);
598
+ out(`\n${styling_1.symbols.info} ${styling_1.colors.dim('Not waiting for results as async flag is set to true')}\n`);
501
599
  return;
502
600
  }
503
- // Poll for results until completion
504
- const pollingResult = await this.resultsPollingService
505
- .pollUntilComplete(results, {
506
- apiKey,
601
+ const pollingResult = await resultsPollingService
602
+ .pollUntilComplete({
603
+ auth,
507
604
  apiUrl,
508
605
  consoleUrl: url,
509
606
  debug,
510
607
  json,
511
- logger: this.log.bind(this),
608
+ logger: (m) => out(m),
512
609
  quiet,
513
610
  uploadId: results[0].test_upload_id,
514
611
  }, testMetadataMap)
515
612
  .catch(async (error) => {
516
613
  if (error instanceof results_polling_service_1.RunFailedError) {
517
- // Handle failed test run
518
614
  const jsonOutput = error.result;
519
- if (flags['json-file']) {
615
+ if (jsonFileFlag) {
520
616
  const jsonFilePath = jsonFileName || `${results[0].test_upload_id}_dcd.json`;
521
- (0, methods_1.writeJSONFile)(jsonFilePath, jsonOutput, this);
617
+ (0, methods_1.writeJSONFile)(jsonFilePath, jsonOutput, {
618
+ log: (m) => out(m),
619
+ warn: (m) => warnOut(m),
620
+ });
522
621
  }
523
622
  if (json) {
524
623
  output = jsonOutput;
525
624
  }
526
- // Download artifacts and reports even when tests fail
527
- if (downloadArtifacts) {
528
- await this.reportDownloadService.downloadArtifacts({
529
- apiKey,
530
- apiUrl,
531
- artifactsPath,
532
- debug,
533
- downloadType: downloadArtifacts,
534
- logger: this.log.bind(this),
535
- uploadId: results[0].test_upload_id,
536
- warnLogger: this.warn.bind(this),
537
- });
625
+ // A download failure must not mask the run-failed signal (it
626
+ // would flip exit code 2 → 1 and drop the JSON output).
627
+ try {
628
+ if (downloadArtifacts) {
629
+ await reportDownloadService.downloadArtifacts({
630
+ auth,
631
+ apiUrl,
632
+ artifactsPath,
633
+ debug,
634
+ downloadType: downloadArtifacts,
635
+ logger: (m) => out(m),
636
+ uploadId: results[0].test_upload_id,
637
+ warnLogger: (m) => warnOut(m),
638
+ });
639
+ }
640
+ if (report) {
641
+ await reportDownloadService.downloadReports({
642
+ allurePath,
643
+ auth,
644
+ apiUrl,
645
+ debug,
646
+ htmlPath,
647
+ junitPath,
648
+ logger: (m) => out(m),
649
+ reportType: report,
650
+ uploadId: results[0].test_upload_id,
651
+ warnLogger: (m) => warnOut(m),
652
+ });
653
+ }
538
654
  }
539
- if (report && ['allure', 'html', 'html-detailed', 'junit'].includes(report)) {
540
- await this.reportDownloadService.downloadReports({
541
- allurePath,
542
- apiKey,
543
- apiUrl,
544
- debug,
545
- htmlPath,
546
- junitPath,
547
- logger: this.log.bind(this),
548
- reportType: report,
549
- uploadId: results[0].test_upload_id,
550
- warnLogger: this.warn.bind(this),
551
- });
655
+ catch (downloadError) {
656
+ warnOut(`Failed to download artifacts/reports for the failed run: ${downloadError instanceof Error
657
+ ? downloadError.message
658
+ : String(downloadError)}`);
552
659
  }
553
660
  throw new Error('RUN_FAILED');
554
661
  }
555
662
  throw error;
556
663
  });
557
- // Handle successful completion - download artifacts and reports
558
664
  if (downloadArtifacts) {
559
- await this.reportDownloadService.downloadArtifacts({
560
- apiKey,
665
+ await reportDownloadService.downloadArtifacts({
666
+ auth,
561
667
  apiUrl,
562
668
  artifactsPath,
563
669
  debug,
564
670
  downloadType: downloadArtifacts,
565
- logger: this.log.bind(this),
671
+ logger: (m) => out(m),
566
672
  uploadId: results[0].test_upload_id,
567
- warnLogger: this.warn.bind(this),
673
+ warnLogger: (m) => warnOut(m),
568
674
  });
569
675
  }
570
- // Handle report downloads based on --report flag
571
- if (report && ['allure', 'html', 'html-detailed', 'junit'].includes(report)) {
572
- await this.reportDownloadService.downloadReports({
676
+ if (report) {
677
+ await reportDownloadService.downloadReports({
573
678
  allurePath,
574
- apiKey,
679
+ auth,
575
680
  apiUrl,
576
681
  debug,
577
682
  htmlPath,
578
683
  junitPath,
579
- logger: this.log.bind(this),
684
+ logger: (m) => out(m),
580
685
  reportType: report,
581
686
  uploadId: results[0].test_upload_id,
582
- warnLogger: this.warn.bind(this),
687
+ warnLogger: (m) => warnOut(m),
583
688
  });
584
689
  }
585
690
  const jsonOutput = pollingResult;
586
- if (flags['json-file']) {
691
+ if (jsonFileFlag) {
587
692
  const jsonFilePath = jsonFileName || `${results[0].test_upload_id}_dcd.json`;
588
- (0, methods_1.writeJSONFile)(jsonFilePath, jsonOutput, this);
693
+ (0, methods_1.writeJSONFile)(jsonFilePath, jsonOutput, {
694
+ log: (m) => out(m),
695
+ warn: (m) => warnOut(m),
696
+ });
589
697
  }
590
698
  if (json) {
591
699
  output = jsonOutput;
@@ -593,41 +701,34 @@ class Cloud extends core_1.Command {
593
701
  }
594
702
  catch (error) {
595
703
  if (debugFlag && error instanceof Error) {
596
- this.log(`[DEBUG] Error in command execution: ${error.message}`);
597
- this.log(`[DEBUG] Error stack: ${error.stack}`);
598
- }
599
- if (error instanceof Error && error.message === 'RUN_FAILED') {
600
- if (jsonFile) {
601
- // mimic oclif's json functionality
602
- this.exit(0);
603
- }
604
- this.exit(2);
605
- }
606
- else {
607
- this.error(error, { exit: 1 });
704
+ out(`[DEBUG] Error in command execution: ${error.message}`);
705
+ out(`[DEBUG] Error stack: ${error.stack}`);
608
706
  }
707
+ // Defer exiting until after the finally block — process.exit here would
708
+ // skip it, dropping the --json output and leaking temp files.
709
+ caughtError = error;
609
710
  }
610
711
  finally {
611
- // Clean up any temp files created during Expo URL download / .tar.gz extraction
712
+ const fsp = await Promise.resolve().then(() => require('node:fs/promises'));
612
713
  for (const p of tempFiles) {
613
- await Promise.resolve().then(() => require('node:fs/promises')).then((fsp) => fsp.rm(p, { recursive: true, force: true }).catch(() => { }));
714
+ await fsp.rm(p, { recursive: true, force: true }).catch(() => { });
614
715
  }
615
716
  if (output) {
616
- // eslint-disable-next-line no-unsafe-finally
617
- return output;
717
+ // eslint-disable-next-line no-console
718
+ console.log(JSON.stringify(output, null, 2));
618
719
  }
619
720
  }
620
- }
621
- toErrorJson(err) {
622
- if (err instanceof Error) {
623
- return {
624
- error: {
625
- message: err.message,
626
- ...(err.code ? { code: err.code } : {}),
627
- },
628
- };
721
+ if (caughtError) {
722
+ if (caughtError instanceof Error && caughtError.message === 'RUN_FAILED') {
723
+ // --json-file keeps exit 0 on a failed run (documented contract);
724
+ // otherwise 2 distinguishes test failure from infra errors (1).
725
+ const exitCode = jsonFile ? 0 : 2;
726
+ telemetry_service_1.telemetry.recordCommandFailure({ error: 'RUN_FAILED', exitCode });
727
+ telemetry_service_1.telemetry.flushSync();
728
+ process.exit(exitCode);
729
+ }
730
+ cli_1.logger.error(caughtError, { exit: 1, json: jsonFlag });
629
731
  }
630
- return { error: { message: String(err) } };
631
- }
632
- }
633
- exports.default = Cloud;
732
+ },
733
+ });
734
+ exports.default = exports.cloudCommand;