@devicecloud.dev/dcd 5.5.0 → 5.6.0-beta.4
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 +4 -2
- package/dist/commands/artifacts.d.ts +1 -1
- package/dist/commands/artifacts.js +1 -1
- package/dist/commands/cloud.d.ts +9 -4
- package/dist/commands/cloud.js +25 -8
- package/dist/commands/switch-org.js +5 -1
- package/dist/commands/upload.js +5 -1
- package/dist/commands/whoami.d.ts +7 -1
- package/dist/commands/whoami.js +26 -2
- package/dist/config/flags/device.flags.d.ts +3 -3
- package/dist/config/flags/device.flags.js +5 -3
- package/dist/config/flags/execution.flags.d.ts +5 -0
- package/dist/config/flags/execution.flags.js +5 -0
- package/dist/config/flags/output.flags.d.ts +1 -1
- package/dist/config/flags/output.flags.js +1 -1
- package/dist/constants.d.ts +9 -4
- package/dist/gateways/api-gateway.js +2 -1
- package/dist/gateways/realtime-gateway.d.ts +7 -0
- package/dist/gateways/realtime-gateway.js +12 -1
- package/dist/mcp/context.d.ts +14 -3
- package/dist/mcp/context.js +15 -3
- package/dist/mcp/tools/download-artifacts.js +9 -6
- package/dist/mcp/tools/list-devices.js +1 -1
- package/dist/mcp/tools/run-cloud-test.js +20 -4
- package/dist/services/device-validation.service.d.ts +17 -2
- package/dist/services/device-validation.service.js +93 -12
- package/dist/services/execution-plan.service.d.ts +6 -0
- package/dist/services/execution-plan.service.js +98 -2
- package/dist/services/execution-plan.utils.js +69 -6
- package/dist/services/flow-paths.d.ts +9 -1
- package/dist/services/flow-paths.js +14 -2
- package/dist/services/notices.service.js +3 -0
- package/dist/services/report-download.service.js +9 -3
- package/dist/services/results-polling.service.d.ts +42 -1
- package/dist/services/results-polling.service.js +133 -7
- package/dist/services/test-submission.service.d.ts +18 -0
- package/dist/services/test-submission.service.js +30 -6
- package/dist/services/workspace-config.schema.d.ts +1 -0
- package/dist/services/workspace-config.schema.js +6 -0
- package/dist/types/domain/auth.types.d.ts +6 -0
- package/dist/types/domain/device.types.d.ts +18 -4
- package/dist/types/domain/device.types.js +17 -3
- package/dist/types/generated/schema.types.d.ts +127 -167
- package/dist/utils/auth.d.ts +33 -0
- package/dist/utils/auth.js +61 -2
- package/package.json +52 -12
|
@@ -4,9 +4,11 @@ import { ApiError, ApiGateway } from '../../gateways/api-gateway.js';
|
|
|
4
4
|
import { plan } from '../../services/execution-plan.service.js';
|
|
5
5
|
import { computeCommonRoot, buildTestMetadataMap } from '../../services/flow-paths.js';
|
|
6
6
|
import { DeviceValidationService } from '../../services/device-validation.service.js';
|
|
7
|
+
import { platformFromAppFile } from '../../services/notices.service.js';
|
|
7
8
|
import { TestSubmissionService } from '../../services/test-submission.service.js';
|
|
8
9
|
import { VersionService } from '../../services/version.service.js';
|
|
9
10
|
import { uploadBinary, uploadFlowZip, verifyAppZip } from '../../methods.js';
|
|
11
|
+
import { refreshAuth } from '../../utils/auth.js';
|
|
10
12
|
import { getCliVersion } from '../../utils/cli.js';
|
|
11
13
|
import { fetchCompatibilityData } from '../../utils/compatibility.js';
|
|
12
14
|
import { isEncryptionEnabled } from '../../utils/envelope.js';
|
|
@@ -100,8 +102,15 @@ export function registerRunCloudTest(server) {
|
|
|
100
102
|
const cliVersion = getCliVersion();
|
|
101
103
|
const compatibilityData = await fetchCompatibilityData(apiUrl, auth);
|
|
102
104
|
const deviceValidation = new DeviceValidationService();
|
|
103
|
-
|
|
104
|
-
|
|
105
|
+
// Hints in validation errors name this tool's parameters, not CLI flags.
|
|
106
|
+
const argNames = {
|
|
107
|
+
androidApiLevel: 'androidApiLevel',
|
|
108
|
+
androidDevice: 'androidDevice',
|
|
109
|
+
iOSDevice: 'iosDevice',
|
|
110
|
+
iOSVersion: 'iosVersion',
|
|
111
|
+
};
|
|
112
|
+
deviceValidation.validateiOSDevice(args.iosVersion, args.iosDevice, compatibilityData, { argNames, logger: logStderr });
|
|
113
|
+
deviceValidation.validateAndroidDevice(args.androidApiLevel, args.androidDevice, Boolean(args.googlePlay), compatibilityData, { argNames, logger: logStderr });
|
|
105
114
|
const resolvedMaestroVersion = new VersionService().resolveMaestroVersion(args.maestroVersion, compatibilityData, { logger: logStderr });
|
|
106
115
|
// Directory inputs must end in a separator so the planner treats them
|
|
107
116
|
// as a workspace rather than a single file.
|
|
@@ -120,7 +129,7 @@ export function registerRunCloudTest(server) {
|
|
|
120
129
|
// stdout is the JSON-RPC channel — config warnings must go to stderr.
|
|
121
130
|
warn: logStderr,
|
|
122
131
|
});
|
|
123
|
-
const commonRoot = computeCommonRoot(executionPlan.flowsToRun, executionPlan.referencedFiles);
|
|
132
|
+
const commonRoot = computeCommonRoot(executionPlan.flowsToRun, executionPlan.referencedFiles, executionPlan.includedFiles);
|
|
124
133
|
const testMetadataMap = buildTestMetadataMap(executionPlan.flowMetadata, commonRoot);
|
|
125
134
|
if (args.dryRun) {
|
|
126
135
|
return jsonResult({
|
|
@@ -162,6 +171,8 @@ export function registerRunCloudTest(server) {
|
|
|
162
171
|
const { buffer, fields } = await testSubmissionService.buildTestPayload({
|
|
163
172
|
apiUrl,
|
|
164
173
|
appBinaryId,
|
|
174
|
+
// Picks the workspace config's per-platform disableAnimations.
|
|
175
|
+
appPlatform: args.appBinaryId ? undefined : platformFromAppFile(args.appFile),
|
|
165
176
|
cliVersion,
|
|
166
177
|
commonRoot,
|
|
167
178
|
continueOnFailure,
|
|
@@ -221,8 +232,13 @@ export function registerRunCloudTest(server) {
|
|
|
221
232
|
// Bounded poll. The run continues in the cloud regardless of timeout —
|
|
222
233
|
// the caller can resume with dcd_get_status using the returned uploadId.
|
|
223
234
|
const deadline = Date.now() + (args.waitTimeoutSeconds ?? 600) * 1000;
|
|
235
|
+
let pollAuth = auth;
|
|
224
236
|
for (;;) {
|
|
225
|
-
|
|
237
|
+
// A wait of up to an hour can outlast a `dcd login` access token.
|
|
238
|
+
pollAuth = await refreshAuth(pollAuth);
|
|
239
|
+
const status = await ApiGateway.getUploadStatus(apiUrl, pollAuth, {
|
|
240
|
+
uploadId,
|
|
241
|
+
});
|
|
226
242
|
if (TERMINAL_STATUSES.has(status.status)) {
|
|
227
243
|
return jsonResult({ uploadId, consoleUrl, status: status.status, tests: status.tests });
|
|
228
244
|
}
|
|
@@ -1,10 +1,25 @@
|
|
|
1
|
-
import { CompatibilityData } from '../utils/compatibility.js';
|
|
1
|
+
import type { CompatibilityData } from '../utils/compatibility.js';
|
|
2
|
+
/** The device / OS inputs a caller exposes, named for "pass it explicitly" hints. */
|
|
3
|
+
export interface DeviceArgNames {
|
|
4
|
+
androidApiLevel: string;
|
|
5
|
+
androidDevice: string;
|
|
6
|
+
iOSDevice: string;
|
|
7
|
+
iOSVersion: string;
|
|
8
|
+
}
|
|
2
9
|
export interface DeviceValidationOptions {
|
|
10
|
+
/** How the caller names its inputs in error hints. Defaults to the CLI flags. */
|
|
11
|
+
argNames?: DeviceArgNames;
|
|
3
12
|
debug?: boolean;
|
|
4
13
|
logger?: (message: string) => void;
|
|
5
14
|
}
|
|
6
15
|
/**
|
|
7
|
-
*
|
|
16
|
+
* Validates requested devices against the compatibility matrix the API serves
|
|
17
|
+
* from `GET /results/compatibility/data`, which is the only authority on what
|
|
18
|
+
* can run. Nothing here consults the CLI's device and OS enums
|
|
19
|
+
* (src/types/domain/device.types.ts) — those only feed `--help` — so a device
|
|
20
|
+
* or OS version the API adds is accepted without a CLI release, and one it
|
|
21
|
+
* withdraws is refused, before anything is uploaded, with the list the API
|
|
22
|
+
* offers today.
|
|
8
23
|
*/
|
|
9
24
|
export declare class DeviceValidationService {
|
|
10
25
|
/**
|
|
@@ -1,5 +1,35 @@
|
|
|
1
|
+
const DEVICES_DOCS_URL = 'https://docs.devicecloud.dev/getting-started/devices-configuration';
|
|
2
|
+
/** `dcd cloud`'s spelling; the MCP tool passes its own parameter names. */
|
|
3
|
+
const CLI_ARG_NAMES = {
|
|
4
|
+
androidApiLevel: '--android-api-level',
|
|
5
|
+
androidDevice: '--android-device',
|
|
6
|
+
iOSDevice: '--ios-device',
|
|
7
|
+
iOSVersion: '--ios-version',
|
|
8
|
+
};
|
|
9
|
+
/** The versions a lookup offers for one device; empty when it offers none. */
|
|
10
|
+
function versionsFor(lookup, device) {
|
|
11
|
+
// hasOwn, so a slug like "constructor" can't resolve to a prototype member.
|
|
12
|
+
if (!lookup || !Object.hasOwn(lookup, device))
|
|
13
|
+
return [];
|
|
14
|
+
const versions = lookup[device];
|
|
15
|
+
return Array.isArray(versions) ? versions : [];
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Devices the lookup can actually run, in the API's order. A rollout gate can
|
|
19
|
+
* leave a device with no versions (every non-Play device has an empty Google
|
|
20
|
+
* Play list), and those must not be offered as alternatives.
|
|
21
|
+
*/
|
|
22
|
+
function offeredDevices(lookup) {
|
|
23
|
+
return Object.keys(lookup ?? {}).filter((device) => versionsFor(lookup, device).length > 0);
|
|
24
|
+
}
|
|
1
25
|
/**
|
|
2
|
-
*
|
|
26
|
+
* Validates requested devices against the compatibility matrix the API serves
|
|
27
|
+
* from `GET /results/compatibility/data`, which is the only authority on what
|
|
28
|
+
* can run. Nothing here consults the CLI's device and OS enums
|
|
29
|
+
* (src/types/domain/device.types.ts) — those only feed `--help` — so a device
|
|
30
|
+
* or OS version the API adds is accepted without a CLI release, and one it
|
|
31
|
+
* withdraws is refused, before anything is uploaded, with the list the API
|
|
32
|
+
* offers today.
|
|
3
33
|
*/
|
|
4
34
|
export class DeviceValidationService {
|
|
5
35
|
/**
|
|
@@ -13,6 +43,10 @@ export class DeviceValidationService {
|
|
|
13
43
|
* @throws Error if device/API level combination is not supported
|
|
14
44
|
*/
|
|
15
45
|
validateAndroidDevice(androidApiLevel, androidDevice, googlePlay, compatibilityData, options = {}) {
|
|
46
|
+
const lookup = googlePlay
|
|
47
|
+
? compatibilityData.androidPlay
|
|
48
|
+
: compatibilityData.android;
|
|
49
|
+
const argNames = options.argNames ?? CLI_ARG_NAMES;
|
|
16
50
|
this.validateDevice({
|
|
17
51
|
debugLines: (deviceID, version, supportedVersions) => [
|
|
18
52
|
`[DEBUG] Android device: ${deviceID}`,
|
|
@@ -20,16 +54,27 @@ export class DeviceValidationService {
|
|
|
20
54
|
`[DEBUG] Google Play enabled: ${googlePlay}`,
|
|
21
55
|
`[DEBUG] Supported Android versions: ${supportedVersions.join(', ')}`,
|
|
22
56
|
],
|
|
57
|
+
// Mirrors the API, which applies these same global defaults to a
|
|
58
|
+
// request that omits either half (test-request-validator.service.ts)
|
|
59
|
+
// and then rejects an incompatible pair.
|
|
23
60
|
defaultDevice: 'pixel-7',
|
|
24
61
|
defaultVersion: '34',
|
|
25
62
|
device: androidDevice,
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
63
|
+
deviceArg: argNames.androidDevice,
|
|
64
|
+
deviceKind: googlePlay ? 'Google Play' : 'Android',
|
|
65
|
+
formatVersion: (version) => `API level ${version}`,
|
|
66
|
+
lookup,
|
|
30
67
|
options,
|
|
68
|
+
platform: 'Android',
|
|
69
|
+
// A real device that merely has no Google Play image deserves a sharper
|
|
70
|
+
// answer than "not supported".
|
|
71
|
+
unknownDeviceMessage: (deviceID) => googlePlay && versionsFor(compatibilityData.android, deviceID).length > 0
|
|
72
|
+
? `Android device "${deviceID}" is not available with Google Play. Google Play devices: ${offeredDevices(lookup).join(', ')}. See ${DEVICES_DOCS_URL}`
|
|
73
|
+
: undefined,
|
|
31
74
|
unsupportedVersionMessage: (deviceID, supportedVersions) => `${deviceID} ${googlePlay ? '(Play Store) ' : ''}only supports these Android API levels: ${supportedVersions.join(', ')}`,
|
|
32
75
|
version: androidApiLevel,
|
|
76
|
+
versionArg: argNames.androidApiLevel,
|
|
77
|
+
versionName: 'Android API level',
|
|
33
78
|
});
|
|
34
79
|
}
|
|
35
80
|
/**
|
|
@@ -42,20 +87,27 @@ export class DeviceValidationService {
|
|
|
42
87
|
* @throws Error if device/version combination is not supported
|
|
43
88
|
*/
|
|
44
89
|
validateiOSDevice(iOSVersion, iOSDevice, compatibilityData, options = {}) {
|
|
90
|
+
const argNames = options.argNames ?? CLI_ARG_NAMES;
|
|
45
91
|
this.validateDevice({
|
|
46
92
|
debugLines: (deviceID, version, supportedVersions) => [
|
|
47
93
|
`[DEBUG] iOS device: ${deviceID}`,
|
|
48
94
|
`[DEBUG] iOS version: ${version}`,
|
|
49
95
|
`[DEBUG] Supported iOS versions: ${supportedVersions.join(', ')}`,
|
|
50
96
|
],
|
|
97
|
+
// Mirrors the API's global defaults, as for Android above.
|
|
51
98
|
defaultDevice: 'iphone-14',
|
|
52
99
|
defaultVersion: '17',
|
|
53
100
|
device: iOSDevice,
|
|
101
|
+
deviceArg: argNames.iOSDevice,
|
|
102
|
+
deviceKind: 'iOS',
|
|
103
|
+
formatVersion: (version) => `iOS ${version}`,
|
|
54
104
|
lookup: compatibilityData?.ios,
|
|
55
|
-
noSupportMessage: (deviceID) => `Device ${deviceID} is not supported. Please check the docs for supported devices: https://docs.devicecloud.dev/getting-started/devices-configuration`,
|
|
56
105
|
options,
|
|
106
|
+
platform: 'iOS',
|
|
57
107
|
unsupportedVersionMessage: (deviceID, supportedVersions) => `${deviceID} only supports these iOS versions: ${supportedVersions.join(', ')}`,
|
|
58
108
|
version: iOSVersion,
|
|
109
|
+
versionArg: argNames.iOSVersion,
|
|
110
|
+
versionName: 'iOS version',
|
|
59
111
|
});
|
|
60
112
|
}
|
|
61
113
|
/**
|
|
@@ -66,20 +118,49 @@ export class DeviceValidationService {
|
|
|
66
118
|
* @throws Error if device/version combination is not supported
|
|
67
119
|
*/
|
|
68
120
|
validateDevice(config) {
|
|
69
|
-
const { debugLines, defaultDevice, defaultVersion, device,
|
|
121
|
+
const { debugLines, defaultDevice, defaultVersion, device, deviceArg, deviceKind, formatVersion, lookup, options, platform, unknownDeviceMessage, unsupportedVersionMessage, version, versionArg, versionName, } = config;
|
|
70
122
|
const { debug = false, logger } = options;
|
|
71
123
|
if (!version && !device) {
|
|
72
124
|
return;
|
|
73
125
|
}
|
|
74
126
|
const deviceID = device || defaultDevice;
|
|
75
|
-
const supportedVersions = lookup
|
|
127
|
+
const supportedVersions = versionsFor(lookup, deviceID);
|
|
76
128
|
const requestedVersion = version || defaultVersion;
|
|
77
129
|
if (supportedVersions.length === 0) {
|
|
78
|
-
|
|
130
|
+
if (!device) {
|
|
131
|
+
// Only a version was requested, and the API no longer offers the
|
|
132
|
+
// device the CLI assumes it defaults to. That assumption is what's
|
|
133
|
+
// stale, not the request: the API resolves and validates its own
|
|
134
|
+
// default device when the run is submitted.
|
|
135
|
+
if (debug && logger) {
|
|
136
|
+
logger(`[DEBUG] Default ${platform} device ${deviceID} is not in the compatibility data; leaving the ${versionName} check to the API`);
|
|
137
|
+
}
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const offered = offeredDevices(lookup);
|
|
141
|
+
throw new Error(unknownDeviceMessage?.(deviceID) ??
|
|
142
|
+
(offered.length > 0
|
|
143
|
+
? `${platform} device "${deviceID}" is not supported. Supported ${deviceKind} devices: ${offered.join(', ')}. See ${DEVICES_DOCS_URL}`
|
|
144
|
+
: `${platform} device "${deviceID}" is not supported. See ${DEVICES_DOCS_URL}`));
|
|
79
145
|
}
|
|
80
|
-
if (
|
|
81
|
-
|
|
82
|
-
|
|
146
|
+
if (!supportedVersions.includes(requestedVersion)) {
|
|
147
|
+
const unsupported = unsupportedVersionMessage(deviceID, supportedVersions);
|
|
148
|
+
if (!version) {
|
|
149
|
+
// Device-only request. The API fills in the same global default
|
|
150
|
+
// version rather than one this device runs, so the submission would
|
|
151
|
+
// be refused there too — say so, and how to avoid it.
|
|
152
|
+
throw new Error(`${unsupported}. No ${versionName} was given, so the default (${defaultVersion}) was checked: pass one of those explicitly with ${versionArg}`);
|
|
153
|
+
}
|
|
154
|
+
if (!device) {
|
|
155
|
+
// Version-only request: name the device that was assumed, and the
|
|
156
|
+
// devices that do offer the requested version.
|
|
157
|
+
const devicesWithVersion = offeredDevices(lookup).filter((d) => versionsFor(lookup, d).includes(requestedVersion));
|
|
158
|
+
throw new Error(`${unsupported}. No ${platform} device was given, so the default (${deviceID}) was checked: ` +
|
|
159
|
+
(devicesWithVersion.length > 0
|
|
160
|
+
? `pass one that offers ${formatVersion(requestedVersion)} with ${deviceArg}: ${devicesWithVersion.join(', ')}`
|
|
161
|
+
: `no ${deviceKind} device offers ${formatVersion(requestedVersion)}`));
|
|
162
|
+
}
|
|
163
|
+
throw new Error(unsupported);
|
|
83
164
|
}
|
|
84
165
|
if (debug && logger) {
|
|
85
166
|
for (const line of debugLines(deviceID, requestedVersion, supportedVersions)) {
|
|
@@ -21,6 +21,12 @@ export interface IExecutionPlan {
|
|
|
21
21
|
flowMetadata: Record<string, Record<string, unknown>>;
|
|
22
22
|
flowOverrides: Record<string, Record<string, unknown>>;
|
|
23
23
|
flowsToRun: string[];
|
|
24
|
+
/**
|
|
25
|
+
* Extra files pulled in by `config.yaml`'s `includedPaths`. Kept separate
|
|
26
|
+
* from `referencedFiles` (which is derived from flow commands) so the zip
|
|
27
|
+
* manifest and the common-root calculation can tell the two apart.
|
|
28
|
+
*/
|
|
29
|
+
includedFiles: string[];
|
|
24
30
|
referencedFiles: string[];
|
|
25
31
|
sequence?: IFlowSequence | null;
|
|
26
32
|
totalFlowFiles: number;
|
|
@@ -90,7 +90,7 @@ function extractDeviceCloudOverrides(config) {
|
|
|
90
90
|
* @param resolvedConfigFile - Optional absolute path to a custom config file
|
|
91
91
|
* @returns Execution plan for the single file with dependencies
|
|
92
92
|
*/
|
|
93
|
-
async function planSingleFile(normalizedInput, warn, resolvedConfigFile) {
|
|
93
|
+
async function planSingleFile(normalizedInput, warn, resolvedConfigFile, debug = false) {
|
|
94
94
|
const inputBasename = path.basename(normalizedInput);
|
|
95
95
|
if (inputBasename === 'config.yaml' ||
|
|
96
96
|
inputBasename === 'config.yml' ||
|
|
@@ -118,11 +118,18 @@ async function planSingleFile(normalizedInput, warn, resolvedConfigFile) {
|
|
|
118
118
|
`Pass the workspace folder instead so the named flows can be discovered and sequenced.`);
|
|
119
119
|
}
|
|
120
120
|
}
|
|
121
|
+
// A single-file input has no workspace directory, so `includedPaths` (which
|
|
122
|
+
// only reaches here via --config) anchors on the flow file's own directory —
|
|
123
|
+
// the same place Maestro resolves an assertScreenshot baseline from.
|
|
124
|
+
const includedFiles = workspaceConfig
|
|
125
|
+
? resolveIncludedPaths(workspaceConfig, path.dirname(normalizedInput), warn, debug)
|
|
126
|
+
: [];
|
|
121
127
|
const checkedDependancies = await checkDependencies(normalizedInput);
|
|
122
128
|
return {
|
|
123
129
|
flowMetadata,
|
|
124
130
|
flowOverrides,
|
|
125
131
|
flowsToRun: [normalizedInput],
|
|
132
|
+
includedFiles,
|
|
126
133
|
referencedFiles: [...new Set(checkedDependancies)],
|
|
127
134
|
totalFlowFiles: 1,
|
|
128
135
|
workspaceConfig,
|
|
@@ -176,6 +183,94 @@ async function applyFlowGlobs(workspaceConfig, normalizedInput, unfilteredFlowFi
|
|
|
176
183
|
}
|
|
177
184
|
return unfilteredFlowFiles.filter((file) => !isExcludedConfig(file));
|
|
178
185
|
}
|
|
186
|
+
/**
|
|
187
|
+
* The whole archive is buffered in memory by `compressFilesFromRelativePath`,
|
|
188
|
+
* so an unbounded `**` glob is a real footgun. These are warn thresholds, not
|
|
189
|
+
* hard limits — a legitimately large baseline set should still upload.
|
|
190
|
+
*/
|
|
191
|
+
const INCLUDED_PATHS_FILE_WARN_THRESHOLD = 200;
|
|
192
|
+
const INCLUDED_PATHS_BYTES_WARN_THRESHOLD = 50 * 1024 * 1024;
|
|
193
|
+
/**
|
|
194
|
+
* Resolve `config.yaml`'s `includedPaths` globs into absolute file paths.
|
|
195
|
+
*
|
|
196
|
+
* This is the general-purpose escape hatch for shipping files the flow
|
|
197
|
+
* commands don't reference: `assertScreenshot` baselines above all, but also
|
|
198
|
+
* fixtures, test data and certificates. Only `addMedia` / `runFlow` /
|
|
199
|
+
* `runScript` arguments are discovered by walking the flows, so without this
|
|
200
|
+
* key such files are silently absent from the uploaded zip.
|
|
201
|
+
*
|
|
202
|
+
* Glob semantics deliberately mirror `flows:` (`applyFlowGlobs`): patterns
|
|
203
|
+
* resolve against the workspace root, never the config file's directory, so
|
|
204
|
+
* `--config ci/workspace.yaml` behaves identically to an auto-detected config.
|
|
205
|
+
*
|
|
206
|
+
* @param workspaceConfig - Validated workspace config
|
|
207
|
+
* @param normalizedInput - Normalized path to the workspace directory
|
|
208
|
+
* @param warn - Sink for non-fatal problems
|
|
209
|
+
* @param debug - Whether to emit debug logging
|
|
210
|
+
* @returns Absolute paths of every matched file, deduped and sorted
|
|
211
|
+
* @throws Error if a pattern escapes the workspace root
|
|
212
|
+
*/
|
|
213
|
+
function resolveIncludedPaths(workspaceConfig, normalizedInput, warn, debug = false) {
|
|
214
|
+
const patterns = workspaceConfig.includedPaths;
|
|
215
|
+
if (!patterns || patterns.length === 0)
|
|
216
|
+
return [];
|
|
217
|
+
const workspaceRoot = path.resolve(normalizedInput);
|
|
218
|
+
const resolved = new Set();
|
|
219
|
+
const unmatched = [];
|
|
220
|
+
for (const pattern of patterns) {
|
|
221
|
+
// fs.globSync lands in Node 22; the CLI's `engines.node` already requires
|
|
222
|
+
// it. No `nodir` option — directories are stripped by the stat check below.
|
|
223
|
+
const matches = fs.globSync(pattern, { cwd: normalizedInput });
|
|
224
|
+
let matchedFile = false;
|
|
225
|
+
for (const match of matches) {
|
|
226
|
+
const absolute = path.resolve(normalizedInput, match);
|
|
227
|
+
// Containment guard: `flows:` has none because its matches are only ever
|
|
228
|
+
// parsed as YAML, but this key ships arbitrary bytes to a remote runner,
|
|
229
|
+
// so a `../../../` climb must not silently leave the workspace.
|
|
230
|
+
const relative = path.relative(workspaceRoot, absolute);
|
|
231
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
232
|
+
throw new Error(`\`includedPaths\` pattern "${pattern}" resolves outside the workspace: ${absolute}\n\n` +
|
|
233
|
+
`Included paths must stay within ${workspaceRoot}.`);
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
if (!fs.statSync(absolute).isFile())
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
matchedFile = true;
|
|
243
|
+
resolved.add(absolute);
|
|
244
|
+
}
|
|
245
|
+
if (!matchedFile)
|
|
246
|
+
unmatched.push(pattern);
|
|
247
|
+
}
|
|
248
|
+
if (unmatched.length > 0) {
|
|
249
|
+
warn(`Warning: \`includedPaths\` pattern(s) in config matched no files:\n` +
|
|
250
|
+
`${unmatched.map((pattern) => ` ${pattern}`).join('\n')}\n\n` +
|
|
251
|
+
`Patterns are resolved relative to ${workspaceRoot}.`);
|
|
252
|
+
}
|
|
253
|
+
const files = [...resolved].sort((a, b) => a.localeCompare(b));
|
|
254
|
+
let totalBytes = 0;
|
|
255
|
+
for (const file of files) {
|
|
256
|
+
try {
|
|
257
|
+
totalBytes += fs.statSync(file).size;
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
// Raced away between glob and stat; the zip step reports it properly.
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (files.length > INCLUDED_PATHS_FILE_WARN_THRESHOLD ||
|
|
264
|
+
totalBytes > INCLUDED_PATHS_BYTES_WARN_THRESHOLD) {
|
|
265
|
+
warn(`Warning: \`includedPaths\` matched ${files.length} file(s) totalling ` +
|
|
266
|
+
`${Math.round(totalBytes / (1024 * 1024))} MB. The flow archive is built in ` +
|
|
267
|
+
`memory, so consider narrowing the patterns.`);
|
|
268
|
+
}
|
|
269
|
+
if (debug) {
|
|
270
|
+
console.log(`[DEBUG] includedPaths matched ${files.length} file(s):\n${files.join('\n')}`);
|
|
271
|
+
}
|
|
272
|
+
return files;
|
|
273
|
+
}
|
|
179
274
|
/**
|
|
180
275
|
* Resolve sequential execution order from workspace config
|
|
181
276
|
* @param workspaceConfig - Workspace configuration with executionOrder
|
|
@@ -241,7 +336,7 @@ export async function plan(options) {
|
|
|
241
336
|
throw new Error(`Flow path does not exist: ${path.resolve(normalizedInput)}`);
|
|
242
337
|
}
|
|
243
338
|
if (fs.lstatSync(normalizedInput).isFile()) {
|
|
244
|
-
return planSingleFile(normalizedInput, warn, resolvedConfigFile);
|
|
339
|
+
return planSingleFile(normalizedInput, warn, resolvedConfigFile, debug);
|
|
245
340
|
}
|
|
246
341
|
let unfilteredFlowFiles = await readDirectory(normalizedInput, isFlowFile);
|
|
247
342
|
if (unfilteredFlowFiles.length === 0) {
|
|
@@ -325,6 +420,7 @@ export async function plan(options) {
|
|
|
325
420
|
flowMetadata,
|
|
326
421
|
flowOverrides,
|
|
327
422
|
flowsToRun: normalFlows,
|
|
423
|
+
includedFiles: resolveIncludedPaths(workspaceConfig, normalizedInput, warn, debug),
|
|
328
424
|
referencedFiles: [...new Set(allFiles)],
|
|
329
425
|
sequence: {
|
|
330
426
|
continueOnFailure: workspaceConfig.executionOrder?.continueOnFailure,
|
|
@@ -2,7 +2,50 @@ import * as yaml from 'js-yaml';
|
|
|
2
2
|
import * as fs from 'node:fs';
|
|
3
3
|
import * as path from 'node:path';
|
|
4
4
|
import { parseWorkspaceConfig, WORKSPACE_CONFIG_KEYS, } from './workspace-config.schema.js';
|
|
5
|
-
const commandsThatRequireFiles = new Set([
|
|
5
|
+
const commandsThatRequireFiles = new Set([
|
|
6
|
+
'addMedia',
|
|
7
|
+
'assertScreenshot',
|
|
8
|
+
'runFlow',
|
|
9
|
+
'runScript',
|
|
10
|
+
]);
|
|
11
|
+
/**
|
|
12
|
+
* Commands whose file references are best-effort rather than mandatory.
|
|
13
|
+
*
|
|
14
|
+
* `assertScreenshot` baselines are legitimately absent on a first run, and
|
|
15
|
+
* Maestro's own "Screenshot file not found — searched in: …" error is more
|
|
16
|
+
* useful than ours, so a missing baseline must not abort the upload the way a
|
|
17
|
+
* missing `addMedia` file does.
|
|
18
|
+
*/
|
|
19
|
+
const commandsWithOptionalFiles = new Set(['assertScreenshot']);
|
|
20
|
+
/**
|
|
21
|
+
* Extensions Maestro's `normalizeScreenshotPath` recognises; anything else
|
|
22
|
+
* gets `.png` appended, so `assertScreenshot: home` means `home.png`.
|
|
23
|
+
*/
|
|
24
|
+
const SCREENSHOT_EXTENSIONS = new Set([
|
|
25
|
+
'.bmp',
|
|
26
|
+
'.gif',
|
|
27
|
+
'.heic',
|
|
28
|
+
'.heif',
|
|
29
|
+
'.jpeg',
|
|
30
|
+
'.jpg',
|
|
31
|
+
'.png',
|
|
32
|
+
'.tiff',
|
|
33
|
+
'.wbmp',
|
|
34
|
+
]);
|
|
35
|
+
/**
|
|
36
|
+
* Mirror Maestro's `Orchestra.normalizeScreenshotPath`: a screenshot path with
|
|
37
|
+
* no image extension gets `.png`. Without this, `assertScreenshot: home` looks
|
|
38
|
+
* like a missing file here while resolving fine on the device.
|
|
39
|
+
*
|
|
40
|
+
* @param relativePath - The path as written in the flow
|
|
41
|
+
* @returns The path with an image extension guaranteed
|
|
42
|
+
*/
|
|
43
|
+
function normalizeScreenshotPath(relativePath) {
|
|
44
|
+
const extension = path.extname(relativePath).toLowerCase();
|
|
45
|
+
return SCREENSHOT_EXTENSIONS.has(extension)
|
|
46
|
+
? relativePath
|
|
47
|
+
: `${relativePath}.png`;
|
|
48
|
+
}
|
|
6
49
|
export function getFlowsToRunInSequence(paths, flowOrder, debug = false) {
|
|
7
50
|
if (flowOrder.length === 0) {
|
|
8
51
|
if (debug) {
|
|
@@ -145,12 +188,27 @@ export const checkIfFilesExistInWorkspace = (commandName, command, absoluteFileP
|
|
|
145
188
|
const errors = [];
|
|
146
189
|
const files = [];
|
|
147
190
|
const directory = path.dirname(absoluteFilePath);
|
|
191
|
+
const isScreenshot = commandName === 'assertScreenshot';
|
|
192
|
+
const isOptional = commandsWithOptionalFiles.has(commandName);
|
|
148
193
|
const buildError = (error) => `Flow file "${absoluteFilePath}" has a command "${commandName}" that references a ${error} ${JSON.stringify(command)}`;
|
|
149
194
|
const processFilePath = (relativePath) => {
|
|
150
|
-
|
|
195
|
+
// A JS/variable-interpolated path (`screenshots/${DCD_DEVICE}/home`) can't
|
|
196
|
+
// be resolved without running the flow. Skip it rather than guessing — the
|
|
197
|
+
// config.yaml `includedPaths` key is how those files get bundled.
|
|
198
|
+
if (relativePath.includes('${'))
|
|
199
|
+
return;
|
|
200
|
+
const resolvedRelativePath = isScreenshot
|
|
201
|
+
? normalizeScreenshotPath(relativePath)
|
|
202
|
+
: relativePath;
|
|
203
|
+
const absoluteFilePath = path.normalize(path.resolve(directory, resolvedRelativePath));
|
|
151
204
|
const error = checkFile(absoluteFilePath);
|
|
152
|
-
if (error)
|
|
205
|
+
if (error) {
|
|
206
|
+
// Optional references drop out entirely when missing: pushing them onto
|
|
207
|
+
// `files` would put a non-existent path into the zip manifest.
|
|
208
|
+
if (isOptional)
|
|
209
|
+
return;
|
|
153
210
|
errors.push(buildError(error));
|
|
211
|
+
}
|
|
154
212
|
files.push(absoluteFilePath);
|
|
155
213
|
};
|
|
156
214
|
// simple command — processFilePath already resolves against `directory`
|
|
@@ -163,10 +221,15 @@ export const checkIfFilesExistInWorkspace = (commandName, command, absoluteFileP
|
|
|
163
221
|
processFilePath(file);
|
|
164
222
|
}
|
|
165
223
|
}
|
|
166
|
-
// object command
|
|
224
|
+
// object command. `file` is addMedia/runFlow/runScript; `path` is
|
|
225
|
+
// assertScreenshot's own key for the same thing.
|
|
167
226
|
const x = command; // prevent annoying ts error
|
|
168
|
-
if (typeof command === 'object' &&
|
|
169
|
-
|
|
227
|
+
if (typeof command === 'object' && !Array.isArray(command)) {
|
|
228
|
+
if (x?.file)
|
|
229
|
+
processFilePath(x.file);
|
|
230
|
+
if (isScreenshot && typeof x?.path === 'string')
|
|
231
|
+
processFilePath(x.path);
|
|
232
|
+
}
|
|
170
233
|
return { errors, files };
|
|
171
234
|
};
|
|
172
235
|
const checkFile = (filePath) => {
|
|
@@ -3,8 +3,16 @@
|
|
|
3
3
|
* file path. Segment comparison (not `startsWith`) so sibling dirs like
|
|
4
4
|
* `flows`/`flows-extra` can't merge, and the file segment itself is never
|
|
5
5
|
* consumed. Returns '' when the paths share no root at all (or none are given).
|
|
6
|
+
*
|
|
7
|
+
* `includedFiles` (config.yaml `includedPaths`) must be folded in for the same
|
|
8
|
+
* reason referenced files are: the zip strips this root as an anchored prefix,
|
|
9
|
+
* so a file outside it would get a non-relative entry name. Folding them in
|
|
10
|
+
* can raise the root — flows in `flows/` beside baselines in `screenshots/`
|
|
11
|
+
* shifts it from `<root>/flows` to `<root>`, so flow keys gain a `flows/`
|
|
12
|
+
* segment. That shift is what preserves the flow→baseline relative offset
|
|
13
|
+
* Maestro resolves against, and is already how `addMedia` behaves.
|
|
6
14
|
*/
|
|
7
|
-
export declare function computeCommonRoot(testFileNames: string[], referencedFiles: string[]): string;
|
|
15
|
+
export declare function computeCommonRoot(testFileNames: string[], referencedFiles: string[], includedFiles?: string[]): string;
|
|
8
16
|
export interface FlowMetadataEntry {
|
|
9
17
|
flowName: string;
|
|
10
18
|
tags: string[];
|
|
@@ -12,9 +12,21 @@ import { toPortableRelativePath } from '../utils/paths.js';
|
|
|
12
12
|
* file path. Segment comparison (not `startsWith`) so sibling dirs like
|
|
13
13
|
* `flows`/`flows-extra` can't merge, and the file segment itself is never
|
|
14
14
|
* consumed. Returns '' when the paths share no root at all (or none are given).
|
|
15
|
+
*
|
|
16
|
+
* `includedFiles` (config.yaml `includedPaths`) must be folded in for the same
|
|
17
|
+
* reason referenced files are: the zip strips this root as an anchored prefix,
|
|
18
|
+
* so a file outside it would get a non-relative entry name. Folding them in
|
|
19
|
+
* can raise the root — flows in `flows/` beside baselines in `screenshots/`
|
|
20
|
+
* shifts it from `<root>/flows` to `<root>`, so flow keys gain a `flows/`
|
|
21
|
+
* segment. That shift is what preserves the flow→baseline relative offset
|
|
22
|
+
* Maestro resolves against, and is already how `addMedia` behaves.
|
|
15
23
|
*/
|
|
16
|
-
export function computeCommonRoot(testFileNames, referencedFiles) {
|
|
17
|
-
const pathsShortestToLongest = [
|
|
24
|
+
export function computeCommonRoot(testFileNames, referencedFiles, includedFiles = []) {
|
|
25
|
+
const pathsShortestToLongest = [
|
|
26
|
+
...testFileNames,
|
|
27
|
+
...referencedFiles,
|
|
28
|
+
...includedFiles,
|
|
29
|
+
].sort((a, b) => a.split(path.sep).length - b.split(path.sep).length);
|
|
18
30
|
if (pathsShortestToLongest.length === 0)
|
|
19
31
|
return '';
|
|
20
32
|
const splitPaths = pathsShortestToLongest.map((p) => p.split(path.sep));
|
|
@@ -80,6 +80,9 @@ function renderNotice(notice, opts) {
|
|
|
80
80
|
export function platformFromAppFile(appFile) {
|
|
81
81
|
if (!appFile)
|
|
82
82
|
return undefined;
|
|
83
|
+
// Expo iOS simulator builds (`dcd cloud` extracts these to a .app first).
|
|
84
|
+
if (appFile.split('?')[0].toLowerCase().endsWith('.tar.gz'))
|
|
85
|
+
return 'ios';
|
|
83
86
|
const ext = appFile.split('?')[0].toLowerCase().match(/.([a-z0-9]+)$/)?.[1];
|
|
84
87
|
if (ext === 'apk' || ext === 'aab')
|
|
85
88
|
return 'android';
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as path from 'node:path';
|
|
2
2
|
import { ApiGateway } from '../gateways/api-gateway.js';
|
|
3
|
+
import { refreshAuth } from '../utils/auth.js';
|
|
3
4
|
/**
|
|
4
5
|
* Service for downloading test artifacts and reports
|
|
5
6
|
*/
|
|
@@ -15,7 +16,9 @@ export class ReportDownloadService {
|
|
|
15
16
|
if (debug && logger) {
|
|
16
17
|
logger(`[DEBUG] Downloading artifacts: ${downloadType}`);
|
|
17
18
|
}
|
|
18
|
-
|
|
19
|
+
// `dcd cloud` downloads after polling, which can outlast a `dcd login`
|
|
20
|
+
// token; a failed refresh lands in the warning below like any failure.
|
|
21
|
+
await ApiGateway.downloadArtifactsZip(apiUrl, await refreshAuth(auth), uploadId, downloadType, artifactsPath);
|
|
19
22
|
if (logger) {
|
|
20
23
|
logger('\n');
|
|
21
24
|
logger(`Test artifacts have been downloaded to ${artifactsPath}`);
|
|
@@ -54,7 +57,9 @@ export class ReportDownloadService {
|
|
|
54
57
|
}
|
|
55
58
|
case 'html':
|
|
56
59
|
case 'html-detailed': {
|
|
57
|
-
|
|
60
|
+
// The HTML report is a ZIP (report.html plus its screenshots and
|
|
61
|
+
// assets), so the default name says so. An explicit path is kept as-is.
|
|
62
|
+
const htmlReportPath = path.resolve(process.cwd(), htmlPath || 'report.zip');
|
|
58
63
|
await this.downloadReport('html', htmlReportPath, {
|
|
59
64
|
...downloadOptions,
|
|
60
65
|
warnLogger,
|
|
@@ -81,7 +86,8 @@ export class ReportDownloadService {
|
|
|
81
86
|
if (debug && logger) {
|
|
82
87
|
logger(`[DEBUG] Downloading ${type.toUpperCase()} report`);
|
|
83
88
|
}
|
|
84
|
-
|
|
89
|
+
// As for artifacts: this can run after a poll that outlasted the token.
|
|
90
|
+
await ApiGateway.downloadReportGeneric(apiUrl, await refreshAuth(auth), uploadId, type, filePath);
|
|
85
91
|
if (logger) {
|
|
86
92
|
logger(`${type.toUpperCase()} test report has been downloaded to ${filePath}`);
|
|
87
93
|
}
|