@devicecloud.dev/dcd 4.4.8 → 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 -282
  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,113 +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
- if (retry && retry > 2) {
176
- 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."));
177
- 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.'));
178
245
  retry = 2;
179
246
  }
180
247
  if (runnerType === 'm4') {
181
- 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.'));
182
250
  }
183
251
  if (runnerType === 'm1') {
184
- 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.'));
185
254
  }
186
255
  if (runnerType === 'gpu1') {
187
- 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.'));
188
258
  }
189
- const { firstFile, secondFile } = args;
259
+ const firstFile = args.firstFile;
260
+ const secondFile = args.secondFile;
190
261
  let finalBinaryId = appBinaryId;
191
262
  let finalAppFile = appFile ?? (appUrl ?? firstFile);
192
263
  let flowFile = flows ?? secondFile;
193
- // Resolve --app-url or a local .tar.gz to a .app path before validation
194
264
  if (finalAppFile && !appBinaryId) {
195
265
  if ((0, expo_1.isUrl)(finalAppFile)) {
196
- this.log(` ${styling_1.colors.dim('→ Downloading Expo build from URL...')}`);
197
- 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);
198
268
  tempFiles.push(tarPath);
199
269
  finalAppFile = tarPath;
200
270
  }
201
271
  if (finalAppFile.endsWith('.tar.gz')) {
202
- this.log(` ${styling_1.colors.dim('→ Extracting Expo archive...')}`);
203
- 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);
204
274
  tempFiles.push(extractDir);
205
275
  finalAppFile = await (0, expo_1.findAppBundle)(extractDir);
206
276
  if (debug) {
207
- this.log(`[DEBUG] Found .app bundle at: ${finalAppFile}`);
277
+ out(`[DEBUG] Found .app bundle at: ${finalAppFile}`);
208
278
  }
209
279
  }
210
280
  }
211
281
  if (debug) {
212
- this.log(`[DEBUG] First file argument: ${firstFile || 'not provided'}`);
213
- this.log(`[DEBUG] Second file argument: ${secondFile || 'not provided'}`);
214
- this.log(`[DEBUG] App binary ID: ${appBinaryId || 'not provided'}`);
215
- this.log(`[DEBUG] App file: ${finalAppFile || 'not provided'}`);
216
- 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'}`);
217
287
  }
218
288
  if (appBinaryId) {
219
289
  if (secondFile) {
220
- 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');
221
291
  }
222
292
  flowFile = flows ?? firstFile;
223
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
+ }
224
299
  if (!flowFile) {
225
- throw new Error('You must provide a flow file');
300
+ throw new cli_1.CliError('You must provide a flow file');
226
301
  }
227
- // Validate iOS device configuration
228
- this.deviceValidationService.validateiOSDevice(iOSVersion, iOSDevice, compatibilityData, { debug, logger: this.log.bind(this) });
229
- // Validate Android device configuration
230
- this.deviceValidationService.validateAndroidDevice(androidApiLevel, androidDevice, googlePlay, compatibilityData, { debug, logger: this.log.bind(this) });
231
- // Warn if maestro-chrome-onboarding flag is used without Android devices
232
- if (flags['maestro-chrome-onboarding'] && !androidApiLevel && !androidDevice) {
233
- 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.');
234
309
  }
235
310
  flowFile = path.resolve(flowFile);
