@devicecloud.dev/dcd 4.4.9 → 5.0.0-beta.1

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