@devicecloud.dev/dcd 5.8.0-beta.1 → 5.8.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.
- package/dist/commands/cloud.d.ts +5 -0
- package/dist/commands/cloud.js +41 -22
- package/dist/config/flags/execution.flags.d.ts +5 -0
- package/dist/config/flags/execution.flags.js +5 -0
- package/dist/constants.d.ts +5 -0
- package/dist/gateways/api-gateway.d.ts +9 -0
- package/dist/gateways/api-gateway.js +22 -2
- package/dist/mcp/tools/run-cloud-test.js +37 -17
- package/dist/services/device-validation.service.js +18 -4
- package/dist/services/flow-submit.service.d.ts +71 -0
- package/dist/services/flow-submit.service.js +117 -0
- package/dist/services/telemetry.service.d.ts +9 -0
- package/dist/services/telemetry.service.js +17 -0
- package/dist/services/test-submission.service.d.ts +7 -0
- package/dist/services/test-submission.service.js +6 -1
- package/package.json +2 -2
package/dist/commands/cloud.d.ts
CHANGED
|
@@ -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";
|
package/dist/commands/cloud.js
CHANGED
|
@@ -3,10 +3,11 @@ import { defineCommand } from 'citty';
|
|
|
3
3
|
import { existsSync } from 'node:fs';
|
|
4
4
|
import * as path from 'node:path';
|
|
5
5
|
import { flags as allFlags } from '../constants.js';
|
|
6
|
-
import {
|
|
6
|
+
import { ApiGateway } from '../gateways/api-gateway.js';
|
|
7
7
|
import { uploadBinary, uploadFlowZip, verifyAppZip, writeJSONFile, } from '../methods.js';
|
|
8
8
|
import { DeviceValidationService } from '../services/device-validation.service.js';
|
|
9
9
|
import { plan } from '../services/execution-plan.service.js';
|
|
10
|
+
import { isSubmitEndpointMissing, submitWithRetry, } from '../services/flow-submit.service.js';
|
|
10
11
|
import { buildTestMetadataMap, computeCommonRoot, } from '../services/flow-paths.js';
|
|
11
12
|
import { MoropoService } from '../services/moropo.service.js';
|
|
12
13
|
import { ReportDownloadService } from '../services/report-download.service.js';
|
|
@@ -15,7 +16,7 @@ import { telemetry } from '../services/telemetry.service.js';
|
|
|
15
16
|
import { TestSubmissionService } from '../services/test-submission.service.js';
|
|
16
17
|
import { VersionService } from '../services/version.service.js';
|
|
17
18
|
import { isIosMatrixConfig } from '../types/domain/device.types.js';
|
|
18
|
-
import { resolveAuth } from '../utils/auth.js';
|
|
19
|
+
import { refreshAuth, resolveAuth } from '../utils/auth.js';
|
|
19
20
|
import { assertMatrixSupported, matrixIsIos, parseDeviceMatrix, } from '../utils/device-matrix.js';
|
|
20
21
|
import { checkKekForEncryption, resolveEncryption } from '../utils/envelope.js';
|
|
21
22
|
import { detectCiContext, isCI } from '../utils/ci.js';
|
|
@@ -42,6 +43,7 @@ const DOWNLOAD_OPTIONS = ['ALL', 'FAILED'];
|
|
|
42
43
|
const REPORT_OPTIONS = ['allure', 'html', 'html-detailed', 'junit'];
|
|
43
44
|
const ORIENTATION_OPTIONS = ['0', '90'];
|
|
44
45
|
const RUNNER_TYPE_OPTIONS = ['default', 'm4', 'm1', 'gpu1', 'cpu1'];
|
|
46
|
+
const PRIORITY_OPTIONS = ['low', 'normal', 'high'];
|
|
45
47
|
const RENDER_ENGINE_OPTIONS = ['lavapipe', 'swiftshader'];
|
|
46
48
|
/**
|
|
47
49
|
* Primary CLI command for executing tests on DeviceCloud.
|
|
@@ -162,6 +164,7 @@ export const cloudCommand = defineCommand({
|
|
|
162
164
|
const moropoApiKey = args['moropo-v1-api-key'];
|
|
163
165
|
const name = args.name;
|
|
164
166
|
const orientation = validateEnum(args.orientation, ORIENTATION_OPTIONS, 'orientation');
|
|
167
|
+
const priority = validateEnum(args.priority, PRIORITY_OPTIONS, 'priority') ?? 'normal';
|
|
165
168
|
let quiet = Boolean(args.quiet);
|
|
166
169
|
const report = validateEnum(args.report, REPORT_OPTIONS, 'report');
|
|
167
170
|
let retry = parseIntFlag(args.retry, 'retry');
|
|
@@ -600,6 +603,7 @@ export const cloudCommand = defineCommand({
|
|
|
600
603
|
metadata: mergedMetadata,
|
|
601
604
|
name,
|
|
602
605
|
orientation,
|
|
606
|
+
priority,
|
|
603
607
|
raw: [],
|
|
604
608
|
report,
|
|
605
609
|
retry,
|
|
@@ -653,31 +657,46 @@ export const cloudCommand = defineCommand({
|
|
|
653
657
|
// 404s (route undefined), some proxies 405 (path/method not allowed); in
|
|
654
658
|
// either case fall back to the legacy multipart POST /uploads/flow, which
|
|
655
659
|
// is byte-identical bar the storage reference.
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
660
|
+
const submitMultipart = (status) => {
|
|
661
|
+
if (debug) {
|
|
662
|
+
out(`[DEBUG] Client-direct flow upload unavailable (HTTP ${status}); falling back to multipart ${apiUrl}/uploads/flow`);
|
|
663
|
+
}
|
|
664
|
+
const testFormData = testSubmissionService.buildFormData(fields, buffer);
|
|
665
|
+
return ApiGateway.uploadFlow(apiUrl, auth, testFormData);
|
|
666
|
+
};
|
|
667
|
+
const submitFlows = async () => {
|
|
668
|
+
let storageRef;
|
|
669
|
+
try {
|
|
670
|
+
storageRef = await uploadFlowZip({ apiUrl, auth, buffer, debug });
|
|
671
|
+
}
|
|
672
|
+
catch (error) {
|
|
673
|
+
if (!isSubmitEndpointMissing(error))
|
|
674
|
+
throw error;
|
|
675
|
+
return submitMultipart(error.status);
|
|
676
|
+
}
|
|
659
677
|
if (debug) {
|
|
660
678
|
out(`[DEBUG] Flow zip uploaded (id=${storageRef.id}, supabase=${storageRef.supabaseSuccess}, backblaze=${storageRef.backblazeSuccess})`);
|
|
661
679
|
out(`[DEBUG] Submitting flow test to ${apiUrl}/uploads/submitFlowTest`);
|
|
662
680
|
}
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
681
|
+
// The same body on every attempt: its upload id is what lets the API
|
|
682
|
+
// answer a repeat with the run it already created (dcd#1617 follow-up).
|
|
683
|
+
const submitBody = { ...fields, ...storageRef };
|
|
684
|
+
// The retries can outlast a `dcd login` access token, so each attempt
|
|
685
|
+
// re-checks it, as the poll loop does.
|
|
686
|
+
let submitAuth = auth;
|
|
687
|
+
const submitted = await submitWithRetry({
|
|
688
|
+
fallback: (error) => submitMultipart(error.status),
|
|
689
|
+
// Recorded before each attempt, so `command failed` carries it too.
|
|
690
|
+
onAttempt: (attempt) => telemetry.recordSubmitAttempts(attempt),
|
|
691
|
+
onRetry: ({ attempt, attempts, delayMs, reason }) => warnOut(`Could not confirm the test submission (${reason}); sending it again in ${delayMs / 1000}s (attempt ${attempt}/${attempts}). It will not run twice.`),
|
|
692
|
+
async submit() {
|
|
693
|
+
submitAuth = await refreshAuth(submitAuth);
|
|
694
|
+
return ApiGateway.submitFlowTest(apiUrl, submitAuth, submitBody);
|
|
695
|
+
},
|
|
666
696
|
});
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
(error.status === 404 || error.status === 405)) {
|
|
671
|
-
if (debug) {
|
|
672
|
-
out(`[DEBUG] Client-direct flow upload unavailable (HTTP ${error.status}); falling back to multipart ${apiUrl}/uploads/flow`);
|
|
673
|
-
}
|
|
674
|
-
const testFormData = testSubmissionService.buildFormData(fields, buffer);
|
|
675
|
-
response = await ApiGateway.uploadFlow(apiUrl, auth, testFormData);
|
|
676
|
-
}
|
|
677
|
-
else {
|
|
678
|
-
throw error;
|
|
679
|
-
}
|
|
680
|
-
}
|
|
697
|
+
return submitted.response;
|
|
698
|
+
};
|
|
699
|
+
const response = await submitFlows();
|
|
681
700
|
const { message, results } = response;
|
|
682
701
|
if (debug) {
|
|
683
702
|
out(`[DEBUG] Flow submission response received`);
|
|
@@ -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',
|
package/dist/constants.d.ts
CHANGED
|
@@ -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";
|
|
@@ -189,6 +189,15 @@ export declare const ApiGateway: {
|
|
|
189
189
|
message?: string;
|
|
190
190
|
results?: components["schemas"]["IDBResult"][];
|
|
191
191
|
}>;
|
|
192
|
+
/**
|
|
193
|
+
* How long one submit attempt may take (dcd#1617 follow-up). A submit
|
|
194
|
+
* normally answers in a second or two; one held by a Supabase stall is
|
|
195
|
+
* abandoned and re-sent by `submitWithRetry`, which is safe because the API
|
|
196
|
+
* answers a repeat with the run it already created. Well under the 300s at
|
|
197
|
+
* which Railway's edge would cut it with a 502. Settable so tests can
|
|
198
|
+
* shorten it.
|
|
199
|
+
*/
|
|
200
|
+
submitAttemptTimeoutMs: number;
|
|
192
201
|
/**
|
|
193
202
|
* Dry-run cost + cell-count estimate for a (possibly device-matrix)
|
|
194
203
|
* submission. Runs the same resolve → validate → fan-out → price core as the
|
|
@@ -51,8 +51,13 @@ export const ApiGateway = {
|
|
|
51
51
|
message += ` • Verify API URL is correct: ${origin}\n`;
|
|
52
52
|
message += ` • Check for proxy/VPN interference\n`;
|
|
53
53
|
message += ` • Try again in a few moments if server is temporarily down\n\n`;
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
// undici puts the real reason on `cause` (UND_ERR_HEADERS_TIMEOUT,
|
|
55
|
+
// ECONNRESET, ENOTFOUND, …); without it a timeout, a reset and a DNS
|
|
56
|
+
// failure all read as the same "fetch failed".
|
|
57
|
+
const cause = error.cause;
|
|
58
|
+
const code = cause?.code;
|
|
59
|
+
message += `Original error: ${error.message}${typeof code === 'string' ? ` (${code})` : ''}`;
|
|
60
|
+
const enhancedError = new Error(message, { cause: cause ?? error });
|
|
56
61
|
enhancedError.name = 'NetworkError';
|
|
57
62
|
enhancedError.stack = error.stack;
|
|
58
63
|
return enhancedError;
|
|
@@ -545,6 +550,7 @@ export const ApiGateway = {
|
|
|
545
550
|
...auth.headers,
|
|
546
551
|
},
|
|
547
552
|
method: 'POST',
|
|
553
|
+
signal: AbortSignal.timeout(this.submitAttemptTimeoutMs),
|
|
548
554
|
});
|
|
549
555
|
if (!res.ok) {
|
|
550
556
|
await this.handleApiError(res, 'Failed to submit test flows');
|
|
@@ -556,9 +562,23 @@ export const ApiGateway = {
|
|
|
556
562
|
if (error instanceof TypeError && error.message === 'fetch failed') {
|
|
557
563
|
throw this.enhanceFetchError(error, `${baseUrl}/uploads/submitFlowTest`);
|
|
558
564
|
}
|
|
565
|
+
if (error instanceof Error && error.name === 'TimeoutError') {
|
|
566
|
+
const timeout = new Error(`Failed to submit test flows: no response from the API within ${this.submitAttemptTimeoutMs / 1000}s`, { cause: error });
|
|
567
|
+
timeout.name = 'SubmitTimeoutError';
|
|
568
|
+
throw timeout;
|
|
569
|
+
}
|
|
559
570
|
throw error;
|
|
560
571
|
}
|
|
561
572
|
},
|
|
573
|
+
/**
|
|
574
|
+
* How long one submit attempt may take (dcd#1617 follow-up). A submit
|
|
575
|
+
* normally answers in a second or two; one held by a Supabase stall is
|
|
576
|
+
* abandoned and re-sent by `submitWithRetry`, which is safe because the API
|
|
577
|
+
* answers a repeat with the run it already created. Well under the 300s at
|
|
578
|
+
* which Railway's edge would cut it with a 502. Settable so tests can
|
|
579
|
+
* shorten it.
|
|
580
|
+
*/
|
|
581
|
+
submitAttemptTimeoutMs: 90_000,
|
|
562
582
|
/**
|
|
563
583
|
* Dry-run cost + cell-count estimate for a (possibly device-matrix)
|
|
564
584
|
* submission. Runs the same resolve → validate → fan-out → price core as the
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import * as path from 'node:path';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import {
|
|
3
|
+
import { ApiGateway } from '../../gateways/api-gateway.js';
|
|
4
4
|
import { plan } from '../../services/execution-plan.service.js';
|
|
5
|
+
import { isSubmitEndpointMissing, submitWithRetry, } from '../../services/flow-submit.service.js';
|
|
5
6
|
import { computeCommonRoot, buildTestMetadataMap } from '../../services/flow-paths.js';
|
|
6
7
|
import { DeviceValidationService } from '../../services/device-validation.service.js';
|
|
7
8
|
import { platformFromAppFile } from '../../services/notices.service.js';
|
|
@@ -70,6 +71,10 @@ export function registerRunCloudTest(server) {
|
|
|
70
71
|
.enum(['default', 'm4', 'm1', 'gpu1', 'cpu1'])
|
|
71
72
|
.optional()
|
|
72
73
|
.describe('Runner type (default "default"). gpu1 is deprecated and will be removed on 9 November 2026; prefer cpu1.'),
|
|
74
|
+
priority: z
|
|
75
|
+
.enum(['low', 'normal', 'high'])
|
|
76
|
+
.optional()
|
|
77
|
+
.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
78
|
configFile: z.string().optional().describe('Path to a workspace config.yaml'),
|
|
74
79
|
ignoreShaCheck: z
|
|
75
80
|
.boolean()
|
|
@@ -208,6 +213,7 @@ export function registerRunCloudTest(server) {
|
|
|
208
213
|
iOSDevice: args.iosDevice,
|
|
209
214
|
iOSVersion: args.iosVersion,
|
|
210
215
|
name: args.name,
|
|
216
|
+
priority: args.priority,
|
|
211
217
|
runnerType: args.runnerType ?? 'default',
|
|
212
218
|
maestroVersion: resolvedMaestroVersion,
|
|
213
219
|
retry: args.retry,
|
|
@@ -219,24 +225,38 @@ export function registerRunCloudTest(server) {
|
|
|
219
225
|
// referencing it. Older API deployments lack these endpoints (404/405);
|
|
220
226
|
// fall back to the legacy multipart POST /uploads/flow. Mirrors
|
|
221
227
|
// `dcd cloud`.
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
...storageRef,
|
|
228
|
-
});
|
|
229
|
-
}
|
|
230
|
-
catch (error) {
|
|
231
|
-
if (error instanceof ApiError &&
|
|
232
|
-
(error.status === 404 || error.status === 405)) {
|
|
233
|
-
const testFormData = testSubmissionService.buildFormData(fields, buffer);
|
|
234
|
-
response = await ApiGateway.uploadFlow(apiUrl, auth, testFormData);
|
|
228
|
+
const submitMultipart = () => ApiGateway.uploadFlow(apiUrl, auth, testSubmissionService.buildFormData(fields, buffer));
|
|
229
|
+
const submitFlows = async () => {
|
|
230
|
+
let storageRef;
|
|
231
|
+
try {
|
|
232
|
+
storageRef = await uploadFlowZip({ apiUrl, auth, buffer });
|
|
235
233
|
}
|
|
236
|
-
|
|
237
|
-
|
|
234
|
+
catch (error) {
|
|
235
|
+
if (!isSubmitEndpointMissing(error))
|
|
236
|
+
throw error;
|
|
237
|
+
return submitMultipart();
|
|
238
238
|
}
|
|
239
|
-
|
|
239
|
+
// Re-sent while its outcome is unknown, always with the same body;
|
|
240
|
+
// see submitWithRetry (dcd#1617 follow-up).
|
|
241
|
+
const submitBody = { ...fields, ...storageRef };
|
|
242
|
+
// The retries can outlast a `dcd login` access token, so each
|
|
243
|
+
// attempt re-checks it, as the wait loop below does.
|
|
244
|
+
let submitAuth = auth;
|
|
245
|
+
const submitted = await submitWithRetry({
|
|
246
|
+
fallback: submitMultipart,
|
|
247
|
+
// Recorded before each attempt, so a failed tool call carries it.
|
|
248
|
+
onAttempt: (attempt) => {
|
|
249
|
+
telemetryExtra.submitAttempts = attempt;
|
|
250
|
+
},
|
|
251
|
+
onRetry: ({ attempt, attempts, delayMs, reason }) => logStderr(`Could not confirm the test submission (${reason}); sending it again in ${delayMs / 1000}s (attempt ${attempt}/${attempts}). It will not run twice.`),
|
|
252
|
+
async submit() {
|
|
253
|
+
submitAuth = await refreshAuth(submitAuth);
|
|
254
|
+
return ApiGateway.submitFlowTest(apiUrl, submitAuth, submitBody);
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
return submitted.response;
|
|
258
|
+
};
|
|
259
|
+
const response = await submitFlows();
|
|
240
260
|
const { message, results } = response;
|
|
241
261
|
if (!results?.length) {
|
|
242
262
|
throw new Error(`No tests were created: ${message}`);
|
|
@@ -47,6 +47,20 @@ export class DeviceValidationService {
|
|
|
47
47
|
? compatibilityData.androidPlay
|
|
48
48
|
: compatibilityData.android;
|
|
49
49
|
const argNames = options.argNames ?? CLI_ARG_NAMES;
|
|
50
|
+
// With Google Play the API does not give an omitted half the global
|
|
51
|
+
// default (no longer a Play cell): it picks one that makes the pair a Play
|
|
52
|
+
// cell, and only rejects when none exists (resolveAndroidTarget in
|
|
53
|
+
// compatibility.service.ts). Fill it the same way so a pair the API will
|
|
54
|
+
// run is not refused here.
|
|
55
|
+
let device = androidDevice;
|
|
56
|
+
let apiLevel = androidApiLevel;
|
|
57
|
+
if (googlePlay && device && !apiLevel) {
|
|
58
|
+
apiLevel = versionsFor(lookup, device)[0];
|
|
59
|
+
}
|
|
60
|
+
else if (googlePlay && apiLevel && !device) {
|
|
61
|
+
const level = apiLevel;
|
|
62
|
+
device = offeredDevices(lookup).find((d) => versionsFor(lookup, d).includes(level));
|
|
63
|
+
}
|
|
50
64
|
this.validateDevice({
|
|
51
65
|
debugLines: (deviceID, version, supportedVersions) => [
|
|
52
66
|
`[DEBUG] Android device: ${deviceID}`,
|
|
@@ -55,11 +69,11 @@ export class DeviceValidationService {
|
|
|
55
69
|
`[DEBUG] Supported Android versions: ${supportedVersions.join(', ')}`,
|
|
56
70
|
],
|
|
57
71
|
// Mirrors the API, which applies these same global defaults to a
|
|
58
|
-
// request that omits either half (
|
|
72
|
+
// request that omits either half (execution-planner.service.ts)
|
|
59
73
|
// and then rejects an incompatible pair.
|
|
60
74
|
defaultDevice: 'pixel-7',
|
|
61
|
-
defaultVersion: '
|
|
62
|
-
device
|
|
75
|
+
defaultVersion: '36',
|
|
76
|
+
device,
|
|
63
77
|
deviceArg: argNames.androidDevice,
|
|
64
78
|
deviceKind: googlePlay ? 'Google Play' : 'Android',
|
|
65
79
|
formatVersion: (version) => `API level ${version}`,
|
|
@@ -72,7 +86,7 @@ export class DeviceValidationService {
|
|
|
72
86
|
? `Android device "${deviceID}" is not available with Google Play. Google Play devices: ${offeredDevices(lookup).join(', ')}. See ${DEVICES_DOCS_URL}`
|
|
73
87
|
: undefined,
|
|
74
88
|
unsupportedVersionMessage: (deviceID, supportedVersions) => `${deviceID} ${googlePlay ? '(Play Store) ' : ''}only supports these Android API levels: ${supportedVersions.join(', ')}`,
|
|
75
|
-
version:
|
|
89
|
+
version: apiLevel,
|
|
76
90
|
versionArg: argNames.androidApiLevel,
|
|
77
91
|
versionName: 'Android API level',
|
|
78
92
|
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { ApiError } from '../gateways/api-gateway.js';
|
|
2
|
+
/**
|
|
3
|
+
* Submitting a flow test when the response may be lost (dcd#1617 follow-up).
|
|
4
|
+
*
|
|
5
|
+
* Supabase's ingress sometimes holds a request for minutes before answering,
|
|
6
|
+
* and Railway's edge cuts a request that is silent for 300s with a 502. Either
|
|
7
|
+
* way the CLI cannot tell whether its run was created. The API makes that safe
|
|
8
|
+
* to find out: it answers a repeat of the same submission (same body, same
|
|
9
|
+
* upload id from getFlowUploadUrl) with the run it already created, or a 409
|
|
10
|
+
* while that run is still being created. It never creates a second run or a
|
|
11
|
+
* second charge. So a submit whose outcome is unknown is simply sent again.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Waits before each re-send: 2s, then up to 30s apart, about three minutes in
|
|
15
|
+
* all. That is long enough for a write held in a stall to land, after which the
|
|
16
|
+
* repeat is answered with the run.
|
|
17
|
+
*/
|
|
18
|
+
export declare const SUBMIT_RETRY_DELAYS_MS: readonly number[];
|
|
19
|
+
/**
|
|
20
|
+
* No attempt starts more than this long after the first. Each attempt can take
|
|
21
|
+
* up to its 90s timeout, so without a cap every attempt timing out would hold
|
|
22
|
+
* the command for about 16 minutes; with it, the worst case is about 6.5.
|
|
23
|
+
*/
|
|
24
|
+
export declare const SUBMIT_RETRY_BUDGET_MS: number;
|
|
25
|
+
/**
|
|
26
|
+
* Whether a failed attempt is worth sending again. Anything that is not an API
|
|
27
|
+
* answer is a transport failure: a network error, the attempt timeout, or a
|
|
28
|
+
* body that died mid-read after a 2xx. In every case the run may already exist,
|
|
29
|
+
* which a repeat finds out. The npm build throws undici's errors and the bun
|
|
30
|
+
* binaries their own; neither is an ApiError, so both retry.
|
|
31
|
+
*/
|
|
32
|
+
export declare function isRetryableSubmitError(error: unknown): boolean;
|
|
33
|
+
/** A one-line reason for the retry notice. Network errors carry a long help text. */
|
|
34
|
+
export declare function describeSubmitError(error: unknown): string;
|
|
35
|
+
/** The API predates the client-direct submit: a real API 404s, some proxies 405. */
|
|
36
|
+
export declare function isSubmitEndpointMissing(error: unknown): boolean;
|
|
37
|
+
export interface SubmitRetryNotice {
|
|
38
|
+
/** The attempt about to be sent, counting from 1. */
|
|
39
|
+
attempt: number;
|
|
40
|
+
attempts: number;
|
|
41
|
+
delayMs: number;
|
|
42
|
+
reason: string;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Send a submission, sending it again while its outcome is unknown.
|
|
46
|
+
*
|
|
47
|
+
* `fallback` is the legacy multipart submit for an API that predates this
|
|
48
|
+
* endpoint. It is used only when the *first* attempt says the endpoint is
|
|
49
|
+
* missing. A 404 on a later attempt comes from an API that took the earlier
|
|
50
|
+
* attempt, so a multipart submit then would create a second run.
|
|
51
|
+
*
|
|
52
|
+
* Resolves with the response and how many attempts it took. Throws the last
|
|
53
|
+
* error when it is not retryable or the attempts run out.
|
|
54
|
+
*/
|
|
55
|
+
export declare function submitWithRetry<T>(opts: {
|
|
56
|
+
budgetMs?: number;
|
|
57
|
+
delaysMs?: readonly number[];
|
|
58
|
+
fallback: (error: ApiError) => Promise<T>;
|
|
59
|
+
now?: () => number;
|
|
60
|
+
/**
|
|
61
|
+
* Called before each attempt with its number, counting from 1, so a caller
|
|
62
|
+
* can record how many were made whether the submit succeeds or not.
|
|
63
|
+
*/
|
|
64
|
+
onAttempt?: (attempt: number) => void;
|
|
65
|
+
onRetry?: (notice: SubmitRetryNotice) => void;
|
|
66
|
+
sleep?: (ms: number) => Promise<void>;
|
|
67
|
+
submit: () => Promise<T>;
|
|
68
|
+
}): Promise<{
|
|
69
|
+
attempts: number;
|
|
70
|
+
response: T;
|
|
71
|
+
}>;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { ApiError } from '../gateways/api-gateway.js';
|
|
2
|
+
import { isDefinitiveAuthFailure } from '../utils/auth.js';
|
|
3
|
+
/**
|
|
4
|
+
* Submitting a flow test when the response may be lost (dcd#1617 follow-up).
|
|
5
|
+
*
|
|
6
|
+
* Supabase's ingress sometimes holds a request for minutes before answering,
|
|
7
|
+
* and Railway's edge cuts a request that is silent for 300s with a 502. Either
|
|
8
|
+
* way the CLI cannot tell whether its run was created. The API makes that safe
|
|
9
|
+
* to find out: it answers a repeat of the same submission (same body, same
|
|
10
|
+
* upload id from getFlowUploadUrl) with the run it already created, or a 409
|
|
11
|
+
* while that run is still being created. It never creates a second run or a
|
|
12
|
+
* second charge. So a submit whose outcome is unknown is simply sent again.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Waits before each re-send: 2s, then up to 30s apart, about three minutes in
|
|
16
|
+
* all. That is long enough for a write held in a stall to land, after which the
|
|
17
|
+
* repeat is answered with the run.
|
|
18
|
+
*/
|
|
19
|
+
export const SUBMIT_RETRY_DELAYS_MS = [
|
|
20
|
+
2000, 5000, 10_000, 20_000, 30_000, 30_000, 30_000, 30_000,
|
|
21
|
+
];
|
|
22
|
+
/**
|
|
23
|
+
* No attempt starts more than this long after the first. Each attempt can take
|
|
24
|
+
* up to its 90s timeout, so without a cap every attempt timing out would hold
|
|
25
|
+
* the command for about 16 minutes; with it, the worst case is about 6.5.
|
|
26
|
+
*/
|
|
27
|
+
export const SUBMIT_RETRY_BUDGET_MS = 5 * 60_000;
|
|
28
|
+
/**
|
|
29
|
+
* Statuses that leave the outcome unknown (502/503/504 from the edge or the
|
|
30
|
+
* API), say the API could not finish (500), or say the first attempt is still
|
|
31
|
+
* creating the run (409 SUBMISSION_IN_PROGRESS). Re-sending is only safe
|
|
32
|
+
* because the API dedupes on the upload id.
|
|
33
|
+
*/
|
|
34
|
+
const RETRYABLE_STATUSES = new Set([409, 500, 502, 503, 504]);
|
|
35
|
+
/**
|
|
36
|
+
* Whether a failed attempt is worth sending again. Anything that is not an API
|
|
37
|
+
* answer is a transport failure: a network error, the attempt timeout, or a
|
|
38
|
+
* body that died mid-read after a 2xx. In every case the run may already exist,
|
|
39
|
+
* which a repeat finds out. The npm build throws undici's errors and the bun
|
|
40
|
+
* binaries their own; neither is an ApiError, so both retry.
|
|
41
|
+
*/
|
|
42
|
+
export function isRetryableSubmitError(error) {
|
|
43
|
+
if (error instanceof ApiError)
|
|
44
|
+
return RETRYABLE_STATUSES.has(error.status);
|
|
45
|
+
// The session can't be refreshed (refused, or logged out): only `dcd login`
|
|
46
|
+
// fixes that, so another attempt would fail the same way.
|
|
47
|
+
if (isDefinitiveAuthFailure(error))
|
|
48
|
+
return false;
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
/** A one-line reason for the retry notice. Network errors carry a long help text. */
|
|
52
|
+
export function describeSubmitError(error) {
|
|
53
|
+
if (error instanceof ApiError)
|
|
54
|
+
return `HTTP ${error.status}`;
|
|
55
|
+
if (error instanceof Error) {
|
|
56
|
+
if (error.name === 'SubmitTimeoutError')
|
|
57
|
+
return 'no response in time';
|
|
58
|
+
const code = error.cause?.code;
|
|
59
|
+
if (typeof code === 'string')
|
|
60
|
+
return code;
|
|
61
|
+
return error.name === 'NetworkError' ? 'network error' : error.name;
|
|
62
|
+
}
|
|
63
|
+
return String(error);
|
|
64
|
+
}
|
|
65
|
+
/** The API predates the client-direct submit: a real API 404s, some proxies 405. */
|
|
66
|
+
export function isSubmitEndpointMissing(error) {
|
|
67
|
+
return (error instanceof ApiError && (error.status === 404 || error.status === 405));
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Send a submission, sending it again while its outcome is unknown.
|
|
71
|
+
*
|
|
72
|
+
* `fallback` is the legacy multipart submit for an API that predates this
|
|
73
|
+
* endpoint. It is used only when the *first* attempt says the endpoint is
|
|
74
|
+
* missing. A 404 on a later attempt comes from an API that took the earlier
|
|
75
|
+
* attempt, so a multipart submit then would create a second run.
|
|
76
|
+
*
|
|
77
|
+
* Resolves with the response and how many attempts it took. Throws the last
|
|
78
|
+
* error when it is not retryable or the attempts run out.
|
|
79
|
+
*/
|
|
80
|
+
export async function submitWithRetry(opts) {
|
|
81
|
+
const delaysMs = opts.delaysMs ?? SUBMIT_RETRY_DELAYS_MS;
|
|
82
|
+
const budgetMs = opts.budgetMs ?? SUBMIT_RETRY_BUDGET_MS;
|
|
83
|
+
const now = opts.now ?? Date.now;
|
|
84
|
+
const startedAt = now();
|
|
85
|
+
const sleep = opts.sleep ??
|
|
86
|
+
((ms) => new Promise((resolve) => {
|
|
87
|
+
setTimeout(resolve, ms);
|
|
88
|
+
}));
|
|
89
|
+
const attempts = delaysMs.length + 1;
|
|
90
|
+
for (let attempt = 1;; attempt++) {
|
|
91
|
+
opts.onAttempt?.(attempt);
|
|
92
|
+
try {
|
|
93
|
+
return { attempts: attempt, response: await opts.submit() };
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
if (attempt === 1 && isSubmitEndpointMissing(error)) {
|
|
97
|
+
return {
|
|
98
|
+
attempts: attempt,
|
|
99
|
+
response: await opts.fallback(error),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
const delayMs = delaysMs[attempt - 1];
|
|
103
|
+
if (delayMs === undefined ||
|
|
104
|
+
!isRetryableSubmitError(error) ||
|
|
105
|
+
now() - startedAt + delayMs > budgetMs) {
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
opts.onRetry?.({
|
|
109
|
+
attempt: attempt + 1,
|
|
110
|
+
attempts,
|
|
111
|
+
delayMs,
|
|
112
|
+
reason: describeSubmitError(error),
|
|
113
|
+
});
|
|
114
|
+
await sleep(delayMs);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -13,6 +13,7 @@ declare class Telemetry {
|
|
|
13
13
|
private config;
|
|
14
14
|
private command;
|
|
15
15
|
private encryption;
|
|
16
|
+
private submitAttempts;
|
|
16
17
|
private sessionId;
|
|
17
18
|
private startedAt;
|
|
18
19
|
private readonly disabled;
|
|
@@ -46,12 +47,20 @@ declare class Telemetry {
|
|
|
46
47
|
* the meta fields it knows, but forwards every event's `extra`.
|
|
47
48
|
*/
|
|
48
49
|
recordEncryption(update: Partial<EncryptionTelemetry>): void;
|
|
50
|
+
/**
|
|
51
|
+
* Record how many attempts the flow submit took (dcd#1617 follow-up), sent
|
|
52
|
+
* as `submit_attempts` on `command completed` / `command failed`. Anything
|
|
53
|
+
* above 1 means a response was lost and the submit was sent again, which is
|
|
54
|
+
* what to count when judging how often the API stalls under the CLI.
|
|
55
|
+
*/
|
|
56
|
+
recordSubmitAttempts(attempts: number): void;
|
|
49
57
|
recordCommandSuccess(): void;
|
|
50
58
|
recordCommandFailure(opts: {
|
|
51
59
|
error: Error | string;
|
|
52
60
|
exitCode: number;
|
|
53
61
|
}): void;
|
|
54
62
|
private encryptionExtra;
|
|
63
|
+
private submitExtra;
|
|
55
64
|
private enqueue;
|
|
56
65
|
flush(): Promise<void>;
|
|
57
66
|
/**
|
|
@@ -24,6 +24,7 @@ class Telemetry {
|
|
|
24
24
|
config = null;
|
|
25
25
|
command = inferCommandFromArgv();
|
|
26
26
|
encryption = null;
|
|
27
|
+
submitAttempts;
|
|
27
28
|
sessionId = randomUUID();
|
|
28
29
|
startedAt = Date.now();
|
|
29
30
|
disabled = !!process.env.DCD_TELEMETRY_DISABLED;
|
|
@@ -94,11 +95,21 @@ class Telemetry {
|
|
|
94
95
|
...update,
|
|
95
96
|
};
|
|
96
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* Record how many attempts the flow submit took (dcd#1617 follow-up), sent
|
|
100
|
+
* as `submit_attempts` on `command completed` / `command failed`. Anything
|
|
101
|
+
* above 1 means a response was lost and the submit was sent again, which is
|
|
102
|
+
* what to count when judging how often the API stalls under the CLI.
|
|
103
|
+
*/
|
|
104
|
+
recordSubmitAttempts(attempts) {
|
|
105
|
+
this.submitAttempts = attempts;
|
|
106
|
+
}
|
|
97
107
|
recordCommandSuccess() {
|
|
98
108
|
this.enqueue('info', 'cli.lifecycle', 'command completed', {
|
|
99
109
|
duration_ms: Date.now() - this.startedAt,
|
|
100
110
|
exit_code: 0,
|
|
101
111
|
...this.encryptionExtra(),
|
|
112
|
+
...this.submitExtra(),
|
|
102
113
|
});
|
|
103
114
|
}
|
|
104
115
|
recordCommandFailure(opts) {
|
|
@@ -110,11 +121,17 @@ class Telemetry {
|
|
|
110
121
|
error_name: opts.error instanceof Error ? opts.error.name : 'CliError',
|
|
111
122
|
error_stack: opts.error instanceof Error ? opts.error.stack : undefined,
|
|
112
123
|
...this.encryptionExtra(),
|
|
124
|
+
...this.submitExtra(),
|
|
113
125
|
});
|
|
114
126
|
}
|
|
115
127
|
encryptionExtra() {
|
|
116
128
|
return this.encryption ? { encrypt: this.encryption } : {};
|
|
117
129
|
}
|
|
130
|
+
submitExtra() {
|
|
131
|
+
return this.submitAttempts === undefined
|
|
132
|
+
? {}
|
|
133
|
+
: { submit_attempts: this.submitAttempts };
|
|
134
|
+
}
|
|
118
135
|
enqueue(level, context, message, extra) {
|
|
119
136
|
if (this.disabled)
|
|
120
137
|
return;
|
|
@@ -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": "^
|
|
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.8.0-beta.
|
|
71
|
+
"version": "5.8.0-beta.3",
|
|
72
72
|
"bugs": {
|
|
73
73
|
"url": "https://discord.gg/gm3mJwcNw8"
|
|
74
74
|
},
|