236
311
  if (!flowFile?.endsWith('.yaml') &&
@@ -239,98 +314,120 @@ class Cloud extends core_1.Command {
239
314
  flowFile += '/';
240
315
  }
241
316
  if (debug) {
242
- this.log(`[DEBUG] Resolved flow file path: ${flowFile}`);
317
+ out(`[DEBUG] Resolved flow file path: ${flowFile}`);
243
318
  }
244
319
  let executionPlan;
245
320
  try {
246
321
  if (debug) {
247
- this.log('[DEBUG] Generating execution plan...');
322
+ out('[DEBUG] Generating execution plan...');
248
323
  }
249
324
  executionPlan = await (0, execution_plan_service_1.plan)({
250
325
  input: flowFile,
251
- includeTags: includeTags.flat(),
252
- excludeTags: excludeTags.flat(),
253
- excludeFlows: excludeFlows.flat(),
326
+ includeTags,
327
+ excludeTags,
328
+ excludeFlows,
254
329
  configFile,
255
330
  debug,
256
331
  });
257
332
  if (debug) {
258
- this.log(`[DEBUG] Execution plan generated`);
259
- this.log(`[DEBUG] Total flow files: ${executionPlan.totalFlowFiles}`);
260
- this.log(`[DEBUG] Flows to run: ${executionPlan.flowsToRun.length}`);
261
- this.log(`[DEBUG] Referenced files: ${executionPlan.referencedFiles.length}`);
262
- 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}`);
263
338
  }
264
339
  }
265
340
  catch (error) {
266
341
  if (debug) {
267
- this.log(`[DEBUG] Error generating execution plan: ${error}`);
342
+ out(`[DEBUG] Error generating execution plan: ${error}`);
268
343
  }
269
344
  throw error;
270
345
  }
271
346
  const { allExcludeTags, allIncludeTags, flowMetadata, flowOverrides, flowsToRun: testFileNames, referencedFiles, sequence, } = executionPlan;
272
347
  if (debug) {
273
- this.log(`[DEBUG] All include tags: ${allIncludeTags?.join(', ') || 'none'}`);
274
- this.log(`[DEBUG] All exclude tags: ${allExcludeTags?.join(', ') || 'none'}`);
275
- 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(', ')}`);
276
351
  }
277
352
  const pathsShortestToLongest = [
278
353
  ...testFileNames,
279
354
  ...referencedFiles,
280
355
  ].sort((a, b) => a.split(path.sep).length - b.split(path.sep).length);
281
- let commonRoot = path.parse(process.cwd()).root;
282
- const folders = pathsShortestToLongest[0].split(path.sep);
283
- for (const [index] of folders.entries()) {
284
- const folderPath = folders.slice(0, index).join(path.sep);
285
- const isRoot = pathsShortestToLongest.every((file) => file.startsWith(folderPath));
286
- if (isRoot)
287
- 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
+ }
288
370
  }
371
+ const commonRoot = shortestSegments.slice(0, matchedSegments).join(path.sep);
289
372
  if (debug) {
290
- this.log(`[DEBUG] Common root directory: ${commonRoot}`);
373
+ out(`[DEBUG] Common root directory: ${commonRoot}`);
291
374
  }
292
- // Build testMetadataMap from flowMetadata (keyed by normalized test file name)
293
- // This map provides flowName and tags for each test for JSON output
294
375
  const testMetadataMap = {};
295
- for (const [absolutePath, metadata] of Object.entries(flowMetadata)) {
296
- // Normalize the path to match the format used in results (e.g., "./flows/test.yaml")
297
- const normalizedPath = absolutePath.replaceAll(commonRoot, '.').split(path.sep).join('/');
298
- 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;
299
379
  const flowName = metadataRecord?.name || path.parse(absolutePath).name;
300
380
  const rawTags = metadataRecord?.tags;
301
- 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
+ : [];
302
386
  testMetadataMap[normalizedPath] = { flowName, tags };
303
387
  }
304
388
  if (debug) {
305
- this.log(`[DEBUG] Built testMetadataMap for ${Object.keys(testMetadataMap).length} flows`);
389
+ out(`[DEBUG] Built testMetadataMap for ${Object.keys(testMetadataMap).length} flows`);
306
390
  }
307
391
  const { continueOnFailure = true, flows: sequentialFlows = [] } = sequence ?? {};
308
392
  if (debug && sequentialFlows.length > 0) {
309
- this.log(`[DEBUG] Sequential flows: ${sequentialFlows.join(', ')}`);
310
- this.log(`[DEBUG] Continue on failure: ${continueOnFailure}`);
393
+ out(`[DEBUG] Sequential flows: ${sequentialFlows.join(', ')}`);
394
+ out(`[DEBUG] Continue on failure: ${continueOnFailure}`);
311
395
  }
