@devicecloud.dev/dcd 5.5.0 → 5.6.0-beta.3

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.
@@ -98,6 +98,11 @@ export declare const cloudCommand: import("citty").CommandDef<{
98
98
  readonly type: "string";
99
99
  readonly description: "Pull request URL for this run (stored as gh_pr_url metadata)";
100
100
  };
101
+ readonly 'cancel-previous': {
102
+ readonly type: "boolean";
103
+ readonly default: false;
104
+ readonly description: "Cancel the still-queued tests of the previous run from the same CI context (repo + branch/PR + check name, read from your CI metadata). Tests already running are left to finish; cancelled tests are refunded at 75%. Does nothing outside CI.";
105
+ };
101
106
  readonly config: {
102
107
  readonly type: "string";
103
108
  readonly description: "Path to custom config.yaml file. If not provided, defaults to config.yaml in root flows folders.";
@@ -132,6 +132,7 @@ export const cloudCommand = defineCommand({
132
132
  const excludeFlows = coerceArray(collectRepeatedFlag(rawArgs, ['--exclude-flows']));
133
133
  const excludeTags = coerceArray(collectRepeatedFlag(rawArgs, ['--exclude-tags']));
134
134
  let flows = args.flows;
135
+ const cancelPrevious = Boolean(args['cancel-previous']);
135
136
  const googlePlay = Boolean(args['google-play']);
136
137
  const ignoreShaCheck = Boolean(args['ignore-sha-check']);
137
138
  // Single opt-in for client-side envelope encryption of every sensitive
@@ -401,15 +402,22 @@ export const cloudCommand = defineCommand({
401
402
  }
402
403
  throw error;
403
404
  }
404
- const { allExcludeTags, allIncludeTags, flowMetadata, flowOverrides, flowsToRun: testFileNames, referencedFiles, sequence, } = executionPlan;
405
+ const { allExcludeTags, allIncludeTags, flowMetadata, flowOverrides, flowsToRun: testFileNames, includedFiles, referencedFiles, sequence, } = executionPlan;
405
406
  if (debug) {
406
407
  out(`[DEBUG] All include tags: ${allIncludeTags?.join(', ') || 'none'}`);
407
408
  out(`[DEBUG] All exclude tags: ${allExcludeTags?.join(', ') || 'none'}`);
408
409
  out(`[DEBUG] Test file names: ${testFileNames.join(', ')}`);
409
410
  }
410
- const commonRoot = computeCommonRoot(testFileNames, referencedFiles);
411
+ const commonRoot = computeCommonRoot(testFileNames, referencedFiles, includedFiles);
411
412
  if (debug) {
412
413
  out(`[DEBUG] Common root directory: ${commonRoot}`);
414
+ // `includedPaths` files sitting beside the flows tree rather than
415
+ // inside it raise the common root, so every server-side flow key gains
416
+ // a leading segment. Harmless but visible in the console, so say it.
417
+ const rootWithoutIncludes = computeCommonRoot(testFileNames, referencedFiles);
418
+ if (includedFiles.length > 0 && rootWithoutIncludes !== commonRoot) {
419
+ out(`[DEBUG] \`includedPaths\` raised the common root from ${rootWithoutIncludes} to ${commonRoot} — flow paths gain a leading segment`);
420
+ }
413
421
  }
414
422
  const testMetadataMap = buildTestMetadataMap(flowMetadata, commonRoot);
415
423
  if (debug) {
@@ -549,6 +557,7 @@ export const cloudCommand = defineCommand({
549
557
  androidNoSnapshot,
550
558
  apiUrl,
551
559
  appBinaryId: finalBinaryId,
560
+ cancelPrevious,
552
561
  cliVersion,
553
562
  commonRoot,
554
563
  continueOnFailure,
@@ -2,6 +2,11 @@
2
2
  * Test execution and flow management flags
3
3
  */
4
4
  export declare const executionFlags: {
5
+ readonly 'cancel-previous': {
6
+ readonly type: "boolean";
7
+ readonly default: false;
8
+ readonly description: "Cancel the still-queued tests of the previous run from the same CI context (repo + branch/PR + check name, read from your CI metadata). Tests already running are left to finish; cancelled tests are refunded at 75%. Does nothing outside CI.";
9
+ };
5
10
  readonly config: {
6
11
  readonly type: "string";
7
12
  readonly description: "Path to custom config.yaml file. If not provided, defaults to config.yaml in root flows folders.";
@@ -2,6 +2,11 @@
2
2
  * Test execution and flow management flags
3
3
  */
4
4
  export const executionFlags = {
5
+ 'cancel-previous': {
6
+ type: 'boolean',
7
+ default: false,
8
+ description: 'Cancel the still-queued tests of the previous run from the same CI context (repo + branch/PR + check name, read from your CI metadata). Tests already running are left to finish; cancelled tests are refunded at 75%. Does nothing outside CI.',
9
+ },
5
10
  config: {
6
11
  type: 'string',
7
12
  description: 'Path to custom config.yaml file. If not provided, defaults to config.yaml in root flows folders.',
@@ -84,6 +84,11 @@ export declare const flags: {
84
84
  readonly type: "string";
85
85
  readonly description: "Pull request URL for this run (stored as gh_pr_url metadata)";
86
86
  };
87
+ readonly 'cancel-previous': {
88
+ readonly type: "boolean";
89
+ readonly default: false;
90
+ readonly description: "Cancel the still-queued tests of the previous run from the same CI context (repo + branch/PR + check name, read from your CI metadata). Tests already running are left to finish; cancelled tests are refunded at 75%. Does nothing outside CI.";
91
+ };
87
92
  readonly config: {
88
93
  readonly type: "string";
89
94
  readonly description: "Path to custom config.yaml file. If not provided, defaults to config.yaml in root flows folders.";
@@ -120,7 +120,7 @@ export function registerRunCloudTest(server) {
120
120
  // stdout is the JSON-RPC channel — config warnings must go to stderr.
121
121
  warn: logStderr,
122
122
  });
123
- const commonRoot = computeCommonRoot(executionPlan.flowsToRun, executionPlan.referencedFiles);
123
+ const commonRoot = computeCommonRoot(executionPlan.flowsToRun, executionPlan.referencedFiles, executionPlan.includedFiles);
124
124
  const testMetadataMap = buildTestMetadataMap(executionPlan.flowMetadata, commonRoot);
125
125
  if (args.dryRun) {
126
126
  return jsonResult({
@@ -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));
@@ -45,9 +45,29 @@ export declare function deviceFromResultRow(r: {
45
45
  config?: unknown;
46
46
  simulator_name?: string | null;
47
47
  }): TestDevice | undefined;
48
+ /**
49
+ * Was this row cancelled because a newer run from the same CI context
50
+ * replaced it (`dcd cloud --cancel-previous`)?
51
+ *
52
+ * Read through a structural cast for the same reason deviceFromResultRow
53
+ * does: the committed generated types are regenerated wholesale from dev's
54
+ * swagger and lag the API, and this must work against an API that already
55
+ * sends the field.
56
+ */
57
+ export declare function isSupersededRow(r: unknown): boolean;
58
+ /** The upload that superseded this run, for the console link. */
59
+ export declare function supersedingUploadId(results: unknown[]): string | undefined;
60
+ /**
61
+ * The superseding run's console link, derived from this run's.
62
+ *
63
+ * The `result` parameter deep-links a single test, and that id belongs to
64
+ * THIS upload — carrying it over would point at a result the newer upload
65
+ * does not contain. Swap the upload id and drop it.
66
+ */
67
+ export declare function supersedingConsoleUrl(consoleUrl: string, uploadId: string, supersededBy: string): string;
48
68
  export interface PollingResult {
49
69
  consoleUrl: string;
50
- status: 'FAILED' | 'PASSED';
70
+ status: 'FAILED' | 'PASSED' | 'SUPERSEDED';
51
71
  tests: Array<{
52
72
  /** Device this result ran on (present when the API reports it). */
53
73
  device?: TestDevice;
@@ -38,6 +38,45 @@ export function deviceFromResultRow(r) {
38
38
  googlePlay: sim ? /(_PLAY|-play)$/.test(sim) : undefined,
39
39
  };
40
40
  }
41
+ /**
42
+ * Was this row cancelled because a newer run from the same CI context
43
+ * replaced it (`dcd cloud --cancel-previous`)?
44
+ *
45
+ * Read through a structural cast for the same reason deviceFromResultRow
46
+ * does: the committed generated types are regenerated wholesale from dev's
47
+ * swagger and lag the API, and this must work against an API that already
48
+ * sends the field.
49
+ */
50
+ export function isSupersededRow(r) {
51
+ const reason = r
52
+ ?.cancellation_reason;
53
+ return typeof reason === 'string' && reason.startsWith('superseded_by:');
54
+ }
55
+ /** The upload that superseded this run, for the console link. */
56
+ export function supersedingUploadId(results) {
57
+ for (const r of results) {
58
+ const reason = r
59
+ ?.cancellation_reason;
60
+ if (typeof reason === 'string' && reason.startsWith('superseded_by:')) {
61
+ return reason.slice('superseded_by:'.length) || undefined;
62
+ }
63
+ }
64
+ return undefined;
65
+ }
66
+ /**
67
+ * The superseding run's console link, derived from this run's.
68
+ *
69
+ * The `result` parameter deep-links a single test, and that id belongs to
70
+ * THIS upload — carrying it over would point at a result the newer upload
71
+ * does not contain. Swap the upload id and drop it.
72
+ */
73
+ export function supersedingConsoleUrl(consoleUrl, uploadId, supersededBy) {
74
+ return consoleUrl
75
+ .replace(uploadId, supersededBy)
76
+ .replace(/&result=[^&]*/, '')
77
+ .replace(/\?result=[^&]*&/, '?')
78
+ .replace(/\?result=[^&]*$/, '');
79
+ }
41
80
  /**
42
81
  * Service for polling test results from the API
43
82
  */
@@ -226,13 +265,23 @@ export class ResultsPollingService {
226
265
  }
227
266
  buildPollingResult(results, uploadId, consoleUrl, testMetadata) {
228
267
  const resultsWithoutEarlierTries = this.filterLatestResults(results);
268
+ // ANY superseded row settles the whole verdict, even alongside a test
269
+ // that had genuinely failed before the newer run replaced this one: this
270
+ // run no longer speaks for the commit, so failing the build on its behalf
271
+ // is wrong. Actions reaches the same conclusion — a cancelled run's
272
+ // conclusion is `cancelled`, whatever had already failed inside it. The
273
+ // failure is still printed and still in `tests[]`; only the exit code
274
+ // changes.
275
+ const superseded = resultsWithoutEarlierTries.some(isSupersededRow);
229
276
  return {
230
277
  consoleUrl,
231
278
  // Anything other than an explicit pass (CANCELLED, ERROR, a status we
232
279
  // don't know about yet) must fail the run — this gates CI exit codes.
233
- status: resultsWithoutEarlierTries.every((result) => result.status === 'PASSED')
234
- ? 'PASSED'
235
- : 'FAILED',
280
+ status: superseded
281
+ ? 'SUPERSEDED'
282
+ : resultsWithoutEarlierTries.every((result) => result.status === 'PASSED')
283
+ ? 'PASSED'
284
+ : 'FAILED',
236
285
  tests: resultsWithoutEarlierTries.map((r) => ({
237
286
  // r carries config/simulator_name at runtime; the committed generated
238
287
  // types lag the API (regenerated wholesale from dev's swagger), so read
@@ -259,8 +308,12 @@ export class ResultsPollingService {
259
308
  const pending = statusCounts.PENDING || 0;
260
309
  const queued = statusCounts.QUEUED || 0;
261
310
  const running = statusCounts.RUNNING || 0;
311
+ // CANCELLED is terminal, so it counts as completed. Without it a
312
+ // cancelled or superseded run's footer sticks at "8/12 completed"
313
+ // forever, having already stopped polling.
314
+ const cancelled = statusCounts.CANCELLED || 0;
262
315
  const total = results.length;
263
- const completed = passed + failed;
316
+ const completed = passed + failed + cancelled;
264
317
  const summary = formatTestSummary({
265
318
  completed,
266
319
  failed,
@@ -386,6 +439,27 @@ export class ResultsPollingService {
386
439
  }
387
440
  this.displayFinalResults(updatedResults, consoleUrl, json, logger);
388
441
  const output = this.buildPollingResult(updatedResults, uploadId, consoleUrl, testMetadata);
442
+ if (output.status === 'SUPERSEDED') {
443
+ // Exit 0: a newer run of the same CI context replaced this one, so
444
+ // failing the build here would fail it for work nobody is waiting on.
445
+ // Falls through to the success return below — RunFailedError, and with
446
+ // it the exit code 2 in `dcd cloud`, is never reached.
447
+ if (logger && !json) {
448
+ const newer = supersedingUploadId(updatedResults);
449
+ logger('\n');
450
+ logger(ui.warn('Run superseded by a newer run from the same CI context — exiting 0'));
451
+ if (newer) {
452
+ logger(ui.branch(ui.fields([
453
+ [
454
+ 'superseded by',
455
+ colors.url(supersedingConsoleUrl(consoleUrl, uploadId, newer)),
456
+ ],
457
+ ])));
458
+ }
459
+ logger('\n');
460
+ }
461
+ return output;
462
+ }
389
463
  if (output.status === 'FAILED') {
390
464
  if (debug && logger) {
391
465
  logger(`[DEBUG] Some tests failed, returning failed status`);
@@ -6,6 +6,13 @@ export interface TestSubmissionConfig {
6
6
  androidNoSnapshot?: boolean;
7
7
  apiUrl?: string;
8
8
  appBinaryId: string;
9
+ /**
10
+ * Ask the API to cancel the still-queued tests of the previous run from
11
+ * the same CI context. Sent as its own field rather than inside `config`,
12
+ * which is stamped onto every result row and shipped to the runner — this
13
+ * is a one-off submission directive, not run configuration.
14
+ */
15
+ cancelPrevious?: boolean;
9
16
  cliVersion: string;
10
17
  commonRoot: string;
11
18
  continueOnFailure?: boolean;
@@ -20,8 +20,8 @@ export class TestSubmissionService {
20
20
  * @returns The flow zip buffer, its SHA-256, and the string-encoded fields
21
21
  */
22
22
  async buildTestPayload(config) {
23
- const { apiUrl, appBinaryId, encrypt = false, flowFile, executionPlan, commonRoot, cliVersion, env = [], metadata = [], googlePlay = false, androidApiLevel, androidDevice, androidNoSnapshot, iOSVersion, iOSDevice, name, runnerType, maestroVersion, deviceLocale, orientation, retry, continueOnFailure = true, renderEngine, report, showCrosshairs, maestroChromeOnboarding, raw, disableAnimations, deviceMatrix, debug = false, logger, } = config;
24
- const { allExcludeTags, allIncludeTags, flowMetadata, flowOverrides, flowsToRun: testFileNames, referencedFiles, sequence, workspaceConfig, } = executionPlan;
23
+ const { apiUrl, appBinaryId, encrypt = false, flowFile, executionPlan, commonRoot, cliVersion, env = [], metadata = [], cancelPrevious = false, googlePlay = false, androidApiLevel, androidDevice, androidNoSnapshot, iOSVersion, iOSDevice, name, runnerType, maestroVersion, deviceLocale, orientation, retry, continueOnFailure = true, renderEngine, report, showCrosshairs, maestroChromeOnboarding, raw, disableAnimations, deviceMatrix, debug = false, logger, } = config;
24
+ const { allExcludeTags, allIncludeTags, flowMetadata, flowOverrides, flowsToRun: testFileNames, includedFiles = [], referencedFiles, sequence, workspaceConfig, } = executionPlan;
25
25
  const { flows: sequentialFlows = [] } = sequence ?? {};
26
26
  const envObject = this.parseKeyValuePairs(env);
27
27
  const metadataObject = this.parseKeyValuePairs(metadata);
@@ -42,6 +42,17 @@ export class TestSubmissionService {
42
42
  }
43
43
  }
44
44
  }
45
+ // Logged separately from referencedFiles: these come from config.yaml's
46
+ // `includedPaths` rather than from a flow command, and they can raise the
47
+ // common root (see computeCommonRoot) — which shows up here as every flow
48
+ // key gaining a leading directory segment.
49
+ if (includedFiles.length > 0) {
50
+ this.logDebug(debug, logger, `[DEBUG] Uploading ${includedFiles.length} file(s) from \`includedPaths\`:`);
51
+ for (const file of includedFiles) {
52
+ const normalizedPath = this.normalizeFilePath(file, commonRoot);
53
+ this.logDebug(debug, logger, `[DEBUG] - ${normalizedPath}`);
54
+ }
55
+ }
45
56
  this.logDebug(debug, logger, `[DEBUG] Compressing files from path: ${flowFile}`);
46
57
  const plaintextZip = await compressFilesFromRelativePath(flowFile?.endsWith('.yaml') || flowFile?.endsWith('.yml')
47
58
  ? path.dirname(flowFile)
@@ -50,6 +61,7 @@ export class TestSubmissionService {
50
61
  ...referencedFiles,
51
62
  ...testFileNames,
52
63
  ...sequentialFlows,
64
+ ...includedFiles,
53
65
  ]),
54
66
  ], commonRoot);
55
67
  this.logDebug(debug, logger, `[DEBUG] Compressed file size: ${plaintextZip.length} bytes`);
@@ -144,6 +156,11 @@ export class TestSubmissionService {
144
156
  fields.metadata = JSON.stringify(metadataPayload);
145
157
  this.logDebug(debug, logger, `[DEBUG] Sending metadata to API: ${JSON.stringify(metadataPayload)}`);
146
158
  }
159
+ // Only sent when asked for, so every other submission's wire shape is
160
+ // unchanged and the flag is simply ignored by an older API.
161
+ if (cancelPrevious) {
162
+ fields.cancelPrevious = 'true';
163
+ }
147
164
  this.setOptionalFields(fields, {
148
165
  androidApiLevel,
149
166
  androidDevice,
@@ -12,6 +12,7 @@ export declare const WorkspaceConfigSchema: z.ZodObject<{
12
12
  flowsOrder: z.ZodArray<z.ZodString>;
13
13
  }, z.core.$strip>>>;
14
14
  flows: z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodString>>>;
15
+ includedPaths: z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodString>>>;
15
16
  includeTags: z.ZodOptional<z.ZodNullable<z.ZodPreprocess<z.ZodArray<z.ZodString>, unknown>>>;
16
17
  local: z.ZodOptional<z.ZodNullable<z.ZodObject<{
17
18
  deterministicOrder: z.ZodOptional<z.ZodNullable<z.ZodBoolean>>;
@@ -42,6 +42,7 @@ export const WorkspaceConfigSchema = z.looseObject({
42
42
  excludeTags: tagList.nullish(),
43
43
  executionOrder: ExecutionOrderSchema.nullish(),
44
44
  flows: z.array(z.string()).nullish(),
45
+ includedPaths: z.array(z.string()).nullish(),
45
46
  includeTags: tagList.nullish(),
46
47
  local: z
47
48
  .looseObject({ deterministicOrder: z.boolean().nullish() })
@@ -80,10 +81,15 @@ export const WORKSPACE_CONFIG_KEYS = new Set(Object.keys(WorkspaceConfigSchema.s
80
81
  * Near-misses that aren't just a casing slip on a real key. Keyed lowercase.
81
82
  */
82
83
  const KEY_ALIASES = {
84
+ assets: 'includedPaths',
83
85
  continueonfailure: 'executionOrder.continueOnFailure',
84
86
  excludetag: 'excludeTags',
87
+ files: 'includedPaths',
85
88
  floworder: 'executionOrder.flowsOrder',
86
89
  flowsorder: 'executionOrder.flowsOrder',
90
+ includedpath: 'includedPaths',
91
+ includefiles: 'includedPaths',
92
+ includepaths: 'includedPaths',
87
93
  includetag: 'includeTags',
88
94
  tags: 'includeTags / excludeTags',
89
95
  };
package/package.json CHANGED
@@ -57,9 +57,19 @@
57
57
  },
58
58
  "repository": {
59
59
  "type": "git",
60
- "url": "https://devicecloud.dev"
60
+ "url": "git+https://github.com/devicecloud-dev/dcd-cli.git"
61
61
  },
62
- "version": "5.5.0",
62
+ "scripts": {
63
+ "dcd": "tsx src/index.ts",
64
+ "build": "shx rm -rf dist && tsc -b && shx chmod +x dist/index.js dist/mcp/index.js",
65
+ "build:binaries": "node scripts/build-binaries.mjs",
66
+ "lint": "eslint src test --ext .ts",
67
+ "prepare": "pnpm build && husky",
68
+ "test": "node scripts/test-runner.mjs",
69
+ "test:unit": "node scripts/test-runner.mjs --unit",
70
+ "typecheck": "tsc --noEmit -p tsconfig.test.json"
71
+ },
72
+ "version": "5.6.0-beta.3",
63
73
  "bugs": {
64
74
  "url": "https://discord.gg/gm3mJwcNw8"
65
75
  },
@@ -72,13 +82,43 @@
72
82
  "testing"
73
83
  ],
74
84
  "types": "dist/index.d.ts",
75
- "scripts": {
76
- "dcd": "tsx src/index.ts",
77
- "build": "shx rm -rf dist && tsc -b && shx chmod +x dist/index.js dist/mcp/index.js",
78
- "build:binaries": "node scripts/build-binaries.mjs",
79
- "lint": "eslint src test --ext .ts",
80
- "test": "node scripts/test-runner.mjs",
81
- "test:unit": "node scripts/test-runner.mjs --unit",
82
- "typecheck": "tsc --noEmit -p tsconfig.test.json"
85
+ "packageManager": "pnpm@10.17.1",
86
+ "pnpm": {
87
+ "overrides": {
88
+ "js-yaml@<3.14.2": ">=3.14.2",
89
+ "js-yaml@>=4.0.0 <4.2.0": ">=4.2.0",
90
+ "js-yaml@>=5.0.0 <5.2.2": ">=5.2.2",
91
+ "tar@<7.5.16": ">=7.5.16",
92
+ "@isaacs/brace-expansion": ">=5.0.1",
93
+ "fast-xml-parser": ">=5.5.7",
94
+ "flatted": ">=3.4.2",
95
+ "lodash": ">=4.18.0",
96
+ "@xmldom/xmldom": ">=0.9.12",
97
+ "minimatch@<3.1.4": "3.1.4",
98
+ "minimatch@>=5.0.0 <5.1.8": "5.1.8",
99
+ "minimatch@>=9.0.0 <9.0.7": "9.0.7",
100
+ "minimatch@>=10.0.0 <10.2.3": "10.2.3",
101
+ "picomatch@<2.3.2": "2.3.2",
102
+ "picomatch@>=4.0.0 <4.0.4": "4.0.4",
103
+ "serialize-javascript": ">=7.0.5",
104
+ "ajv@<7.0.0": "6.14.0",
105
+ "diff@>=4.0.0 <6.0.0": "4.0.4",
106
+ "diff@>=6.0.0": "8.0.3",
107
+ "brace-expansion@<1.1.16": "1.1.16",
108
+ "brace-expansion@>=2.0.0 <2.0.3": "2.0.3",
109
+ "brace-expansion@>=3.0.0 <5.0.9": "5.0.9",
110
+ "ws@>=8.0.0 <8.21.0": "8.21.0",
111
+ "esbuild@<0.28.1": ">=0.28.1",
112
+ "qs@<6.16.0": "6.16.0",
113
+ "fast-uri@>=3.0.0 <3.1.6": "3.1.6",
114
+ "hono@>=4.0.0 <4.13.5": "4.13.5",
115
+ "micromatch>picomatch": "^2.3.2",
116
+ "tinyglobby>picomatch": "^4.0.4"
117
+ },
118
+ "auditConfig": {
119
+ "ignoreGhsas": [
120
+ "GHSA-frvp-7c67-39w9"
121
+ ]
122
+ }
83
123
  }
84
- }
124
+ }