@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.
- package/README.md +75 -2
- package/dist/commands/artifacts.d.ts +47 -18
- package/dist/commands/artifacts.js +69 -64
- package/dist/commands/cloud.d.ts +228 -88
- package/dist/commands/cloud.js +430 -342
- package/dist/commands/list.d.ts +39 -38
- package/dist/commands/list.js +124 -131
- package/dist/commands/live.d.ts +2 -0
- package/dist/commands/live.js +520 -0
- package/dist/commands/login.d.ts +17 -0
- package/dist/commands/login.js +252 -0
- package/dist/commands/logout.d.ts +2 -0
- package/dist/commands/logout.js +30 -0
- package/dist/commands/status.d.ts +23 -42
- package/dist/commands/status.js +170 -179
- package/dist/commands/switch-org.d.ts +12 -0
- package/dist/commands/switch-org.js +76 -0
- package/dist/commands/upgrade.d.ts +2 -0
- package/dist/commands/upgrade.js +120 -0
- package/dist/commands/upload.d.ts +33 -18
- package/dist/commands/upload.js +72 -78
- package/dist/commands/whoami.d.ts +2 -0
- package/dist/commands/whoami.js +31 -0
- package/dist/config/environments.d.ts +31 -0
- package/dist/config/environments.js +52 -0
- package/dist/config/flags/api.flags.d.ts +10 -2
- package/dist/config/flags/api.flags.js +13 -14
- package/dist/config/flags/binary.flags.d.ts +17 -4
- package/dist/config/flags/binary.flags.js +14 -18
- package/dist/config/flags/device.flags.d.ts +49 -11
- package/dist/config/flags/device.flags.js +43 -38
- package/dist/config/flags/environment.flags.d.ts +27 -6
- package/dist/config/flags/environment.flags.js +24 -29
- package/dist/config/flags/execution.flags.d.ts +35 -8
- package/dist/config/flags/execution.flags.js +31 -41
- package/dist/config/flags/github.flags.d.ts +23 -5
- package/dist/config/flags/github.flags.js +19 -15
- package/dist/config/flags/output.flags.d.ts +57 -13
- package/dist/config/flags/output.flags.js +48 -47
- package/dist/constants.d.ts +218 -51
- package/dist/constants.js +17 -20
- package/dist/gateways/api-gateway.d.ts +72 -16
- package/dist/gateways/api-gateway.js +298 -104
- package/dist/gateways/cli-auth-gateway.d.ts +13 -0
- package/dist/gateways/cli-auth-gateway.js +54 -0
- package/dist/gateways/realtime-gateway.d.ts +32 -0
- package/dist/gateways/realtime-gateway.js +103 -0
- package/dist/gateways/supabase-gateway.d.ts +11 -11
- package/dist/gateways/supabase-gateway.js +20 -48
- package/dist/index.d.ts +2 -1
- package/dist/index.js +98 -4
- 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 +34 -5
- package/dist/methods.js +266 -215
- package/dist/services/device-validation.service.d.ts +9 -1
- package/dist/services/device-validation.service.js +56 -40
- package/dist/services/execution-plan.service.js +40 -31
- package/dist/services/execution-plan.utils.d.ts +3 -0
- package/dist/services/execution-plan.utils.js +25 -55
- package/dist/services/flow-paths.d.ts +17 -0
- package/dist/services/flow-paths.js +52 -0
- package/dist/services/metadata-extractor.service.d.ts +0 -2
- package/dist/services/metadata-extractor.service.js +75 -78
- package/dist/services/moropo.service.js +33 -34
- package/dist/services/report-download.service.d.ts +12 -1
- package/dist/services/report-download.service.js +34 -27
- package/dist/services/results-polling.service.d.ts +23 -9
- package/dist/services/results-polling.service.js +257 -123
- package/dist/services/telemetry.service.d.ts +49 -0
- package/dist/services/telemetry.service.js +252 -0
- package/dist/services/test-submission.service.d.ts +21 -4
- package/dist/services/test-submission.service.js +51 -33
- package/dist/services/version.service.d.ts +4 -3
- package/dist/services/version.service.js +28 -16
- package/dist/types/domain/auth.types.d.ts +20 -0
- package/dist/types/domain/auth.types.js +1 -0
- package/dist/types/domain/device.types.js +8 -11
- package/dist/types/domain/live.types.d.ts +76 -0
- package/dist/types/domain/live.types.js +3 -0
- 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 +13 -0
- package/dist/utils/auth.js +141 -0
- package/dist/utils/ci.d.ts +12 -0
- package/dist/utils/ci.js +39 -0
- package/dist/utils/cli.d.ts +35 -0
- package/dist/utils/cli.js +118 -0
- package/dist/utils/compatibility.d.ts +2 -1
- package/dist/utils/compatibility.js +6 -8
- package/dist/utils/config-store.d.ts +35 -0
- package/dist/utils/config-store.js +115 -0
- package/dist/utils/connectivity.js +8 -7
- package/dist/utils/expo.js +29 -24
- package/dist/utils/orgs.d.ts +11 -0
- package/dist/utils/orgs.js +36 -0
- package/dist/utils/paths.d.ts +11 -0
- package/dist/utils/paths.js +21 -0
- package/dist/utils/progress.d.ts +13 -0
- package/dist/utils/progress.js +47 -0
- package/dist/utils/styling.d.ts +42 -36
- package/dist/utils/styling.js +78 -82
- package/dist/utils/ui.d.ts +41 -0
- package/dist/utils/ui.js +95 -0
- package/package.json +36 -45
- package/bin/dev.cmd +0 -3
- package/bin/dev.js +0 -6
- package/bin/run.cmd +0 -3
- package/bin/run.js +0 -7
- package/dist/types/schema.types.d.ts +0 -2702
- package/dist/types/schema.types.js +0 -3
- package/oclif.manifest.json +0 -884
package/dist/commands/cloud.js
CHANGED
|
@@ -1,30 +1,43 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
1
|
/* eslint-disable complexity */
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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 (
|
|
164
|
+
if (jsonFileFlag) {
|
|
112
165
|
quiet = true;
|
|
113
|
-
|
|
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
|
-
|
|
195
|
+
warnOut(`WARNING: You are using node version ${major}. DeviceCloud requires node version 20 or later`);
|
|
128
196
|
if (major < 18) {
|
|
129
|
-
throw new
|
|
197
|
+
throw new CliError('Invalid node version');
|
|
130
198
|
}
|
|
131
199
|
}
|
|
132
|
-
await
|
|
133
|
-
// Download and expand Moropo zip if API key is present
|
|
200
|
+
await versionCheck();
|
|
134
201
|
if (moropoApiKey) {
|
|
135
|
-
flows = await
|
|
202
|
+
flows = await moropoService.downloadAndExtract({
|
|
136
203
|
apiKey: moropoApiKey,
|
|
137
204
|
branchName: 'main',
|
|
138
205
|
debug,
|
|
139
206
|
json,
|
|
140
|
-
logger:
|
|
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
|
|
220
|
+
compatibilityData = await fetchCompatibilityData(apiUrl, auth);
|
|
151
221
|
if (debug) {
|
|
152
|
-
|
|
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
|
-
|
|
228
|
+
out(`[DEBUG] Failed to fetch compatibility data from API: ${errorMessage}`);
|
|
159
229
|
}
|
|
160
|
-
throw new
|
|
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
|
-
|
|
233
|
+
out(`[DEBUG] API URL: ${apiUrl}`);
|
|
164
234
|
}
|
|
165
|
-
|
|
166
|
-
const resolvedMaestroVersion = this.versionService.resolveMaestroVersion(maestroVersion, compatibilityData, {
|
|
235
|
+
const resolvedMaestroVersion = versionService.resolveMaestroVersion(maestroVersion, compatibilityData, {
|
|
167
236
|
debug,
|
|
168
|
-
logger:
|
|
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
|
-
|
|
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
|
-
|
|
176
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
255
|
+
out(ui.info('runnerType gpu1 is Android-only (all devices, API Level 34 or 35), available to all users.'));
|
|
194
256
|
}
|
|
195
|
-
const
|
|
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 (
|
|
202
|
-
|
|
203
|
-
const tarPath = await
|
|
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
|
-
|
|
209
|
-
const extractDir = await
|
|
270
|
+
out(` ${colors.dim('→ Extracting Expo archive...')}`);
|
|
271
|
+
const extractDir = await extractTarGz(finalAppFile, debug);
|
|
210
272
|
tempFiles.push(extractDir);
|
|
211
|
-
finalAppFile = await
|
|
273
|
+
finalAppFile = await findAppBundle(extractDir);
|
|
212
274
|
if (debug) {
|
|
213
|
-
|
|
275
|
+
out(`[DEBUG] Found .app bundle at: ${finalAppFile}`);
|
|
214
276
|
}
|
|
215
277
|
}
|
|
216
278
|
}
|
|
217
279
|
if (debug) {
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
|
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
|
|
298
|
+
throw new CliError('You must provide a flow file');
|
|
232
299
|
}
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
if (
|
|
239
|
-
|
|
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
|
-
|
|
315
|
+
out(`[DEBUG] Resolved flow file path: ${flowFile}`);
|
|
249
316
|
}
|
|
250
317
|
let executionPlan;
|
|
251
318
|
try {
|
|
252
319
|
if (debug) {
|
|
253
|
-
|
|
320
|
+
out('[DEBUG] Generating execution plan...');
|
|
254
321
|
}
|
|
255
|
-
executionPlan = await
|
|
322
|
+
executionPlan = await plan({
|
|
256
323
|
input: flowFile,
|
|
257
|
-
includeTags
|
|
258
|
-
excludeTags
|
|
259
|
-
excludeFlows
|
|
324
|
+
includeTags,
|
|
325
|
+
excludeTags,
|
|
326
|
+
excludeFlows,
|
|
260
327
|
configFile,
|
|
261
328
|
debug,
|
|
262
329
|
});
|
|
263
330
|
if (debug) {
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
316
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
372
|
+
out(`[DEBUG] Verifying iOS app zip file: ${finalAppFile}`);
|
|
328
373
|
}
|
|
329
|
-
await
|
|
374
|
+
await verifyAppZip(finalAppFile);
|
|
330
375
|
}
|
|
331
376
|
}
|
|
332
377
|
const flagLogs = [];
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
360
|
-
|
|
405
|
+
submitRows.push('', colors.bold('Options'));
|
|
406
|
+
submitRows.push(...ui.fields(flagLogs.map((flagLog) => {
|
|
361
407
|
const [key, ...valueParts] = flagLog.split(': ');
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
}
|
|
408
|
+
return [key, colors.highlight(valueParts.join(': '))];
|
|
409
|
+
})));
|
|
365
410
|
}
|
|
366
411
|
if (hasOverrides) {
|
|
367
|
-
|
|
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
|
-
|
|
421
|
+
out(ui.section('Submitting new job'));
|
|
422
|
+
out(ui.branch(submitRows));
|
|
370
423
|
if (dryRun) {
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
379
|
-
|
|
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
|
|
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
|
-
|
|
438
|
+
out(`[DEBUG] Uploading binary file: ${finalAppFile}`);
|
|
392
439
|
}
|
|
393
|
-
const binaryId = await
|
|
394
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
422
|
-
const
|
|
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
|
|
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:
|
|
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
|
|
451
|
-
maestroChromeOnboarding
|
|
452
|
-
disableAnimations
|
|
495
|
+
showCrosshairs,
|
|
496
|
+
maestroChromeOnboarding,
|
|
497
|
+
disableAnimations,
|
|
453
498
|
});
|
|
454
|
-
|
|
455
|
-
|
|
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 } =
|
|
529
|
+
const { message, results } = response;
|
|
458
530
|
if (debug) {
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
-
|
|
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(
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
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 ||
|
|
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 (
|
|
573
|
+
if (jsonFileFlag) {
|
|
494
574
|
const jsonFilePath = jsonFileName || `${results[0].test_upload_id}_dcd.json`;
|
|
495
|
-
|
|
575
|
+
writeJSONFile(jsonFilePath, jsonOutput, {
|
|
576
|
+
log: (m) => out(m),
|
|
577
|
+
warn: (m) => warnOut(m),
|
|
578
|
+
});
|
|
496
579
|
}
|
|
497
580
|
if (json) {
|
|
498
|
-
|
|
581
|
+
// eslint-disable-next-line no-console
|
|
582
|
+
console.log(JSON.stringify(jsonOutput, null, 2));
|
|
583
|
+
return;
|
|
499
584
|
}
|
|
500
|
-
|
|
585
|
+
out(ui.info('Not waiting for results as async flag is set to true'));
|
|
501
586
|
return;
|
|
502
587
|
}
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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:
|
|
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
|
|
517
|
-
// Handle failed test run
|
|
600
|
+
if (error instanceof RunFailedError) {
|
|
518
601
|
const jsonOutput = error.result;
|
|
519
|
-
if (
|
|
602
|
+
if (jsonFileFlag) {
|
|
520
603
|
const jsonFilePath = jsonFileName || `${results[0].test_upload_id}_dcd.json`;
|
|
521
|
-
|
|
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
|
-
//
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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
|
|
560
|
-
|
|
652
|
+
await reportDownloadService.downloadArtifacts({
|
|
653
|
+
auth,
|
|
561
654
|
apiUrl,
|
|
562
655
|
artifactsPath,
|
|
563
656
|
debug,
|
|
564
657
|
downloadType: downloadArtifacts,
|
|
565
|
-
logger:
|
|
658
|
+
logger: (m) => out(m),
|
|
566
659
|
uploadId: results[0].test_upload_id,
|
|
567
|
-
warnLogger:
|
|
660
|
+
warnLogger: (m) => warnOut(m),
|
|
568
661
|
});
|
|
569
662
|
}
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
await this.reportDownloadService.downloadReports({
|
|
663
|
+
if (report) {
|
|
664
|
+
await reportDownloadService.downloadReports({
|
|
573
665
|
allurePath,
|
|
574
|
-
|
|
666
|
+
auth,
|
|
575
667
|
apiUrl,
|
|
576
668
|
debug,
|
|
577
669
|
htmlPath,
|
|
578
670
|
junitPath,
|
|
579
|
-
logger:
|
|
671
|
+
logger: (m) => out(m),
|
|
580
672
|
reportType: report,
|
|
581
673
|
uploadId: results[0].test_upload_id,
|
|
582
|
-
warnLogger:
|
|
674
|
+
warnLogger: (m) => warnOut(m),
|
|
583
675
|
});
|
|
584
676
|
}
|
|
585
677
|
const jsonOutput = pollingResult;
|
|
586
|
-
if (
|
|
678
|
+
if (jsonFileFlag) {
|
|
587
679
|
const jsonFilePath = jsonFileName || `${results[0].test_upload_id}_dcd.json`;
|
|
588
|
-
|
|
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
|
-
|
|
597
|
-
|
|
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
|
-
|
|
699
|
+
const fsp = await import('node:fs/promises');
|
|
612
700
|
for (const p of tempFiles) {
|
|
613
|
-
await
|
|
701
|
+
await fsp.rm(p, { recursive: true, force: true }).catch(() => { });
|
|
614
702
|
}
|
|
615
703
|
if (output) {
|
|
616
|
-
// eslint-disable-next-line no-
|
|
617
|
-
|
|
704
|
+
// eslint-disable-next-line no-console
|
|
705
|
+
console.log(JSON.stringify(output, null, 2));
|
|
618
706
|
}
|
|
619
707
|
}
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
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
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
exports.default = Cloud;
|
|
719
|
+
},
|
|
720
|
+
});
|
|
721
|
+
export default cloudCommand;
|