@devicecloud.dev/dcd 5.0.0-beta.0 → 5.0.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +52 -0
- package/dist/commands/artifacts.d.ts +28 -28
- package/dist/commands/artifacts.js +20 -23
- package/dist/commands/cloud.d.ts +57 -57
- package/dist/commands/cloud.js +224 -192
- package/dist/commands/list.d.ts +22 -22
- package/dist/commands/list.js +43 -40
- package/dist/commands/live.js +134 -127
- package/dist/commands/login.d.ts +11 -11
- package/dist/commands/login.js +46 -44
- package/dist/commands/logout.js +16 -18
- package/dist/commands/status.d.ts +11 -11
- package/dist/commands/status.js +53 -44
- package/dist/commands/switch-org.d.ts +7 -7
- package/dist/commands/switch-org.js +19 -21
- package/dist/commands/upgrade.js +41 -33
- package/dist/commands/upload.d.ts +10 -10
- package/dist/commands/upload.js +42 -43
- package/dist/commands/whoami.js +17 -20
- package/dist/config/environments.js +6 -12
- package/dist/config/flags/api.flags.js +1 -4
- package/dist/config/flags/binary.flags.js +1 -4
- package/dist/config/flags/device.flags.js +6 -9
- package/dist/config/flags/environment.flags.js +1 -4
- package/dist/config/flags/execution.flags.js +1 -4
- package/dist/config/flags/github.flags.js +1 -4
- package/dist/config/flags/output.flags.js +1 -4
- package/dist/constants.js +15 -18
- package/dist/gateways/api-gateway.d.ts +31 -6
- package/dist/gateways/api-gateway.js +70 -16
- package/dist/gateways/cli-auth-gateway.d.ts +1 -1
- package/dist/gateways/cli-auth-gateway.js +3 -6
- package/dist/gateways/realtime-gateway.d.ts +32 -0
- package/dist/gateways/realtime-gateway.js +103 -0
- package/dist/gateways/supabase-gateway.d.ts +1 -1
- package/dist/gateways/supabase-gateway.js +10 -14
- package/dist/index.js +41 -38
- package/dist/mcp/context.d.ts +33 -0
- package/dist/mcp/context.js +33 -0
- package/dist/mcp/helpers.d.ts +16 -0
- package/dist/mcp/helpers.js +34 -0
- package/dist/mcp/index.d.ts +2 -0
- package/dist/mcp/index.js +24 -0
- package/dist/mcp/server.d.ts +7 -0
- package/dist/mcp/server.js +27 -0
- package/dist/mcp/tools/download-artifacts.d.ts +11 -0
- package/dist/mcp/tools/download-artifacts.js +84 -0
- package/dist/mcp/tools/get-status.d.ts +7 -0
- package/dist/mcp/tools/get-status.js +39 -0
- package/dist/mcp/tools/list-devices.d.ts +7 -0
- package/dist/mcp/tools/list-devices.js +27 -0
- package/dist/mcp/tools/list-runs.d.ts +3 -0
- package/dist/mcp/tools/list-runs.js +60 -0
- package/dist/mcp/tools/run-cloud-test.d.ts +14 -0
- package/dist/mcp/tools/run-cloud-test.js +233 -0
- package/dist/methods.d.ts +32 -1
- package/dist/methods.js +133 -79
- package/dist/services/device-validation.service.d.ts +1 -1
- package/dist/services/device-validation.service.js +1 -5
- package/dist/services/execution-plan.service.js +14 -17
- package/dist/services/execution-plan.utils.js +15 -23
- package/dist/services/flow-paths.d.ts +17 -0
- package/dist/services/flow-paths.js +52 -0
- package/dist/services/metadata-extractor.service.js +22 -25
- package/dist/services/moropo.service.js +18 -20
- package/dist/services/report-download.service.d.ts +1 -1
- package/dist/services/report-download.service.js +5 -9
- package/dist/services/results-polling.service.d.ts +18 -3
- package/dist/services/results-polling.service.js +211 -108
- package/dist/services/telemetry.service.d.ts +10 -1
- package/dist/services/telemetry.service.js +40 -18
- package/dist/services/test-submission.service.d.ts +21 -4
- package/dist/services/test-submission.service.js +51 -34
- package/dist/services/version.service.d.ts +30 -7
- package/dist/services/version.service.js +88 -32
- package/dist/types/domain/auth.types.d.ts +8 -0
- package/dist/types/domain/auth.types.js +1 -2
- package/dist/types/domain/device.types.js +8 -11
- package/dist/types/domain/live.types.js +1 -2
- package/dist/types/generated/schema.types.js +1 -2
- package/dist/types/index.d.ts +2 -2
- package/dist/types/index.js +2 -18
- package/dist/types.js +1 -2
- package/dist/utils/auth.d.ts +1 -1
- package/dist/utils/auth.js +27 -28
- package/dist/utils/ci.d.ts +12 -0
- package/dist/utils/ci.js +39 -0
- package/dist/utils/cli.d.ts +16 -2
- package/dist/utils/cli.js +57 -29
- package/dist/utils/compatibility.d.ts +1 -1
- package/dist/utils/compatibility.js +5 -7
- package/dist/utils/config-store.js +33 -43
- package/dist/utils/connectivity.js +1 -4
- package/dist/utils/expo.js +15 -21
- package/dist/utils/orgs.js +8 -12
- package/dist/utils/paths.js +2 -5
- package/dist/utils/progress.d.ts +3 -0
- package/dist/utils/progress.js +47 -8
- package/dist/utils/styling.d.ts +35 -37
- package/dist/utils/styling.js +52 -86
- package/dist/utils/ui.d.ts +41 -0
- package/dist/utils/ui.js +95 -0
- package/package.json +27 -24
package/dist/commands/cloud.js
CHANGED
|
@@ -1,28 +1,28 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.cloudCommand = void 0;
|
|
4
1
|
/* eslint-disable complexity */
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
2
|
+
import { defineCommand } from 'citty';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import { flags as allFlags } from '../constants.js';
|
|
6
|
+
import { ApiError, ApiGateway } from '../gateways/api-gateway.js';
|
|
7
|
+
import { uploadBinary, uploadFlowZip, verifyAppZip, writeJSONFile, } from '../methods.js';
|
|
8
|
+
import { DeviceValidationService } from '../services/device-validation.service.js';
|
|
9
|
+
import { plan } from '../services/execution-plan.service.js';
|
|
10
|
+
import { buildTestMetadataMap, computeCommonRoot, } from '../services/flow-paths.js';
|
|
11
|
+
import { MoropoService } from '../services/moropo.service.js';
|
|
12
|
+
import { ReportDownloadService } from '../services/report-download.service.js';
|
|
13
|
+
import { ResultsPollingService, RunFailedError, } from '../services/results-polling.service.js';
|
|
14
|
+
import { telemetry } from '../services/telemetry.service.js';
|
|
15
|
+
import { TestSubmissionService } from '../services/test-submission.service.js';
|
|
16
|
+
import { VersionService } from '../services/version.service.js';
|
|
17
|
+
import { EAndroidApiLevels, EAndroidDevices, EiOSDevices, EiOSVersions, } from '../types/domain/device.types.js';
|
|
18
|
+
import { resolveAuth } from '../utils/auth.js';
|
|
19
|
+
import { isCI } from '../utils/ci.js';
|
|
20
|
+
import { CliError, coerceArray, collectRepeatedFlag, getCliVersion, getUpgradeCommand, logger, parseIntFlag, validateEnum, } from '../utils/cli.js';
|
|
21
|
+
import { fetchCompatibilityData, } from '../utils/compatibility.js';
|
|
22
|
+
import { resolveApiUrl } from '../utils/config-store.js';
|
|
23
|
+
import { downloadExpoUrl, extractTarGz, findAppBundle, isUrl } from '../utils/expo.js';
|
|
24
|
+
import { colors, formatId, formatUrl, getConsoleUrl, } from '../utils/styling.js';
|
|
25
|
+
import { ui } from '../utils/ui.js';
|
|
26
26
|
// Suppress punycode deprecation warning (caused by whatwg, supabase dependency).
|
|
27
27
|
// Every other warning must still reach the user — removeAllListeners drops
|
|
28
28
|
// Node's default printer, so re-emit manually.
|
|
@@ -51,13 +51,13 @@ const RUNNER_TYPE_OPTIONS = ['default', 'm4', 'm1', 'gpu1', 'cpu1'];
|
|
|
51
51
|
*
|
|
52
52
|
* Replaces `maestro cloud` with DeviceCloud-specific functionality.
|
|
53
53
|
*/
|
|
54
|
-
|
|
54
|
+
export const cloudCommand = defineCommand({
|
|
55
55
|
meta: {
|
|
56
56
|
name: 'cloud',
|
|
57
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
58
|
},
|
|
59
59
|
args: {
|
|
60
|
-
...
|
|
60
|
+
...allFlags,
|
|
61
61
|
firstFile: {
|
|
62
62
|
type: 'positional',
|
|
63
63
|
required: false,
|
|
@@ -70,24 +70,24 @@ exports.cloudCommand = (0, citty_1.defineCommand)({
|
|
|
70
70
|
},
|
|
71
71
|
},
|
|
72
72
|
// eslint-disable-next-line complexity
|
|
73
|
-
async run({ args }) {
|
|
74
|
-
const cliVersion =
|
|
75
|
-
const deviceValidationService = new
|
|
76
|
-
const moropoService = new
|
|
77
|
-
const reportDownloadService = new
|
|
78
|
-
const resultsPollingService = new
|
|
79
|
-
const testSubmissionService = new
|
|
80
|
-
const versionService = new
|
|
73
|
+
async run({ args, rawArgs }) {
|
|
74
|
+
const cliVersion = getCliVersion();
|
|
75
|
+
const deviceValidationService = new DeviceValidationService();
|
|
76
|
+
const moropoService = new MoropoService();
|
|
77
|
+
const reportDownloadService = new ReportDownloadService();
|
|
78
|
+
const resultsPollingService = new ResultsPollingService();
|
|
79
|
+
const testSubmissionService = new TestSubmissionService();
|
|
80
|
+
const versionService = new VersionService();
|
|
81
81
|
const versionCheck = async () => {
|
|
82
|
-
const
|
|
83
|
-
if (
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
82
|
+
const result = await versionService.checkLatestCliVersion(cliVersion);
|
|
83
|
+
if (result.ok &&
|
|
84
|
+
result.version &&
|
|
85
|
+
versionService.isOutdated(cliVersion, result.version)) {
|
|
86
|
+
out(ui.warn(colors.bold('Update available')));
|
|
87
|
+
out(ui.branch([
|
|
88
|
+
`A new version of the DeviceCloud CLI is available: ${colors.highlight(result.version)}`,
|
|
89
|
+
`${colors.dim('Run:')} ${colors.info(getUpgradeCommand())}`,
|
|
90
|
+
]));
|
|
91
91
|
}
|
|
92
92
|
};
|
|
93
93
|
let output = null;
|
|
@@ -100,15 +100,15 @@ exports.cloudCommand = (0, citty_1.defineCommand)({
|
|
|
100
100
|
// Chatty progress logs are suppressed when --json is active so stdout stays parseable.
|
|
101
101
|
const out = (m) => {
|
|
102
102
|
if (!jsonFlag)
|
|
103
|
-
|
|
103
|
+
logger.log(m);
|
|
104
104
|
};
|
|
105
105
|
const warnOut = (m) => {
|
|
106
106
|
if (!jsonFlag)
|
|
107
|
-
|
|
107
|
+
logger.warn(m);
|
|
108
108
|
};
|
|
109
109
|
try {
|
|
110
110
|
const apiKeyFlag = args['api-key'];
|
|
111
|
-
const apiUrl =
|
|
111
|
+
const apiUrl = resolveApiUrl(args['api-url']);
|
|
112
112
|
const appBinaryId = args['app-binary-id'];
|
|
113
113
|
const appFile = args['app-file'];
|
|
114
114
|
const appUrl = args['app-url'];
|
|
@@ -120,34 +120,36 @@ exports.cloudCommand = (0, citty_1.defineCommand)({
|
|
|
120
120
|
const configFile = args.config;
|
|
121
121
|
const debug = Boolean(args.debug);
|
|
122
122
|
const deviceLocale = args['device-locale'];
|
|
123
|
-
const downloadArtifacts =
|
|
123
|
+
const downloadArtifacts = validateEnum(args['download-artifacts'], DOWNLOAD_OPTIONS, 'download-artifacts');
|
|
124
124
|
const dryRun = Boolean(args['dry-run']);
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const
|
|
125
|
+
// Repeatable flags are collected from rawArgs: citty/parseArgs only keeps
|
|
126
|
+
// the last occurrence, so reading args.* directly drops earlier values.
|
|
127
|
+
const env = coerceArray(collectRepeatedFlag(rawArgs, ['--env', '-e']), false);
|
|
128
|
+
const excludeFlows = coerceArray(collectRepeatedFlag(rawArgs, ['--exclude-flows']));
|
|
129
|
+
const excludeTags = coerceArray(collectRepeatedFlag(rawArgs, ['--exclude-tags']));
|
|
128
130
|
let flows = args.flows;
|
|
129
131
|
const googlePlay = Boolean(args['google-play']);
|
|
130
132
|
const ignoreShaCheck = Boolean(args['ignore-sha-check']);
|
|
131
|
-
const includeTags = (
|
|
132
|
-
const iOSDevice =
|
|
133
|
-
const iOSVersion =
|
|
134
|
-
const androidApiLevel =
|
|
135
|
-
const androidDevice =
|
|
133
|
+
const includeTags = coerceArray(collectRepeatedFlag(rawArgs, ['--include-tags']));
|
|
134
|
+
const iOSDevice = validateEnum(args['ios-device'], Object.values(EiOSDevices), 'ios-device');
|
|
135
|
+
const iOSVersion = validateEnum(args['ios-version'], Object.values(EiOSVersions), 'ios-version');
|
|
136
|
+
const androidApiLevel = validateEnum(args['android-api-level'], Object.values(EAndroidApiLevels), 'android-api-level');
|
|
137
|
+
const androidDevice = validateEnum(args['android-device'], Object.values(EAndroidDevices), 'android-device');
|
|
136
138
|
const androidNoSnapshot = Boolean(args['android-no-snapshot']);
|
|
137
139
|
const json = Boolean(args.json);
|
|
138
140
|
const jsonFileFlag = Boolean(args['json-file']);
|
|
139
141
|
const jsonFileName = args['json-file-name'];
|
|
140
142
|
const maestroVersion = args['maestro-version'];
|
|
141
|
-
const metadata = (
|
|
143
|
+
const metadata = coerceArray(collectRepeatedFlag(rawArgs, ['--metadata', '-m']), false);
|
|
142
144
|
const mitmHost = args.mitmHost;
|
|
143
145
|
const mitmPath = args.mitmPath;
|
|
144
146
|
const moropoApiKey = args['moropo-v1-api-key'];
|
|
145
147
|
const name = args.name;
|
|
146
|
-
const orientation =
|
|
148
|
+
const orientation = validateEnum(args.orientation, ORIENTATION_OPTIONS, 'orientation');
|
|
147
149
|
let quiet = Boolean(args.quiet);
|
|
148
|
-
const report =
|
|
149
|
-
let retry =
|
|
150
|
-
const runnerType =
|
|
150
|
+
const report = validateEnum(args.report, REPORT_OPTIONS, 'report');
|
|
151
|
+
let retry = parseIntFlag(args.retry, 'retry');
|
|
152
|
+
const runnerType = validateEnum(args['runner-type'], RUNNER_TYPE_OPTIONS, 'runner-type') ?? 'default';
|
|
151
153
|
const showCrosshairs = Boolean(args['show-crosshairs']);
|
|
152
154
|
const maestroChromeOnboarding = Boolean(args['maestro-chrome-onboarding']);
|
|
153
155
|
const disableAnimations = Boolean(args['disable-animations']);
|
|
@@ -158,7 +160,7 @@ exports.cloudCommand = (0, citty_1.defineCommand)({
|
|
|
158
160
|
const ghPrUrl = args['pr-url'];
|
|
159
161
|
debugFlag = debug;
|
|
160
162
|
jsonFile = jsonFileFlag;
|
|
161
|
-
out(
|
|
163
|
+
out(colors.dim(`dcd v${cliVersion}`));
|
|
162
164
|
if (debug) {
|
|
163
165
|
out('[DEBUG] Starting command execution with debug logging enabled');
|
|
164
166
|
out(`[DEBUG] Node version: ${process.versions.node}`);
|
|
@@ -169,19 +171,19 @@ exports.cloudCommand = (0, citty_1.defineCommand)({
|
|
|
169
171
|
out('--json-file is true: JSON output will be written to file, forcing --quiet flag for better CI output');
|
|
170
172
|
}
|
|
171
173
|
if (mitmPath && !mitmHost) {
|
|
172
|
-
throw new
|
|
174
|
+
throw new CliError('--mitmPath requires --mitmHost to be set');
|
|
173
175
|
}
|
|
174
176
|
if (jsonFileName && !jsonFileFlag) {
|
|
175
|
-
throw new
|
|
177
|
+
throw new CliError('--json-file-name requires --json-file');
|
|
176
178
|
}
|
|
177
179
|
if (artifactsPath && !downloadArtifacts) {
|
|
178
|
-
throw new
|
|
180
|
+
throw new CliError('--artifacts-path requires --download-artifacts');
|
|
179
181
|
}
|
|
180
182
|
if ((junitPath || allurePath || htmlPath) && !report) {
|
|
181
|
-
throw new
|
|
183
|
+
throw new CliError('Report path flags (--junit-path/--allure-path/--html-path) require --report');
|
|
182
184
|
}
|
|
183
185
|
if (appFile && appUrl) {
|
|
184
|
-
throw new
|
|
186
|
+
throw new CliError('--app-file and --app-url are mutually exclusive');
|
|
185
187
|
}
|
|
186
188
|
if (json) {
|
|
187
189
|
const originalStdoutWrite = process.stdout.write;
|
|
@@ -197,7 +199,7 @@ exports.cloudCommand = (0, citty_1.defineCommand)({
|
|
|
197
199
|
if (major < 20) {
|
|
198
200
|
warnOut(`WARNING: You are using node version ${major}. DeviceCloud requires node version 20 or later`);
|
|
199
201
|
if (major < 18) {
|
|
200
|
-
throw new
|
|
202
|
+
throw new CliError('Invalid node version');
|
|
201
203
|
}
|
|
202
204
|
}
|
|
203
205
|
await versionCheck();
|
|
@@ -212,10 +214,15 @@ exports.cloudCommand = (0, citty_1.defineCommand)({
|
|
|
212
214
|
});
|
|
213
215
|
tempFiles.push(flows);
|
|
214
216
|
}
|
|
215
|
-
const auth = await
|
|
217
|
+
const auth = await resolveAuth({ apiKeyFlag });
|
|
218
|
+
// Nudge interactive api-key users toward `dcd login`, which unlocks live
|
|
219
|
+
// (realtime) status updates. Suppressed in CI and non-interactive output.
|
|
220
|
+
if (auth.mode === 'apiKey' && !json && !quiet && !isCI()) {
|
|
221
|
+
out(colors.dim('Tip: run `dcd login` for live test updates and a smoother experience than passing --api-key.'));
|
|
222
|
+
}
|
|
216
223
|
let compatibilityData;
|
|
217
224
|
try {
|
|
218
|
-
compatibilityData = await
|
|
225
|
+
compatibilityData = await fetchCompatibilityData(apiUrl, auth);
|
|
219
226
|
if (debug) {
|
|
220
227
|
out('[DEBUG] Successfully fetched compatibility data from API');
|
|
221
228
|
}
|
|
@@ -225,7 +232,7 @@ exports.cloudCommand = (0, citty_1.defineCommand)({
|
|
|
225
232
|
if (debug) {
|
|
226
233
|
out(`[DEBUG] Failed to fetch compatibility data from API: ${errorMessage}`);
|
|
227
234
|
}
|
|
228
|
-
throw new
|
|
235
|
+
throw new CliError(`Failed to fetch device compatibility data: ${errorMessage}. Please check your API key and connection.`);
|
|
229
236
|
}
|
|
230
237
|
if (debug) {
|
|
231
238
|
out(`[DEBUG] API URL: ${apiUrl}`);
|
|
@@ -234,27 +241,29 @@ exports.cloudCommand = (0, citty_1.defineCommand)({
|
|
|
234
241
|
debug,
|
|
235
242
|
logger: (m) => out(m),
|
|
236
243
|
});
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
244
|
+
// Soft deprecation notice for Maestro versions slated for removal on
|
|
245
|
+
// 26 June 2026. Non-fatal — these still run during the grace period.
|
|
246
|
+
const DEPRECATED_MAESTRO_VERSIONS = ['1.39.5', '1.41.0'];
|
|
247
|
+
if (DEPRECATED_MAESTRO_VERSIONS.includes(resolvedMaestroVersion)) {
|
|
248
|
+
warnOut(ui.warn(colors.bold(`Maestro ${resolvedMaestroVersion} is deprecated`)));
|
|
249
|
+
warnOut(ui.branch([
|
|
250
|
+
`Maestro ${resolvedMaestroVersion} will be removed on 26 June 2026; after that, tests pinned to it will fail.`,
|
|
251
|
+
'Upgrade to Maestro 2.6.0 or above.',
|
|
252
|
+
`${colors.dim('See:')} ${colors.url('https://docs.devicecloud.dev/configuration/maestro-versions')}`,
|
|
253
|
+
]));
|
|
241
254
|
}
|
|
242
255
|
if (retry !== undefined && retry > 2) {
|
|
243
|
-
out(
|
|
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.'));
|
|
256
|
+
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.'));
|
|
245
257
|
retry = 2;
|
|
246
258
|
}
|
|
247
259
|
if (runnerType === 'm4') {
|
|
248
|
-
out(
|
|
249
|
-
styling_1.colors.dim('Note: runnerType m4 is experimental and currently supports iOS only, Android will revert to default.'));
|
|
260
|
+
out(ui.info('runnerType m4 is experimental and currently supports iOS only, Android will revert to default.'));
|
|
250
261
|
}
|
|
251
262
|
if (runnerType === 'm1') {
|
|
252
|
-
out(
|
|
253
|
-
styling_1.colors.dim('Note: runnerType m1 is experimental and currently supports Android (Pixel 7, API Level 34) only.'));
|
|
263
|
+
out(ui.info('runnerType m1 is experimental and currently supports Android (Pixel 7, API Level 34) only.'));
|
|
254
264
|
}
|
|
255
265
|
if (runnerType === 'gpu1') {
|
|
256
|
-
out(
|
|
257
|
-
styling_1.colors.dim('Note: runnerType gpu1 is Android-only (all devices, API Level 34 or 35), available to all users.'));
|
|
266
|
+
out(ui.info('runnerType gpu1 is Android-only (all devices, API Level 34 or 35), available to all users.'));
|
|
258
267
|
}
|
|
259
268
|
const firstFile = args.firstFile;
|
|
260
269
|
const secondFile = args.secondFile;
|
|
@@ -262,21 +271,28 @@ exports.cloudCommand = (0, citty_1.defineCommand)({
|
|
|
262
271
|
let finalAppFile = appFile ?? (appUrl ?? firstFile);
|
|
263
272
|
let flowFile = flows ?? secondFile;
|
|
264
273
|
if (finalAppFile && !appBinaryId) {
|
|
265
|
-
if (
|
|
266
|
-
out(` ${
|
|
267
|
-
const tarPath = await
|
|
274
|
+
if (isUrl(finalAppFile)) {
|
|
275
|
+
out(` ${colors.dim('→ Downloading Expo build from URL...')}`);
|
|
276
|
+
const tarPath = await downloadExpoUrl(finalAppFile, debug);
|
|
268
277
|
tempFiles.push(tarPath);
|
|
269
278
|
finalAppFile = tarPath;
|
|
270
279
|
}
|
|
271
280
|
if (finalAppFile.endsWith('.tar.gz')) {
|
|
272
|
-
out(` ${
|
|
273
|
-
const extractDir = await
|
|
281
|
+
out(` ${colors.dim('→ Extracting Expo archive...')}`);
|
|
282
|
+
const extractDir = await extractTarGz(finalAppFile, debug);
|
|
274
283
|
tempFiles.push(extractDir);
|
|
275
|
-
finalAppFile = await
|
|
284
|
+
finalAppFile = await findAppBundle(extractDir);
|
|
276
285
|
if (debug) {
|
|
277
286
|
out(`[DEBUG] Found .app bundle at: ${finalAppFile}`);
|
|
278
287
|
}
|
|
279
288
|
}
|
|
289
|
+
// Validate the resolved local app file early — dry-run otherwise skips
|
|
290
|
+
// the upload that would surface a missing file, so a typo'd path would
|
|
291
|
+
// pass a dry-run and only fail on the real run. (URL/.tar.gz inputs are
|
|
292
|
+
// already resolved to existing temp paths by this point.)
|
|
293
|
+
if (!existsSync(finalAppFile)) {
|
|
294
|
+
throw new CliError(`App file does not exist: ${finalAppFile}`);
|
|
295
|
+
}
|
|
280
296
|
}
|
|
281
297
|
if (debug) {
|
|
282
298
|
out(`[DEBUG] First file argument: ${firstFile || 'not provided'}`);
|
|
@@ -287,7 +303,7 @@ exports.cloudCommand = (0, citty_1.defineCommand)({
|
|
|
287
303
|
}
|
|
288
304
|
if (appBinaryId) {
|
|
289
305
|
if (secondFile) {
|
|
290
|
-
throw new
|
|
306
|
+
throw new CliError('You cannot provide both an appBinaryId and a binary file');
|
|
291
307
|
}
|
|
292
308
|
flowFile = flows ?? firstFile;
|
|
293
309
|
}
|
|
@@ -297,12 +313,24 @@ exports.cloudCommand = (0, citty_1.defineCommand)({
|
|
|
297
313
|
flowFile = firstFile;
|
|
298
314
|
}
|
|
299
315
|
if (!flowFile) {
|
|
300
|
-
throw new
|
|
316
|
+
throw new CliError('You must provide a flow file');
|
|
301
317
|
}
|
|
302
318
|
deviceValidationService.validateiOSDevice(iOSVersion, iOSDevice, compatibilityData, {
|
|
303
319
|
debug,
|
|
304
320
|
logger: (m) => out(m),
|
|
305
321
|
});
|
|
322
|
+
// iOS 16 deprecation notice (soft warning during the grace period;
|
|
323
|
+
// removed on 23 August 2026). Only fires on an explicit --ios-version 16 —
|
|
324
|
+
// when omitted the API defaults to iOS 17, so no false warning.
|
|
325
|
+
const DEPRECATED_IOS_VERSIONS = ['16'];
|
|
326
|
+
if (iOSVersion && DEPRECATED_IOS_VERSIONS.includes(iOSVersion)) {
|
|
327
|
+
warnOut(ui.warn(colors.bold('iOS 16 is deprecated')));
|
|
328
|
+
warnOut(ui.branch([
|
|
329
|
+
'iOS 16 will be removed on 23 August 2026; after that, tests targeting it will fail.',
|
|
330
|
+
'Switch to iOS 17 or newer — iPhone 14 also supports 17 and 18.',
|
|
331
|
+
`${colors.dim('See:')} ${colors.url('https://docs.devicecloud.dev/getting-started/devices-configuration')}`,
|
|
332
|
+
]));
|
|
333
|
+
}
|
|
306
334
|
deviceValidationService.validateAndroidDevice(androidApiLevel, androidDevice, googlePlay, compatibilityData, { debug, logger: (m) => out(m) });
|
|
307
335
|
if (maestroChromeOnboarding && !androidApiLevel && !androidDevice) {
|
|
308
336
|
warnOut('The --maestro-chrome-onboarding flag only applies to Android tests and will be ignored for iOS tests.');
|
|
@@ -321,7 +349,7 @@ exports.cloudCommand = (0, citty_1.defineCommand)({
|
|
|
321
349
|
if (debug) {
|
|
322
350
|
out('[DEBUG] Generating execution plan...');
|
|
323
351
|
}
|
|
324
|
-
executionPlan = await
|
|
352
|
+
executionPlan = await plan({
|
|
325
353
|
input: flowFile,
|
|
326
354
|
includeTags,
|
|
327
355
|
excludeTags,
|
|
@@ -349,42 +377,11 @@ exports.cloudCommand = (0, citty_1.defineCommand)({
|
|
|
349
377
|
out(`[DEBUG] All exclude tags: ${allExcludeTags?.join(', ') || 'none'}`);
|
|
350
378
|
out(`[DEBUG] Test file names: ${testFileNames.join(', ')}`);
|
|
351
379
|
}
|
|
352
|
-
const
|
|
353
|
-
...testFileNames,
|
|
354
|
-
...referencedFiles,
|
|
355
|
-
].sort((a, b) => a.split(path.sep).length - b.split(path.sep).length);
|
|
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
|
-
}
|
|
370
|
-
}
|
|
371
|
-
const commonRoot = shortestSegments.slice(0, matchedSegments).join(path.sep);
|
|
380
|
+
const commonRoot = computeCommonRoot(testFileNames, referencedFiles);
|
|
372
381
|
if (debug) {
|
|
373
382
|
out(`[DEBUG] Common root directory: ${commonRoot}`);
|
|
374
383
|
}
|
|
375
|
-
const testMetadataMap =
|
|
376
|
-
for (const [absolutePath, meta] of Object.entries(flowMetadata)) {
|
|
377
|
-
const normalizedPath = (0, paths_1.toPortableRelativePath)(absolutePath, commonRoot);
|
|
378
|
-
const metadataRecord = meta;
|
|
379
|
-
const flowName = metadataRecord?.name || path.parse(absolutePath).name;
|
|
380
|
-
const rawTags = metadataRecord?.tags;
|
|
381
|
-
const tags = Array.isArray(rawTags)
|
|
382
|
-
? rawTags.map(String)
|
|
383
|
-
: rawTags
|
|
384
|
-
? [String(rawTags)]
|
|
385
|
-
: [];
|
|
386
|
-
testMetadataMap[normalizedPath] = { flowName, tags };
|
|
387
|
-
}
|
|
384
|
+
const testMetadataMap = buildTestMetadataMap(flowMetadata, commonRoot);
|
|
388
385
|
if (debug) {
|
|
389
386
|
out(`[DEBUG] Built testMetadataMap for ${Object.keys(testMetadataMap).length} flows`);
|
|
390
387
|
}
|
|
@@ -395,16 +392,16 @@ exports.cloudCommand = (0, citty_1.defineCommand)({
|
|
|
395
392
|
}
|
|
396
393
|
if (!appBinaryId) {
|
|
397
394
|
if (!(flowFile && finalAppFile)) {
|
|
398
|
-
throw new
|
|
395
|
+
throw new CliError('You must provide a flow file and an app binary id');
|
|
399
396
|
}
|
|
400
397
|
if (!['.apk', '.app', '.zip', '.tar.gz'].some((ext) => finalAppFile.endsWith(ext))) {
|
|
401
|
-
throw new
|
|
398
|
+
throw new CliError('App file must be a .apk for Android, .app/.zip for iOS, or .tar.gz (Expo iOS build)');
|
|
402
399
|
}
|
|
403
400
|
if (finalAppFile.endsWith('.zip')) {
|
|
404
401
|
if (debug) {
|
|
405
402
|
out(`[DEBUG] Verifying iOS app zip file: ${finalAppFile}`);
|
|
406
403
|
}
|
|
407
|
-
await
|
|
404
|
+
await verifyAppZip(finalAppFile);
|
|
408
405
|
}
|
|
409
406
|
}
|
|
410
407
|
const flagLogs = [];
|
|
@@ -417,10 +414,21 @@ exports.cloudCommand = (0, citty_1.defineCommand)({
|
|
|
417
414
|
'appUrl',
|
|
418
415
|
]);
|
|
419
416
|
// Only log canonical flag keys (skip citty-populated alias duplicates like apiURL/apiUrl).
|
|
420
|
-
const canonicalFlagKeys = new Set(Object.keys(
|
|
417
|
+
const canonicalFlagKeys = new Set(Object.keys(allFlags));
|
|
418
|
+
// Repeatable flags are recovered from rawArgs (args.* only holds the last
|
|
419
|
+
// occurrence), so echo the fully-collected values rather than args.*.
|
|
420
|
+
const repeatableDisplay = {
|
|
421
|
+
env,
|
|
422
|
+
metadata,
|
|
423
|
+
'include-tags': includeTags,
|
|
424
|
+
'exclude-tags': excludeTags,
|
|
425
|
+
'exclude-flows': excludeFlows,
|
|
426
|
+
};
|
|
421
427
|
for (const [k, v] of Object.entries(args)) {
|
|
422
428
|
if (!canonicalFlagKeys.has(k))
|
|
423
429
|
continue;
|
|
430
|
+
if (k in repeatableDisplay)
|
|
431
|
+
continue;
|
|
424
432
|
if (v === undefined || v === null || v === false)
|
|
425
433
|
continue;
|
|
426
434
|
const asString = String(v);
|
|
@@ -428,61 +436,53 @@ exports.cloudCommand = (0, citty_1.defineCommand)({
|
|
|
428
436
|
flagLogs.push(`${k}: ${asString}`);
|
|
429
437
|
}
|
|
430
438
|
}
|
|
439
|
+
for (const [k, values] of Object.entries(repeatableDisplay)) {
|
|
440
|
+
if (values.length > 0)
|
|
441
|
+
flagLogs.push(`${k}: ${values.join(', ')}`);
|
|
442
|
+
}
|
|
431
443
|
const overridesEntries = Object.entries(flowOverrides);
|
|
432
444
|
const hasOverrides = overridesEntries.some(([, overrides]) => Object.keys(overrides).length > 0);
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
if (Object.keys(overrides).length > 0) {
|
|
438
|
-
const relativePath = flowPath.replace(process.cwd(), '.');
|
|
439
|
-
overridesLog += `\n ${styling_1.colors.dim('→')} ${relativePath}:`;
|
|
440
|
-
for (const [key, value] of Object.entries(overrides)) {
|
|
441
|
-
overridesLog += `\n ${styling_1.colors.dim(key + ':')} ${styling_1.colors.highlight(String(value))}`;
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
}
|
|
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 || '')}`);
|
|
445
|
+
const submitRows = ui.fields([
|
|
446
|
+
['flow(s)', colors.highlight(flowFile)],
|
|
447
|
+
['app', colors.highlight(appBinaryId || finalAppFile || '')],
|
|
448
|
+
]);
|
|
449
449
|
if (flagLogs.length > 0) {
|
|
450
|
-
|
|
451
|
-
|
|
450
|
+
submitRows.push('', colors.bold('Options'));
|
|
451
|
+
submitRows.push(...ui.fields(flagLogs.map((flagLog) => {
|
|
452
452
|
const [key, ...valueParts] = flagLog.split(': ');
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
}
|
|
453
|
+
return [key, colors.highlight(valueParts.join(': '))];
|
|
454
|
+
})));
|
|
456
455
|
}
|
|
457
456
|
if (hasOverrides) {
|
|
458
|
-
|
|
457
|
+
submitRows.push('', colors.bold('Overrides'));
|
|
458
|
+
for (const [flowPath, overrides] of overridesEntries) {
|
|
459
|
+
if (Object.keys(overrides).length === 0) {
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
submitRows.push(colors.dim(`${flowPath.replace(process.cwd(), '.')}:`));
|
|
463
|
+
submitRows.push(...ui.fields(Object.entries(overrides).map(([key, value]) => [key, colors.highlight(String(value))])));
|
|
464
|
+
}
|
|
459
465
|
}
|
|
460
|
-
out('');
|
|
466
|
+
out(ui.section('Submitting new job'));
|
|
467
|
+
out(ui.branch(submitRows));
|
|
461
468
|
if (dryRun) {
|
|
462
|
-
out(
|
|
463
|
-
out(
|
|
464
|
-
out(
|
|
465
|
-
for (const test of testFileNames) {
|
|
466
|
-
out((0, styling_1.listItem)(test));
|
|
467
|
-
}
|
|
469
|
+
out(ui.warn(`${colors.bold('Dry run mode')} ${colors.dim('— no tests were actually triggered')}`));
|
|
470
|
+
out(ui.section('The following tests would have been run'));
|
|
471
|
+
out(ui.branch(testFileNames));
|
|
468
472
|
if (sequentialFlows.length > 0) {
|
|
469
|
-
out(
|
|
470
|
-
out(
|
|
471
|
-
for (const flow of sequentialFlows) {
|
|
472
|
-
out((0, styling_1.listItem)(flow));
|
|
473
|
-
}
|
|
473
|
+
out(ui.section('Sequential flows'));
|
|
474
|
+
out(ui.branch(sequentialFlows));
|
|
474
475
|
}
|
|
475
|
-
out('\n');
|
|
476
476
|
return;
|
|
477
477
|
}
|
|
478
478
|
if (!finalBinaryId) {
|
|
479
479
|
if (!finalAppFile) {
|
|
480
|
-
throw new
|
|
480
|
+
throw new CliError('You must provide either an app binary id or an app file');
|
|
481
481
|
}
|
|
482
482
|
if (debug) {
|
|
483
483
|
out(`[DEBUG] Uploading binary file: ${finalAppFile}`);
|
|
484
484
|
}
|
|
485
|
-
const binaryId = await
|
|
485
|
+
const binaryId = await uploadBinary({
|
|
486
486
|
auth,
|
|
487
487
|
apiUrl,
|
|
488
488
|
debug,
|
|
@@ -496,7 +496,7 @@ exports.cloudCommand = (0, citty_1.defineCommand)({
|
|
|
496
496
|
}
|
|
497
497
|
}
|
|
498
498
|
if (!finalBinaryId) {
|
|
499
|
-
throw new
|
|
499
|
+
throw new CliError('Internal error: finalBinaryId should be defined after validation');
|
|
500
500
|
}
|
|
501
501
|
const ghMetadataOverrides = [];
|
|
502
502
|
if (ghBranch)
|
|
@@ -510,7 +510,7 @@ exports.cloudCommand = (0, citty_1.defineCommand)({
|
|
|
510
510
|
if (ghPrUrl)
|
|
511
511
|
ghMetadataOverrides.push(`gh_pr_url=${ghPrUrl}`);
|
|
512
512
|
const mergedMetadata = [...ghMetadataOverrides, ...metadata];
|
|
513
|
-
const
|
|
513
|
+
const { buffer, fields } = await testSubmissionService.buildTestPayload({
|
|
514
514
|
androidApiLevel,
|
|
515
515
|
androidDevice,
|
|
516
516
|
androidNoSnapshot,
|
|
@@ -541,31 +541,63 @@ exports.cloudCommand = (0, citty_1.defineCommand)({
|
|
|
541
541
|
maestroChromeOnboarding,
|
|
542
542
|
disableAnimations,
|
|
543
543
|
});
|
|
544
|
-
|
|
545
|
-
|
|
544
|
+
// New path: upload the zip directly to storage, then submit a JSON test
|
|
545
|
+
// referencing it. Older API deployments lack these endpoints — a real API
|
|
546
|
+
// 404s (route undefined), some proxies 405 (path/method not allowed); in
|
|
547
|
+
// either case fall back to the legacy multipart POST /uploads/flow, which
|
|
548
|
+
// is byte-identical bar the storage reference.
|
|
549
|
+
let response;
|
|
550
|
+
try {
|
|
551
|
+
const storageRef = await uploadFlowZip({ apiUrl, auth, buffer, debug });
|
|
552
|
+
if (debug) {
|
|
553
|
+
out(`[DEBUG] Flow zip uploaded (id=${storageRef.id}, supabase=${storageRef.supabaseSuccess}, backblaze=${storageRef.backblazeSuccess})`);
|
|
554
|
+
out(`[DEBUG] Submitting flow test to ${apiUrl}/uploads/submitFlowTest`);
|
|
555
|
+
}
|
|
556
|
+
response = await ApiGateway.submitFlowTest(apiUrl, auth, {
|
|
557
|
+
...fields,
|
|
558
|
+
...storageRef,
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
catch (error) {
|
|
562
|
+
if (error instanceof ApiError &&
|
|
563
|
+
(error.status === 404 || error.status === 405)) {
|
|
564
|
+
if (debug) {
|
|
565
|
+
out(`[DEBUG] Client-direct flow upload unavailable (HTTP ${error.status}); falling back to multipart ${apiUrl}/uploads/flow`);
|
|
566
|
+
}
|
|
567
|
+
const testFormData = testSubmissionService.buildFormData(fields, buffer);
|
|
568
|
+
response = await ApiGateway.uploadFlow(apiUrl, auth, testFormData);
|
|
569
|
+
}
|
|
570
|
+
else {
|
|
571
|
+
throw error;
|
|
572
|
+
}
|
|
546
573
|
}
|
|
547
|
-
const { message, results } =
|
|
574
|
+
const { message, results } = response;
|
|
548
575
|
if (debug) {
|
|
549
|
-
out(`[DEBUG] Flow
|
|
576
|
+
out(`[DEBUG] Flow submission response received`);
|
|
550
577
|
out(`[DEBUG] Message: ${message}`);
|
|
551
578
|
out(`[DEBUG] Results count: ${results?.length || 0}`);
|
|
552
579
|
}
|
|
553
580
|
if (!results?.length) {
|
|
554
|
-
throw new
|
|
581
|
+
throw new CliError('No tests created: ' + message);
|
|
555
582
|
}
|
|
556
|
-
out(`${
|
|
583
|
+
out(`${ui.success('Submitted')} ${colors.dim(message)}`);
|
|
557
584
|
const testNames = results
|
|
558
585
|
.map((r) => r.test_file_name)
|
|
559
586
|
.sort((a, b) => a.localeCompare(b))
|
|
560
|
-
.join(
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
out(
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
587
|
+
.join(colors.dim(', '));
|
|
588
|
+
const url = getConsoleUrl(apiUrl, results[0].test_upload_id, results[0].id);
|
|
589
|
+
out(ui.section(`Created ${results.length} test${results.length === 1 ? '' : 's'}`));
|
|
590
|
+
out(ui.branch([
|
|
591
|
+
testNames,
|
|
592
|
+
...ui.fields([
|
|
593
|
+
['results', formatUrl(url)],
|
|
594
|
+
['upload id', formatId(results[0].test_upload_id)],
|
|
595
|
+
[
|
|
596
|
+
'poll status',
|
|
597
|
+
colors.info(`dcd status --upload-id ${results[0].test_upload_id}`),
|
|
598
|
+
],
|
|
599
|
+
]),
|
|
600
|
+
]));
|
|
569
601
|
if (async) {
|
|
570
602
|
if (debug) {
|
|
571
603
|
out(`[DEBUG] Async flag is set, not waiting for results`);
|
|
@@ -585,7 +617,7 @@ exports.cloudCommand = (0, citty_1.defineCommand)({
|
|
|
585
617
|
};
|
|
586
618
|
if (jsonFileFlag) {
|
|
587
619
|
const jsonFilePath = jsonFileName || `${results[0].test_upload_id}_dcd.json`;
|
|
588
|
-
|
|
620
|
+
writeJSONFile(jsonFilePath, jsonOutput, {
|
|
589
621
|
log: (m) => out(m),
|
|
590
622
|
warn: (m) => warnOut(m),
|
|
591
623
|
});
|
|
@@ -595,7 +627,7 @@ exports.cloudCommand = (0, citty_1.defineCommand)({
|
|
|
595
627
|
console.log(JSON.stringify(jsonOutput, null, 2));
|
|
596
628
|
return;
|
|
597
629
|
}
|
|
598
|
-
out(
|
|
630
|
+
out(ui.info('Not waiting for results as async flag is set to true'));
|
|
599
631
|
return;
|
|
600
632
|
}
|
|
601
633
|
const pollingResult = await resultsPollingService
|
|
@@ -610,11 +642,11 @@ exports.cloudCommand = (0, citty_1.defineCommand)({
|
|
|
610
642
|
uploadId: results[0].test_upload_id,
|
|
611
643
|
}, testMetadataMap)
|
|
612
644
|
.catch(async (error) => {
|
|
613
|
-
if (error instanceof
|
|
645
|
+
if (error instanceof RunFailedError) {
|
|
614
646
|
const jsonOutput = error.result;
|
|
615
647
|
if (jsonFileFlag) {
|
|
616
648
|
const jsonFilePath = jsonFileName || `${results[0].test_upload_id}_dcd.json`;
|
|
617
|
-
|
|
649
|
+
writeJSONFile(jsonFilePath, jsonOutput, {
|
|
618
650
|
log: (m) => out(m),
|
|
619
651
|
warn: (m) => warnOut(m),
|
|
620
652
|
});
|
|
@@ -690,7 +722,7 @@ exports.cloudCommand = (0, citty_1.defineCommand)({
|
|
|
690
722
|
const jsonOutput = pollingResult;
|
|
691
723
|
if (jsonFileFlag) {
|
|
692
724
|
const jsonFilePath = jsonFileName || `${results[0].test_upload_id}_dcd.json`;
|
|
693
|
-
|
|
725
|
+
writeJSONFile(jsonFilePath, jsonOutput, {
|
|
694
726
|
log: (m) => out(m),
|
|
695
727
|
warn: (m) => warnOut(m),
|
|
696
728
|
});
|
|
@@ -709,7 +741,7 @@ exports.cloudCommand = (0, citty_1.defineCommand)({
|
|
|
709
741
|
caughtError = error;
|
|
710
742
|
}
|
|
711
743
|
finally {
|
|
712
|
-
const fsp = await
|
|
744
|
+
const fsp = await import('node:fs/promises');
|
|
713
745
|
for (const p of tempFiles) {
|
|
714
746
|
await fsp.rm(p, { recursive: true, force: true }).catch(() => { });
|
|
715
747
|
}
|
|
@@ -723,12 +755,12 @@ exports.cloudCommand = (0, citty_1.defineCommand)({
|
|
|
723
755
|
// --json-file keeps exit 0 on a failed run (documented contract);
|
|
724
756
|
// otherwise 2 distinguishes test failure from infra errors (1).
|
|
725
757
|
const exitCode = jsonFile ? 0 : 2;
|
|
726
|
-
|
|
727
|
-
|
|
758
|
+
telemetry.recordCommandFailure({ error: 'RUN_FAILED', exitCode });
|
|
759
|
+
telemetry.flushSync();
|
|
728
760
|
process.exit(exitCode);
|
|
729
761
|
}
|
|
730
|
-
|
|
762
|
+
logger.error(caughtError, { exit: 1, json: jsonFlag });
|
|
731
763
|
}
|
|
732
764
|
},
|
|
733
765
|
});
|
|
734
|
-
|
|
766
|
+
export default cloudCommand;
|