@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.
Files changed (46) hide show
  1. package/README.md +4 -2
  2. package/dist/commands/artifacts.d.ts +1 -1
  3. package/dist/commands/artifacts.js +1 -1
  4. package/dist/commands/cloud.d.ts +9 -4
  5. package/dist/commands/cloud.js +25 -8
  6. package/dist/commands/switch-org.js +5 -1
  7. package/dist/commands/upload.js +5 -1
  8. package/dist/commands/whoami.d.ts +7 -1
  9. package/dist/commands/whoami.js +26 -2
  10. package/dist/config/flags/device.flags.d.ts +3 -3
  11. package/dist/config/flags/device.flags.js +5 -3
  12. package/dist/config/flags/execution.flags.d.ts +5 -0
  13. package/dist/config/flags/execution.flags.js +5 -0
  14. package/dist/config/flags/output.flags.d.ts +1 -1
  15. package/dist/config/flags/output.flags.js +1 -1
  16. package/dist/constants.d.ts +9 -4
  17. package/dist/gateways/api-gateway.js +2 -1
  18. package/dist/gateways/realtime-gateway.d.ts +7 -0
  19. package/dist/gateways/realtime-gateway.js +12 -1
  20. package/dist/mcp/context.d.ts +14 -3
  21. package/dist/mcp/context.js +15 -3
  22. package/dist/mcp/tools/download-artifacts.js +9 -6
  23. package/dist/mcp/tools/list-devices.js +1 -1
  24. package/dist/mcp/tools/run-cloud-test.js +20 -4
  25. package/dist/services/device-validation.service.d.ts +17 -2
  26. package/dist/services/device-validation.service.js +93 -12
  27. package/dist/services/execution-plan.service.d.ts +6 -0
  28. package/dist/services/execution-plan.service.js +98 -2
  29. package/dist/services/execution-plan.utils.js +69 -6
  30. package/dist/services/flow-paths.d.ts +9 -1
  31. package/dist/services/flow-paths.js +14 -2
  32. package/dist/services/notices.service.js +3 -0
  33. package/dist/services/report-download.service.js +9 -3
  34. package/dist/services/results-polling.service.d.ts +42 -1
  35. package/dist/services/results-polling.service.js +133 -7
  36. package/dist/services/test-submission.service.d.ts +18 -0
  37. package/dist/services/test-submission.service.js +30 -6
  38. package/dist/services/workspace-config.schema.d.ts +1 -0
  39. package/dist/services/workspace-config.schema.js +6 -0
  40. package/dist/types/domain/auth.types.d.ts +6 -0
  41. package/dist/types/domain/device.types.d.ts +18 -4
  42. package/dist/types/domain/device.types.js +17 -3
  43. package/dist/types/generated/schema.types.d.ts +127 -167
  44. package/dist/utils/auth.d.ts +33 -0
  45. package/dist/utils/auth.js +61 -2
  46. 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
- deviceValidation.validateiOSDevice(args.iosVersion, args.iosDevice, compatibilityData, { logger: logStderr });
104
- deviceValidation.validateAndroidDevice(args.androidApiLevel, args.androidDevice, Boolean(args.googlePlay), compatibilityData, { logger: logStderr });
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
- const status = await ApiGateway.getUploadStatus(apiUrl, auth, { uploadId });
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
- * Service for validating device configurations against compatibility data
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
- * Service for validating device configurations against compatibility data
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
- lookup: googlePlay
27
- ? compatibilityData.androidPlay
28
- : compatibilityData.android,
29
- noSupportMessage: () => `We don't support that device configuration - please check the docs for supported devices: https://docs.devicecloud.dev/getting-started/devices-configuration`,
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, lookup, noSupportMessage, options, unsupportedVersionMessage, version, } = config;
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?.[deviceID] || [];
127
+ const supportedVersions = versionsFor(lookup, deviceID);
76
128
  const requestedVersion = version || defaultVersion;
77
129
  if (supportedVersions.length === 0) {
78
- throw new Error(noSupportMessage(deviceID));
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 (Array.isArray(supportedVersions) &&
81
- !supportedVersions.includes(requestedVersion)) {
82
- throw new Error(unsupportedVersionMessage(deviceID, supportedVersions));
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(['addMedia', 'runFlow', 'runScript']);
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
- const absoluteFilePath = path.normalize(path.resolve(directory, relativePath));
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' && x?.file)
169
- processFilePath(x.file);
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 = [...testFileNames, ...referencedFiles].sort((a, b) => a.split(path.sep).length - b.split(path.sep).length);
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
- await ApiGateway.downloadArtifactsZip(apiUrl, auth, uploadId, downloadType, artifactsPath);
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
- const htmlReportPath = path.resolve(process.cwd(), htmlPath || 'report.html');
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
- await ApiGateway.downloadReportGeneric(apiUrl, auth, uploadId, type, filePath);
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
  }