312
396
  if (!appBinaryId) {
313
397
  if (!(flowFile && finalAppFile)) {
314
- 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');
315
399
  }
316
- if (!['apk', '.app', '.zip', '.tar.gz'].some((ext) => finalAppFile.endsWith(ext))) {
317
- 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)');
318
402
  }
319
403
  if (finalAppFile.endsWith('.zip')) {
320
404
  if (debug) {
321
- this.log(`[DEBUG] Verifying iOS app zip file: ${finalAppFile}`);
405
+ out(`[DEBUG] Verifying iOS app zip file: ${finalAppFile}`);
322
406
  }
323
407
  await (0, methods_1.verifyAppZip)(finalAppFile);
324
408
  }
325
409
  }
326
410
  const flagLogs = [];
327
- const sensitiveFlags = new Set(['api-key', 'apiKey', 'moropo-v1-api-key']);
328
- for (const [k, v] of Object.entries(flags)) {
329
- if (v && v.toString().length > 0 && !sensitiveFlags.has(k)) {
330
- 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}`);
331
429
  }
332
430
  }
333
- // Format overrides information
334
431
  const overridesEntries = Object.entries(flowOverrides);
335
432
  const hasOverrides = overridesEntries.some(([, overrides]) => Object.keys(overrides).length > 0);
336
433
  let overridesLog = '';
@@ -341,51 +438,52 @@ class Cloud extends core_1.Command {
341
438
  const relativePath = flowPath.replace(process.cwd(), '.');
342
439
  overridesLog += `\n ${styling_1.colors.dim('→')} ${relativePath}:`;
343
440
  for (const [key, value] of Object.entries(overrides)) {
344
- 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))}`;
345
442
  }
346
443
  }
347
444
  }
348
445
  }
