@devicecloud.dev/dcd 5.7.1 → 5.8.0-beta.2

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.
@@ -129,6 +129,11 @@ export declare const cloudCommand: import("citty").CommandDef<{
129
129
  readonly alias: ["maestroVersion"];
130
130
  readonly description: "Maestro version to run your flow against. Use \"latest\" for the most recent version. See https://docs.devicecloud.dev/configuration/maestro-versions for supported versions.";
131
131
  };
132
+ readonly priority: {
133
+ readonly type: "string";
134
+ readonly default: "normal";
135
+ readonly description: "Run priority within your organization (options: low, normal, high). Reorders only your own queued runs across your concurrency pool, for every flow in this upload; it never moves them ahead of other customers. Runs queued longer than 6 hours are promoted first, so low runs are never starved. Retries keep the same priority.";
136
+ };
132
137
  readonly retry: {
133
138
  readonly type: "string";
134
139
  readonly description: "Automatically retry the run up to the number of times specified (same as pressing retry in the UI) - this is free of charge";
@@ -42,6 +42,7 @@ const DOWNLOAD_OPTIONS = ['ALL', 'FAILED'];
42
42
  const REPORT_OPTIONS = ['allure', 'html', 'html-detailed', 'junit'];
43
43
  const ORIENTATION_OPTIONS = ['0', '90'];
44
44
  const RUNNER_TYPE_OPTIONS = ['default', 'm4', 'm1', 'gpu1', 'cpu1'];
45
+ const PRIORITY_OPTIONS = ['low', 'normal', 'high'];
45
46
  const RENDER_ENGINE_OPTIONS = ['lavapipe', 'swiftshader'];
46
47
  /**
47
48
  * Primary CLI command for executing tests on DeviceCloud.
@@ -162,6 +163,7 @@ export const cloudCommand = defineCommand({
162
163
  const moropoApiKey = args['moropo-v1-api-key'];
163
164
  const name = args.name;
164
165
  const orientation = validateEnum(args.orientation, ORIENTATION_OPTIONS, 'orientation');
166
+ const priority = validateEnum(args.priority, PRIORITY_OPTIONS, 'priority') ?? 'normal';
165
167
  let quiet = Boolean(args.quiet);
166
168
  const report = validateEnum(args.report, REPORT_OPTIONS, 'report');
167
169
  let retry = parseIntFlag(args.retry, 'retry');
@@ -600,6 +602,7 @@ export const cloudCommand = defineCommand({
600
602
  metadata: mergedMetadata,
601
603
  name,
602
604
  orientation,
605
+ priority,
603
606
  raw: [],
604
607
  report,
605
608
  retry,
@@ -33,6 +33,11 @@ export declare const executionFlags: {
33
33
  readonly alias: ["maestroVersion"];
34
34
  readonly description: "Maestro version to run your flow against. Use \"latest\" for the most recent version. See https://docs.devicecloud.dev/configuration/maestro-versions for supported versions.";
35
35
  };
36
+ readonly priority: {
37
+ readonly type: "string";
38
+ readonly default: "normal";
39
+ readonly description: "Run priority within your organization (options: low, normal, high). Reorders only your own queued runs across your concurrency pool, for every flow in this upload; it never moves them ahead of other customers. Runs queued longer than 6 hours are promoted first, so low runs are never starved. Retries keep the same priority.";
40
+ };
36
41
  readonly retry: {
37
42
  readonly type: "string";
38
43
  readonly description: "Automatically retry the run up to the number of times specified (same as pressing retry in the UI) - this is free of charge";
@@ -33,6 +33,11 @@ export const executionFlags = {
33
33
  alias: ['maestroVersion'],
34
34
  description: 'Maestro version to run your flow against. Use "latest" for the most recent version. See https://docs.devicecloud.dev/configuration/maestro-versions for supported versions.',
35
35
  },
36
+ priority: {
37
+ type: 'string',
38
+ default: 'normal',
39
+ description: 'Run priority within your organization (options: low, normal, high). Reorders only your own queued runs across your concurrency pool, for every flow in this upload; it never moves them ahead of other customers. Runs queued longer than 6 hours are promoted first, so low runs are never starved. Retries keep the same priority.',
40
+ },
36
41
  retry: {
37
42
  type: 'string',
38
43
  description: 'Automatically retry the run up to the number of times specified (same as pressing retry in the UI) - this is free of charge',
@@ -115,6 +115,11 @@ export declare const flags: {
115
115
  readonly alias: ["maestroVersion"];
116
116
  readonly description: "Maestro version to run your flow against. Use \"latest\" for the most recent version. See https://docs.devicecloud.dev/configuration/maestro-versions for supported versions.";
117
117
  };
118
+ readonly priority: {
119
+ readonly type: "string";
120
+ readonly default: "normal";
121
+ readonly description: "Run priority within your organization (options: low, normal, high). Reorders only your own queued runs across your concurrency pool, for every flow in this upload; it never moves them ahead of other customers. Runs queued longer than 6 hours are promoted first, so low runs are never starved. Retries keep the same priority.";
122
+ };
118
123
  readonly retry: {
119
124
  readonly type: "string";
120
125
  readonly description: "Automatically retry the run up to the number of times specified (same as pressing retry in the UI) - this is free of charge";
@@ -1,7 +1,7 @@
1
1
  import { TAppMetadata } from '../types.js';
2
2
  import type { AuthContext } from '../types/domain/auth.types.js';
3
3
  import type { LiveCommandStatus, LiveExecResult, LiveSession, LiveSessionSummary } from '../types/domain/live.types.js';
4
- import { components } from '../types/generated/schema.types.js';
4
+ import { components, paths } from '../types/generated/schema.types.js';
5
5
  /**
6
6
  * Error thrown for non-OK API responses, carrying the HTTP status so callers
7
7
  * can branch on auth failures etc. without string matching.
@@ -10,6 +10,17 @@ export declare class ApiError extends Error {
10
10
  status: number;
11
11
  constructor(message: string, status: number);
12
12
  }
13
+ type ResultsResponse = paths['/results/{uploadId}']['get']['responses']['200']['content']['application/json'];
14
+ /**
15
+ * The last results body a poll loop received and the ETag it came with, so
16
+ * the next poll can revalidate with `If-None-Match` and get an empty 304 back
17
+ * when nothing changed. Create one per poll loop; `getResultsForUpload` fills
18
+ * and clears it.
19
+ */
20
+ export interface ResultsPollCache {
21
+ body?: ResultsResponse;
22
+ etag?: string;
23
+ }
13
24
  export declare const ApiGateway: {
14
25
  /**
15
26
  * Enhances generic "fetch failed" errors with more specific diagnostic information
@@ -98,7 +109,23 @@ export declare const ApiGateway: {
98
109
  token?: string;
99
110
  }>;
100
111
  finishLargeFile(baseUrl: string, auth: AuthContext, fileId: string, partSha1Array: string[]): Promise<unknown>;
101
- getResultsForUpload(baseUrl: string, auth: AuthContext, uploadId: string): Promise<{
112
+ /**
113
+ * Fetch the result rows for an upload, for the `dcd cloud` poll loop.
114
+ *
115
+ * Asks for `?view=summary`, which leaves out `result_files` and the rest of
116
+ * the columns the CLI never reads. An API that predates the view ignores
117
+ * the parameter and returns the full rows, a superset, so this works
118
+ * against either.
119
+ *
120
+ * With a `cache`, a repeat poll revalidates with `If-None-Match` and a 304
121
+ * hands back the cached body.
122
+ * @param baseUrl API base URL
123
+ * @param auth AuthContext carrying request headers
124
+ * @param uploadId Upload ID to fetch results for
125
+ * @param cache Optional per-poll-loop cache, filled and cleared here
126
+ * @returns The results response body
127
+ */
128
+ getResultsForUpload(baseUrl: string, auth: AuthContext, uploadId: string, cache?: ResultsPollCache): Promise<{
102
129
  statusCode?: number;
103
130
  results?: components["schemas"]["TResultResponse"][];
104
131
  }>;
@@ -209,3 +236,4 @@ export declare const ApiGateway: {
209
236
  stopLiveSession(baseUrl: string, auth: AuthContext, sessionName: string): Promise<void>;
210
237
  getLiveSession(baseUrl: string, auth: AuthContext, sessionName: string): Promise<LiveSession>;
211
238
  };
239
+ export {};
@@ -347,20 +347,73 @@ export const ApiGateway = {
347
347
  throw error;
348
348
  }
349
349
  },
350
- async getResultsForUpload(baseUrl, auth, uploadId) {
350
+ /**
351
+ * Fetch the result rows for an upload, for the `dcd cloud` poll loop.
352
+ *
353
+ * Asks for `?view=summary`, which leaves out `result_files` and the rest of
354
+ * the columns the CLI never reads. An API that predates the view ignores
355
+ * the parameter and returns the full rows, a superset, so this works
356
+ * against either.
357
+ *
358
+ * With a `cache`, a repeat poll revalidates with `If-None-Match` and a 304
359
+ * hands back the cached body.
360
+ * @param baseUrl API base URL
361
+ * @param auth AuthContext carrying request headers
362
+ * @param uploadId Upload ID to fetch results for
363
+ * @param cache Optional per-poll-loop cache, filled and cleared here
364
+ * @returns The results response body
365
+ */
366
+ async getResultsForUpload(baseUrl, auth, uploadId, cache) {
351
367
  // TODO: merge with getUploadStatus
368
+ const url = `${baseUrl}/results/${uploadId}?view=summary`;
352
369
  try {
353
- const res = await fetch(`${baseUrl}/results/${uploadId}`, {
354
- headers: { ...auth.headers },
355
- });
370
+ const headers = { ...auth.headers };
371
+ if (cache?.body && cache.etag) {
372
+ headers['if-none-match'] = cache.etag;
373
+ // Per the Fetch spec, undici appends `Cache-Control: no-cache` to any
374
+ // request that carries a conditional header and no Cache-Control of
375
+ // its own, and Express's `fresh` never answers 304 to a no-cache
376
+ // request. So every poll would get a full 200. max-age=0 still asks
377
+ // for revalidation, and keeps undici from adding no-cache.
378
+ headers['cache-control'] = 'max-age=0';
379
+ }
380
+ const res = await fetch(url, { headers });
381
+ // Before the `!res.ok` check, since a 304 is not `ok`.
382
+ if (res.status === 304) {
383
+ if (cache?.body) {
384
+ return cache.body;
385
+ }
386
+ // We sent no If-None-Match, so there is nothing to reuse. Start over
387
+ // with an unconditional request on the next poll.
388
+ if (cache) {
389
+ delete cache.body;
390
+ delete cache.etag;
391
+ }
392
+ throw new Error('Failed to get results: API returned 304 Not Modified to an unconditional request');
393
+ }
356
394
  if (!res.ok) {
357
395
  await this.handleApiError(res, 'Failed to get results');
358
396
  }
359
- return await parseJsonResponse(res, 'Failed to get results');
397
+ const body = await parseJsonResponse(res, 'Failed to get results');
398
+ if (cache) {
399
+ // Only pin a real result set. The API reports some errors as a 200
400
+ // with a `{ statusCode, message }` envelope, and an empty set is
401
+ // rejected by the poll loop; caching either would replay it on 304.
402
+ const etag = res.headers.get('etag');
403
+ if (etag && Array.isArray(body.results) && body.results.length > 0) {
404
+ cache.body = body;
405
+ cache.etag = etag;
406
+ }
407
+ else {
408
+ delete cache.body;
409
+ delete cache.etag;
410
+ }
411
+ }
412
+ return body;
360
413
  }
361
414
  catch (error) {
362
415
  if (error instanceof TypeError && error.message === 'fetch failed') {
363
- throw this.enhanceFetchError(error, `${baseUrl}/results/${uploadId}`);
416
+ throw this.enhanceFetchError(error, url);
364
417
  }
365
418
  throw error;
366
419
  }
@@ -70,6 +70,10 @@ export function registerRunCloudTest(server) {
70
70
  .enum(['default', 'm4', 'm1', 'gpu1', 'cpu1'])
71
71
  .optional()
72
72
  .describe('Runner type (default "default"). gpu1 is deprecated and will be removed on 9 November 2026; prefer cpu1.'),
73
+ priority: z
74
+ .enum(['low', 'normal', 'high'])
75
+ .optional()
76
+ .describe('Run priority within the organization (default "normal"). Reorders only the queued runs of this organization, for every flow in this upload; it never moves them ahead of other customers. Runs queued over 6 hours are promoted first, so low runs are never starved.'),
73
77
  configFile: z.string().optional().describe('Path to a workspace config.yaml'),
74
78
  ignoreShaCheck: z
75
79
  .boolean()
@@ -208,6 +212,7 @@ export function registerRunCloudTest(server) {
208
212
  iOSDevice: args.iosDevice,
209
213
  iOSVersion: args.iosVersion,
210
214
  name: args.name,
215
+ priority: args.priority,
211
216
  runnerType: args.runnerType ?? 'default',
212
217
  maestroVersion: resolvedMaestroVersion,
213
218
  retry: args.retry,
@@ -118,6 +118,7 @@ export declare class ResultsPollingService {
118
118
  * @param apiUrl API base URL
119
119
  * @param auth AuthContext carrying request headers
120
120
  * @param uploadId Upload ID to fetch results for
121
+ * @param cache The poll loop's results cache, for ETag revalidation
121
122
  * @param debug Whether debug logging is enabled
122
123
  * @param logger Optional logger function
123
124
  * @returns Promise resolving to test results
@@ -133,6 +133,9 @@ export class ResultsPollingService {
133
133
  let { auth } = options;
134
134
  this.initializePollingDisplay(json, logger);
135
135
  let sequentialPollFailures = 0;
136
+ // The last result set and its ETag, so a poll that finds nothing changed
137
+ // gets an empty 304 instead of the whole set again.
138
+ const resultsCache = {};
136
139
  const pollIntervalMs = auth.mode === 'bearer'
137
140
  ? this.BEARER_POLL_INTERVAL_MS
138
141
  : this.APIKEY_POLL_INTERVAL_MS;
@@ -229,7 +232,7 @@ export class ResultsPollingService {
229
232
  nextPollAt = null;
230
233
  renderStatus();
231
234
  auth = await this.refreshPollingAuth(auth, uploadId, subscription, debug, logger);
232
- const updatedResults = await this.fetchAndLogResults(apiUrl, auth, uploadId, debug, logger);
235
+ const updatedResults = await this.fetchAndLogResults(apiUrl, auth, uploadId, resultsCache, debug, logger);
233
236
  const { summary } = this.calculateStatusSummary(updatedResults);
234
237
  if (!json) {
235
238
  statusBody = this.buildStatusBody(updatedResults, quiet, summary);
@@ -395,21 +398,28 @@ export class ResultsPollingService {
395
398
  * @param apiUrl API base URL
396
399
  * @param auth AuthContext carrying request headers
397
400
  * @param uploadId Upload ID to fetch results for
401
+ * @param cache The poll loop's results cache, for ETag revalidation
398
402
  * @param debug Whether debug logging is enabled
399
403
  * @param logger Optional logger function
400
404
  * @returns Promise resolving to test results
401
405
  */
402
- async fetchAndLogResults(apiUrl, auth, uploadId, debug, logger) {
406
+ async fetchAndLogResults(apiUrl, auth, uploadId, cache, debug, logger) {
403
407
  if (debug && logger) {
404
408
  logger(`[DEBUG] Polling for results: ${uploadId}`);
405
409
  }
406
- const { results: updatedResults } = await ApiGateway.getResultsForUpload(apiUrl, auth, uploadId);
410
+ // A 304 hands back the cached body itself; a 200 is always a new object.
411
+ const cachedBody = cache.body;
412
+ const response = await ApiGateway.getResultsForUpload(apiUrl, auth, uploadId, cache);
413
+ const notModified = cachedBody !== undefined && response === cachedBody;
414
+ const { results: updatedResults } = response;
407
415
  // An empty array would otherwise read as "all complete, all passed".
408
416
  if (!updatedResults || updatedResults.length === 0) {
409
417
  throw new Error('no results');
410
418
  }
411
419
  if (debug && logger) {
412
- logger(`[DEBUG] Poll received ${updatedResults.length} results`);
420
+ logger(notModified
421
+ ? `[DEBUG] Poll not modified (304), reusing ${updatedResults.length} cached results`
422
+ : `[DEBUG] Poll received ${updatedResults.length} results`);
413
423
  for (const result of updatedResults) {
414
424
  logger(`[DEBUG] Result status: ${result.test_file_name} - ${result.status}`);
415
425
  }
@@ -50,6 +50,13 @@ export interface TestSubmissionConfig {
50
50
  metadata?: string[];
51
51
  name?: string;
52
52
  orientation?: string;
53
+ /**
54
+ * Where this upload sits among the org's own queued runs. Like
55
+ * `cancelPrevious`, sent as its own field rather than inside `config`: it is
56
+ * a scheduling directive for the whole upload, not run configuration. Only
57
+ * reorders the org's own queue; it never moves the org ahead of others.
58
+ */
59
+ priority?: 'low' | 'normal' | 'high';
53
60
  raw?: unknown;
54
61
  /**
55
62
  * Which emulator software renderer to boot with (Android only). Omitted
@@ -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, appPlatform, encrypt, 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;
23
+ const { apiUrl, appBinaryId, appPlatform, encrypt, flowFile, executionPlan, commonRoot, cliVersion, env = [], metadata = [], cancelPrevious = false, googlePlay = false, androidApiLevel, androidDevice, androidNoSnapshot, iOSVersion, iOSDevice, name, runnerType, maestroVersion, deviceLocale, orientation, priority, retry, continueOnFailure = true, renderEngine, report, showCrosshairs, maestroChromeOnboarding, raw, disableAnimations, deviceMatrix, debug = false, logger, } = config;
24
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);
@@ -168,6 +168,11 @@ export class TestSubmissionService {
168
168
  if (cancelPrevious) {
169
169
  fields.cancelPrevious = 'true';
170
170
  }
171
+ // Same reasoning: 'normal' is the default and is never sent, so the
172
+ // default path's wire shape is unchanged and an older API never sees it.
173
+ if (priority && priority !== 'normal') {
174
+ fields.priority = priority;
175
+ }
171
176
  this.setOptionalFields(fields, {
172
177
  androidApiLevel,
173
178
  androidDevice,
package/package.json CHANGED
@@ -30,7 +30,7 @@
30
30
  "chai": "^6.2.2",
31
31
  "eslint": "^10.5.0",
32
32
  "eslint-config-prettier": "^10.1.8",
33
- "eslint-plugin-unicorn": "^76.0.0",
33
+ "eslint-plugin-unicorn": "^77.0.0",
34
34
  "husky": "^9.1.7",
35
35
  "mocha": "^12.0.0",
36
36
  "prettier": "^3.8.4",
@@ -68,7 +68,7 @@
68
68
  "test:unit": "node scripts/test-runner.mjs --unit",
69
69
  "typecheck": "tsc --noEmit -p tsconfig.test.json"
70
70
  },
71
- "version": "5.7.1",
71
+ "version": "5.8.0-beta.2",
72
72
  "bugs": {
73
73
  "url": "https://discord.gg/gm3mJwcNw8"
74
74
  },