@devicecloud.dev/dcd 5.5.0-beta.4 → 5.5.0-beta.5

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
@@ -549,6 +550,7 @@ export const cloudCommand = defineCommand({
549
550
  androidNoSnapshot,
550
551
  apiUrl,
551
552
  appBinaryId: finalBinaryId,
553
+ cancelPrevious,
552
554
  cliVersion,
553
555
  commonRoot,
554
556
  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.";
@@ -45,9 +45,21 @@ 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;
48
60
  export interface PollingResult {
49
61
  consoleUrl: string;
50
- status: 'FAILED' | 'PASSED';
62
+ status: 'FAILED' | 'PASSED' | 'SUPERSEDED';
51
63
  tests: Array<{
52
64
  /** Device this result ran on (present when the API reports it). */
53
65
  device?: TestDevice;
@@ -38,6 +38,31 @@ 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
+ }
41
66
  /**
42
67
  * Service for polling test results from the API
43
68
  */
@@ -226,13 +251,23 @@ export class ResultsPollingService {
226
251
  }
227
252
  buildPollingResult(results, uploadId, consoleUrl, testMetadata) {
228
253
  const resultsWithoutEarlierTries = this.filterLatestResults(results);
254
+ // ANY superseded row settles the whole verdict, even alongside a test
255
+ // that had genuinely failed before the newer run replaced this one: this
256
+ // run no longer speaks for the commit, so failing the build on its behalf
257
+ // is wrong. Actions reaches the same conclusion — a cancelled run's
258
+ // conclusion is `cancelled`, whatever had already failed inside it. The
259
+ // failure is still printed and still in `tests[]`; only the exit code
260
+ // changes.
261
+ const superseded = resultsWithoutEarlierTries.some(isSupersededRow);
229
262
  return {
230
263
  consoleUrl,
231
264
  // Anything other than an explicit pass (CANCELLED, ERROR, a status we
232
265
  // 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',
266
+ status: superseded
267
+ ? 'SUPERSEDED'
268
+ : resultsWithoutEarlierTries.every((result) => result.status === 'PASSED')
269
+ ? 'PASSED'
270
+ : 'FAILED',
236
271
  tests: resultsWithoutEarlierTries.map((r) => ({
237
272
  // r carries config/simulator_name at runtime; the committed generated
238
273
  // types lag the API (regenerated wholesale from dev's swagger), so read
@@ -259,8 +294,12 @@ export class ResultsPollingService {
259
294
  const pending = statusCounts.PENDING || 0;
260
295
  const queued = statusCounts.QUEUED || 0;
261
296
  const running = statusCounts.RUNNING || 0;
297
+ // CANCELLED is terminal, so it counts as completed. Without it a
298
+ // cancelled or superseded run's footer sticks at "8/12 completed"
299
+ // forever, having already stopped polling.
300
+ const cancelled = statusCounts.CANCELLED || 0;
262
301
  const total = results.length;
263
- const completed = passed + failed;
302
+ const completed = passed + failed + cancelled;
264
303
  const summary = formatTestSummary({
265
304
  completed,
266
305
  failed,
@@ -386,6 +425,27 @@ export class ResultsPollingService {
386
425
  }
387
426
  this.displayFinalResults(updatedResults, consoleUrl, json, logger);
388
427
  const output = this.buildPollingResult(updatedResults, uploadId, consoleUrl, testMetadata);
428
+ if (output.status === 'SUPERSEDED') {
429
+ // Exit 0: a newer run of the same CI context replaced this one, so
430
+ // failing the build here would fail it for work nobody is waiting on.
431
+ // Falls through to the success return below — RunFailedError, and with
432
+ // it the exit code 2 in `dcd cloud`, is never reached.
433
+ if (logger && !json) {
434
+ const newer = supersedingUploadId(updatedResults);
435
+ logger('\n');
436
+ logger(ui.warn('Run superseded by a newer run from the same CI context — exiting 0'));
437
+ if (newer) {
438
+ logger(ui.branch(ui.fields([
439
+ [
440
+ 'superseded by',
441
+ colors.url(consoleUrl.replace(uploadId, newer)),
442
+ ],
443
+ ])));
444
+ }
445
+ logger('\n');
446
+ }
447
+ return output;
448
+ }
389
449
  if (output.status === 'FAILED') {
390
450
  if (debug && logger) {
391
451
  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,7 +20,7 @@ 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;
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
24
  const { allExcludeTags, allIncludeTags, flowMetadata, flowOverrides, flowsToRun: testFileNames, referencedFiles, sequence, workspaceConfig, } = executionPlan;
25
25
  const { flows: sequentialFlows = [] } = sequence ?? {};
26
26
  const envObject = this.parseKeyValuePairs(env);
@@ -144,6 +144,11 @@ export class TestSubmissionService {
144
144
  fields.metadata = JSON.stringify(metadataPayload);
145
145
  this.logDebug(debug, logger, `[DEBUG] Sending metadata to API: ${JSON.stringify(metadataPayload)}`);
146
146
  }
147
+ // Only sent when asked for, so every other submission's wire shape is
148
+ // unchanged and the flag is simply ignored by an older API.
149
+ if (cancelPrevious) {
150
+ fields.cancelPrevious = 'true';
151
+ }
147
152
  this.setOptionalFields(fields, {
148
153
  androidApiLevel,
149
154
  androidDevice,
package/package.json CHANGED
@@ -59,7 +59,7 @@
59
59
  "type": "git",
60
60
  "url": "https://devicecloud.dev"
61
61
  },
62
- "version": "5.5.0-beta.4",
62
+ "version": "5.5.0-beta.5",
63
63
  "bugs": {
64
64
  "url": "https://discord.gg/gm3mJwcNw8"
65
65
  },