349
- this.log(`\n${(0, styling_1.sectionHeader)('Submitting new job')}`);
350
- this.log(` ${styling_1.colors.dim('→ Flow(s):')} ${styling_1.colors.highlight(flowFile)}`);
351
- 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 || '')}`);
352
449
  if (flagLogs.length > 0) {
353
- this.log(`\n ${styling_1.colors.bold('With options')}`);
450
+ out(`\n ${styling_1.colors.bold('With options')}`);
354
451
  for (const flagLog of flagLogs) {
355
452
  const [key, ...valueParts] = flagLog.split(': ');
356
453
  const value = valueParts.join(': ');
357
- this.log(` ${styling_1.colors.dim('→ ' + key + ':')} ${styling_1.colors.highlight(value)}`);
454
+ out(` ${styling_1.colors.dim('→ ' + key + ':')} ${styling_1.colors.highlight(value)}`);
358
455
  }
359
456
  }
360
457
  if (hasOverrides) {
361
- this.log(overridesLog);
458
+ out(overridesLog);
362
459
  }
363
- this.log('');
460
+ out('');
364
461
  if (dryRun) {
365
- this.log(`\n${styling_1.colors.warning('⚠')} ${styling_1.colors.bold('Dry run mode')} ${styling_1.colors.dim('- no tests were actually triggered')}\n`);
366
- this.log(styling_1.colors.bold('The following tests would have been run:'));
367
- 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);
368
465
  for (const test of testFileNames) {
369
- this.log((0, styling_1.listItem)(test));
466
+ out((0, styling_1.listItem)(test));
370
467
  }
371
468
  if (sequentialFlows.length > 0) {
372
- this.log(`\n${styling_1.colors.bold('Sequential flows:')}`);
373
- this.log(styling_1.dividers.short);
469
+ out(`\n${styling_1.colors.bold('Sequential flows:')}`);
470
+ out(styling_1.dividers.short);
374
471
  for (const flow of sequentialFlows) {
375
- this.log((0, styling_1.listItem)(flow));
472
+ out((0, styling_1.listItem)(flow));
376
473
  }
377
474
  }
378
- this.log('\n');
475
+ out('\n');
379
476
  return;
380
477
  }
381
478
  if (!finalBinaryId) {
382
- if (!finalAppFile)
383
- 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
+ }
384
482
  if (debug) {
385
- this.log(`[DEBUG] Uploading binary file: ${finalAppFile}`);
483
+ out(`[DEBUG] Uploading binary file: ${finalAppFile}`);
386
484
  }
387
485
  const binaryId = await (0, methods_1.uploadBinary)({
388
- apiKey,
486
+ auth,
389
487
  apiUrl,
390
488
  debug,
391
489
  filePath: finalAppFile,
@@ -394,12 +492,11 @@ class Cloud extends core_1.Command {
394
492
  });
395
493
  finalBinaryId = binaryId;
396
494
  if (debug) {
397
- this.log(`[DEBUG] Binary uploaded with ID: ${binaryId}`);
495
+ out(`[DEBUG] Binary uploaded with ID: ${binaryId}`);
398
496
  }
399
497
  }
400
- // finalBinaryId should always be defined after validation - fail fast if not
401
498
  if (!finalBinaryId) {
402
- 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');
403
500
  }
404
501
  const ghMetadataOverrides = [];
405
502
  if (ghBranch)
@@ -412,14 +509,13 @@ class Cloud extends core_1.Command {
412
509
  ghMetadataOverrides.push(`gh_pr_number=${ghPrNumber}`);
413
510
  if (ghPrUrl)
414
511
  ghMetadataOverrides.push(`gh_pr_url=${ghPrUrl}`);
415
- // Explicit --metadata values take precedence (last-write-wins in parseKeyValuePairs)
416
- const mergedMetadata = [...ghMetadataOverrides, ...(metadata ?? [])];
417
- const testFormData = await this.testSubmissionService.buildTestFormData({
512
+ const mergedMetadata = [...ghMetadataOverrides, ...metadata];
513
+ const testFormData = await testSubmissionService.buildTestFormData({
418
514
  androidApiLevel,
419
515
  androidDevice,
420
516
  androidNoSnapshot,
421
517
  appBinaryId: finalBinaryId,
422
- cliVersion: this.config.version,
518
+ cliVersion,
423
519
  commonRoot,
424
520
  continueOnFailure,
425
521
  debug,
@@ -430,156 +526,174 @@ class Cloud extends core_1.Command {
430
526
  googlePlay,
431
527
  iOSDevice,
432
528
  iOSVersion,
433
- logger: this.log.bind(this),
529
+ logger: (m) => out(m),
434
530
  maestroVersion: resolvedMaestroVersion,
435
531
  metadata: mergedMetadata,
436
532
  mitmHost,
437
533
  mitmPath,
438
534
  name,
439
535
  orientation,
440
- raw,
536
+ raw: [],
441
537
  report,
442
538
  retry,
443
539
  runnerType,
444
- showCrosshairs: flags['show-crosshairs'],
445
- maestroChromeOnboarding: flags['maestro-chrome-onboarding'],
446
- disableAnimations: flags['disable-animations'],
540
+ showCrosshairs,
541
+ maestroChromeOnboarding,
542
+ disableAnimations,
447
543
  });
448
544
  if (debug) {
449
- this.log(`[DEBUG] Submitting flow upload request to ${apiUrl}/uploads/flow`);
545
+ out(`[DEBUG] Submitting flow upload request to ${apiUrl}/uploads/flow`);
450
546
  }
451
- 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);
452
548
  if (debug) {
453
- this.log(`[DEBUG] Flow upload response received`);
454
- this.log(`[DEBUG] Message: ${message}`);
455
- 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}`);
552
+ }
553
+ if (!results?.length) {
554
+ throw new cli_1.CliError('No tests created: ' + message);
456
555
  }
457
- if (!results?.length)
458
- (0, errors_1.error)('No tests created: ' + message);
459
- this.log(styling_1.colors.success('✓') + ' ' + styling_1.colors.dim(message));
556
+ out(`${styling_1.symbols.success} ${styling_1.colors.bold('Submitted')} ${styling_1.colors.dim(message)}`);
460
557
  const testNames = results
461
558
  .map((r) => r.test_file_name)
462
559
  .sort((a, b) => a.localeCompare(b))
463
560
  .join(styling_1.colors.dim(', '));
464
- 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`);
465
562
  const url = (0, styling_1.getConsoleUrl)(apiUrl, results[0].test_upload_id, results[0].id);
466
- this.log(styling_1.colors.bold('Run triggered') + styling_1.colors.dim(', you can access the results at:'));
467
- this.log((0, styling_1.formatUrl)(url));
468
- this.log(``);
469
- this.log(styling_1.colors.dim('Your upload ID is: ') + (0, styling_1.formatId)(results[0].test_upload_id));
470
- 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}`));
471
569
  if (async) {
472
570
  if (debug) {
473
- this.log(`[DEBUG] Async flag is set, not waiting for results`);
571
+ out(`[DEBUG] Async flag is set, not waiting for results`);
474
572
  }
475
573
  const jsonOutput = {
476
574
  consoleUrl: url,
477
575
  status: 'PENDING',
478
576
  tests: results.map((r) => ({
479
577
  fileName: r.test_file_name,
480
- 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,
481
580
  name: r.test_file_name,
482
581
  status: r.status,
483
582
  tags: testMetadataMap[r.test_file_name]?.tags || [],
484
583
  })),
485
584
  uploadId: results[0].test_upload_id,
486
585
  };
487
- if (flags['json-file']) {
586
+ if (jsonFileFlag) {
488
587
  const jsonFilePath = jsonFileName || `${results[0].test_upload_id}_dcd.json`;
489
- (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
+ });
490
592
  }
491
593
  if (json) {
492
- return jsonOutput;
594
+ // eslint-disable-next-line no-console
595
+ console.log(JSON.stringify(jsonOutput, null, 2));
596
+ return;
493
597
  }
494
- 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`);
495
599
  return;
496
600
  }
497
- // Poll for results until completion
498
- const pollingResult = await this.resultsPollingService
499
- .pollUntilComplete(results, {
500
- apiKey,
601
+ const pollingResult = await resultsPollingService
602
+ .pollUntilComplete({
603
+ auth,
501
604
  apiUrl,
502
605
  consoleUrl: url,
503
606
  debug,
504
607
  json,
505
- logger: this.log.bind(this),
608
+ logger: (m) => out(m),
506
609
  quiet,
507
610
  uploadId: results[0].test_upload_id,
508
611
  }, testMetadataMap)
509
612
  .catch(async (error) => {
510
613
  if (error instanceof results_polling_service_1.RunFailedError) {
511
- // Handle failed test run
512
614
  const jsonOutput = error.result;
513
- if (flags['json-file']) {
615
+ if (jsonFileFlag) {
514
616
  const jsonFilePath = jsonFileName || `${results[0].test_upload_id}_dcd.json`;
515
- (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
+ });
516
621
  }
517
622
  if (json) {
518
623
  output = jsonOutput;
519
624
  }
520
- // Download artifacts and reports even when tests fail
521
- if (downloadArtifacts) {
522
- await this.reportDownloadService.downloadArtifacts({
523
- apiKey,
524
- apiUrl,
525
- artifactsPath,
526
- debug,
527
- downloadType: downloadArtifacts,
528
- logger: this.log.bind(this),
529
- uploadId: results[0].test_upload_id,
530
- warnLogger: this.warn.bind(this),
531
- });
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
+ }
532
654
  }
533
- if (report && ['allure', 'html', 'html-detailed', 'junit'].includes(report)) {
534
- await this.reportDownloadService.downloadReports({
535
- allurePath,
536
- apiKey,
537
- apiUrl,
538
- debug,
539
- htmlPath,
540
- junitPath,
541
- logger: this.log.bind(this),
542
- reportType: report,
543
- uploadId: results[0].test_upload_id,
544
- warnLogger: this.warn.bind(this),
545
- });
655
+ catch (downloadError) {
656
+ warnOut(`Failed to download artifacts/reports for the failed run: ${downloadError instanceof Error
657
+ ? downloadError.message
658
+ : String(downloadError)}`);
546
659
  }
547
660
  throw new Error('RUN_FAILED');
548
661
  }
549
662
  throw error;
550
663
  });
551
- // Handle successful completion - download artifacts and reports
552
664
  if (downloadArtifacts) {
553
- await this.reportDownloadService.downloadArtifacts({
554
- apiKey,
665
+ await reportDownloadService.downloadArtifacts({
666
+ auth,
555
667
  apiUrl,
556
668
  artifactsPath,
557
669
  debug,
558
670
  downloadType: downloadArtifacts,
559
- logger: this.log.bind(this),
671
+ logger: (m) => out(m),
560
672
  uploadId: results[0].test_upload_id,
561
- warnLogger: this.warn.bind(this),
673
+ warnLogger: (m) => warnOut(m),
562
674
  });
563
675
  }
564
- // Handle report downloads based on --report flag
565
- if (report && ['allure', 'html', 'html-detailed', 'junit'].includes(report)) {
566
- await this.reportDownloadService.downloadReports({
676
+ if (report) {
677
+ await reportDownloadService.downloadReports({
567
678
  allurePath,
568
- apiKey,
679
+ auth,
569
680
  apiUrl,
570
681
  debug,
571
682
  htmlPath,
572
683
  junitPath,
573
- logger: this.log.bind(this),
684
+ logger: (m) => out(m),
574
685
  reportType: report,
575
686
  uploadId: results[0].test_upload_id,
576
- warnLogger: this.warn.bind(this),
687
+ warnLogger: (m) => warnOut(m),
577
688
  });
578
689
  }
579
690
  const jsonOutput = pollingResult;
580
- if (flags['json-file']) {
691
+ if (jsonFileFlag) {
581
692
  const jsonFilePath = jsonFileName || `${results[0].test_upload_id}_dcd.json`;
582
- (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
+ });
583
697
  }
584
698
  if (json) {
585
699
  output = jsonOutput;
@@ -587,41 +701,34 @@ class Cloud extends core_1.Command {
587
701
  }
588
702
  catch (error) {
589
703
  if (debugFlag && error instanceof Error) {
590
- this.log(`[DEBUG] Error in command execution: ${error.message}`);
591
- this.log(`[DEBUG] Error stack: ${error.stack}`);
592
- }
593
- if (error instanceof Error && error.message === 'RUN_FAILED') {
594
- if (jsonFile) {
595
- // mimic oclif's json functionality
596
- this.exit(0);
597
- }
598
- this.exit(2);
599
- }
600
- else {
601
- this.error(error, { exit: 1 });
704
+ out(`[DEBUG] Error in command execution: ${error.message}`);
705
+ out(`[DEBUG] Error stack: ${error.stack}`);
602
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;
603
710
  }
604
711
  finally {
605
- // Clean up any temp files created during Expo URL download / .tar.gz extraction
712
+ const fsp = await Promise.resolve().then(() => require('node:fs/promises'));
606
713
  for (const p of tempFiles) {
607
- 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(() => { });
608
715
  }
609
716
  if (output) {
610
- // eslint-disable-next-line no-unsafe-finally
611
- return output;
717
+ // eslint-disable-next-line no-console
718
+ console.log(JSON.stringify(output, null, 2));
612
719
  }
613
720
  }
614
- }
615
- toErrorJson(err) {
616
- if (err instanceof Error) {
617
- return {
618
- error: {
619
- message: err.message,
620
- ...(err.code ? { code: err.code } : {}),
621
- },
622
- };
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 });
623
731
  }
624
- return { error: { message: String(err) } };
625
- }
626
- }
627
- exports.default = Cloud;
732
+ },
733
+ });
734
+ exports.default = exports.cloudCommand;